应用内更新这件事,做过的都知道,第一次接入的时候觉得不就是下载个包然后装上吗,真跑起来才发现坑全在细节里。我手头这个 uni-app 项目从最早让用户自己去应用市场搜关键词更新,一路迭代到现在整包更新加资源包热更新双通道并行,中间在真机上摔过的跟头能写满两页笔记本。这篇就把 uniapp 打包 APP 之后怎么实现应用内整包更新和热更新这两条完整链路拆开讲,包括版本号体系怎么设计、服务端接口怎么约定、Android 上下载 APK 并调起安装的实操、iOS 跳转商店的写法、wgt 资源包的打包与安装、以及文档里基本不会提的权限与路径细节。
适合谁看?如果你正在用 uni-app 做 App,或者准备把已有的 H5 项目打包成安卓和 iOS 应用,又不想每次改个活动文案就逼着用户跑应用市场手动更新,那这篇就是写给你的。前端基础一般也能跟下来,关键代码我会给全,同时把每处参数为什么这么设讲清楚,方便你自己根据项目体量改。整篇内容偏实战,代码可以按需抄,但更建议看懂逻辑再落地,因为不同团队的服务端结构和发布流程差别很大。
1. 先搞清楚整包更新和热更新的适用边界
1.1 两种更新方式的本质差异
很多人一上来就问"热更新怎么搞",其实先要回答的问题不是怎么搞,而是这次改动到底该用哪种。整包更新指的是让用户重新下载一个完整的安装包(Android 是 apk,iOS 是跳转应用商店),覆盖安装到设备上;热更新通常指的是只替换前端资源包,在 uni-app 里就是 wgt 包,里面装的是编译后的 html、js、css、静态图片以及配置文件,不包含原生层的内容。
用生活里的例子打个比方,整包更新像是把整栋房子拆了重建,地基、水电、承重墙全能改;热更新像是只换室内的家具和墙纸,房子结构动不了。你改了一个页面的按钮颜色、调整了一段接口请求的地址、修了一个 JS 逻辑的 bug,这类纯前端改动走 wgt 就够了,用户几乎无感,下载量也就几百 KB 到几 MB。但你如果新增了一个原生插件、改了权限声明、换了启动图、升级了 5+ Runtime 版本、改了应用图标或者应用名称,这些统统在原生层,wgt 包带不动,只能整包。
注意:wgt 包里的 manifest.json 配置项中,只有一部分是运行时生效的前端配置,权限、模块、原生插件、SDK 版本这些属于编译进安装包的内容,热更新覆盖不了。
1.2 什么场景该走哪条通道
我把项目里实际的决策逻辑整理成了一张表,团队内部现在直接照这个表判断,省得每次发版都开会讨论。
| 改动类型 | 推荐通道 | 原因说明 |
|---|---|---|
| 页面样式、文案、静态资源 | 热更新 wgt | 纯前端资源,体积小,用户无感 |
| JS 业务逻辑 bug 修复 | 热更新 wgt | 同上,改动只在 webview 层 |
| 新增或调整接口域名 | 热更新 wgt | 配置写在 js 里,可动态下发 |
| 新增应用权限(相机、定位、麦克风等) | 整包更新 | 权限声明编译进 manifest,需重新打包 |
| 引入新的原生插件 | 整包更新 | 插件代码在原生层,wgt 无法注入 |
| 升级 5+ Runtime 或 uni-app SDK 版本 | 整包更新 | 引擎版本必须跟随安装包 |
| 更换应用图标、启动图、应用名称 | 整包更新 | 这些属于安装包元数据 |
| 需要修改包名或签名 | 整包更新 | 签名变了无法覆盖安装 |
这张表背后其实是一条很朴素的规则:只要改动会落到"安装包里编译期确定的东西",就别想着热更新绕过去。我见过有团队硬要用 wgt 去覆盖权限变更,结果用户更新完发现调用相机还是弹不出授权,白白浪费了一波发版机会。
1.3 双通道并行时的优先级设计
实际项目里两条通道是同时存在的,所以必须定一个优先级。我现在的策略是:先请求服务端接口,接口一次性把最新版本信息返回,服务端根据客户端上报的当前版本号判断该下发 wgt 还是 apk。客户端拿到 updateType 字段再决定走哪条分支。
为什么不把判断逻辑放在客户端?因为客户端只知道自己的版本,不知道你这次发版到底改了什么。服务端才是掌握发布信息的一方,比如你这次同时提了前端资源和原生插件,服务端就应该直接下发整包,而不是客户端自己猜。这种"服务端决策、客户端执行"的模式后期维护起来最省心,运营同学在后台点一下就能控制下发内容,不用重新发版客户端。
2. 版本号体系与更新服务端接口设计
2.1 版本号怎么定、怎么比
这一块是很多项目的万恶之源。我见过版本号写成 1.0、1.0.1、1.0.10 然后比较出错的,也见过 Android 的 versionCode 和 versionName 混着用的。先说结论:版本名称用三段式语义化数字(如 1.4.2),版本号用单调递增的整数(如 10402),这两个值在 manifest.json 的"应用版本名称"和"应用版本号"里分别配置。
为什么非要拆成两个?因为字符串比较会出问题。'1.0.10' 和 '1.0.9' 按字符串比,结果 '1.0.10' 小于 '1.0.9',这是经典陷阱。所以运行时的版本比较我建议用"分段转数字再逐位比较"的方式,不依赖字符串字典序。
// 版本名称比较:返回 1 表示 v1 大于 v2,-1 表示小于,0 表示相等 function compareVersion(v1, v2) { const a1 = String(v1).split('.').map(n => parseInt(n, 10) || 0); const a2 = String(v2).split('.').map(n => parseInt(n, 10) || 0); const len = Math.max(a1.length, a2.length); for (let i = 0; i < len; i++) { const n1 = a1[i] || 0; const n2 = a2[i] || 0; if (n1 > n2) return 1; if (n1 < n2) return -1; } return 0; }那版本号(VersionCode)怎么用?它主要给服务端做"最低兼容版本"判断。比如你发了一个新接口,老版本客户端调不通,就在服务端配置 minVersionCode,客户端上报的 versionCode 低于这个值就强制更新,不给"稍后再说"的选项。
这里有个容易被忽略的细节:plus.runtime.getProperty 拿到的版本信息里只有版本名称,没有版本号。所以要么你在 manifest.json 里额外定义一个自定义字段把 code 存进去一起读取,要么客户端用版本名称按上面的方式折算出一个整数上报。我目前的方案是在 manifest 的扩展配置里加一个versionCode字段,打包时同步改,客户端启动时和版本名称一起读出来上报,两边数据对得上,排查问题也方便。
2.2 更新接口的数据结构约定
接口结构定得好,后面客户端代码会清爽很多。我这边服务端返回的结构大致是这样,兼容 wgt 和 apk 两种情况:
{ "code": 0, "message": "ok", "data": { "latestVersionName": "1.4.2", "latestVersionCode": 10402, "minVersionCode": 10000, "forceUpdate": false, "updateType": "wgt", "releaseNote": "1. 修复订单列表分页异常\n2. 优化首页加载速度\n3. 新增消息红点提示", "fileSize": 2411724, "fileUrl": "https://cdn.example.com/app/__UNI__A1B2C3.wgt", "fileMd5": "9f2c1e7b8a4d5f6e0c3b2a1d4e5f6a7b", "apkUrl": "https://cdn.example.com/app/app-release-v142.apk", "apkMd5": "3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d", "iosStoreUrl": "https://apps.apple.com/cn/app/id1234567890" } }几个字段的设计意图说明一下。updateType决定客户端走哪条分支,取值为wgt、apk、store、none;forceUpdate控制弹窗能不能关闭;minVersionCode用来做强制更新的兜底判断;releaseNote里用\n换行,客户端按行渲染成列表;fileSize是为了在弹窗里显示下载体积,让用户心里有数,实测显示体积能明显降低用户中途取消的比例。
注意:fileUrl 一定要放在支持断点续传的 CDN 或者对象存储上,且保证 URL 稳定不变。我踩过一次坑,运营把更新包传到了一个带时效签名的临时地址,结果用户半小时后下载直接 403,弹窗卡在"下载中"不动。
2.3 灰度与强制更新策略
强制更新是个双刃剑,用得太频繁用户会烦,用得不够又会留下一堆老版本在各处跑。我的做法是分三档:普通更新(可关闭,普通弹窗)、推荐更新(可关闭,但连续三天进入首页弹一次)、强制更新(不可关闭,只保留"立即更新"按钮)。三档由服务端的 forceUpdate 和 minVersionCode 共同决定。
灰度方面,服务端可以根据设备 ID 的哈希值取模来分批放量,比如先放 10%,观察一两天崩溃率和更新成功率再放到 50%、100%。这一步在整包更新上尤其重要,因为 APK 覆盖安装失败是真实存在的,尤其是某些定制安卓系统。灰度期间如果发现安装失败率异常,立刻回滚接口返回的版本,只影响一小批用户。
具体的分批实现,服务端拿客户端上报的设备唯一标识做hash % 100,小于当前放量比例就下发更新。设备标识可以用 uni-app 的plus.device.uuid,注意这个值在不同设备上稳定性有差异,服务端要做好容错,拿不到就按全量处理或者不更新。
3. 整包更新的完整实现流程
3.1 Android 下载 APK 并调起系统安装
Android 端的整包更新,核心就三步:下载文件、校验、调起安装。uni-app 里用的是 plus.downloader 这套 API,不用自己写原生代码,这算是 5+ 引擎给的红利。
创建下载任务的时候有几个参数值得说清楚。filename指定保存路径,我统一放在_doc/update/这个应用私有目录下,好处是不需要申请外部存储权限,Android 10 之后的分区存储也不会拦你。retry是失败重试次数,timeout是超时秒数,移动网络下这两个值稍微给大一点比较稳。
const UPDATE_DIR = '_doc/update/'; function downloadPackage(url, onProgress) { return new Promise((resolve, reject) => { // 先清掉同名残留,避免旧文件被复用 const task = plus.downloader.createDownload(url, { filename: UPDATE_DIR, retry: 2, timeout: 60 }, (d, status) => { if (status === 200 && d.filename) { resolve(d.filename); } else { reject(new Error('下载失败,状态码:' + status)); } }); task.addEventListener('statechanged', (t) => { // state 为 3 表示正在接收数据 if (t.state === 3 && t.totalSize > 0 && onProgress) { onProgress(Math.floor(t.downloadedSize / t.totalSize * 100)); } }); task.start(); }); }下载完成之后调起安装:
function installApk(filePath) { plus.runtime.install(filePath, { force: false }, () => { // 安装界面已拉起,这里不用做额外操作 console.log('已调起安装界面'); }, (err) => { uni.showToast({ title: '安装失败,请检查安装权限', icon: 'none', duration: 3000 }); console.error('install error', JSON.stringify(err)); }); }force: false这个参数的含义是不要强制覆盖,交给系统走标准安装流程,如果不是同一个签名,系统会直接拒绝,避免出现"安装包损坏"这类让用户困惑的提示。这里一定要注意新包的签名必须和已安装版本完全一致,用 HBuilderX 云打包时如果换了证书,老用户就装不上了。
3.2 Android 8.0 以上的安装权限与 FileProvider
Android 8.0 引入了一个限制:应用不能直接在内部触发"安装其他应用",必须声明REQUEST_INSTALL_PACKAGES权限,并且在系统设置里开启"允许来自此来源的应用"。好消息是,5+ Runtime 的plus.runtime.install内部已经帮我们处理了 FileProvider 和临时授权这一层,不需要自己写 XML 配置,但权限声明还是要在 manifest.json 里加上。
{ "app-plus": { "distribute": { "android": { "permissions": [ "<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>", "<uses-permission android:name=\"android.permission.INTERNET\"/>", "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>", "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>", "<uses-permission android:name=\"android.permission.INSTALL_PACKAGES\"/>" ] } } } }写权限现在看起来不太需要,因为文件放在私有目录,但部分低版本 Android 系统读取下载文件时仍会校验,加上保险。另外如果你的项目是离线打包(用 Android Studio 配合 5+ SDK),情况会复杂一些,需要在 AndroidManifest.xml 里手动配置 FileProvider:
<provider android:name="io.dcloud.common.util.DCloud_FileProvider" android:authorities="${applicationId}.dc.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/dcloud_file_provider" /> </provider>离线打包时这个 provider 一旦漏配,表现就是"下载成功但安装没反应",日志里会提示 FileUriExposedException。这个坑我在一个老项目里排查了整整一个下午,最后发现是升级 SDK 之后 AndroidManifest 没跟着更新。
3.3 iOS 上怎么处理整包更新
iOS 端没有自下载安装这一说,系统就是不允许。所以 iOS 的整包更新只有一条路:跳到 App Store,让用户在商店里点更新。调用很简单:
function gotoAppStore(storeUrl) { if (!storeUrl) { // 兜底:用应用名称搜索 storeUrl = 'https://apps.apple.com/cn/app/id0000000000'; } plus.runtime.openURL(storeUrl, () => { uni.showToast({ title: '请手动前往应用商店更新', icon: 'none' }); }); }实测下来有几个经验点。第一,plus.runtime.openURL打开 https 链接时系统会自动唤起 App Store 客户端,不用特意写itms-apps://这种 scheme,后者在某些系统版本上反而会被拦截。第二,跳到商店之后用户可能看完详情页就返回了,所以更新弹窗不能只在按下按钮时关闭,还要监听应用回到前台的时机重新检查一次版本,避免出现"点了更新但版本没变"的困惑。
提示:iOS 的版本比较参考值建议用
CFBundleShortVersionString对应的版本名称,而整数版本号在 iOS 侧没有强约束,服务端判断时以版本名称为准更稳妥。
3.4 下载校验与文件清理
包下载下来之后要不要校验?我的答案是热更新包一定要校验,整包按需。原因是 wgt 包体积小,MD5 计算快,一个字节损坏就可能导致安装后白屏;而 APK 动辄几十 MB,在 JS 层读文件算 MD5 性能代价太高,得不偿失。
大文件校验我更推荐两条路:一是服务端同时下发文件大小,客户端下载后比对downloadedSize === fileSize,能拦住大部分断流导致的半包;二是如果需要严格校验,写一个原生插件在 Android 侧用 MessageDigest 计算,iOS 侧用 CommonCrypto,速度比 JS 快一个数量级。社区里也有现成的文件校验插件可以直接引入。
文件清理很容易被忽略,但它是真实存在的故障源。_doc/update/目录下的旧包如果不删,一是占空间,二是如果服务端换了 CDN 而文件名不变,plus.downloader 可能会命中已存在的文件直接返回成功,实际拿到的是旧包。所以我在应用启动时做一次目录清理:
function clearUpdateDir() { plus.io.resolveLocalFileSystemURL(UPDATE_DIR, (entry) => { entry.removeRecursively(() => { console.log('更新目录已清空'); }, (e) => { console.warn('清空更新目录失败', JSON.stringify(e)); }); }, () => { // 目录不存在,忽略 }); }这个清理动作放在应用冷启动、且不在更新流程中时执行,避免把正在下载的文件删掉。
4. 热更新 wgt 包的实现路径
4.1 wgt 包到底是什么、包含哪些内容
wgt 是 5+ 引擎定义的一种资源包格式,本质上就是一个 zip 压缩文件换了后缀,里面按特定目录结构存放编译产物。用 HBuilderX 打包时选"发行 - 原生App-制作应用wgt包",得到的文件可以直接扔到 CDN 上。它里面包含的东西就是编译后的前端资源,比如app-service.js、页面模板、静态图片、manifest.json中运行时可读的部分。
需要建立的一个认知是:wgt 包和安装包里的资源是两套,安装后 wgt 会覆盖到应用私有目录的一个独立位置。这意味着一件事——用户在更新之后如果卸载重装,又会回到安装包里内置的那个版本,得重新走一次更新流程。这是正常现象,不是 bug。
4.2 wgt 安装与重启的代码实现
热更新的 API 和整包差异不大,下载流程可以复用,只有最后一步从plus.runtime.install(apk)变成plus.runtime.install(wgt):
function applyWgtUpdate(filePath) { plus.runtime.install(filePath, { force: true }, () => { uni.showModal({ title: '更新完成', content: '新版本已就绪,需要重启应用生效', showCancel: false, confirmText: '立即重启', success: () => { plus.runtime.restart(); } }); }, (err) => { console.error('wgt 安装失败', JSON.stringify(err)); // 降级:走整包更新 fallbackToApkUpdate(); }); }force: true在 wgt 场景下建议打开,因为资源包不存在签名冲突问题,强制安装能少一层确认交互。安装成功之后必须调plus.runtime.restart()重启,页面不会自动刷新,这点和很多人直觉不一样。
重启之后有个体验细节要注意:如果用户当时正在填表单或者浏览列表,重启会丢失当前状态。所以我的做法是弹一个自定义的确认框,提示"更新将在下次启动生效",让用户自己选是立刻重启还是稍后,如果选稍后,就在下次冷启动时自动应用。这个是纯产品层面的取舍,没有标准答案。
4.3 打包 wgt 之前必须做的版本号处理
这是整条热更新链路里最容易翻车的一环。plus.runtime.install在安装 wgt 时会读取包内 manifest 的版本信息,如果版本号不高于当前运行版本,安装会被静默拒绝,回调里的错误信息通常很含糊,很容易被误判成"下载失败"。
所以每次打 wgt 包之前,必须先在 manifest.json 里把"应用版本名称"和"应用版本号"递增。比如当前线上是 1.4.1,那么这次 wgt 包就要打成 1.4.2,哪怕安装包里内置的还是 1.4.1。等下一次整包发版时,安装包的版本号再统一提到 1.4.2 或者更高,保持版本序号单调递增。
{ "name": "你的应用名", "appid": "__UNI__A1B2C3", "versionName": "1.4.2", "versionCode": 10402 }注意:wgt 包内 manifest 的 appid 必须和当前安装包的 appid 完全一致,也就是那个
__UNI__开头的字符串,否则安装会失败。跨项目复制 wgt 包这种操作千万别干。
4.4 热更新的两种失败降级方案
热更新虽然轻,但它不是万能的,必须准备降级路径。我这边定义了两种降级。
第一种是接口层降级。客户端请求更新接口时上报当前版本,如果服务端发现"理论上该有 wgt,但客户端上报的安装包版本过低导致 wgt 装不上",就直接返回 apk 类型的更新。这个判断逻辑我放在服务端,用minWgtBaseVersion字段控制——只有安装包版本达到某个基线,才允许下发 wgt。
第二种是运行时降级。wgt 安装回调查到错误时,客户端立刻自动请求一次强制整包更新的接口,把用户导到 APK 下载流程。这样即使 wgt 出了问题,用户也不至于卡在旧版本上不动。降级逻辑一定要做,因为 Android 机型碎片化严重,某些定制系统对私有目录写入有限制,wgt 安装确实存在小概率失败。
5. 一个可复用的更新模块该怎么组装
5.1 模块目录与职责划分
我把更新相关的代码抽成了一个独立目录,结构大概是这样:
/common/update/ ├── index.js # 对外统一入口,checkUpdate() ├── version.js # 版本比较、版本号折算 ├── downloader.js # 下载与进度封装 ├── installer.js # wgt 与 apk 安装分支 ├── cleaner.js # 更新目录清理 └── ui.js # 更新弹窗与进度界面拆开的好处是排查问题时定位快。比如用户反馈"更新不动",先看 downloader 的日志,再看 installer 的回调,不用在一大坨代码里翻。对外只暴露一个checkUpdate(options),业务页面不用关心内部走的是哪条通道。
// index.js 精简示意 import { getCurrentVersion } from './version'; import { downloadPackage } from './downloader'; import { installWgt, installApk, gotoStore } from './installer'; export async function checkUpdate(options = {}) { const versionInfo = await getCurrentVersion(); const res = await requestUpdateApi(versionInfo); if (!res || res.updateType === 'none') return { hasUpdate: false }; const userConfirmed = await showUpdateDialog(res); if (!userConfirmed) return { hasUpdate: false, canceled: true }; const filePath = await downloadPackage(res.fileUrl, (p) => { updateProgress(p); }); if (res.updateType === 'wgt') { installWgt(filePath); } else if (res.updateType === 'apk') { installApk(filePath); } else { gotoStore(res.iosStoreUrl); } return { hasUpdate: true }; }5.2 弹窗与进度的交互设计细节
更新弹窗看着简单,实际上细节很多。我这边的弹窗包含:版本号对比、发布说明、下载体积、更新按钮、稍后按钮(强制更新时隐藏)。下载开始后弹窗切换成进度态,显示百分比和已下载体积,同时提供一个取消按钮——但取消之后要能续传,不能让用户重头再来。
进度回调的触发频率要注意,statechanged事件触发非常密集,如果每次都 setData 更新界面,页面会卡。我的做法是做一个节流,只在百分比整数变化时才更新视图:
let lastPercent = -1; function onProgress(p) { if (p === lastPercent) return; lastPercent = p; // 这里再更新 UI progressValue.value = p; }另外低端机上大文件下载会造成 UI 卡顿,建议下载期间减少动画和复杂渲染,进度条用简单的 CSS 宽度过渡就行,别用 canvas 画圆环。
5.3 manifest.json 关键配置项清单
打包相关的配置集中在 manifest.json,我列一下和更新功能直接相关的几项配置:
| 配置项 | 位置 | 作用 |
|---|---|---|
| versionName | 基础配置 | 应用版本名称,参与版本比较 |
| versionCode | 基础配置 | 整数版本号,服务端强更判断依据 |
| appid | 基础配置 | 应用唯一标识,wgt 包必须一致 |
| REQUEST_INSTALL_PACKAGES | app-plus > distribute > android > permissions | Android 8.0+ 安装 APK 必需 |
| INTERNET | 同上 | 下载更新包必需 |
| 应用图标与启动图 | app-plus > distribute | 属于安装包资源,热更新覆盖不了 |
| 原生插件配置 | app-plus > nativePlugins | 变更新增必须整包更新 |
这份清单建议直接贴在项目 README 里,每次发版前对照检查一遍。我团队里就出现过"忘了加权限结果更新装不上"的事故,后来把这张表变成了发布 checklist 的固定项。
5.4 服务端发布侧的配合要点
客户端做得再顺,服务端发布流程不配合也是白搭。我现在要求运营同学发版时必须走固定流程:先上传安装包到 CDN 并记录地址和 MD5,再上传 wgt 包(如果这次有前端改动),然后在后台配置版本信息、发布说明、强制更新范围,最后按灰度比例发布。
这里有个顺序问题特别重要:先上传文件,再更新接口数据。如果反过来,接口先返回了新版本信息,但 CDN 上的文件还没传完,用户就会下载到 404 或者残缺文件。这个顺序我踩过一次,那天晚上大概有几十个用户卡在下载进度条上,后来在下载完成后加了一层文件大小比对才兜住。
6. 常见问题排查实录
6.1 下载成功但安装没反应
这个问题的排查路径我总结成了一条线:先看权限,再看路径,最后看签名。
权限这一层,Android 8.0 以上如果没有REQUEST_INSTALL_PACKAGES,plus.runtime.install的回调里通常会带一个很模糊的错误,日志里能看到Permission Denied或者INSTALL_FAILED_*之类。解决方式是在 manifest 里补权限重新云打包。另外用户手动关闭了"未知来源安装"也会导致失败,这种情况要在失败回调里引导用户去设置页打开。
路径这一层,重点看文件是不是真的下载到了预期目录。可以在成功后立刻打印d.filename,如果返回的是_downloads/开头的路径,说明 filename 参数没生效,那就需要检查是否漏了目录末尾的斜杠。_doc/update/和_doc/update在行为上有差异,前者被识别为目录,后者会被当成文件名前缀。
签名这一层,用keytool比对一下老包和新包的签名指纹:
keytool -printcert -jarfile old.apk keytool -printcert -jarfile new.apk两个 MD5 指纹不一致就说明签名换了,老用户无法覆盖安装,只能让用户卸载重装。这种情况对用户流失影响很大,所以千万要保管好签名文件,别用不同电脑的 HBuilderX 默认证书去打正式包。
6.2 wgt 更新后白屏或者版本号没变
白屏的典型原因是资源包和安装包不匹配。比如你打 wgt 时用的是新版 HBuilderX,而线上安装包是用旧版打的,两者编译产物的结构和 API 可能有差异。解决办法是保证 wgt 包和安装包用同一套编译环境。另一种情况是 wgt 包里包含了新的原生能力调用,但安装包没有对应插件,运行时直接报错,页面自然就白了。
版本号没变则大概率是前面提到的"版本号没有递增"导致的安装被拒。判断方法很简单,在安装回调的成功和失败分支都打日志,如果两边都没进,说明安装请求根本没被执行,这时候去检查一下plus.runtime.install的第一个参数路径是不是拼错了。
还有一种很隐蔽的情况:wgt 安装成功了,但应用重启后读取到的还是旧版本。这通常是因为重启时机不对,plus.runtime.restart()在部分机型上需要等一小段延迟,或者应用被系统冻结在后台。我的处理方式是重启之前先把更新完成的状态写入本地存储,下次冷启动时检查这个标记,如果存在就再触发一次重启。
6.3 更新相关的问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 下载进度卡在 0% | CDN 地址失效、跨域或证书问题 | 用浏览器直接访问地址验证,检查 https 证书链完整性 |
| 下载完成但文件大小为 0 | filename 参数为目录但服务端 URL 无文件名 | 检查 URL 末尾是否有正常文件名,或显式指定完整路径 |
| 安装提示"解析包错误" | 文件下载不完整或 MD5 不匹配 | 比对文件大小,补充完整性校验,重试下载 |
| 安装提示"应用未安装" | 签名不一致、版本号低于已装版本 | 比对签名指纹,确认版本号递增 |
| wgt 安装无任何回调 | 版本号未递增被静默拒绝 | 提升 manifest 版本号后重新打包 |
| 更新后页面白屏 | 资源包与安装包不匹配、原生插件缺失 | 用同环境重新打包,检查是否含新增原生能力 |
| iOS 跳转商店无反应 | URL 格式错误或被拦截 | 改用标准 https 商店链接,不要用自定义 scheme |
| 强制更新弹窗可关闭 | forceUpdate 判断被绕过 | 检查 minVersionCode 与客户端上报值的单位是否一致 |
6.4 真机测试的几个实用技巧
更新功能一定要在真机上测,模拟器和云真机都会失真。我的测试流程是:先装一个低版本包到手机上,然后修改服务端接口返回一个高版本信息,走一遍完整流程。为了不每次都改服务端,我会在客户端加一个隐藏的调试入口,手动输入版本信息和文件地址来触发更新,长按应用 logo 五秒调出来,只在开发包里保留。
另外重点测的场景包括:弱网下载中断后续传、下载过程中切后台再回来、点击取消后再重新更新、强制更新时按返回键的表现。这几个场景在真实用户那里出现频率远比想象中高,尤其是后台切换,Android 上应用切后台后下载任务可能会被系统挂起,需要在恢复前台时检查任务状态并决定是否重启下载。
7. 实操心得与上线前的注意事项
7.1 我在真机测试中踩过的坑
第一个坑是应用私有目录的清理时机。有一次我在应用启动时就清空更新目录,结果用户点更新时下载包刚下到 30%,应用被系统回收后重新启动,清理逻辑把半个包删了,任务恢复时读不到文件,直接报错。后来改成"只在没有进行中的下载任务时才清理"。
第二个坑是下载文件名的可预测性。有些 CDN 返回的 Content-Disposition 里带文件名,有些直接把 URL 路径最后一段当文件名。如果两次发版的文件名一样,比如都叫app.apk,第二次可能命中缓存或者旧文件,导致用户装上的还是老版本。解决办法是让文件名带版本号,比如app-v1.4.2.apk,从命名上就杜绝复用。
第三个坑是安装回调的时机。plus.runtime.install的 success 回调只代表"安装界面被拉起来了",不代表安装完成。所以别在 success 里就把本地状态标记成"已更新",用户很可能在系统安装界面点了取消。我的做法是不做本地标记,每次启动都重新请求接口判断,以服务端返回为准,这样最不容易出错。
7.2 应用市场上的合规注意点
国内安卓应用市场对"应用内下载安装"这件事各自有要求,上架前建议逐家确认。有些市场倾向于由它们自己承载更新,会把应用内的自更新行为纳入审核范围。为了减少沟通成本,我的策略是在合法合规前提下,优先保证应用内更新功能的可用性,同时准备一套开关,通过服务端配置可以在特定渠道包上关闭自下载安装,只保留跳转市场的路径。
隐私合规方面,更新接口请求会涉及设备信息上报,这部分要跟隐私政策里的描述对上,别上报了设备标识却没写进政策,这在近两年的审核里很容易被打回。我通常只上报版本名称、版本号、设备平台这三个字段,能用最少的信息判断就不多报。
iOS 方面,应用内动态下发资源需要自行确认是否符合当时有效的审核要求,稳妥的做法是 iOS 侧尽量以整包更新为主,热更新只在必要的时候使用,并且更新内容严格限定在前端展示与逻辑层面。
7.3 上线前的检查清单
发版前我固定跑一遍这些检查:manifest 里的版本名称和版本号是否已经递增;wgt 包和 apk 包是否用同一套编译环境产出;服务端接口返回的字段是否和客户端解析代码一致;CDN 上的文件 URL 是否可以直接访问,大小是否和接口返回的 fileSize 一致;Android 权限是否齐全;强制更新的 minVersionCode 是否配置合理;灰度比例是否从低到高设置。
还有一个容易漏的点:测试包和正式包的更新接口地址要分开。我见过测试同学用内网地址打包,结果提交应用市场时忘了改,导致正式包一发布就请求不到更新接口,用户永远收不到更新提示。现在的做法是用环境变量在打包时区分,正式包里硬编码正式域名,从源头避免手误。
最后再分享一个小技巧,关于 wgt 包的体积控制。打包时把没用的静态资源清掉,图片走 CDN 引用而不是打进包里,一个正常的业务 App,wgt 包控制在 2MB 以内是比较理想的,用户点更新几乎瞬间完成,体验上非常顺。你可以在构建脚本里加一条产物大小检查,超过阈值就在 CI 里报出来,逼着团队养成控制包体积的习惯。这个东西一旦松懈,半年之后包体积翻三倍是常态。