uni-app 鸿蒙离线打包:一个改动从源码到真机要穿过几条链

uni-app 鸿蒙改造系列 · 第三篇(幕后篇)。前两篇讲「鸿蒙 BLE 插件怎么写」「怎么对接血压计」,这一篇讲更工程化的那件事:一个改动从源码到真机,到底要穿过几条链,哪些环节必须手动,哪些地方会把你辛苦改的原生代码倒回旧版。

系列第一篇(平台基建层):uni-app 跑上鸿蒙,发现蓝牙全瞎(一)基建篇 第二篇(设备对接层):鸿蒙端对接 BLE 血压计(二)设备篇

TL;DR

uni-app 鸿蒙离线工程有三种构建形态,能力边界完全不同:CLI 只能出前端资源(appResource / wgt),出不了原生工程,更出不了 har;完整 ArkTS 原生工程必须由 IDE GUI 触发;新增 UTS 插件链必须由 DevEco / hvigor 单独产出 har。搞混这三条线,就会出现「改了源码不生效」「同步一次把原生覆盖没了」这类典型困境。

三条底线:① build 产物只有前端资源,认知到位能消除 90% 焦虑;② 同步只同步 www,原生工程独立 git 管理 + 每次 diff 复核;③ 新增插件走的是 .uts → .ets → .har → ohpm 重装 → hap 五步链,少一步都不会生效。


1. 先分清三种构建形态:谁能产出什么

这是所有混乱的源头。三种形态不是"配置不同",而是能力集合不同:

形态 触发方式 产物 不能做什么
前端资源构建 CLI 构建命令 / IDE 运行到鸿蒙 www 目录(JS 包、静态资源、前端 manifest.json) 不生成任何 ArkTS 原生代码
完整原生工程 仅 IDE GUI【运行到鸿蒙 / 打包】 ArkTS 工程根(entry/、AppScope/、build-profile.json5、module.json5、证书引用) ——
UTS 插件 har DevEco / hvigor assembleHar .har 包,放入原生工程 libs/ 不能由前端构建替代

两个最容易踩的空转:

  • 用 CLI 想生成原生工程模板:CLI 只能发布 appResource 这类前端资源,不会生成原生工程目录树。所以「工程还没在 IDE 里跑起来过」就直接找 .har 构建入口,一定是徒劳。
  • 指望前端构建带上原生改动:改了 .uts / ArkTS / module.json5 里的权限,再跑一次前端构建,产物里根本没有这些东西。

判断口诀:改的是 JS,走前端链;改的是原生配置或 .uts,走 IDE / hvigor 链。


2. build 产物里到底有什么(这一步能消除 90% 焦虑)

很多人第一次看到 dist/dev/app-harmony/ 会以为那是一个完整的鸿蒙工程,于是每次 rebuild 都战战兢兢,怕盖掉 DevEco 里手改的代码。

实际内容只有前端资源:

<前端构建产物>/
├── pages/  subPackages/  static/  assets/   # 页面与资源
├── uni_modules/                             # uni_modules 的 JS 产物
├── app-service.js  app-renderjs.js  app-wxs.js
├── app.css
└── manifest.json                            # 前端 manifest

没有 entry/src/main/ets/、AppScope/、build-profile.json5、oh-package.json5、module.json5。

所以真正的风险只有一个:把整个构建产物目录当成"工程"整体覆盖进原生工程。只要同步范围严格限定在 www 内,原生代码天然不在射程里。


3. 双仓结构与四条产物链

推荐结构(我们就是两仓):

