鸿蒙也能热更新了:uni-app WGT 热更新的真机结论、六道校验闸与五个深坑

关于本文的占位符:文中所有项目标识类信息均已脱敏,落地时请逐项替换为你自己的取值。

  • <你的鸿蒙 AppId>:鸿蒙原生应用的 DCloud AppId(不带 __UNI__ 前缀,形如 UNIXXXXXX)
  • <你的包名>:鸿蒙 bundleName(形如 com.example.app)
  • <热更新配置键>:你在自建后台新建的配置项 key(本文只用它代指你自己的那个键名,不出现真实取值)
  • <你的配置查询接口>:按 key 拉取配置值的接口
  • <prod-api.example.com> / <qa-api.example.com>:生产与测试环境域名

结论均在真机实测得到,具体取值与命令细节以你项目内部档案为准(见文末)。


先给结论

  1. 普通 uni-app(不是 uni-app x 蒸汽模式)在 HarmonyOS Next 上可以走 WGT 资源热更新 ,plus.runtime.install / getProperty / quit 实测均可用,连续两次热更成功。
  2. 最大风险不是"热更失败",而是"整包升级被旧 WGT 永久遮蔽" 。运行时只用"目录是否存在"判断有没有热更包,不做任何版本比较。这条我会在下面单独展开,它足以让你的新版本在用户手机上永远不生效。
  3. 热更新的生效方式 只有一个:安装成功后调 plus.runtime.quit() 让用户重开。官方链路在鸿蒙不支持自动重启,别指望 restart()。
  4. 第一个"具备热更能力的整包基线"必须先发出去,用户装上之后,远程热更通道才算打开。线上老包根本不读你的热更配置。

一、先划清能力边界

很多人第一步就搞错了范围,把原生改动塞进热更包里。

能走 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 内置资源

它只判断热更释放目录存不存在,完全没有版本比较。只要目录在,就永远优先加载那份资源,新整包内置的资源被架空。

对策(方案层收敛,别指望运行时自愈):

  1. 每次整包上架,同渠道同步下发一份与新包同源构建、版本高于线上任何 WGT 的热更包,让覆盖层内容和新包对齐;
  2. 卸载重装 / 清除数据是唯一的自然恢复路径,不能指望用户主动做;
  3. 把"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 域名被烧进正式包(编译期折叠)

产物核验时发现正式包里带着测试环境域名。两个原因:

  1. .env 里的"生产 API"变量本身被写成了测试域名(而且 .env 是全平台共用的,于是所有端的正式包都连了测试环境);
  2. 更隐蔽的一个: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,但会让你做出完全相反的错误决策。三次实测翻车:

  1. 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() 不能同时用 ,否则 \. 变成字面反斜杠,永远匹配不上。

  1. 编译缓存目录长期并存 。ArkTS 编译产物在 ...\default@CompileArkTS\esmodule\debug\... 与 ...\release\... 两个目录里同时存在,另一个目录残留着几个月前的历史代码。必须选与本次 buildMode 一致的那个子目录,否则会读到旧版本代码并得出相反结论。

  2. 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)

  1. 先发一个具备热更能力的整包基线,用户装上后远程通道才打开。
  2. 整包上架必须同发同源 WGT(原因见坑 1),且版本高于线上任何 WGT。
  3. 资源版本单调递增,永不复用;壳版本与资源版本各自独立维护。
  4. wgtUrl 必须 https,且只从你自己可控的 CDN 下发。
  5. enabled: false 只能阻止新增安装,已安装设备不会自动回滚。真要回滚,就下发一份"内容回退但版本号更高"的包。
  6. 热更内容仍受应用市场政策约束,有技术支持 ≠ 免审核。
  7. 每次出包跑一遍产物指纹核验 + 三重 SHA256 一致性,不合格不装机。

七、还没做的事(诚实清单)

  • WGT 完整性校验缺失 :目前是 downloadFile 拿到临时文件直接 install,没有 SHA256 比对,只信 https 通道。补的话要在配置里加 sha256 字段 + 引入鸿蒙的文件摘要能力。
  • 白屏自恢复没有实现:热更包如果本身有问题导致白屏,设备端没有回退入口(因为运行时不会用版本比较去淘汰旧覆盖层)。需要额外设计原生启动健康检查 + 恢复入口。
  • 灰度 / 分渠道 / 审计要自己做 :这套自建方案只有一个 enabled 一刀切开关。要精细化灰度,要么给配置加白名单字段并同步改客户端校验,要么迁到官方 uni-upgrade-center(前提是你在维护 uniCloud 服务空间)。

八、如果你只记住三件事

  1. 覆盖安装新整包后,先查有没有旧 WGT 覆盖层------它不会自己消失,运行时不做版本比较。
  2. 正式包的环境要性靠产物指纹证明 ,不靠"我改过配置了"。.env 无 mode 后缀会污染所有构建,import.meta.env.X === '常量' 会被编译期折叠。
  3. install 用 force: false,生效靠 quit() + 下次启动核验标记 ,并且一定要核验 appWgtVersion 真的变了才算升级完成,别在安装成功回调里就直接记"已升级"。

本文是脱敏对外版,结论与取值以项目内部档案为准:《鸿蒙端 WGT 热更新:方案、实现与运维总结》与《鸿蒙端发版流程(整包基线 + WGT 热更新)操作手册》。

相关推荐
掘黄金的打工人3 小时前
DeepSeek 参数解读
openai·ai编程
金銀銅鐵3 小时前
[Java] 用纯 Swing 做一个《論語·學而》阅读器
java·ai编程·交互设计
李剑一3 小时前
为什么几乎所有的知名开源项目都是外国人写的?
前端·ai编程·deepseek
threerocks4 小时前
土豆姐 Lauren 关于 Grok Bot 团队打造指南
aigc·openai·ai编程
9i编程5 小时前
用 Codex 改代码,依据是什么?
人工智能·openai·ai编程
尘世中一位迷途小书童7 小时前
别再堆万字 Prompt:用 Agent Skills 给团队写一个「前端 Review」Skill
前端·ai编程·cursor
ZzT7 小时前
vibe coding 做小游戏·打包篇:发 Mac 试玩包、查启动慢,和一次 AI 自己改出来的崩溃
ai编程·游戏开发
LV技术派7 小时前
【成为 AI Native Builder · 02】什么是 AI Native Builder:一个人 + AI,交付完整闭环
前端·程序员·ai编程
GoldenSpider.AI8 小时前
一张户型图做成浏览器 3D 漫游:house.json 统一模型、1.2 m 剖切图纸与分阶段验收
ai编程·webgl·three.js·3d漫游·claude code