上周有兄弟在群里问uni.getUpdateManager()在 App 端怎么死活不回调这问题我见过不下十次答案其实有点扎心——它本来就不该在 App 端生效。那个 API 是微信小程序专用的一套东西你把它搬到 App 上就像拿着地铁卡去刷小区门禁接口长得再像也不是一个体系。App 端的更新逻辑得自己搭而且要分成两条完全不同的链路一条是整包更新重新下载 APK 或跳应用商店另一条是热更新下发一个 wgt 资源包把页面和 JS 换掉。这两条链路的适用场景、实现细节、审核风险都不一样混着用必然出事。下面我把这套东西从头到尾拆一遍包括版本比对、下载安装、权限配置、白屏回滚这些绕不开的环节代码都是可以直接抄走改的。1. 先厘清一个高频误解App 端没有更新管理器很多人第一次做 uniapp 的 App 更新第一反应是去翻官方文档找现成 API然后找到uni.getUpdateManager()兴冲冲地接进去结果在真机上一点反应都没有。这不是兼容性问题是认知错位。1.1 三种更新通道别搞混了App 端的更新其实有三条完全独立的通道各自管各自的事应用市场通道用户自己去华为、小米、OPPO、vivo、应用宝这些市场点更新。这是最省事、审核风险最低的方式但触达率完全看用户心情。整包更新通道App 自己检测到新版本下载新的 APK 安装包然后拉起系统安装器覆盖安装。iOS 上做不到覆盖安装只能跳 App Store 详情页。wgt 热更新通道只替换www目录下的前端资源JS、CSS、页面模板、静态图片原生部分不动。用户感知是打开 App 就变了几乎没有安装等待。uni.getUpdateManager()属于小程序体系的代码包更新小程序平台自己做了版本管理你调个 API 就能拉新包。App 端没有这层平台托管服务端接口、下载、安装、重启全套都得自己写。1.2 什么改动只能走整包什么改动可以走 wgt这个边界如果划不清楚上线之后一定会出事故。判断标准只有一条改动是否触及原生层。必须走整包的改动新增或移除了原生插件比如你从没用推送变成要接厂商推送修改了manifest.json里影响原生配置的部分比如 appid、模块权限、启动图、权限声明、iOS 的 URL Schemes升级了 HBuilderX 的打包基座版本或者改了 targetSdkVersion更换了应用图标、启动页这类打包时就固化进去的资源可以走 wgt 的改动页面结构调整、样式修改、业务逻辑调整新增纯前端页面静态图片、字体、配置文件的替换大部分 JS 层面的 bug 修复我一般跟团队的说法是只要改了 manifest 里跟原生相关的字段或者动了 nativeplugins 目录就别想热更新了。硬要热更新用户装完之后表现会非常诡异——要么插件调用直接报错要么权限弹窗不出来排查起来极度痛苦。1.3 选型对照表维度整包更新wgt 热更新生效范围全部代码与原生配置仅前端资源用户操作需确认安装有系统安装界面无感或仅一次重启包体积通常 20MB 起通常 1~10MBiOS 支持只能跳 App Store支持但需注意审核条款强制更新能力支持可拦截使用支持可拦截使用回滚难度低发新包即可中需发更高版本号的 wgt审核风险低中部分市场对热更新有额外要求实际项目里我的建议是两条链路都要保留主链路走整包跟着应用市场节奏走wgt 用来做紧急 bug 修复和小的运营活动页快速上线。2. 更新链路的地基版本号、manifest 与接口设计更新功能最容易翻车的地方不是下载和安装而是版本号。我见过好几个项目代码写得漂漂亮亮结果因为版本号规则混乱导致用户装完新版还被反复提示更新或者干脆检测不到新版本。2.1 versionCode 与 versionName 谁说了算manifest.json 里有两个版本相关字段{ versionName: 1.4.2, versionCode: 10402 }versionName是给用户看的形如1.4.2展示在更新弹窗里。versionCode是给程序判断的纯数字必须单调递增Android 系统安装新包时也会用它做校验。我强烈建议版本比对逻辑优先用 versionCode只在展示时用 versionName。为什么因为1.10.0和1.9.0用字符串比较会得出错误结果用分段数字比较虽然能解决但只要有人手抖写了个1.10和1.10.0又会出问题。纯数字的 versionCode 没有这些歧义。如果你确实要用 versionName 做比对比如服务端接口只返回它那必须写一套健壮的分段比较算法后面第 3 节我会给完整实现。2.2 manifest.json 里必须提前配置的几项很多坑是在打包那一刻就埋下的改代码救不回来。云打包之前在 HBuilderX 里把这几个地方确认一遍应用版本名称 / 版本号每次发版必须手动往上加别指望自动。App 权限配置Android 侧至少要勾上REQUEST_INSTALL_PACKAGESAndroid 8.0 以上安装 APK 必须、WRITE_EXTERNAL_STORAGE、READ_EXTERNAL_STORAGE。少了第一个整包更新一定会卡在下载完了没反应。Android 包名一旦上架就不能改改了等于换了一个 App。iOS Bundle ID同理。网络请求域名更新接口和下载地址必须走 HTTPS。Android 9 以后系统默认禁止明文 HTTP 请求iOS 的 ATS 策略也一样用 HTTP 会直接被拦掉而且报错信息非常隐晦。注意云打包时 manifest.json 的可视化界面和源码视图是同一份数据的不同投影。改权限建议直接切到源码视图改可视化界面偶尔会漏显示某些权限项。2.3 服务端接口该返回什么字段更新接口的返回结构设计得好客户端逻辑能少写一半。我用的这套字段结构跑了三个项目都没出过问题{ code: 0, data: { latestVersionName: 1.5.0, latestVersionCode: 10500, minVersionCode: 10300, updateType: wgt, wgtUrl: https://cdn.xxx.com/app/1.5.0_10500.wgt, wgtSize: 2458112, wgtMd5: a1b2c3d4..., pkgUrl: https://cdn.xxx.com/app/1.5.0_10500.apk, pkgSize: 31457280, pkgMd5: e5f6g7h8..., forceUpdate: false, releaseNote: 1. 修复了订单页在部分机型上的闪退\n2. 新增优惠券批量选择, publishedAt: 2025-03-11 10:20:00 } }几个字段的设计意图说明一下minVersionCode是最低可用版本低于它的客户端必须强制整包升级。这个字段是兜底用的——当某次更新涉及了无法热更新兼容的原生改动时你必须保证老版本用户不会拿到一个跑不起来的新资源包。updateType让服务端决定这次走 wgt 还是整包客户端不做判断。这样做的好处是更新时间点的控制权在服务端出问题时可以把 updateType 从 wgt 切成 pkg客户端不用发版。wgtMd5/pkgMd5用于下载后校验完整性。移动网络环境下下载中断、CDN 返回错误页的情况并不罕见如果没有校验你会把一个残缺文件丢给安装接口然后得到一句毫无信息量的失败回调。forceUpdate和minVersionCode是两级强更策略前者是这个版本建议都升后者是你不升就没法用了。两个都返回布尔/数字值客户端做或运算。3. 版本比对逻辑别小看这十几行代码版本比对看起来是幼儿园难度的活但我确实见过线上事故是从这里出的。3.1 分段数字比较的正确实现先把坑摆出来。下面这段是网上流传最广的错误写法// 错误示范不要抄 if (currentVersion latestVersion) { // 提示更新 }字符串比较的问题在于字典序。1.9.0 1.10.0在字典序下是false因为逐字符比较时1 1然后. .再然后9 1直接判定左边大。结果就是 1.9.0 的用户永远收不到 1.10.0 的更新提示。正确的实现是按点号切分再逐段比较数字/** * 比较两个版本号 * returns 1 表示 v1 v2-1 表示 v1 v20 表示相等 */ function compareVersion(v1, v2) { const arr1 String(v1 || 0).split(.).map(n parseInt(n, 10) || 0) const arr2 String(v2 || 0).split(.).map(n parseInt(n, 10) || 0) const len Math.max(arr1.length, arr2.length) for (let i 0; i len; i) { const a arr1[i] || 0 const b arr2[i] || 0 if (a b) return 1 if (a b) return -1 } return 0 }parseInt(n, 10) || 0这个写法是有意为之的它能同时消化undefined、空字符串、非法字符这三种脏数据。真实项目里服务端返回的版本号字段你敢保证它永远是干净的1.5.0格式吗我见过带后缀的1.5.0-beta也见过多打一个点的1.5..0。加上这层兜底至少不会把整个 App 卡在更新检测环节。3.2 强更、弱更、忽略三种弹窗分支拿到比对结果之后不要急着弹窗先把分支理清楚function checkUpdate(remote) { const currentCode plus.runtime.versionCode // 注意这是 5 的运行时版本号 // 分支一低于最低可用版本强制整包更新无取消按钮 if (remote.minVersionCode currentCode remote.minVersionCode) { return showUpdateDialog(remote, { force: true, type: pkg }) } // 分支二有新版本按服务端指定的类型走 if (currentCode remote.latestVersionCode) { // 用户之前点过忽略此版本就不再打扰 const ignored uni.getStorageSync(ignored_version_code) if (!remote.forceUpdate String(ignored) String(remote.latestVersionCode)) { return } return showUpdateDialog(remote, { force: !!remote.forceUpdate, type: remote.updateType }) } // 分支三已是最新什么都不做 }这里有个细节值得展开plus.runtime.versionCode返回的是当前 App 的运行时版本号。如果你用了 wgt 热更新这个值会不会变答案是不会自动变。wgt 包的版本号是独立的一套需要用plus.runtime.getProperty去读。plus.runtime.getProperty(plus.runtime.appid, (info) { // info.version 是 wgt 资源包的版本号 // info.name 是应用名称 console.log(wgt version:, info.version) })所以如果你打算用 wgt 做常态化更新服务端比对的时候要同时拿到整包版本号和wgt 版本号两个值用两者中较大的那个作为当前版本。这块逻辑如果一开始没设计好后期补起来会很别扭。3.3 检查时机与节流策略更新检测放在哪里触发直接影响接口压力和用户体验。我的做法是冷启动必查在App.vue的onLaunch里延迟 1 秒左右调用等plus环境完全就绪。热启动节流查在onShow里记录时间戳与上次检查间隔超过 10 分钟才发请求。手动查在设置-关于我们页面放一个检查更新入口用户可以主动触发。// App.vue let lastCheckTime 0 const CHECK_INTERVAL 10 * 60 * 1000 export default { onLaunch() { // #ifdef APP-PLUS setTimeout(() this.tryCheckUpdate(), 1000) // #endif }, onShow() { // #ifdef APP-PLUS const now Date.now() if (now - lastCheckTime CHECK_INTERVAL) { this.tryCheckUpdate() } // #endif }, methods: { tryCheckUpdate() { lastCheckTime Date.now() // 调用更新服务 } } }#ifdef APP-PLUS条件编译一定要加。同一份代码可能还要编译成小程序或 H5plus对象在这些平台根本不存在不加条件编译会直接报plus is not defined。4. 整包更新落地从下载 APK 到拉起安装整包更新这条链路里Android 和 iOS 是两套完全不同的剧本。Android 能下载能安装iOS 只能跳商店所以第一步永远是先判断平台。4.1 平台判断与 Android 下载实现const platform uni.getSystemInfoSync().platform // android | ios if (platform ios) { // iOS 只跳 App Store plus.runtime.openURL(itms-apps://itunes.apple.com/cn/app/id1234567890) return } // Android 走下载安装流程 downloadAndInstall(remote.pkgUrl)Android 的下载我用uni.downloadFile它自带进度回调比plus.downloader简单而且能拿到临时文件路径function downloadApk(url, onProgress) { return new Promise((resolve, reject) { const task uni.downloadFile({ url, success: (res) { if (res.statusCode 200) { resolve(res.tempFilePath) } else { reject(new Error(HTTP res.statusCode)) } }, fail: (err) reject(err) }) task.onProgressUpdate((p) { onProgress onProgress(p.progress, p.totalBytesWritten, p.totalBytesExpectedToWrite) }) }) }进度条这块有个体验细节onProgressUpdate在网速波动时会来回跳直接绑定到 UI 上看着很难受。我的处理方式是对进度值做一次单调递增约束——只允许变大不允许回退let lastProgress 0 task.onProgressUpdate((p) { const cur Math.max(p.progress, lastProgress) lastProgress cur onProgress(cur) })这样进度条就是一条平滑上升的曲线用户不会怀疑自己网络出了问题。4.2 Android 安装权限八成的安装没反应都出在这下载成功之后调安装接口一行代码的事plus.runtime.install( apkPath, { force: true }, () { plus.runtime.restart() }, (err) { uni.showModal({ title: 安装失败, content: JSON.stringify(err), showCancel: false }) } )但真正让这条链路跑起来关键在权限。Android 8.0API 26引入了REQUEST_INSTALL_PACKAGES这个特殊权限应用内安装 APK 必须声明它否则系统会静默拦截安装流程——注意是静默你的 fail 回调里可能什么都不返回或者只返回一个模糊的错误码排查起来很折磨。在manifest.json的源码视图里找app-plus.distribute.android.permissions节点确认包含permissions: [ uses-permission android:name\android.permission.REQUEST_INSTALL_PACKAGES\/, uses-permission android:name\android.permission.WRITE_EXTERNAL_STORAGE\/, uses-permission android:name\android.permission.READ_EXTERNAL_STORAGE\/ ]另外在 HBuilderX 云打包界面Android 权限配置里也要勾选对应的项两边保持一致。还有一个路径上的坑。uni.downloadFile返回的tempFilePath形如_doc/uniapp_temp/xxx.apk这是 5 的相对路径格式。绝大多数情况下plus.runtime.install能直接识别但如果遇到个别机型安装失败可以用plus.io.convertLocalFileSystemURL转成系统绝对路径再传const absPath plus.io.convertLocalFileSystemURL(tempFilePath)我实测在华为、小米、vivo 的部分机型上转换之后的路径兼容性更好。这个操作成本很低建议直接加上。4.3 iOS 上你能做的事其实很少iOS 系统层面就不允许应用自己安装 ipa 包这是系统沙盒机制决定的不是 uniapp 的限制。所以 iOS 端的整包更新只有一个动作跳转 App Store 详情页。// 用 itms-apps 协议会直接唤起 App Store 应用 plus.runtime.openURL(itms-apps://itunes.apple.com/cn/app/id你的应用ID)如果用户没装 App Store极少见可以降级用https://apps.apple.com/cn/app/idxxx。iOS 端真正值得关注的是wgt 热更新的审核风险。苹果的开发者条款里对下载并执行会改变应用功能的代码是有约束的。实践经验是wgt 只用来修复 bug、调整 UI不做大功能变更一般不会触发审核问题但如果你的热更新包每次都在加新功能模块被审核发现是有下架风险的。提示iOS 端做热更新建议把 wgt 的更新内容控制在小范围内并且不要做绕过审核上架新功能这种事。4.4 应用市场审核里跟更新相关的那几条规则国内几家应用市场对应用内更新都有自己的要求踩过线的话会被拒审理由通常写得比较含糊。我的经验是不要在应用内引导用户跳转到第三方下载渠道安装 APK尤其是非自家域名的下载地址。强更弹窗不要做成无法关闭且无法退出 App的形式至少提供一个退出入口否则容易被判定为骚扰用户。各大市场现在都推荐应用跟随市场自身的更新通道如果你用了应用内整包更新最好保留同时跳转市场的选项。最稳妥的策略其实是双通道并存应用内检测到新版本时优先跳转到用户当前手机所属的应用市场详情页比如华为手机跳华为市场小米手机跳小米市场跳转失败再降级到自己的 APK 下载。5. wgt 热更新制作、下发、安装、重启的完整链路热更新是 uniapp 在 App 端最有价值的能力之一也是坑最集中的地方。整条链路涉及四个环节打包、上传、下载、安装重启每一环都有讲究。5.1 wgt 包怎么打版本号为什么要往上加制作 wgt 包在 HBuilderX 里操作菜单栏发行→原生App-制作应用wgt包。生成的文件名通常形如__UNI__XXXXXX_1.5.0.wgt。这里有个必须理解清楚的机制wgt 包自带版本号安装时系统会比对版本。如果你打的 wgt 包版本号跟当前设备上的相同或者更低plus.runtime.install会直接失败。所以每次做热更新都要先把manifest.json里的versionName往上加一位比如从 1.5.0 加到 1.5.1再打包。这一点跟整包更新的版本号是同一套字段所以实际操作中会出现一个情况你为了发一个热更新把 versionName 从 1.5.0 改成了 1.5.1但对应的 APK 并没有重新打包上架。下次你再发整包时versionName 已经是 1.5.1 了得继续往上加。这套规则要在团队内说清楚否则两个人同时改很容易出现版本号打架。我的做法是维护一个简单的版本记录表每次发版记一行日期版本号versionCode更新类型内容03-111.5.010500整包新增优惠券模块03-131.5.110501wgt修复订单页闪退03-201.5.210502wgt活动页改版04-021.6.010600整包接入新原生插件有这张表在版本号基本不会乱。另外提醒一句wgt 包的体积要控制。我一般建议不要超过 10MB超过 20MB 就该考虑直接发整包了。原因是热更新包越大下载失败率越高而且用户重启 App 的等待时间也越长体验反而比走应用市场更新更差。5.2 install 与 restart 的时机选择安装和重启是两个动作可以分开做这个设计其实挺巧妙的function installWgt(filePath) { plus.runtime.install( filePath, { force: false }, () { // 安装成功但还没生效 uni.showToast({ title: 更新已就绪, icon: none }) setTimeout(() { plus.runtime.restart() }, 1200) }, (err) { uni.showModal({ title: 热更新失败, content: JSON.stringify(err), showCancel: false }) } ) }plus.runtime.restart()会重启整个 App用户看到的是短暂的启动页然后就是新版本了。实测在大多数机型上这个过程在 1 秒内完成体验上可以接受。如果你想做得更无感可以在 install 成功后不立即 restart而是等用户下次冷启动时自动生效。这样用户完全感知不到重启代价是这次更新的内容要等到下次打开 App 才起作用。对于非紧急的更新这种静默模式体验更好。还有一种更激进的方案叫静默下载在后台把 wgt 下载好连 install 都做了但只提示一句新版本已准备好重启后生效把重启的决定权交给用户。我一般会在设置页面放一个立即重启生效的按钮。5.3 force 参数到底影响什么plus.runtime.install的第二个参数force有点容易误解。它的作用不是强制用户更新而是跳过版本号检查强制安装。force: false默认安装前会检查 wgt 包的版本号是否大于当前版本不大于就拒绝安装回调 fail。force: true不管版本号直接安装覆盖。什么时候需要用true比如你在测试阶段想反复用同一个 wgt 包往设备上装这时候用true会方便很多。但生产环境强烈建议用false因为一旦服务端配置出错把一个低版本的 wgt 推下来force: true会让用户设备上的代码直接回退后果很难收拾。5.4 热更新白屏之后怎么救回来白屏是热更新最可怕的事故因为它表现为App 打不开了用户除了卸载重装没有别的办法。我遇到过两次白屏原因不一样但排查思路可以复用第一次是资源路径问题。wgt 里的某个静态资源用了绝对路径/static/xxx.png而实际打包后路径不带前导斜杠导致资源 404。这种情况不会影响 JS 执行页面能渲染只是图片挂掉。真正致命的是第二种。第二次是 nvue 页面的兼容问题。项目里有两个 nvue 页面热更新后其中一个直接白屏。原因是 nvue 的渲染依赖原生层的某些配置wgt 只换了前端资源两边对不上。最后的解决方案是——nvue 页面不做热更新涉及 nvue 改动的版本全部走整包。所以我现在给自己定了几条规矩打 wgt 之前必须在真机上完整走一遍核心流程尤其是所有 nvue 页面和原生插件调用点。wgt 包必须先在 10% 的灰度用户里放一两天再全量。服务端随时保留撤回能力——发现白屏立刻把updateType切成pkg或者直接把latestVersionCode调回低值。真出现白屏了怎么办如果 App 已经装上了有问题的 wgt还有一个补救手段发一个更高版本号的修复 wgt。因为设备上的版本号已经被推高了你只能往上加。这就是为什么第 5.1 节强调版本号记录表的原因慌乱之中很容易把版本号搞错。6. 那些年踩过的坑完整排查链路前面讲的都是正常应该怎么做但真实项目里 80% 的时间花在为什么不生效上。把这几个高频问题的排查链路记下来能省很多时间。6.1 下载完了安装毫无反应这是最高频的问题。排查顺序按下面走第一步看权限配置。打开打包后的 APK用aapt dump permissions或者直接在手机上查看应用信息确认REQUEST_INSTALL_PACKAGES在列。这一步能解决大部分问题。第二步看下载的文件是否完整。在 fail 回调里把文件大小打出来跟服务端返回的pkgSize比一下。如果明显偏小说明下载中断了或者 CDN 返回了一个错误页面比如 302 之后的 HTML 页面状态码还是 200。第三步看文件的临时目录。uni.downloadFile下载的文件放在_doc/uniapp_temp目录下这个目录理论上不会被执行权限拦截。但有个例外情况——某些厂商定制系统对从临时目录安装应用有额外限制这时候把文件复制到_downloads目录再安装// 复制到 _downloads 目录再安装兼容性更好 plus.io.resolveLocalFileSystemURL(tempFilePath, (entry) { entry.copyTo( plus.io.resolveLocalFileSystemURL(_downloads), fileName, (newEntry) { plus.runtime.install(newEntry.toLocalURL(), { force: true }, onSuccess, onFail) }, onFail ) }, onFail)第四步看系统设置。有些手机尤其小米、OPPO需要用户在设置-应用管理-你的App-安装未知应用里手动开启权限开关。这种情况下你应该在发起安装前先做一个检测引导用户去开启。6.2 热更新成功了但页面一点没变这个现象也常见通常有三类原因。原因一wgt 装上了但没重启。plus.runtime.install成功之后资源文件其实已经替换了但当前运行的 JS 上下文还在内存里不重启就不会加载新资源。所以在 install 成功后一定要做restart或者明确提示用户重启后生效。原因二浏览器缓存或者是旧的 JS 被内存缓存。Webview 在某些情况下会缓存 JS 文件。这种情况下换一个页面路径名比如从/pages/index/index改成/pages/index/index2能绕过缓存但这明显是治标不治本。更好的做法是在打包配置里给静态资源加 hash 指纹不过 uniapp 的默认打包行为这块控制粒度有限实际影响也没那么严重。原因三你改的东西被原生层覆盖了。比如你在 JS 里改了某个页面的标题栏颜色但 manifest 里配置了原生导航栏热更新之后原生配置仍然生效看起来就像没更新。这类问题只能靠整包解决。6.3 版本号回退导致的更新死循环设想这个场景服务端运维误操作把latestVersionCode从 10502 改成了 10500。此时设备上装了 10502 的用户会发现当前版本大于服务端返回的版本正常逻辑应该是什么都不做。但如果你的代码是这么写的// 错误示范 if (currentVersion ! latestVersion) { showUpdateDialog() }那就完蛋了——它会把服务端版本比本地低也识别为需要更新然后开始下载 10500 的包安装时系统又会因为版本号低而拒绝形成死循环。正确的逻辑必须包含服务端版本更高才提示这个前置判断if (compareVersion(remote.latestVersionName, currentVersionName) 0) { return // 服务端版本不高于本地直接返回 }另外服务端侧也应该做防护版本号字段只允许人工递增出错了靠发新版本修正不允许直接改小。6.4 下载中断和 CDN 的隐蔽问题移动网络环境下下载中断很常见。我这边的标准做法是下载前先检查本地是否已经有同版本的完整包用 MD5 校验有就直接用避免重复下载。下载失败后提供重试按钮最多重试 3 次并且第二次重试时换一个下载地址如果服务端提供了备用 CDN。下载完成后必须做 MD5 校验校验不通过就删掉文件重新下载。CDN 还有个坑值得单独说有些 CDN 对未备案的域名或者频繁请求的 IP 会返回一个访问受限的 HTML 页面HTTP 状态码是 200。如果你的代码只判断了statusCode 200就继续安装会拿一个 HTML 文件去给安装器然后得到一个莫名其妙的错误。所以 MD5 校验这一步不能省// #ifdef APP-PLUS function checkMd5(filePath, expectedMd5) { return new Promise((resolve) { plus.io.resolveLocalFileSystemURL(filePath, (entry) { // 这里需要配合原生插件或者服务端二次校验 // 简单场景下可以直接比对文件大小 entry.getMetadata((meta) { resolve(meta.size) }) }) }) } // #endif纯前端算 MD5 需要引入第三方库会增大包体积。如果项目里已经有 crypto 相关的库可以复用如果没有一个折中的方案是用文件大小做初步校验服务端返回pkgSize比对误差在 1KB 以内视为正常基本能拦住 CDN 返回 HTML 的情况。7. 上线前的自检清单与工程化建议更新功能有个特点测试阶段怎么点都没问题一上线就开始出各种妖蛾子。因为它依赖真实的网络环境、真实的设备、真实的安装权限状态这些在开发机上很难完全模拟。7.1 一份可以直接抄的检查表每次发版前我会照着这张表过一遍检查项检查方式常见问题versionName / versionCode 是否递增对比上一版本记录忘记改热更新直接失败manifest 权限是否完整打包后查 APK 权限列表缺 REQUEST_INSTALL_PACKAGES更新接口是否为 HTTPS浏览器直连测试HTTP 被系统拦截wgt 包能否在真机安装手动触发更新版本号相同导致 failiOS 跳转链接是否有效真机点击应用 ID 写错强更弹窗是否可退出真机操作被应用市场判定骚扰下载进度条是否正常弱网环境测试进度回跳更新后核心流程是否正常全量回归nvue 页面白屏灰度开关是否可用改服务端配置验证无法紧急止损这张表里我最看重的是最后两项。更新功能的价值不只是能更新更是出问题时能马上停。所以服务端必须有能力一键关闭更新推送这个开关的成本很低但真出事的时候能救命。7.2 代码组织上的一点建议更新相关逻辑不要散落在各个页面里统一收口到一个模块。我通常的组织方式是utils/ update/ index.js // 对外统一入口 checkUpdate() compare.js // 版本比对 download.js // 下载 进度 校验 installer.js // 安装区分 apk / wgt / iOS 跳转 dialog.js // 弹窗 UI这样做的直接好处是条件编译好管理。所有跟plus相关的代码集中在一个目录其他部分完全不需要关心平台差异#ifdef APP-PLUS也不会到处乱撒。另外一个工程化上的小细节把是否已经提示过该版本的状态存在本地 storage 里key 用ignored_version_code值是用户点击忽略的那个 versionCode。这样既能避免重复打扰用户又能在下一个版本到来时自动重新提示——因为 versionCode 变了key 对应的值自然就对不上了。我自己在这块踩的最大的一个坑是早期版本里把检查更新写在了首页的onShow里。首页每次从子页面返回都会触发 onShow用户进进出出几次就被弹了三四次更新提示反馈非常差。后来改成在 App.vue 的 onShow 里统一做并且加了 10 分钟的时间间隔问题才解决。所以更新检测的位置一定要放在 App 级别不要放页面级别。最后分享一个实操上的小技巧在开发的调试阶段可以在 App 里藏一个隐藏入口比如连续点击关于我们里的版本号 7 次点开后能手动输入一个 wgt 包的 URL 直接下载安装。这个入口在测试热更新流程时极其方便不用每次都去搭服务端接口也能让测试同学自己在真机上验证。上线前记得把这个入口用条件编译屏蔽掉或者加入一个白名单判断。