前端仓(uni-app 源码)
├── src/**                                   # 业务代码
├── src/uni_modules/<插件>/utssdk/app-harmony/index.uts
└── dist/dev/app-harmony/                     # 前端构建产物(仅 JS 资源)

原生仓(DevEco 工程,独立 git)
├── entry/src/main/ets/**                     # ArkTS 原生代码(人工维护)
├── entry/src/main/module.json5               # 权限、元数据
├── entry/src/main/resources/resfile/apps/<APPID>/www/   # ← 前端资源落点
├── libs/*.har                                # UTS 插件产物
└── build-profile.json5 / oh-package.json5

四条产物链,任何一次改动都要能对上其中一条:

改了什么 链路 大致耗时
业务 JS / 样式 / pages.json 前端构建 → 同步 www → 覆盖安装 分钟级
uni_modules 里的 .uts 插件 har 五步链(见 §6) 十分钟级
ArkTS 原生代码 / module.json5 原生工程直接构建 分钟级
插件 JS 层 API(非 .uts) 前端构建 → 同步 www → 必须重打 hap/wgt(见 §9) 分钟级,但最容易漏

4. 同步策略:三层决策,不是三选一

第一层(根本解):只同步前端产物到 www。

# 方向:前端构建产物 -> 原生工程 www。只读校验,不改文件
robocopy "<前端构建产物>" "<原生工程>\entry\src\main\resources\resfile\apps\<APPID>\www" `
  /MIR /E /R:2 /W:3 /NP /NFL /NDL

脚本必须自带三道硬校验,缺一个就可能出事故:

# ① 源目录存在(否则说明前端根本没构建成功)
if (!(Test-Path $src)) { throw "源目录不存在,先完成前端构建" }
# ② 目标必须以 \www 结尾(防止把 www 指到工程根,那就是整体覆盖)
if ($dst -notmatch '\\www$') { throw "目标不是 www 资源目录,拒绝执行" }
# ③ robocopy 退出码语义:0-7 是成功(含"无变化"),>=8 才是失败
if ($LASTEXITCODE -ge 8) { throw "同步失败,退出码 $LASTEXITCODE" }

第 ③ 条尤其值得写进团队规范:robocopy 返回 1 不代表失败(表示"有文件被复制"),照抄 if ($LASTEXITCODE -ne 0) 会让每次正常同步都报错,久而久之大家就忽略报错分析了。

第二层(配套纪律):原生工程独立 git + 每次同步后 git diff 复核。

同步完先跑 git status / git diff,只 stage 前端资源的变化。这一层不是可选项——它是唯一能在"误拷了文件"当场就发现异常的手段,比事后从装机表现倒推便宜一个数量级。

第三层(长期优化):把反复手改的原生配置迁进官方约定目录。

uni-app 有个官方约定的原生配置目录(前端工程根下的 harmony-configs/),IDE 构建完整工程时会自动合并/覆盖进生成的原生工程。适合固化权限、签名、entry 里的固定参数。

但它有个前提:只在"IDE 一键构建完整鸿蒙工程"的工作流下有效。如果你用的是"只构建前端资源 + 手动同步 www",这个目录完全用不到。

三层关系:

层级 内容 性质
第一层 只同步 www 根本解,必做
第二层 原生仓独立 git + diff 复核 配套纪律,必做
第三层 harmony-configs/ 看工作流决定,别过度设计

一句话立场:官方约定是为官方工作流服务的。你的流程不是那套,硬套只会增加维护成本。


5. AppID 的四处一致性

如果登记的是「鸿蒙原生应用」(不是 uni-app 混合应用),AppID 不带 __UNI__ 前缀。这个字符串会在四个地方出现,任何一处不一致就会白屏或取不到资源:

  1. 前端 manifest.json 的 appid
  2. 原生壳 EntryAbility.ets 里的 UNI_APPID 常量
  3. 推送等 SDK 在 module.json5 里的元数据值
  4. 资源目录名 entry/src/main/resources/resfile/apps/<APPID>/www

第 4 处最隐蔽——它是个目录名。改 AppID 时如果没同步重命名目录,构建不会报错,运行时才找不到包。同步脚本里建议把它当参数而不是硬编码。


6. 新增一个 UTS 插件的完整链路(本篇的重头戏)

前提:原生工程已由 IDE GUI 生成过(§1),否则没有 .har 构建入口。

6.1 从 .uts 到可安装的 har
# ① 用 IDE 的 node + npm,调用工程自带 hvigor 构建目标模块
node <IDE 安装目录>\tools\ohpm
ode\bin
ode_modules
pm\bin
pm-cli.js `
  exec -- --registry https://repo.<私有源>/harmony-ohpm/ `
  hvigorw --mode module -p product=default `
  -p module=<插件模块名>@default assembleHar --no-daemon

# ② 产物落入原生工程 libs
Copy-Item <dev 工程>\<插件模块>\build\default\outputs\default\*.har `
  <原生工程>\libs\ -Force

# ③ entry 通过相对路径引用
#    "oh-package.json5": dependencies: { "<插件模块名>": "file:../libs/<插件模块名>.har" }

# ④ 强制重解依赖
Remove-Item <原生工程>\oh_modules, <原生工程>\entry\oh_modules, <原生工程>\libs\oh_modules `
  -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item <原生工程>\oh-package-lock.json5, <原生工程>\entry\oh-package-lock.json5 `
  -Force -ErrorAction SilentlyContinue
ohpm install --all

# ⑤ 出包并装机
node <...>/npm-cli.js exec -- hvigorw assembleHap --mode module -p product=default
hdc -t <设备序列号> install -r <hap 路径>

每一步为什么不能省:

步骤 跳过的后果
删 oh_modules + 两个 lock ohpm install 会复用旧版本,同名同版本的 har 内容变了也不会被重新解析,表现是"改了没生效"
ohpm install --all 依赖未解析,构建期报模块找不到
装机前 hdc shell hilog -r 系统 rate limit 缓冲堆积上一轮的旧日志,取证时张冠李戴

私有源参数(--registry)必须显式带上,否则 hvigor 会去公网拉不存在的包名。这是"命令看起来一样但就是跑不通"的第一嫌疑人。

6.2 前端必须引用它,才会被编译进产物

一条容易被忽略的框架行为:uni-app 只会把「前端代码引用到」的 UTS 插件编译进产物。纯前端逻辑改动不会触发原生重编,反过来,只写了 .uts 而前端没有任何引用,插件也根本不会进包。

我们的做法是在垫片模块顶部留一行空引用作为编译触发器:

// harmony-ble.ts 顶部
import * as hbScanResults from '@/uni_modules/harmony-ble/utssdk/app-harmony/index.uts';
// 仅为触发 uni-app 编译 UTS 插件,运行时不调用
void hbScanResults;

早期我们还加过一个假的调用点做探针,确认转译链路通了之后删掉了——探针是手段,不是产出,验证完必须删,否则它会在每次检索"这个函数在哪调用"时制造噪音。

6.3 插件安装方式:不依赖自动注册

框架的自动注册机制要求以【插件安装】形式引入(会自动生成 entry/src/main/ets/unimodules/<插件>/index.generated.ets)。如果目标工程已经生成过、不再重复走安装流程,自动注册不会发生。

两条路:

方式 做法 代价
手工补注册 在 EntryAbility.onCreate 调 registerUTSPlugin + 补 index.generated.ets 的依赖与 imports 模板文件每次重新生成都要重打补丁
前端挂载(我们选的) 在 JS 适配层把 uni.xxx 指向 uni_modules 的兼容层 只需维护一个兼容层,零注册依赖

第二条路有个 ArkTS 语法陷阱:

// 索引签名在 ArkTS 严格模式禁止(TS2384),且会连带后续声明报"not defined"
(uni as any)['hbOpenBluetoothAdapter'] = fn;        // ❌

// Reflect.set 是普通 API 调用,不触发该语法检查,语义与赋值完全等价
Reflect.set(uni, 'hbOpenBluetoothAdapter', fn);      // ✅

排障提示:not defined 报错指向的往往不是真正的问题行,而是被前一个语法错误(这里是索引签名)破坏了解析器状态后的下一行。看到成片的 not defined,先往前找有没有 any、索引签名、as 这类被禁语法。

另外,index.generated.ets 属于构建期模板。一旦目标工程里的版本比模板新(多了 API),不要整份覆盖,只把缺失的函数手工补进现有文件。


7. 入口形态:调试壳 vs 正式壳

原生壳的 Ability 有两种形态,混用会出现"装上了但行为不对":

调试形态:extends UniEntryAbilityDev  +  import hmr  +  hmr.init(APPID)  +  openUniApp(APPID)
正式形态:extends UniEntryAbility      +  无 hmr    +  onWindowStageCreate 内只调 super

调试形态带热更新(HMR),出包验证时必须切回正式形态,否则包体带着 HMR 客户端与开发期连接逻辑。切换是纯文本替换,可以用脚本做,但有两个细节:

# 混合编码文件(中文注释可能是 GBK 字节)必须按字节无损往返读写,
# 用 UTF-8 读再写会把注释字节损坏
$enc = [System.Text.Encoding]::GetEncoding(28591)   # Latin1:byte<->char 无损
$t = [System.IO.File]::ReadAllText($f, $enc)
$t = $t.Replace('extends UniEntryAbilityDev {', 'extends UniEntryAbility {')
# ...同时移除 hmr import 与 hmr.init / openUniApp 调用
[System.IO.File]::WriteAllText($f, $t, $enc)

并且替换后立刻自检,把"应该消失的"和"应该出现的"都断言一遍:

"still references hmr.init            = " + $t.Contains('hmr.init')
"still Dev form                       = " + $t.Contains('UniEntryAbilityDev')
"uses UniEntryAbility(production)     = " + $t.Contains('extends UniEntryAbility {')

改前先落一个 .bak-hmr 副本,出问题一键回滚。这类"脚本改源码"的操作,自检断言比事后构建报错便宜得多。


8. 版本配套四件套

出包失败里有一类报错完全和代码无关——版本配套。四个配置必须落在同一个兼容矩阵内:

配置项 位置 冲突表现
compileSdkVersion / compatibleSdkVersion 原生工程 build-profile.json5 00306004 Specification Violation
@dcloudio/* 依赖版本 原生工程 oh-package.json5 运行时 API 缺失 / 行为不符
UniAppRuntime 版本 同上(uni-app-runtime / uni-mp-sdk / uni-mp-music / uni_getui_sdk_oh) 与前端构建产物不匹配
compatibleSdkVersionStage build-profile.json5 当前 SDK 已 release 但仍写着 beta6 → 直接报错

实测教训:5.0.0(12) 配 @dcloudio 5.x 会报

003060041 Specification Limit Violation
The project installableDeliveryFiles requires SDK version components:{5.0.1(13)}
to fully compile, but the current version of SDK is:{'5.0.0(12)'}

这个报错的坑在于它没告诉你缺哪个依赖。当时的解法是:把 IDE 生成的新工程里的完整配套(build-profile.json5 + oh-package.json5 + 锁文件 + UniAppRuntime 版本)整体覆盖到离线工程,而不是逐个猜版本。

另一类是构建噪声:IDE 可能报 Get Crc32: 系统资源不足,无法完成,或【运行】按钮灰掉——这类通常与你的代码无关,重启 IDE 或清缓存即可。判断依据是去查构建日志而不是盯着弹窗,日志位于 <用户目录>\.hvigor\outputs\build-logs\build.log,真实错误码在里面。


9. 双 www 源陷阱(我们栽过的那次)

原生工程的前端资源存在两个候选源目录:

<前端仓>\dist\dev\app-harmony\                          # 形态一:dev 构建产物
<前端仓>\unpackage\resources\.<hash>\www\               # 形态二:IDE 集成构建产物

而同步脚本里往往写死了其中一个。那次事故链是这样的:

IDE 集成构建把新 JS 写进 unpackage/resources(此时 www 里已有新代码)
  ↓ 随后执行了同步脚本,SRC 指向 dist/dev
robocopy /MIR 把【旧的 dist/dev】镜像进原生工程 www
  ↓ /MIR 的删除语义
新代码被整目录倒回旧版
  ↓ 之后正常出包、正常装机
表现为"改了没生效",一路查编译、查注册、查缓存,全错

三条规矩:

  1. 同步前先看两个源目录的写入时间和体积,确认哪一份才是最新的;
  2. 一个阶段只用一种构建方式,不在两种模式之间来回切;
  3. 判断 www 是否真的更新,用语义指纹而不是时间戳——在前端代码里放一个独特的常量串(如某个新增日志前缀),解包压缩产物后确认它存在于字节码里。裸文本搜索对压缩产物无效,会给你假的"我代码没进去"结论。

10. 决策树:改了 X,该跑哪条链

这次改动是……
├─ 业务 JS / 样式 / pages.json
│   └─ 前端构建 → 核对最新 www 源 → 同步 www → git diff 原生仓 → 覆盖安装
├─ uni_modules 下的 .uts
│   └─ 前端有引用(否则不会被编译)→ IDE GUI 已生成过工程 → assembleHar
│      → 拷 libs → 删 oh_modules/lock → ohpm install --all → assembleHap → 装机
├─ ArkTS 原生代码 / module.json5 权限
│   └─ 原生仓直接构建(与前端产物无关)→ assembleHap → 装机
└─ 出正式包前
    └─ 入口切正式形态(去 Dev / hmr)→ 断言自检 → 版本四件套核对 → assembleHap

配套的一条产品级规矩:热更新(wgt)换不了原生插件。任何涉及 .uts 或原生配置的改动,都必须走整包并检查应用市场版本,不能指望热更下发。


11. 痛点要记录,但不必立刻解决

工程里有两处已知缺陷,我们有意不修:

  • 签名配置写死了本机绝对路径(.p12 / .cer / .p7b 在个人目录下),换机器或加人会失效 → 触发条件明确:换机器 / 加人时再做模板化 + .gitignore 剥离;
  • release 产品引用的签名配置实际不存在,release 构建会失败 → 触发条件:首次要出正式发布包时再补。

判断标准是:痛点有没有明确的触发条件。有,就记录在案等触发;没有,说明它其实不重要。过早抽象出一套"多机器签名方案",在单机开发阶段的收益是负的。

顺带一条同源的纪律:签名段本身不要提交(含本机路径与口令),提交前检查清单里固定一条——git diff --cached 里不得出现证书路径与密码字段。


12. 小结

  1. 三种构建形态的能力边界不同,CLI 出不了原生工程和 har,先把改动归到正确的链上;
  2. 前端构建产物只有 JS 资源,同步严格限定在 www,配三道校验(源存在 / 目标是 www / robocopy 退出码 0-7 为成功);
  3. 原生工程独立 git + 每次同步后 diff 复核是必做纪律,harmony-configs/ 只在"一键构建完整工程"的工作流下才有价值;
  4. AppID 在四处出现,其中资源目录名最难查;
  5. 新增 UTS 插件是五步链,删 oh_modules 与 lock 是"改了没生效"的第一嫌疑人;前端必须有引用才会被编译;注册优先用 Reflect.set 挂载,绕开 ArkTS 禁索引签名;
  6. 出正式包前记得把入口从调试壳切回正式壳,脚本替换后要做断言自检;
  7. 版本四件套要整体配套,报错没告诉你缺哪个时,用生成工程的完整配套覆盖比逐个猜版本快;构建噪声去 build.log 找真错误码;
  8. 双 www 源 + /MIR 能把新包倒回旧包,判断产物是否生效要用语义指纹;
  9. 热更换不了原生插件;痛点按触发条件决定修不修,别过早优化。

posted on 2026-11-06 09:50  码间留白  阅读(89)  评论(0)    收藏  举报

导航