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__ 前缀。这个字符串会在四个地方出现,任何一处不一致就会白屏或取不到资源:
- 前端
manifest.json的 appid - 原生壳
EntryAbility.ets里的UNI_APPID常量 - 推送等 SDK 在
module.json5里的元数据值 - 资源目录名
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 的删除语义
新代码被整目录倒回旧版
↓ 之后正常出包、正常装机
表现为"改了没生效",一路查编译、查注册、查缓存,全错
三条规矩:
- 同步前先看两个源目录的写入时间和体积,确认哪一份才是最新的;
- 一个阶段只用一种构建方式,不在两种模式之间来回切;
- 判断
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. 小结
- 三种构建形态的能力边界不同,CLI 出不了原生工程和 har,先把改动归到正确的链上;
- 前端构建产物只有 JS 资源,同步严格限定在
www,配三道校验(源存在 / 目标是 www / robocopy 退出码 0-7 为成功); - 原生工程独立 git + 每次同步后 diff 复核是必做纪律,
harmony-configs/只在"一键构建完整工程"的工作流下才有价值; - AppID 在四处出现,其中资源目录名最难查;
- 新增 UTS 插件是五步链,删
oh_modules与 lock 是"改了没生效"的第一嫌疑人;前端必须有引用才会被编译;注册优先用Reflect.set挂载,绕开 ArkTS 禁索引签名; - 出正式包前记得把入口从调试壳切回正式壳,脚本替换后要做断言自检;
- 版本四件套要整体配套,报错没告诉你缺哪个时,用生成工程的完整配套覆盖比逐个猜版本快;构建噪声去
build.log找真错误码; - 双
www源 +/MIR能把新包倒回旧包,判断产物是否生效要用语义指纹; - 热更换不了原生插件;痛点按触发条件决定修不修,别过早优化。
uni-app 鸿蒙离线工程有三种构建形态,能力边界完全不同:CLI 只能出前端资源,出不了原生工程,更出不了 har;完整 ArkTS 工程必须由 IDE 触发;UTS 插件必须单独 assembleHar。搞混这三条线,就会出现"改了源码不生效""同步一次把原生代码覆盖没了"。本文是一套实战打法:build 产物里到底有什么(认清它能消除 90% 焦虑)、同步的三层决策(只同步 www + 原生仓独立 git + harmony-configs 的适用边界)、新增 UTS 插件的五步链与"删 oh_modules/lock"为什么是硬要求、注册绕开 ArkTS 索引签名、出包前入口从调试壳切正式壳、版本四件套配错的 00306004,以及我们把新包 /MIR 倒回旧包的那次事故。附一张"改了 X 走哪条链"的决策树
浙公网安备 33010602011771号