基于 luckymiaow/uni-builder 的“傻瓜式”极速打包脚本

74 阅读4分钟

基于 luckymiaow/uni-builder 的“傻瓜式”极速打包脚本

在上一篇分享中,我们解决了 uni-app 本地生成 APK 的基础问题,但实际落地中发现:HBuilderX 手动操作(打开工程、导出资源、发布打包等)流程割裂,不仅容易出错,还会导致团队协作时操作标准不一致。

为此,我们基于 luckymiaow/uni-builder 镜像做了进一步优化:将复杂的安卓构建环境封装到 Docker 容器中,再通过 Node 脚本串联“自动生成打包资源 + 容器化构建”全流程。只需提前配置环境变量并登录 HBuilderX,一行命令即可完成出包,同时通过本机缓存挂载大幅提升构建速度。

核心优势:一句话完成全流程打包

  • 执行方式:项目根目录运行 npm run gen:app(或直接执行 node builder/docker-build.js
  • 脚本入口:package.json 中配置的 gen:app 脚本
  • 自动化流程:脚本自动完成「生成打包资源 → 复制到 Android 工程 → 触发 Docker 构建」全链路
  • 速度优化:挂载本机 .gradleandroid-sdk 缓存到容器,规避重复下载依赖,显著提升构建效率(具体实现见脚本中 Docker 命令拼装逻辑)
  • 前置条件:配置好 env 环境变量,HBuilderX 已登录并绑定对应 uni-app 账号
  • 无手动操作:无需打开 HBuilderX、无需手动导出资源,脚本内置 help 检查 → open → publish → 可选 quit 全自动化逻辑

前置准备

  1. 环境变量配置:在 env/.envenv/.env.local 中配置以下变量:
    • VITE_UNI_APPID:uni-app 应用ID
    • VITE_UNI_PROJECT:项目名称(对应项目目录名称,并且要在hb中打开过)
    • VITE_UNI_CLI:HBuilderX 的 cli.exe 所在路径
  2. HBuilderX 配置:提前登录并绑定目标 uni-app 账号,确保账号有权限发布对应应用
  3. 缓存挂载(推荐):将本机 .gradleandroid-sdk 目录挂载到容器,首次构建后后续速度提升 80%+

常见问题与排查方案

问题现象排查方向
环境变量未配置检查 env 目录下是否存在 .env/.env.local,确认三项核心变量均已配置;脚本会在缺失时明确退出并提示
publish 操作失败确认 HBuilderX 已登录,且登录账号绑定了对应 uni-app 应用ID的开发者权限
help 无响应/ open 重试失败检查 VITE_UNI_CLI 路径是否正确,需指向包含 cli.exe 的 HBuilderX 安装目录
发布前报错退出确认项目根目录存在 src 目录,源码结构符合 uni-app 规范
Docker 报错/权限问题1. 确认 Docker Desktop 已运行;2. 确认 luckymiaow/uni-builder 镜像可正常拉取;3. Windows 环境下路径与盘符需用双引号包裹;4. 优先挂载缓存目录提升速度

完整脚本(docker-build.js)

#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-var-requires */

const os = require('node:os')
const { execSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const dotenv = require('dotenv')

// ---------- 打包耗时统计 ----------
const __startTime = Date.now()
console.log('⏱️ 打包流程开始...')
console.log('⚠️ 确保Hbuilder登录账号已绑定对应的uni-app账号,否则可能打包失败')

// ---------- 读取命令行参数 ----------
let projectPath = process.argv[2]
const buildType = process.argv[3] || 'appResource' // appResource 生成APK资源 | wgt 生成wgt包

// 封装读取 env/.env(.local) 环境变量的函数
function getEnvVars() {
  const envDir = path.resolve(process.cwd(), 'env')
  const envLocalPath = path.join(envDir, '.env.local')
  const envPath = path.join(envDir, '.env')
  let envVars = {}
  let loaded = false

  // 优先读取 .env,再读取 .env.local(local 覆盖默认配置)
  if (fs.existsSync(envPath)) {
    const result = dotenv.config({ path: envPath })
    if (result.error) {
      console.error('❌ 解析 .env 文件失败:', result.error)
      process.exit(1)
    }
    envVars = { ...result.parsed }
    loaded = true
  }
  if (fs.existsSync(envLocalPath)) {
    const resultLocal = dotenv.config({ path: envLocalPath })
    if (resultLocal.error) {
      console.error('❌ 解析 .env.local 文件失败:', resultLocal.error)
      process.exit(1)
    }
    envVars = { ...envVars, ...resultLocal.parsed }
    loaded = true
  }
  if (!loaded) {
    console.error('❌ 未找到 .env 或 .env.local 文件,请确保项目根目录有 env/.env 或 env/.env.local')
    process.exit(1)
  }
  return envVars
}

// ---------- 自动补全项目路径(未传参时从环境变量读取) ----------
const envVars = getEnvVars()
if (!projectPath) {
  console.log('⚠️ 未传入路径参数,尝试从 env 配置读取 VITE_UNI_APPID 自动生成路径...')
  const appId = envVars.VITE_UNI_APPID
  if (!appId) {
    console.error('❌ 未在 .env/.env.local 中配置 VITE_UNI_APPID')
    process.exit(1)
  }
  console.log(`✅ 读取到 VITE_UNI_APPID: ${appId}`)

  // 根据打包类型设置默认输出路径
  projectPath = buildType === 'wgt' 
    ? `./unpackage/release` 
    : `./unpackage/resources/${appId}`
}

// ---------- 自动生成打包资源(配置了 CLI 路径时) ----------
const uniCliPath = envVars.VITE_UNI_CLI
if (!uniCliPath) {
  console.warn('⚠️ 未配置 VITE_UNI_CLI(HBuilderX CLI 路径),请确保已手动生成打包资源')
} else {
  console.log(`✅ 读取到 VITE_UNI_CLI: ${uniCliPath},开始自动生成打包资源`)

  let needQuit = false
  try {
    // 校验源码目录
    const srcDir = path.resolve(process.cwd(), 'src')
    if (!fs.existsSync(srcDir)) throw new Error('src 目录不存在,无法生成打包资源')

    // 拼接 CLI 可执行文件路径
    const cliBin = `${uniCliPath}/cli.exe`

    // 1. 检查 CLI 可用性
    let helpOutput = ''
    try {
      helpOutput = execSync(`"${cliBin}" help`, {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
      })
    } catch (e) {
      helpOutput = ''
    }

    // 2. CLI 无响应时尝试启动 HBuilderX
    if (!helpOutput?.trim()) {
      console.log('ℹ️ HBuilderX CLI 无响应,尝试自动启动 HBuilderX...')
      const { spawnSync } = require('node:child_process')
      const openResult = spawnSync(`"${cliBin}"`, ['open'], { stdio: 'inherit', shell: true })
      
      if (openResult.error || openResult.status !== 0) throw new Error('CLI open 命令执行失败')

      // 重试检查 CLI 可用性(最多5次,间隔1秒)
      let openOk = false
      for (let i = 0; i < 5; i++) {
        try {
          helpOutput = execSync(`"${cliBin}" help`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
          if (helpOutput?.trim()) {
            openOk = true
            break
          }
        } catch (e) { /* 忽略重试错误 */ }
        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000)
      }
      if (!openOk) throw new Error('启动 HBuilderX 后 CLI 仍无响应')
      needQuit = true // 标记后续需要退出 HBuilderX
    }

    // 3. 校验项目名称配置
    const pkgName = envVars.VITE_UNI_PROJECT
    if (!pkgName) {
      console.error('❌ 未配置 VITE_UNI_PROJECT(项目名称)')
      process.exit(1)
    }

    // 4. 执行打包命令
    const cliCmd = `"${cliBin}" publish --platform APP --type ${buildType} --project ${pkgName}`
    console.log(`🚦 执行打包命令: cd ${srcDir} && ${cliCmd}`)
    execSync(`cd /d "${srcDir}" && ${cliCmd}`, { stdio: 'inherit' })
    console.log(`✅ 打包资源生成完成 (类型: ${buildType})`)

    // 5. 自动退出 HBuilderX(如果是脚本启动的)
    if (needQuit) {
      console.log('🛑 打包完成,尝试关闭 HBuilderX...')
      try {
        execSync(`"${cliBin}" app quit`, { stdio: 'inherit' })
      } catch (e) {
        console.warn('⚠️ 自动关闭 HBuilderX 失败,可手动关闭')
      }
    }
  } catch (e) {
    console.error('❌ 生成打包资源失败:', e.message)
    process.exit(1)
  }
}

// ---------- 路径规范化与目录创建 ----------
projectPath = path.resolve(process.cwd(), projectPath)
console.log(`📁 目标项目路径: ${projectPath}`)

if (!fs.existsSync(projectPath)) {
  console.log(`📁 路径不存在,自动创建: ${projectPath}`)
  fs.mkdirSync(projectPath, { recursive: true })
}

// ---------- 分类型处理打包流程 ----------
if (buildType === 'wgt') {
  console.log('✅ wgt 包生成完成')
  console.log(`📦 输出路径: ${projectPath}`)
} else {
  // 复制资源到 Android 工程目录
  const targetDir = path.resolve('./builder/override/simpleDemo/src/main/assets/apps', path.basename(projectPath))
  
  // 清理旧资源
  if (fs.existsSync(targetDir)) {
    console.log(`🗑️ 清理旧资源目录: ${targetDir}`)
    fs.rmSync(targetDir, { recursive: true, force: true })
  }

  // 复制新资源
  console.log(`📦 复制打包资源: ${projectPath}${targetDir}`)
  fs.cpSync(projectPath, targetDir, { recursive: true })

  // 执行 Docker 构建
  const absPath = path.resolve(process.cwd(), 'builder')
  const gradleCache = path.join(os.homedir(), '.gradle')
  const sdkCache = path.join(os.homedir(), 'android-sdk')
  const dockerCmd = `docker run --rm -v "${gradleCache}:/root/.gradle" -v "${sdkCache}:/opt/sdk-cache" -v "${absPath}:/workspace"  luckymiaow/uni-builder:latest`
  
  console.log(`🚀 执行 Docker 构建命令:\n${dockerCmd}`)
  execSync(dockerCmd, { stdio: 'inherit' })
}

// ---------- 输出打包耗时 ----------
const __endTime = Date.now()
const __cost = (__endTime - __startTime) / 1000
console.log(`⏱️ 打包流程全部完成,总耗时: ${__cost.toFixed(2)} 秒`)