关于本文的占位符:文中所有项目标识类信息均已脱敏,落地时请逐项替换为你自己的取值。
<你的鸿蒙 AppId>:鸿蒙原生应用的 DCloud AppId(不带__UNI__前缀,形如UNIXXXXXX)<你的包名>:鸿蒙bundleName(形如com.example.app)<热更新配置键>:你在自建后台新建的配置项 key(本文只用它代指你自己的那个键名,不出现真实取值)<你的配置查询接口>:按 key 拉取配置值的接口<prod-api.example.com>/<qa-api.example.com>:生产与测试环境域名结论均在真机实测得到,具体取值与命令细节以你项目内部档案为准(见文末)。
先给结论
- 普通 uni-app(不是 uni-app x 蒸汽模式)在 HarmonyOS Next 上可以走 WGT 资源热更新 ,
plus.runtime.install/getProperty/quit实测均可用,连续两次热更成功。 - 最大风险不是"热更失败",而是"整包升级被旧 WGT 永久遮蔽" 。运行时只用"目录是否存在"判断有没有热更包,不做任何版本比较。这条我会在下面单独展开,它足以让你的新版本在用户手机上永远不生效。
- 热更新的生效方式 只有一个:安装成功后调
plus.runtime.quit()让用户重开。官方链路在鸿蒙不支持自动重启,别指望restart()。 - 第一个"具备热更能力的整包基线"必须先发出去,用户装上之后,远程热更通道才算打开。线上老包根本不读你的热更配置。
一、先划清能力边界
很多人第一步就搞错了范围,把原生改动塞进热更包里。
| 能走 WGT 热更 | 必须整包升级 |
|---|---|
| 页面/组件 JS、CSS、renderjs、wxs | ArkTS(entry/src/main/ets/**)、UTS 插件 |
| 分包、静态图片等资源 | HAR / 原生 SDK(推送、支付、IM、音视频...) |
| 业务逻辑、接口调用、条件编译分支 | module.json5 权限、AppScope 壳配置 |
| 调用壳内已存在的原生能力 | uni-app 运行时本身升级 |
一句话判据:凡是新增或变更了原生能力,就必须先出整包,热更才有地方调它。
顺带说一个容易忽略的点:区分壳版本 和资源版本 。壳版本来自原生 AppScope/app.json5,是华为应用市场比较用的;资源版本来自前端 manifest.json,是 WGT 比较用的。两者数值不同、更新节奏不同,混用必然误判。客户端读资源版本要用:
ts
const info = uni.getAppBaseInfo();
const resourceVersion = info?.appWgtVersion || info?.appVersion || '';
读到空串就中止本次资源更新,不要拿壳版本冒充资源版本往下比。
二、整体链路
冷启动 / 用户主动点「检查更新」
│
├── 开发态直接 return(重要!见第五节坑 5)
▼
上次热更是否已生效?(pending 标记核验)
▼
① 整包优先:原生应用市场检查
弹窗已出现 / 仍在检查中 → 本次不做热更
▼
② 读当前资源版本(空则中止)
▼
③ 拉远程配置 → 六道校验(任一不过 = 静默视为无更新)
▼
④ uni.downloadFile(wgtUrl) 真实进度上报给 UI
▼
⑤ plus.runtime.install(tempFilePath, { force: false })
▼
⑥ 写入「待生效」标记 → UI 切到「更新已安装 / 立即重启」
▼
⑦ 用户点确认 → plus.runtime.quit()
或点「稍后」→ 下次冷启动自然生效
▼
⑧ 下次启动比对 pending.version === appWgtVersion → 一致才清标记
改动面可以很小:一个更新检查 Hook + 一个更新弹窗组件,全部包在条件编译里,其他平台路径一行不动。
ts
// #ifdef APP-HARMONY
// 鸿蒙专属:市场整包优先,其次资源热更
// #endif
// #ifndef APP-HARMONY
// 原有 Android/iOS 逻辑,读另一个配置键
// #endif
⚠️ 条件编译宏是 APP-HARMONY,它和 APP-PLUS(Android/iOS)是并列关系,不是包含关系 。写了 #ifdef APP-PLUS 鸿蒙不会命中。
三、配置协议与六道校验闸
后台就存一个 JSON 字符串:
json
{
"appid": "<你的鸿蒙 AppId>",
"enabled": true,
"resourceVersion": "2.0.20",
"wgtUrl": "https://你的CDN/app-2.0.20.wgt",
"updateLog": "本次更新说明(可选)"
}
客户端逐条校验,任一不过就返回 null、静默当作"无更新":
| # | 校验 | 踩坑提示 |
|---|---|---|
| 1 | 配置值非空、能 JSON.parse 成对象 |
后台把值存成带 BOM 或多余转义会静默失败,务必打日志 |
| 2 | enabled === true |
必须是布尔真。字符串 "true" 不算 |
| 3 | resourceVersion 是非空字符串 |
建议带 updateLog 方便运营核对 |
| 4 | wgtUrl 匹配 ^https:// |
http 直接跳过,鸿蒙不接受明文下载地址 |
| 5 | appid 与 getAppBaseInfo().appId 一致 |
防跨应用误装。Android 的 WGT 绝不能被鸿蒙装上 |
| 6 | compareVersion(resourceVersion, 当前) > 0 |
严格大于。这条是"配置了却没弹更新"的头号原因 |
版本比较不要用"主版本 + 第三段"这类偷懒规则,做完整分段数字比较:
ts
function compareVersion(a: string, b: string): number {
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const va = pa[i] || 0;
const vb = pb[i] || 0;
if (va !== vb) return va - vb;
}
return 0;
}
还有一条纪律:资源版本线单调递增,永不复用 。基线是 2.0.19,热更配置就得写 ≥2.0.20;下发过的版本号不能回收再用。
四、UI 只要处理好两个状态
| 状态 | 标题 | 主按钮 | 次按钮 | 进度 | 点主按钮 | 点次按钮 |
|---|---|---|---|---|---|---|
| 下载/安装中 | 发现新版本 | 正在下载更新... → 正在安装更新... |
取消 | 真实进度 | 无动作 | 关弹窗 |
| 安装完成 | 更新已安装 |
立即重启 |
稍后 |
保留完成态 | plus.runtime.quit() |
只关弹窗(下次冷启动生效) |
两个细节:
- 进度必须是真实的。有些端上原来是用定时器"模拟进度"爬格子,鸿蒙有
onProgressUpdate,就别再演了:
ts
const task = uni.downloadFile({ url, success: ..., fail: ... });
task.onProgressUpdate((res) => { progress.progress = res.progress; });
install用{ force: false }。旧链路里force: true+ 三秒后自动restart的组合在鸿蒙会直接打断用户,且官方链路根本不支持自动重启。
还有一个容易漏的并发去重:冷启动自动检查和用户立刻进"关于页"手动检查可能同时跑,用一个模块级标志位挡住,否则会出现两个下载任务叠加。
五、五个深坑
坑 1:整包升级被旧 WGT 永久遮蔽(最凶的一个)
现象 :热更装了 2.0.20,随后你上架了一个内置资源 2.0.21、versionCode 也从 1 升到 2 的新整包。用户覆盖安装后打开------跑的还是 2.0.20。登录态、本地存储都完好,看起来一切正常,只有代码是旧的。
根因 :直接逆向我们用的运行时(@dcloudio/uni-app-runtime,uni-mp-sdk/sdk.js):
js
function isWgtRelease(appId) { return fs.accessSync(getWWWPath(appId)); }
function isExistsApp(appId) { return isWgtRelease(appId); }
// getResourceStr: 只有 !isWgtRelease(appId) 时才把路径回退到 resfile/raw 内置资源
它只判断热更释放目录存不存在,完全没有版本比较。只要目录在,就永远优先加载那份资源,新整包内置的资源被架空。
对策(方案层收敛,别指望运行时自愈):
- 每次整包上架,同渠道同步下发一份与新包同源构建、版本高于线上任何 WGT 的热更包,让覆盖层内容和新包对齐;
- 卸载重装 / 清除数据是唯一的自然恢复路径,不能指望用户主动做;
- 把"WGT 版本线与整包版本线绑定递增"写进发布纪律。
排查这类"我明明发了新版却没生效",第一步就是查覆盖层目录:
powershell
hdc shell "ls -d /data/app/el2/100/base/<你的包名>/files/apps/<你的鸿蒙 AppId>/www"
# No such file or directory = 没有覆盖层,跑的是包内基线
坑 2:入口形态切换导致启动闪退
离线打包的鸿蒙工程里,入口 Ability 有两种形态:带 HMR 的调试形态和正式形态,靠继承不同的父类 区分(UniEntryAbilityDev vs UniEntryAbility)。
从调试切正式时,如果照搬调试子类里的手动打开调用,启动会抛未捕获 JS Error:
UniMP: <你的鸿蒙 AppId> already opened exit 254
因为正式父类的 onWindowStageCreate 内部已经做了 init + openUniApp ,你再手动 openUniApp 就是重复打开。两个父类构造签名一致,所以切换动作必须是:改继承目标 且 删掉子类的 hmr.init() / openUniApp() 两行 + 清理无用 import。
顺带回答一个常被问到的问题:这个入口形态能不能用前端 .env 控制?不能。 .env 的 VITE_* 由 Vite 烧进前端 bundle,入口 Ability 属于另一个仓库、由 hvigor 编译成 ArkTS,两者产物完全不相交;而 extends 是编译期静态决定的,运行期开关换不了父类。
我的做法是:仓库里永久提交正式形态,需要 HMR 调试时用脚本临时打 Dev 补丁、调完还原,补丁不进版本控制。这样"误打包带 HMR 的正式包"在物理上不可能发生。
坑 3:QA 域名被烧进正式包(编译期折叠)
产物核验时发现正式包里带着测试环境域名。两个原因:
.env里的"生产 API"变量本身被写成了测试域名(而且.env是全平台共用的,于是所有端的正式包都连了测试环境);- 更隐蔽的一个:
VITE_SYS_ENV这类分流键写在.env(无 mode 后缀)里,对所有构建生效。代码里import.meta.env.VITE_SYS_ENV === 'DEV'会被编译期直接折叠成 true,于是那些按它分流的业务域名------即使你走的是发行构建------也全指到了测试环境。
正确分层:
| 文件 | 进 git | 职责 |
|---|---|---|
.env |
是 | 默认值(开发友好,指向测试环境) |
.env.production |
是 | 只覆盖分流键,让发行构建走正式 |
.env.production.local |
否(.gitignore 用 *.local 忽略) |
本机临时把发行构建也拉回测试环境,便于联调 |
Vite 优先级:.env.production.local > .env.production > .env.local > .env。
并且要用产物指纹验证配置真的生效,而不是"我改了配置所以应该生效"。挑几个只属于某个环境的域名字符串,在产物里计数:
| 指纹 | 正式包 | 测试包 |
|---|---|---|
<prod-api.example.com> |
>0 | 0 |
<qa-api.example.com> |
0 | >0 |
⚠️ 别凭构建日志里打印的 XXX_BASE_URL>> ... 判断产物环境,那一行在发行构建里也会打印。
坑 4:取证手段本身会骗你
这个坑不修 bug,但会让你做出完全相反的错误决策。三次实测翻车:
Select-String在超长单行文件上假失效 。对一个 4MB、逻辑上只有极少行的app-service.js,Select-String -SimpleMatch -AllMatches对确定存在的字符串返回 0 命中,看起来像"代码没打进包"。正确做法是整文件读进来手动数:
powershell
$t = [IO.File]::ReadAllText($绝对路径)
$c = 0; $i = 0
while (($i = $t.IndexOf('关键字', $i)) -ge 0) { $c++; $i += '关键字'.Length }
$c
另外 -SimpleMatch 和 [regex]::Escape() 不能同时用 ,否则 \. 变成字面反斜杠,永远匹配不上。
-
编译缓存目录长期并存 。ArkTS 编译产物在
...\default@CompileArkTS\esmodule\debug\...与...\release\...两个目录里同时存在,另一个目录残留着几个月前的历史代码。必须选与本次 buildMode 一致的那个子目录,否则会读到旧版本代码并得出相反结论。 -
PowerShell 的
cd不改变 .NET 进程的当前目录 。[IO.File]::ReadAllText('相对路径')会抛异常,而脚本若没中止,下一行输出的就是上一轮残留变量------看起来"验证通过了",其实读的是旧数据。这类读取一律用绝对路径。
一条通用纪律:证明改动进了最终安装包,而不是进了某个中间目录。 中间产物目录可能残留数月前的陈旧文件,其时间戳和内容都不能作判断依据。要三重比对:
导出产物 app-service.js
└─ 原生工程 www/app-service.js (同步是否到位)
└─ HAP 中间产物 resfile/.../app-service.js(是否真打进包)
三者做 SHA256 比对即可。
坑 5:HMR 与生产热更是两条链路,必须隔离
开发期"运行到设备"触发的差量编译(HMR)和生产 WGT 资源安装走的是完全不同的机制。HMR 成功不代表热更新可行,反之,HMR 通道开着的时候测热更,结论无效。
另外:热更检查在开发态是不执行的------我的代码里第一行就 if (isDevEnv()) return;。所以验证热更必须用发行构建,这一点经常让测试同学误判为"热更没生效"。
六、发布纪律清单(可直接抄进 checklist)
- 先发一个具备热更能力的整包基线,用户装上后远程通道才打开。
- 整包上架必须同发同源 WGT(原因见坑 1),且版本高于线上任何 WGT。
- 资源版本单调递增,永不复用;壳版本与资源版本各自独立维护。
wgtUrl必须 https,且只从你自己可控的 CDN 下发。enabled: false只能阻止新增安装,已安装设备不会自动回滚。真要回滚,就下发一份"内容回退但版本号更高"的包。- 热更内容仍受应用市场政策约束,有技术支持 ≠ 免审核。
- 每次出包跑一遍产物指纹核验 + 三重 SHA256 一致性,不合格不装机。
七、还没做的事(诚实清单)
- WGT 完整性校验缺失 :目前是
downloadFile拿到临时文件直接install,没有 SHA256 比对,只信 https 通道。补的话要在配置里加sha256字段 + 引入鸿蒙的文件摘要能力。 - 白屏自恢复没有实现:热更包如果本身有问题导致白屏,设备端没有回退入口(因为运行时不会用版本比较去淘汰旧覆盖层)。需要额外设计原生启动健康检查 + 恢复入口。
- 灰度 / 分渠道 / 审计要自己做 :这套自建方案只有一个
enabled一刀切开关。要精细化灰度,要么给配置加白名单字段并同步改客户端校验,要么迁到官方 uni-upgrade-center(前提是你在维护 uniCloud 服务空间)。
八、如果你只记住三件事
- 覆盖安装新整包后,先查有没有旧 WGT 覆盖层------它不会自己消失,运行时不做版本比较。
- 正式包的环境要性靠产物指纹证明 ,不靠"我改过配置了"。
.env无 mode 后缀会污染所有构建,import.meta.env.X === '常量'会被编译期折叠。 install用force: false,生效靠quit()+ 下次启动核验标记 ,并且一定要核验appWgtVersion真的变了才算升级完成,别在安装成功回调里就直接记"已升级"。
本文是脱敏对外版,结论与取值以项目内部档案为准:《鸿蒙端 WGT 热更新:方案、实现与运维总结》与《鸿蒙端发版流程(整包基线 + WGT 热更新)操作手册》。