1. 先分清“自动检测、静默更新、强制更新”这三件事
维护一个 UniApp 打包出来的 App,最容易被忽视的就是更新模块。很多团队上线后只做一件事:改版本号,打新包,传到分发平台。用户那边能不能收到、什么时候收到、收到之后愿不愿意点,基本靠天吃饭。我之前接手的几个项目也全是这种状态,后来有一次线上出了个支付回调相关的 bug,修复版本发了三天,还有将近一半用户停留在旧版本上,我才决定把 App 端的更新链路重新做一遍。
先说清楚概念。App 更新不是一个单一动作,至少可以拆成三件独立的事:
- 自动检测:App 启动或回到前台时,主动问一次服务端“现在有没有我能用的新版本”。
- 静默更新:用户无感知地下载并应用更新包,通常是针对前端资源的增量包,也就是 uni-app 里的 wgt 资源包。
- 强制更新:服务端明确告诉你“必须升级才能继续用”,客户端不给你“暂不更新”的入口。
很多人把这三件事混在一起,以为“检测到新版本弹个窗”就是更新。实际上,弹窗只是最低配置。真正要做的是一套可进退的更新策略:能静默的就静默,该提示的才提示,必须强制的一点商量余地都不给。
三种常见更新方式的区别,我用一张表先放在这里:
| 维度 | 整包更新(APK/IPA) | wgt 资源包更新 |
|---|---|---|
| 更新内容 | 原生代码、插件、资源全部更新 | 主要是前端资源、JS、图片、配置 |
| 是否需要重新安装 | 需要,Android 走系统安装器,iOS 走应用商店 | 不需要,App 内部热更新后重启 |
| 用户感知 | 强,必须用户确认或跳转 | 弱,可以做到无感 |
| 审核风险 | 按各平台正常流程走 | iOS 端有合规风险,通常只用于 Android 内部分发 |
| 适用场景 | 原生模块变更、SDK 升级、大版本发版 | 修复页面逻辑、改动 UI、调整接口地址等纯前端变更 |
我第一次接触 wgt 静默更新时也有疑问:既然资源包能更新,为什么还要整包更新?后来踩了坑才明白,wgt 更新不涉及原生层。如果你新增了一个原生插件,或者改了原生配置,wgt 包根本覆盖不了。这种时候只能走整包更新。
所以更新方案设计的第一步不是写代码,而是先想清楚“这次改了什么”。改前端资源,走 wgt;改了原生能力,走整包。
2. 服务端版本协议设计:字段和判定逻辑决定了客户端多省事
UniApp 的客户端只负责执行更新,但“要不要更新”“是静默还是强制”这两个判断最好不要写在客户端里。我见过很多项目在客户端自己比版本号,各种正则、split('.') 写得眼花缭乱,最后一样翻车。
原因是版本号比较没有想象中简单。语义化版本1.10.0和1.9.2,用字符串比较1.10.0 < 1.9.2就是错的。再加上 Android 端还有 versionCode 这种整型版本号,iOS 又有 build 号,两边版本体系不是一回事。与其在客户端维护一套比较逻辑,不如让服务端直接给结论。
我用的协议长这样:
{ "code": 0, "message": "ok", "data": { "hasUpdate": true, "updateType": "force", "targetVersion": "1.3.0", "targetBuild": 2024091801, "minVersion": "1.1.0", "packageUrl": "https://cdn.example.com/app/1.3.0.wgt", "androidApkUrl": "https://cdn.example.com/app/1.3.0.apk", "iosAppUrl": "https://apps.apple.com/cn/app/your-app/id0000000000", "releaseNote": "修复支付回调异常并优化启动速度", "md5": "a1b2c3d4e5f6..." } }字段不多,但每个都有用途:
hasUpdate:客户端最关心的结果,避免自己比较版本。updateType:silent表示静默更新,prompt表示普通提示更新,force表示强制更新。targetVersion/targetBuild:目标版本号,给用户看,也给客户端做缓存判断。minVersion:最低允许版本。这个字段对强制更新尤其重要,后面细说。packageUrl:wgt 资源包地址。androidApkUrl/iosAppUrl:整包更新时使用。Android 可以放 APK 下载地址,iOS 放应用商店地址。md5:下载包完整性校验用,能拦掉不少因 CDN 缓存或半包下载导致的“更新后白屏”问题。
服务端判定逻辑大概是这样的:
- 客户端把当前
appId、platform、version、build传上来。 - 服务端查一下该平台该产品线的发布记录。
- 如果当前版本低于线上最新版本,返回
hasUpdate: true。 - 如果当前版本的 build 号低于服务端设置的强制更新门槛,
updateType直接给force。 - 否则根据运营配置给
silent或prompt。
为什么强制更新门槛要用单独的minVersion,而不是简单让最新版设置为 force?因为强制更新往往不可能只针对某一个版本生效。比如线上最新版是 1.3.0,但 1.0.0 的老版本有兼容性风险,1.2.0 还可以继续用几天。这时候只需要把minVersion设为 1.2.0,服务端判断“当前版本 < 1.2.0 的都给 force”即可。客户端不用知道全局策略,它只负责执行服务端返回的updateType。
这里有个很关键的补充:强制更新不能只依赖客户端弹窗。用户如果绕过弹窗继续用老版本,你拿他没办法。所以服务端接口层面也要做版本拦截,比如登录、支付、下单这些核心接口统一在网关层校验请求头里的app-version,低于minVersion的请求直接返回“请升级后继续使用”。客户端弹窗只是第一道闸,后端拒绝服务才是第二道闸,两道一起做,强制更新才真正“强制”。我在项目里把这道校验做在了接入层的一个公共拦截器里,所有业务接口共用,避免每个业务开发各自判断。
3. 自动检测的落地实现:启动时检查、回到前台检查、防重复检查
客户端这边,第一步是把自动检测做干净。我把它拆成几个小部分:检测入口、版本信息获取、请求服务端、结果分发。
3.1 版本信息获取的兼容写法
UniApp 的老项目和新项目用的 API 不完全一样。老项目常用uni.getSystemInfoSync(),新工程推荐uni.getAppBaseInfo()。线上项目升级基础库后,老 API 也未必立刻失效,但为了不给自己埋雷,我一般写成兼容函数:
function getAppVersionInfo() { // 新 API 优先 if (uni.getAppBaseInfo) { const baseInfo = uni.getAppBaseInfo() let platform = '' if (uni.getDeviceInfo) { platform = uni.getDeviceInfo().platform } else { platform = uni.getSystemInfoSync().platform } return { version: baseInfo.appVersion || '', platform: platform.toLowerCase(), // 'android' / 'ios' } } const systemInfo = uni.getSystemInfoSync() return { version: systemInfo.appVersion || '', platform: (systemInfo.platform || '').toLowerCase(), } }appVersion对应的是manifest.json里的应用版本名称。注意它不是 Android 的 versionCode,服务端如果需要 versionCode,可以额外用原生插件获取;但大多数场景下版本名称加 build 号就够用了。
3.2 检测入口放在 App.vue 而不是首页
我见过有些项目把检测逻辑写在首页onLoad里,这样有三个问题:一是用户必须先进入首页才会检测,二是从后台切回来不会再次触发,三是首页如果改版导致检测逻辑被误删,更新就悄悄没了。
更稳妥的做法是把检测放在App.vue的onLaunch和onShow里。onLaunch只执行一次,App 冷启动时检测;onShow在每次从后台回到前台时执行。这样可以覆盖用户停留老版本期间的多次回前台场景。
let updateChecked = false let updateCheckPromise = null export default { onLaunch() { // #ifdef APP-PLUS this.runUpdateCheck() // #endif }, onShow() { // #ifdef APP-PLUS if (updateChecked) return this.runUpdateCheck() // #endif }, methods: { runUpdateCheck() { if (updateCheckPromise) return updateCheckPromise = this.checkForUpdate().finally(() => { updateChecked = true updateCheckPromise = null }) }, checkForUpdate() { return new Promise((resolve) => { const { version, platform } = getAppVersionInfo() uni.request({ url: UPDATE_CHECK_URL, method: 'POST', data: { appId: window.plus ? plus.runtime.appid : '', platform, version, }, success: (res) => { const data = res.data && res.data.data if (data && data.hasUpdate) { this.dispatchUpdate(data) } resolve() }, fail: (err) => { console.log('更新检测失败', err) resolve() }, }) }) }, }, }这段代码看起来简单,但有两个细节值得注意。
updateCheckPromise的作用是防止检测请求还没返回时,onShow又触发一次重复请求。如果没有这个锁,用户快速切前后台,可能会同时发出好几个更新检测请求。服务端没压力还好,有压力或者网络极端情况下,重复请求会导致后续逻辑错乱。
检测失败不能抛异常。网络差、服务端临时抖动,都应该静默失败,下次回到前台再试。千万不要在启动时因为更新检测失败导致整个 App 卡住。
3.3 结果分发:按 updateType 走不同分支
dispatchUpdate是核心分发函数,逻辑很简单,但一定要收敛:
dispatchUpdate(update) { if (update.updateType === 'force') { // 强制更新,走独立页面 uni.navigateTo({ url: `/pages/update/force-update?data=${encodeURIComponent(JSON.stringify(update))}` }) return } if (update.updateType === 'silent') { // 静默更新,只处理 wgt this.applySilentUpdate(update) return } // 普通提示更新 this.showUpdateDialog(update) }强制更新不要用uni.showModal,因为showModal的“取消”按钮你很难完全禁止,Android 返回键还可能直接关掉弹窗。我后面会专门讲强制更新页的做法。这里先理解分发的思路:force 走页面,silent 走下载,prompt 走弹窗。
普通提示弹窗的代码不多,但要注意用户点击“立即更新”后,如果下载失败,要给一个可恢复的入口,而不是让用户重新杀死 App 才能再触发:
showUpdateDialog(update) { uni.showModal({ title: '发现新版本', content: update.releaseNote || '请更新到最新版本', showCancel: true, confirmText: '立即更新', cancelText: '以后再说', success: (res) => { if (res.confirm) { this.startDownload(update) } }, }) }4. 静默更新最容易踩坑的细节:wgt 包下载、校验、安装与重启
静默更新听起来高级,实际上就是把原本需要用户手动做的“下载 + 安装 + 重启”变成程序自动执行。UniApp 里用 wgt 包实现,流程并不复杂,但细节坑很多。
4.1 wgt 包怎么生成
wgt 包不是普通的 zip 压缩包。在 HBuilderX 里,你需要切换成发版配置,然后选择“发行 -> 制作应用资源升级包”。它生成的是一个.wgt文件,里面是编译后的前端资源。每次生成 wgt 前,记得先修改manifest.json里的版本号,让它跟本次更新目标保持一致。
这里有个很容易忽略的点:wgt 包的版本号和整包的版本号必须对应清楚。比如线上整包是 1.0.0,你现在要发布 1.1.0 的 wgt 资源更新,那么生成 wgt 时manifest.json版本号必须是 1.1.0,并且这个 wgt 只能在 1.0.0 或更低版本上安装。如果旧 App 当前版本比 wgt 版本还高,安装会失败或直接被忽略。所以发布资源包之前在后台记录清楚“基线版本”非常重要。
4.2 下载与 MD5 校验
下载我直接用uni.downloadFile,它在 App 端底层也是走原生网络栈的。为了不把用户卡在某个页面,下载过程可以做成静默进度条,或者在状态栏通知栏里显示进度。我建议至少要有一个可见进度,否则用户会以为手机卡了。
applySilentUpdate(update) { if (this.silentUpdateInProgress) return this.silentUpdateInProgress = true const task = uni.downloadFile({ url: update.packageUrl, timeout: 60000, success: async (res) => { if (res.statusCode !== 200) { this.silentUpdateInProgress = false console.log('wgt 下载失败', res.statusCode) return } // 如果服务端返回了 md5,先校验再安装 if (update.md5) { const fileMd5 = await getFileMd5(res.tempFilePath) if (fileMd5 !== update.md5) { this.silentUpdateInProgress = false console.log('wgt 文件校验失败') return } } this.installWgt(update, res.tempFilePath) }, fail: (err) => { this.silentUpdateInProgress = false console.log('下载异常', err) }, }) task.onProgressUpdate((res) => { // 可以在这里更新自定义进度 UI console.log('下载进度', res.progress) }) }MD5 校验不是必须的,但强烈建议加。我之前遇到过 CDN 没刷新,用户下到一个旧的半截 wgt 包,安装后页面白屏,还得再发一个修复包去救。加了 MD5 之后,至少能从源头拦住损坏包。
getFileMd5在 App 端可以借助原生能力实现,也可以让服务端在文件下发前计算好值。如果项目不想引入原生插件,也可以先不校验,但至少要在安装失败时做兜底提示。
4.3 安装与重启
wgt 安装用的是plus.runtime.install,安装成功后必须重启 App 才能加载新资源。
installWgt(update, tempFilePath) { plus.runtime.install(tempFilePath, { force: true }, () => { // 重启前保存当前页面,方便更新后恢复 const pages = getCurrentPages() const currentPage = pages[pages.length - 1] uni.setStorageSync('UPDATE_RESTORE_ROUTE', currentPage.route) plus.runtime.restart() }, (err) => { this.silentUpdateInProgress = false console.log('wgt 安装失败', err) // 兜底:提示用户手动去应用市场更新 uni.showModal({ title: '更新失败', content: '自动更新没有成功,请手动更新到最新版本', showCancel: false, confirmText: '去更新', success: () => { // #ifdef APP-PLUS plus.runtime.openURL(update.iosAppUrl || update.androidApkUrl) // #endif }, }) }) }plus.runtime.restart()一调用,整个 App 会重新启动。如果用户当时正在填写一个表单,重启后表单内容就丢了。所以我一般不会在 App 处于活跃页面时马上静默更新,而是等用户切到后台或者到了首页无操作状态再执行。这里可以结合uni.onAppHide:
if (update.updateType === 'silent') { this.pendingSilentUpdate = update // 先不立刻下载,等 App 退到后台再处理 uni.onAppHide(() => { if (this.pendingSilentUpdate && !this.silentUpdateInProgress) { const u = this.pendingSilentUpdate this.pendingSilentUpdate = null this.applySilentUpdate(u) } }) }这样能最大程度降低静默更新对用户当前操作的打扰。注意onAppHide要防重复注册,最好在全局只注册一次,维护一个待更新状态变量即可。
4.4 静默更新的边界条件
不是所有更新都能静默。以下情况必须降级为“普通提示更新”或“强制更新”:
- 原生插件有变化,wgt 覆盖不了。
manifest.json里原生模块配置有调整。- iOS 端。大多数上架项目不建议在 iOS 端通过远程资源包静默更新,应用商店对远程代码执行一直管得严,踩线很容易出问题。iOS 端老老实实跳应用商店。
- 包体特别大的 wgt。虽然 wgt 比整包小,但如果塞了大量图片视频,下载过程还是很明显。
我在客户端加了一个守护逻辑:如果服务端返回updateType: silent,但当前平台是 iOS,或者本地检测到需要整包更新,就把updateType自动升一级,变成prompt。这个判断放在dispatchUpdate里:
dispatchUpdate(update) { const { platform } = getAppVersionInfo() if (platform === 'ios' && update.updateType === 'silent') { update.updateType = 'prompt' } if (update.needWholePackage && update.updateType === 'silent') { update.updateType = 'prompt' } // ...后续分发 }这个兜底让我少踩了很多线上坑。因为配置服务端的人可能不了解客户端平台的限制,客户端作为一个“有脑子的执行者”,必须懂得什么能做,什么不能做。
5. 强制更新的交互设计:防绕过的独立更新页
强制更新是整个模块里最需要小心处理的场景。它不只是一个弹窗,而是一整套“不让用户继续用老版本”的策略。
5.1 为什么不用 showModal
uni.showModal做普通提示没问题,但做强制更新有硬伤:它除了“确定”“取消”两个按钮之外,基本没有自定义空间。你可以把showCancel设为 false,但用户按 Android 返回键时,弹窗仍然可能被取消。一旦取消,用户就能继续留在老版本里。
所以我给强制更新单独做了一个页面,比如pages/update/force-update。这个页面不叫“更新失败”,也不搞“稍后再说”,就是一个占屏页面:从标题、说明、进度到“立即更新”按钮,全部固定在页面上,App 不升级就无法进入业务页面。
5.2 页面上的关键实现
强制更新页的核心功能是:显示更新说明、下载进度、安装引导。Android 端如果是整包更新,我会用plus.runtime.openURL打开 APK 下载地址,让用户在浏览器里完成下载安装;如果是 wgt 更新,则在页面内直接下载并安装。
<template> <view class="force-update-page"> <view class="title">请升级到最新版本</view> <view class="note">{{ releaseNote }}</view> <view class="progress" v-if="downloadProgress > 0"> 下载中 {{ downloadProgress }}% </view> <button @click="handleUpdate" :disabled="downloading"> {{ downloading ? '正在下载' : '立即更新' }} </button> </view> </template>拦截返回键是重中之重:
onLoad(options) { this.updateData = JSON.parse(decodeURIComponent(options.data)) // #ifdef APP-PLUS plus.key.addEventListener('backbutton', this.preventBack) // #endif }, onBackPress(e) { if (e.from === 'backbutton' || e.from === 'navigateBack') { return true } }, onUnload() { // #ifdef APP-PLUS plus.key.removeEventListener('backbutton', this.preventBack) // #endif }, methods: { preventBack() { // 强制更新页不允许返回 }, }注意onBackPress的返回值:返回true表示阻止默认返回行为。如果你只在onBackPress里返回 true,有些 Android 物理返回键事件还是会走到plus.key监听。两个位置都处理,才能在绝大多数机型上拦得住。
5.3 后端也要防绕过
前面提过,强制更新不能只靠前端。我在服务端加了三个层面的配合:
- 发布记录里配置一个“最低允许版本”,小于它的客户端一律返回
force。 - 核心业务接口统一校验客户端上报的版本号,不满足直接拒绝并返回更新提示。
- 强制更新期间,旧版本的所有接口调用都标记为“已废弃”,日志里打 warning。
这样就算用户想办法绕开了前端强制更新页,进入业务页面后也会因为接口请求失败而无法正常使用,最终只能回来升级。强制更新之所以叫“强制”,本质上靠的是服务端不服务,而不是前端不让点。
5.4 增量场景的强制更新
还有一种情况:当前版本不是特别老,但刚好在一个有严重 bug 的版本区间。服务端可以把minVersion设置为“包含问题的上一个版本”,只强制这部分的用户升级,其他用户走普通提示。这是我在实际项目中用得比较多的灰度策略,比“一刀切强制所有人升级”温和得多,也避免了一次大版本把一批还在稳定使用的用户全打乱。
6. 更新模块上线前的验收清单与长期维护建议
更新模块不像页面功能,问题都是线上才暴露。整理一份验收清单,每次发版前跑一遍,能省掉大量麻烦。
6.1 客户端验收清单
- 从低于当前版本的老包启动,确认检测接口正常返回并有新版本。
- 服务端返回
force时,确认进入强制更新页,Android 返回键无效,切后台再回来仍在更新页。 - 服务端返回
silent时,确认 wgt 下载后自动重启,用户无感知,重启后版本号变更为目标版本。 - 服务端返回
prompt时,确认弹窗出现,点“立即更新”能正常下载安装。 - 弱网环境下,确认检测失败不会白屏,也不会反复弹窗。
- iOS 端收到
silent时,确认被客户端兜底改成了prompt,跳转的是 iOS 应用商店。 - wgt 包损坏或 MD5 不一致时,确认不会执行安装,而是走失败兜底。
- 用户正在填写表单时触发静默更新,确认不会立刻重启,等切后台再执行。
- 强制更新下载过程中断网,恢复网络后能继续或至少重新提示。
6.2 服务端验收清单
- 版本比较逻辑稳定,
1.10.0大于1.9.2。 minVersion配置正确,老版本能收到 force,新一点但还不达标的版本也能收到 force。- 发布记录里有 wgt 的 MD5 值,CDN 上确实存在对应文件,且内容没有缓存过期问题。
- 核心接口的版本校验已开启,被拒绝的客户端能收到统一的升级提示结构。
6.3 我在实际项目里总结的几点经验
第一,更新检测的并发锁一定要有。没有锁之前,我遇到过 App 从后台切回前台连续触发三次检测,结果三个请求同时返回,弹了三个更新对话框的尴尬情况。用户在手机上连续点三次关闭,体验非常差。
第二,把更新日志做成可监控的埋点。不只记录“请求成功/失败”,还要记录“返回了哪种 updateType”“用户选择更新还是放弃”“wgt 安装是否成功”。这些数据能帮你判断:是不是有一批用户卡在老版本上,是网络问题、弹窗被忽略,还是安装失败。没有数据,你根本不知道线上有多少用户没更新。
第三,wgt 更新虽然叫“静默”,但它仍然是一次版本变更。前端资源更新后,接口数据结构可能也变了,老版本用户如果还在用旧接口,一样会出问题。所以发布 wgt 之前,一定要确认接口兼容,或者做好“接口版本跟随 App 版本”的强制升级策略。这一点很容易被忽略,因为大家总觉得 wgt 只是换个 UI。
第四,强制更新页不要写成“公告页”。不要把运营活动、广告信息堆在上面,它唯一的任务就是让用户完成升级。页面越简洁,用户越能理解“不升级不行”;页面越复杂,用户越容易困惑。
最后说一个我反复踩过又修好的细节:更新模块在调试环境跟线上环境的行为要分开。开发模式下我通常会直接关闭自动检测,不然每次联调都被更新弹窗打断。可以通过自定义编译条件或环境变量控制,上线时再打开。更新模块本身不复杂,但它牵一发而动全身,值得在发版过程中被认真对待。