多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

UniApp App自动更新方案:静默更新与强制更新实战

UniApp App自动更新方案:静默更新与强制更新实战 做 UniApp 开发这几年被问得最多的一个需求就是App 到底怎么自动更新。其实逻辑本身不复杂但真做起来坑不少版本号比较怎么算、静默更新和强制更新怎么分层、iOS 和 Android 行为差异怎么处理、用户点了升级之后下载安装失败怎么办。今天这篇就把我在实际项目里沉淀下来的完整方案拆开讲从版本自动检测到静默更新再到强制更新每一步都给出可以直接抄走的代码和设计思路。这套方案我们是同时用在了原生壳 UniApp 混合架构的几款 C 端 App 上上线一年多跑了十几个版本更新成功率稳定在 99% 以上。如果你是 UniApp 开发者或者正在规划 App 的版本更新体系这篇文章应该能帮你省掉至少一周的探索时间。1. 更新需求分析与方案选型1.1 为什么必须做自动检测 静默更新很多团队一开始以为App 只要上架应用市场用户就会乖乖更新。实际上用户打开应用市场找更新这个行为路径太长数据非常惨淡。我见过好几个产品后端接口改了不兼容老版本用户打不开运营只能靠短信、公众号推送催更新效果还不好。根本解法就是客户端启动时自动请求更新接口发现新版本就在 App 内直接引导更新。另一个核心场景是wgt 资源包热更新。UniApp 打包出来的 App逻辑代码和页面资源大部分都在 wgt 包里原生壳只是容器。只要不改原生插件、不升原生 SDK我们完全可以用静默下载 wgt 包的方式实现无感更新。用户无感知也绕开了应用市场审核周期。而强制更新则是保底手段。当服务端接口做了不兼容的升级老版本流量会直接影响线上稳定性这时候客户端必须强制升级到指定版本否则就拒绝提供服务。这类场景多半伴随重大功能改版、安全漏洞修复、后端协议切换用户没得选。1.2 三种常见更新方案对比先说明一下我对更新的理解更新分资源更新和整包更新两层资源更新只替换前端代码整包更新则涉及原生安装包。UniApp 下最常用的三种方案各有适用场景。方案原理优势劣势wgt 资源包热更新只下载新的 wgt 包用plus.runtime.install覆盖安装无需重新打包、无需过市场审核、体积小、更新快无法变更原生模块和 SDK整包静默下载安装下载 apk/ipa调用系统安装器或跳 App Store可以升级原生功能Android 需处理安装权限iOS 无法自动装应用市场更新跳转应用市场详情页合规性最好用户信任感高更新率低转化难预估我做项目时从不只依赖单一渠道。常规迭代走 wgt 热更 市场引导紧急问题用静默整包 强制更新兜底。分层思路避免了一个漏洞如果每次小改动都强制用户装新包用户卸载率会明显升高但全用热更新原生层升级能力又会锁死。1.3 我的选型结论与实践建议直接给结论默认走 wgt 热更新服务端按版本号控制是否强制只有涉及原生模块变更时才走整包更新且将整包更新与强制状态绑定。这个策略在成本和体验之间取了最大公约数。需要注意一点wgt 热更新也有限制。App 原生层如果引用了第三方原生插件插件升级了但 wgt 包没变热更新解决不了。我曾经遇到过一个推送 SDK 升级以为 wgt 包替换就完事结果推送服务起不来排查半天才发现原生层和资源层版本错配。自此以后我们约定只要原生依赖有变化必须打整包热更只用于纯前端逻辑和页面调整。2. 版本检测接口与更新逻辑设计2.1 后端接口一粒精心设计的返回体更新功能的核心是服务端接口。我建议的请求方式很简单客户端启动后带当前版本号请求后端返回最新版本信息。接口设计好坏直接决定前端判断逻辑有多简单所以返回字段一定要明确。{ code: 0, message: ok, data: { latestVersion: 2.3.1, minVersion: 2.2.0, updateType: wgt, forceUpdate: true, downloadUrl: https://cdn.example.com/app/2.3.1.wgt, installUrl: https://cdn.example.com/app/2.3.1.apk, iosAppStoreUrl: https://apps.apple.com/cn/app/idxxxxx, releaseNotes: 修复若干问题优化体验 } }这里的字段有几个关键含义latestVersion线上最新版本号用于跟本地版本比较。minVersion最低可用版本号本地版本低于它则必须强制升级。updateType更新包类型wgt表示资源热更apk/ipa表示整包更新。forceUpdate是否强制更新为 true 时用户只能升级不能取消。downloadUrl和installUrl资源包和整包的下载地址。iosAppStoreUrliOS 整包更新时跳转的 App Store 链接。后端逻辑要特别注意minVersion和latestVersion的联动。我的做法是当前端传上来的版本号低于minVersion时强制返回forceUpdate: true这时候updateType强制改成apk或ipa防止前端用热更跳过强制整包。服务端掌握强制判断的最终权力客户端只做执行这样最不容易被绕过。2.2 版本号比较比想象中更容易出错版本号是字符串常见格式是x.y.z直接字符串比较会有大坑。比如2.3.10和2.3.9按字典序后者更大因为字符9排在1后面。所以必须拆段落按整数比较。我这里提供一段可直接用的解析函数这段代码我在多个项目里复用没有出过问题。function compareVersion(version1, version2) { const v1 String(version1).split(.) const v2 String(version2).split(.) const len Math.max(v1.length, v2.length) for (let i 0; i len; i) { const num1 parseInt(v1[i] || 0) const num2 parseInt(v2[i] || 0) if (num1 ! num2) { return num1 num2 ? 1 : -1 } } return 0 }测试用例很关键compareVersion(2.3.10, 2.3.9)返回 1compareVersion(2.3.1, 2.3.1)返回 0compareVersion(2.2.9, 2.3.0)返回 -1。如果你用的是旧版本号比如1.0函数也能正确处理缺失部分按 0 处理。还有一个容易被忽视的点本地版本号的获取方式。UniApp 中直接读plus.runtime.version不一定每次都准确尤其 debug 包和 release 包会有差异。我们统一用 manifest.json 配置的版本号通过uni.getSystemInfo拿不到版本号要用 App 平台的运行时属性function getLocalVersion() { return plus.runtime.version || }plus.runtime.version返回的是manifest.json里versionName字段对应的值也就是x.y.z格式。这个值在 App 安装后是固定的用它做比较基准最可靠。2.3 客户端检测流程三步走客户端检测我总结成三个步骤启动检查 → 异步请求更新接口 → 按优先级处理更新。实际编码时我建议不要在onLaunch里同步阻塞等待更新因为会导致冷启动变慢。正确做法是onLaunch里触发异步检查页面正常渲染更新弹窗在请求返回后根据策略弹出或在首页onReady之后再执行。核心判断逻辑如下async function checkAppUpdate() { const localVersion getLocalVersion() try { const res await uni.request({ url: https://api.example.com/checkUpdate, method: POST, data: { version: localVersion, platform: app, os: uni.getSystemInfoSync().platform } }) const data res.data.data if (!data) return const cmpLatest compareVersion(data.latestVersion, localVersion) if (cmpLatest 0) return // 本地已是最新无需更新 if (data.updateType wgt !data.forceUpdate) { handleWgtUpdate(data) } else { handleWholeUpdate(data) } } catch (e) { console.log(更新检测失败, e) } }细心的读者会发现这里我用了compareVersion判断是否需要更新而不是直接信任接口返回的forceUpdate。一遍双保险避免后端偶尔抽风把latestVersion配错成旧版本号还强制下发导致用户被无意义强制升级。3. 静默更新核心实现3.1 wgt 资源包热更新用户无感的秘密wgt包更新是 UniApp 体系中体验最好的更新方式。用户在不知情的情况下就完成了整包资源替换重启 App 后新代码生效。原理是下载新的 wgt 文件后调用plus.runtime.install完成覆盖安装。重点讲代码实现因为坑主要藏在细节里。首先是下载我推荐使用uni.downloadFile它对于常规文件体积一般几百 KB 到几 MB足够稳定而且天然支持断点续传的返回信息。给一个完整实例function handleWgtUpdate(updateInfo) { uni.showLoading({ title: 正在下载更新包 }) const downloadTask uni.downloadFile({ url: updateInfo.downloadUrl, success: (res) { uni.hideLoading() if (res.statusCode 200) { installWgt(res.tempFilePath) } else { uni.showToast({ title: 更新包下载失败, icon: none }) } }, fail: () { uni.hideLoading() uni.showToast({ title: 网络异常请稍后重试, icon: none }) } }) }注意res.tempFilePath是下载临时目录安装前建议先把它 copy 到_downloads目录。这是因为plus.runtime.install在部分 Android 机型上对临时目录路径的处理不够稳定直接安装可能报无法安装该文件错误。保险做法如下function installWgt(tempFilePath) { const targetPath _downloads/update_ Date.now() .wgt plus.io.resolveLocalFileSystemURL(file:///storage/emulated/0/, () { // Android 常见路径处理 plus.io.resolveLocalFileSystemURL(tempFilePath, (entry) { entry.copyTo(plus.io.resolveLocalFileSystemURL(_downloads/), (newEntry) { plus.runtime.install(newEntry.toLocalURL(), {}, (result) { uni.showModal({ title: 更新完成, content: 点击确定后 App 将重启, showCancel: false, success: () { plus.runtime.restart() } }) }, (err) { uni.showToast({ title: 安装失败 err.message, icon: none }) }) }, (e) console.log(e)) }, (e) console.log(e)) }, () console.log(路径解析失败)) }当然这个写法嵌套太深实际项目里我们可以用plus.io的转换方法封装成 Promise。其核心逻辑就是先把临时文件转成应用沙箱内的正式文件再调用安装接口。这个步骤看似多余实际避免了很多安卓厂商 ROM 的路径兼容性问题。plus.runtime.install第二个参数是 options常用配置如下{ force: true // 强制安装覆盖同名应用资源 }不设置force时部分机型会弹出系统安装确认框静默效果就打折扣了。设了force: true后App 会在应用内部直接替换资源不需要用户确认这才是真正的静默。安装完成后建议立即重启应用否则用户停留在旧页面会看到新旧混杂的 UI。重启接口是plus.runtime.restart()实测在 Android 和 iOS 上都能稳定触发冷启动。3.2 整包静默更新Android 安装权限怎么过整包更新绕不开 Android 的未知来源应用安装权限。从 Android 8.0API 26开始系统强制要求安装未知来源应用之前必须跳转到设置页让用户授权允许安装未知应用。我的处理方式是分三步用uni.downloadFile下载 apk 到本地。检查当前系统版本判断是否需要请求安装权限。调plus.runtime.install走系统安装器。关键点在于 Android 权限判断。UniApp 的plus.runtime.install本身会尝试调起安装流程如果它直接抛错说安装被拒绝那就说明没有授权。我们可以提前做权限引导function installApk(filePath) { // Android 8.0 及以上可能需要动态请求未知来源权限 // 使用 plus.android 原生 API 判断是否有安装权限 const main plus.android.runtimeMainActivity() if (compareVersion(uni.getSystemInfoSync().osVersion, 8.0.0) 0) { // 尝试获取一个 Intent检查是否有 REQUEST_INSTALL_PACKAGES 权限 // 这里用通用的方式直接调安装失败后引导用户授权 } plus.runtime.install(filePath, {}, (res) { uni.showToast({ title: 安装完成, icon: success }) }, (err) { // 常见错误1321001 未知来源权限未授予 if (err.code 1321001) { uni.showModal({ title: 需要安装权限, content: 请在设置中允许本应用安装未知来源应用, confirmText: 去设置, success: (res) { if (res.confirm) { openInstallPermissionSetting() } } }) } }) }openInstallPermissionSetting的具体实现我直接使用plus.android.invoke比较底层的方式但在 HBuilderX 较新版本里也可以用uni.chooseLocation的权限跳转方式不太优雅。真实项目中我用的是function openInstallPermissionSetting() { const main plus.android.runtimeMainActivity() const Uri plus.android.importClass(android.net.Uri) const Intent plus.android.importClass(android.content.Intent) const Build plus.android.importClass(android.os.Build) const Settings plus.android.importClass(android.provider.Settings) if (Build.VERSION.SDK_INT 26) { const intent new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) const pkgName main.getPackageName() intent.setData(Uri.parse(package: pkgName)) main.startActivity(intent) } }这个代码在华为、小米、OPPO、vivo 上都跑通过。少数机型会进一步要求打开纯净模式或外部来源应用下载权限华为和小米尤其常见。这类厂商定制拦截没法用标准 API 绕过只能提示用户手动设置。我们的做法是在引导页里写清楚三个入口名称让用户按图索骥。还有一点值得注意下载 apk 时务必检查文件完整性。apk 的体积通常比 wgt 大很多建议下载完成后再请求一次 HEAD 接口比对 Content-Length 或 MD5防止不完整安装包导致安装失败。我在后端下载接口上加了文件大小的返回字段前端下载成功后比对本地文件大小不一致就提示重新下载。3.3 iOS 整包更新的现实约束iOS 上不能像 Android 那样静默装包。苹果的生态决定了任何安装应用的行为必须经过 App Store。所以 iOS 的整包更新只有一个思路检测到新版本后弹窗引导用户跳转到 App Store 下载页。实现上非常简单function goIosAppStore(storeUrl) { const appleId 你的AppID plus.runtime.openURL(https://itunes.apple.com/cn/app/id appleId ?mt8) // 或者直接用接口返回的 iosAppStoreUrl plus.runtime.openURL(storeUrl) }需要给 UI 上的提示文案做区分iOS 用户看到的是 前往 App Store 更新而不是 更新中 或 下载中。因为跳转 App Store 后用户可能不会立刻回来我们需要在 App 回到前台时重新检查版本状态如果仍未更新继续提示。iOS 还有一个冷知识App Store 的版本更新接口有缓存机制新版本审核通过后可能不会立即对全量用户可见苹果官方说法是最长可能需 24 小时。所以我们的 iOS 更新判断不能完全依赖服务端返回的latestVersion还要让后端在迭代上线时就把最新版本号返回。用户看到 App Store 里没有新版本时只能默默等待这个属于平台特性不算 bug。3.4 下载进度与断电保护静默更新不等于完全不显示 UI尤其是整包更新包体积一上兆用户很容易以为 App 卡死了。我的经验是至少做到三件事下载过程显示进度条进度回调。提供继续使用按钮允许用户切到后台下载。断网后恢复时自动续传。uni.downloadFile自带进度监听能力通过DownloadTask.onProgressUpdate拿数据。const downloadTask uni.downloadFile({ url: updateInfo.installUrl, success: ... }) downloadTask.onProgressUpdate((res) { const percent res.progress updateProgressUI(percent) })注意onProgressUpdate在 iOS 和 Android 上表现差异不大但 Android 上如果下载线程被系统回收进度可能一段时间不刷新。我们给进度条加了超时兜底超过 20 秒无进度提示用户网络异常或切换网络。断点续传方面uni.downloadFile并不保证断点续传真正实现需要自己维护下载 offset。但项目迭代发现与其实现复杂的断点续传不如重下一次。因为 wgt 包普遍小于 10MB即使整包 apk 也就几十 MB4G/5G 或 Wi-Fi 环境下重下成本可控。如果你真希望实现续传可以用plus.downloader.createDownload并传filename配合后台任务但这会引入更多状态管理运维成本高普通产品没必要。4. 强制更新实现与边界处理4.1 什么情况下必须强制强制更新是双刃剑用不好会流失用户所以我给产品定了一条红线只要老版本会直接影响服务端稳定性、用户数据安全或造成致命 bug才触发强制更新。具体场景比如后端接口协议重构老版本客户端无法解析新接口返回。发现严重安全漏洞如数据明文传输、支付逻辑缺陷。运营活动需要的核心依赖项老版本缺失。原生 SDK 升级后强制要求最低版本。强制更新的判断我建议放在服务端做用minVersion字段控制。如果前端永远只信任forceUpdate就会出现用户篡改本地版本号跳过更新检查的问题。把策略放在服务端客户端即使改了本地版本号服务端返回的minVersion仍会兜底。判断逻辑const cmpMin compareVersion(localVersion, data.minVersion) if (cmpMin 0 || data.forceUpdate) { // 必须强制更新 showForceUpdateDialog(data) } else { // 普通更新 showOptionalUpdateDialog(data) }4.2 强制更新弹窗的不可取消策略强制更新时用户必须进入更新流程。为了确保用户无法绕过我做了三个 UI 层面的限制弹窗设置showCancel: false没有取消按钮。点击底部返回键时拦截不能关闭弹窗。弹窗关闭事件里重新弹出。UniApp 的uni.showModal在强制更新场景中有些力不从心它不支持自定义按钮和强制阻断返回。我实际项目中是用自定义组件实现的强制更新弹窗内置一个全屏遮罩再配合plus.key.addEventListener(backbutton, ...)拦截安卓返回键。拦截返回键示例function lockBackButton() { plus.key.addEventListener(backbutton, backHandler, false) } function backHandler() { // 不执行任何返回操作仅提示 uni.showToast({ icon: none, title: 请先完成更新 }) }弹窗 UI 的核心内容是版本更新说明、进度条、更新按钮。强制更新模式下按钮文案是立即更新点击后开始下载下载过程中按钮置灰文案改为下载中 x%。如果下载失败按钮恢复可点击并提示失败原因。这里有个取舍问题强制更新弹窗要不要在用户进入 App 首页前就弹出我的建议是首页渲染完成后弹出。因为强制更新弹窗如果拦截了首页加载一旦弹窗组件初始化失败用户会卡在白屏。首页先给一个基础界面弹窗覆盖在最上层这样即使更新组件抛错App 至少还能正常展示。4.3 强制更新与静默更新如何分层配合很多开发者以为强制更新和静默更新是对立的实际上我做了三层状态机客户端状态服务端返回处理策略正常使用updateType wgt, forceUpdate false后台静默下载完成后提示重启版本过低updateType apk/ipa, forceUpdate true弹强制更新窗下载整包安装版本严重过低接口直接拒绝业务强制弹窗且禁用 App 内容引导升级最后一层最严格当用户版本比minVersion低太多时后端业务接口除了版本检查之外还会在业务响应体里带一个upgradeRequired标识客户端收到后连业务数据都不渲染直接跳强制更新页。这在后端接口做兼容时要特别注意防止旧版本用户绕过客户端检查直接请求业务接口拿到脏数据。4.4 强制更新失败时的降级策略强制更新也可能失败比如用户存储空间不足、下载服务器 CDN 故障、Android 未知来源权限被系统限制。这种情况下我们绝不能把用户永久锁死在旧版本里必须给出稍后重试入口和客服联系方式。做了降级策略之后我观察到一个重要现象真正死磕强制更新的用户比例极低。绝大多数用户在弹窗出现后一小时内都会完成升级。失败重试的用户中大部分是存储空间不足清理后就能成功。所以降级设计不需要复杂一个重试按钮 一句联系客服就够用。5. 常见问题与排查技巧实录5.1 常见问题速查表直接整理一张排查表按症状定位原因。现象可能原因解决方案检测不到新版本本地版本号取的 debug 包版本改用plus.runtime.versionwgt 下载成功但安装失败文件路径为临时目录先拷贝到_downloads再安装安装失败报 1321001Android 未知来源权限被禁跳转系统设置页引导开启iOS 跳转 App Store 后无更新App Store 缓存未刷新服务端延迟返回 latestVersion安装后页面乱码wgt 包编译版本与壳不匹配用 HBuilderX 对应版本重新编译静默更新后用户回旧页面没有强制重启安装成功后调plus.runtime.restart下载进度一直 0%域名未加入 downloadFile 合法域名检查 CDN 域名建议走 https5.2 安装失败权限、签名、空间排查安装失败是整包更新最高频问题。权限问题上面说过这里提两个容易被忽略的坑。签名不一致。如果 apk 是通过应用市场渠道重签名过的直接走plus.runtime.install静默安装大概率会失败因为系统认为签名与已安装应用不一致。这种冷启动场景只能跳应用市场或者引导用户卸载重装不做自动安装。磁盘空间不足。Android 系统安装 apk 时需要的空间远大于文件本身大小。我遇到过 80MB 的 apk 安装包在只剩 200MB 空间的低端机上安装失败的情况。建议在下载前就检查设备剩余存储低于 apk 大小 3 倍时直接提示用户清理空间。5.3 测试技巧模拟多版本与强升场景更新功能不像普通业务必须在多种版本状态下测试。我的测试方法分三条线wgt 热更测试用一个旧版本 wgt 包安装到模拟器服务端配置新版本验证静默下载、重启后生效。整包强升测试安装一个低于minVersion的版本验证返回键拦截、弹窗不可关闭、跳转设置页等逻辑。断网与弱网测试用 Charles 模拟断网、限速 3KB/s验证下载失败后重试机制。模拟器上要注意Android 模拟器的未知来源应用安装权限通常默认是关闭的所以强升流程在模拟器上会暴露很多权限问题反而有利于提前排查。真机测试则重点测厂商 ROM 的权限设置跳转是否正常。5.4 我的几个额外经验最后分享几个容易被遗忘的细节。更新接口要加节流。App 每次冷启动都请求一次更新接口用户一天打开几十次就请求几十次虽然接口压力不大但 CDN 流量和日志量会成倍增加。我在客户端做了策略同一版本 24 小时内只检查一次除非今日首次启动。具体实现可以用uni.setStorageSync存储上次检查时间和版本号。下载路径用动态时间戳命名。因为安卓系统对同名文件有缓存策略如果使用固定文件名update.wgt第二次下载可能拿到旧缓存导致安装失败。用update_ Date.now() .wgt能彻底避免这个问题。强制更新弹窗优先级要最高。如果 App 里同时有公告弹窗、更新弹窗、引导弹窗强制更新永远要在最顶层否则用户会先被其他弹窗引导走错过更新。我建议创建一个全局的updateManager单例所有弹窗组件注册进它的优先级队列由它统一控制显示顺序。关于权限申请的时机。不要在冷启动第一时间就去申请未知来源安装权限Android 系统对这种敏感权限的申请审查很严格。正确做法是先检测到新版本用户点击立即更新后再申请权限这样系统的弹窗解释跟用户意图完美匹配授过率会高很多。做到这些整体方案就完整了。从接口设计到静默热更从整包安装到强制弹窗再到异常兜底每一步都经历了我线上项目的验证。如果你正在做一个 UniApp 的 App 更新模块按这套思路落地基本不会跑偏。实际操作中如果遇到本文没覆盖到的机型兼容问题建议先从plus.runtime的版本适配和厂商 ROM 权限差异两个方向排查九成问题都出在这里。
返回列表