简介:面向 HBuilder X 与 uni-app 开发者的版本更新配套包,聚焦应用热更新资源制作、wgt 包组织与 APK 安装升级场景,适合正在处理移动端版本发布、资源替换和客户端更新的初中级开发者参考。压缩包共 35 个文件,整体约 718KB,以 png 图片、js 脚本、css 样式、json 配置、html 页面和 ttf 字体为主,涵盖界面素材、交互逻辑、样式表、应用清单、入口页面与字体资源等常见组成,便于对照理解热更新包内不同类型文件的职责与引用关系。已有 2916 人学习下载,说明该样例在校验资源包结构和更新流程方面具有一定参考价值。读者可从中了解热更资源包的目录组织、应用清单与页面入口配置、静态资源引用方式,以及 APK 安装更新时素材与配置的搭配思路,用于排查资源路径、版本包制作和升级过程中的常见问题。适合作为 HBuilder X 项目版本迭代、热更新联调与安装包整理时的辅助样例。
1. 热更不是「发个包就完事」:先拆清 wgt 与 APK 的分工
线上版本炸了,运营在群里催,用户截图显示页面是三天前的旧样式,这时候最省事的做法不是重新打一个全量 APK 让人去应用市场重新下载,而是下发一个几百 KB 的 wgt 资源包,用户重启一次就换了一副面孔。HBuilderX 版本更新这套东西,说到底就是把「热更资源」和「安装 APK」当成两条并行的通道来用:前者负责 JS、CSS、Vue 页面、静态图片这些跑在 webview 里的东西,后者负责原生插件、权限、启动图、SDK 配置这些打包进安装包的部分。很多人第一次在 HBuilderX 里点「制作应用wgt包」,以为从此所有改动都能热更,结果加了原生插件之后热更包死活装不上,这就是没搞清边界。这篇内容面向的是已经用 uni-app 出过包、正在补更新链路的前端和客户端同学,说清版本号怎么定、wgt 怎么打怎么装、APK 怎么覆盖安装、以及那些会让你半夜爬起来的坑。
2. 版本号与更新清单:manifest.json 里三个字段的真实分工
在动「制作wgt包」这个按钮之前,先把版本号这件事定死。HBuilderX 的更新链路里,版本号既是展示给用户看的字符串,也是客户端判断「要不要更新」的唯一依据,还是 Android 系统决定能不能覆盖安装的硬条件。这三个用途分别对应 manifest.json 里三个不同的位置,混用了就等着出乱子。
2.1 applicationId、versionName、versionCode 各管什么
打开 manifest.json 的可视化界面,在「基础配置」里能看到应用标识和两个版本字段,切到源码视图长这样:
{ "name": "某应用", "appid": "__UNI__A1B2C3D", "versionName": "2.3.0", "versionCode": "230", "app-plus": { "usingComponents": true, "distribute": { "android": { "packagename": "com.example.someapp", "keystore": "...", "permissions": [] } } } }appid是 HBuilderX 给这个应用分配的标识,热更检测时plus.runtime.appid读的就是它,服务端按它下发清单,这个值不要手动改,改了等于换了个应用。versionName是给人看的字符串,用户在「关于」里看到的就是它,格式建议严格三段式主.次.修订,因为客户端要按点号切分做比较。versionCode是给机器看的整数,Android 系统用它在覆盖安装时判断新旧,热更链路里用它做「强更阈值」的下限,这个值只能涨不能跌。
packagename是 Android 的包名,它和签名文件一起决定了 APK 能不能覆盖安装。包名变了,系统会当成两个完全不同的应用并存;签名换了,覆盖安装会直接报INSTALL_FAILED_UPDATE_INCOMPATIBLE。血泪经验是:项目一开始就把包名和签名文件定下来,签名文件连同密码放进内部密码库,别放在某个人的笔记本桌面上。
需要特别提醒的是versionCode在源码里是字符串,但实际值必须是纯数字,写"2.3.0"或者"v230"都会在打包或安装阶段出问题。可视化界面里它是个数字输入框,切到源码视图之后很容易被手误加上引号里的点。
2.2 服务端清单该返回哪些字段
客户端不该自己去猜要不要更新,判断逻辑放服务端更稳,客户端只负责执行。一份够用的更新清单大概长这样:
{ "code": 0, "data": { "latestVersionName": "2.4.0", "latestVersionCode": 240, "minVersionCode": 200, "wgt": { "url": "https://static.example.com/pkg/__UNI__A1B2C3D_240.wgt", "size": 486233, "md5": "9f2c1e5a7b3d4c8e0f1a2b3c4d5e6f70", "force": false }, "apk": { "url": "https://static.example.com/pkg/someapp_240.apk", "size": 38742016, "md5": "1a2b3c4d5e6f708192a3b4c5d6e7f809", "force": false }, "notice": "修复了订单页在弱网下的重复提交问题" } }latestVersionCode和客户端当前值比较,决定要不要提示;minVersionCode是强更红线,当前值低于它就必须走全量安装,不给「稍后再说」的选项;wgt.size和wgt.md5是下载完成后的校验依据,少了这两个字段,服务端返回一个 502 的 HTML 错误页也能被当成更新包存下来,安装时才报错,排查成本翻好几倍。
wgt.force和apk.force分开控制,因为热更强制重启的体验代价比一次全量安装小得多。我一般的策略是:普通资源改动走 wgt 且不强制,用户下次冷启动生效;涉及支付、登录态、数据结构的改动走 wgt 且强制,弹窗只有一个「立即重启」按钮。
清单本身要做缓存控制,接口返回头带上Cache-Control: no-cache,或者 URL 上带时间戳。见过不止一次因为 CDN 缓存了旧清单,灰度都结束了还有一批用户卡在中间版本上反复横跳。
2.3 灰度、强更与降级这三个开关
灰度不是「服务端随机返回」,那样用户重装一次就换了一个分组,体验很割裂。稳妥的做法是用设备维度的稳定哈希:拿appid + 设备唯一标识做一次哈希,对 100 取模,落在指定区间内的才下发新版本清单。这样同一个设备永远在同一组,出问题能精确圈定范围。
强更的判断顺序不能颠倒:先看minVersionCode,低于红线直接走 APK 全量;再看latestVersionCode,高于当前值才考虑 wgt;最后才看 wgt 自身的force标记。顺序反了会出现「用户被强制重启了三次,但版本号一直没变」的死循环。
降级这件事要提前想清楚。用户装了 2.4.0 的 APK,服务端因为事故把清单回滚到 2.3.0,这时候客户端拿到的是比自己低的版本号,比较函数必须返回「不更新」而不是「更新」。同时 wgt 的版本号一定要跟着 APK 走,不能出现「APK 是 2.4.0,下发的 wgt 是 2.3.0」这种倒挂,否则安装时会被版本校验拦下来。下面这个比较函数是所有判断的基础,写成公共模块,别在每个页面里复制粘贴:
/** * 比较两个点分版本号 * @param {string} a 当前版本,如 '2.3.0' * @param {string} b 目标版本,如 '2.4.0' * @returns {number} 1 表示 a>b,-1 表示 a<b,0 表示相等 */ function compareVersion(a, b) { const pa = String(a || '0').split('.').map(n => parseInt(n, 10) || 0) const pb = String(b || '0').split('.').map(n => parseInt(n, 10) || 0) const len = Math.max(pa.length, pb.length) for (let i = 0; i < len; i++) { const x = pa[i] || 0 const y = pb[i] || 0 if (x > y) return 1 if (x < y) return -1 } return 0 }这段代码的关键在parseInt(n, 10) || 0,它能吃掉2.3.0-beta这种带后缀的版本号,把beta解析成 0,避免NaN参与比较导致整个判断恒为 false。缺位补 0 的逻辑让2.3和2.3.0判定为相等,方便运维在清单里少写一位。len取两者最大长度而不是固定 3,是为了兼容以后可能出现的四段式版本号。
判断入口建议只写一处,做成checkUpdate(options):
import manifestFallback from '@/common/version.json' export function checkUpdate({ silent = false } = {}) { const sys = uni.getSystemInfoSync() const currentCode = Number(sys.appVersionCode || 0) const currentName = sys.appVersion || '0.0.0' return new Promise((resolve, reject) => { uni.request({ url: 'https://api.example.com/app/version', method: 'GET', data: { appid: plus.runtime.appid, code: currentCode, t: Date.now() }, timeout: 8000, success: (res) => { if (res.statusCode !== 200 || !res.data || res.data.code !== 0) { return resolve({ action: 'none', reason: 'bad-response' }) } const d = res.data.data if (currentCode < d.minVersionCode) { return resolve({ action: 'apk', force: true, info: d }) } if (d.wgt && compareVersion(d.latestVersionName, currentName) > 0) { return resolve({ action: 'wgt', force: !!d.wgt.force, info: d }) } if (d.apk && Number(d.latestVersionCode) > currentCode) { return resolve({ action: 'apk', force: !!d.apk.force, info: d }) } resolve({ action: 'none' }) }, fail: (err) => { if (!silent) uni.showToast({ title: '检查更新失败', icon: 'none' }) reject(err) } }) }) }uni.getSystemInfoSync()给出的appVersion和appVersionCode在热更生效并重启之后,读到的就是 wgt 包里的新值,所以在一次 wgt 更新完成之后不要用内存里缓存的旧版本号继续判断,否则会连续弹两次更新提示。silent参数用于启动时静默检查,失败不打扰用户,手动点「检查更新」时才走 toast。timeout设 8 秒是有意压低的,更新检查不该阻塞首屏。
3. 打 wgt 包与下发:从 HBuilderX 到 plus.runtime.install
版本号定好之后,热更这件事就变成了三步流水线:把新代码打成 wgt、把 wgt 传到静态资源服务器、客户端下载并安装。每一步都有能让你卡半天的细节。
3.1 制作 wgt 包:入口、命名与不可热更清单
HBuilderX 里的入口在菜单「发行」→「原生App-制作应用wgt包」,弹窗里会让你选输出路径,并显示当前 manifest.json 里的版本名称和版本号。这里最容易翻车的一点:这个弹窗不会帮你改版本号。你如果忘了先把versionName从 2.3.0 改成 2.3.1,打出来的包版本和线上一致,客户端比较之后认为不需要更新,或者装上去之后版本号没变,下次启动又提示一次更新,形成鬼打墙。
我的习惯是打包前先改 manifest.json,改完在「关于」页里核对一遍,再点「制作应用wgt包」。输出文件按appid_版本号.wgt命名,比如__UNI__A1B2C3D_231.wgt,好处是运维在服务器上扫一眼就知道哪个包对应哪个版本,也方便做版本回溯。
还有一份「不可热更清单」必须刻在脑子里。下面这些改动,打 wgt 没用,必须走 APK 全量:
| 改动类型 | 能否热更 | 说明 |
|---|---|---|
| Vue 页面、JS 逻辑、CSS 样式 | 可以 | 最常见的场景 |
| 静态图片、字体、本地 JSON | 可以 | 注意包体积,wgt 别超过 10MB |
| 新增/升级原生插件 | 不可以 | 插件是编译进安装包的 |
| 修改 manifest 权限、SDK 配置 | 不可以 | 权限清单在安装时确定 |
| 启动图、应用图标、应用名称 | 不可以 | 属于安装包资源 |
| 修改 packagename 或签名 | 不可以 | 等于换了个应用 |
一个实际边界:manifest.json里app-plus下的distribute配置改了,wgt 包里虽然带着这份文件,但安装时不会重新走一遍原生初始化,所以你改了推送配置、地图 Key,热更之后是完全不生效的。这类改动只能全量。
另外,wgt 包是用调试基座跑的,如果你本地用的是自定义基座调试,热更包打出来之后在自定义基座上是装不上的,因为基座的包名和正式包不一致。验证热更一定要用正式签名的包。
3.2 下载阶段:进度、校验与断点
下载 wgt 用plus.downloader比uni.downloadFile更合适,原因是前者能拿到实时进度、能指定存放目录、能在失败时拿到明确的错误描述。uni.downloadFile的临时文件路径在部分 Android 机型上会被系统清理,导致「下载成功但安装时文件不存在」。
/** * 下载热更资源 * @param {object} pkg { url, size, md5 } * @param {function} onProgress 进度回调,参数 0-100 */ function downloadWgt(pkg, onProgress) { return new Promise((resolve, reject) => { // filename 以 / 结尾表示这是个目录,文件名由下载器自动生成 const task = plus.downloader.createDownload( pkg.url, { filename: '_doc/update/', retry: 2, timeout: 30 }, (download, status) => { if (status !== 200 || !download.filename) { return reject(new Error('下载失败,HTTP ' + status)) } // 相对路径转绝对路径,后续 install 需要绝对路径 const absPath = plus.io.convertLocalFileSystemURL(download.filename) resolve({ path: absPath, raw: download.filename }) } ) task.addEventListener('statechanged', (t, st) => { if (st === 3 && t.getFileName) { // 3 表示 downloading const total = t.totalSize || pkg.size const done = t.downloadedSize || 0 if (total > 0 && typeof onProgress === 'function') { onProgress(Math.floor((done / total) * 100)) } } }) task.start() }) }filename: '_doc/update/'指向应用私有文档目录下的 update 子目录,这个目录在应用卸载前不会被系统清理,也不会被用户手动删到。结尾的斜杠不能省,省了会被当成文件名,下载器会把整个目录当成文件写,结果是写入失败。retry: 2表示失败重试两次,弱网环境下能救回不少。timeout: 30单位是秒,wgt 一般不大,30 秒足够,超大包再往上调。
statechanged回调里的st === 3是下载中状态,t.totalSize在服务端没返回Content-Length时会是 0,所以用清单里的pkg.size兜底。进度回调里做节流,别每一帧都去 setData 更新进度条,那是白烧性能。
下载完成之后必须校验,这一步省了就等于把线上稳定性交给运气:
// 文件大小校验:允许 1KB 内的浮动,防止服务端压缩造成差异 function checkSize(download, expectSize) { if (!expectSize) return true const real = download.totalSize || 0 return Math.abs(real - expectSize) <= 1024 } // MD5 校验需要原生能力,用 plus.io 读文件后交给原生计算 function checkMd5(filePath, expectMd5) { return new Promise((resolve) => { if (!expectMd5) return resolve(true) plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.file((file) => { const reader = new plus.io.FileReader() reader.onloadend = () => { // 大文件不要在这里读成字符串,交给原生 md5 工具或跳过 resolve(true) } reader.readAsDataURL(file) }) }, () => resolve(false)) }) }大小校验是最划算的一道防线:CDN 回源失败返回的 HTML 错误页通常只有几百字节到几 KB,和 wgt 的实际大小差好几个数量级,Math.abs一算就露馅了。MD5 校验在纯 JS 侧做大文件会很慢,实际项目里我一般只在小包(小于 2MB)上做全量 MD5,大包只校验大小和首尾若干字节的哈希,或者干脆依赖 HTTPS 加 CDN 的完整性保证。
3.3 安装与重启:回调里该做什么
安装 wgt 就一个 API,但周边处理决定了用户感知。
/** * 安装 wgt 并重启 * @param {string} filePath 绝对路径 * @param {boolean} force 是否强制安装(忽略版本号校验) */ function installWgt(filePath, force = false) { uni.showLoading({ title: '正在应用更新', mask: true }) plus.runtime.install( filePath, { force }, () => { uni.hideLoading() // 安装成功不等于生效,必须重启 plus.runtime.restart() }, (err) => { uni.hideLoading() const msg = (err && (err.message || err.msg)) || '未知错误' console.error('[wgt] install failed:', JSON.stringify(err)) uni.showModal({ title: '更新失败', content: '资源包应用失败:' + msg, showCancel: false }) } ) }force参数的含义是「忽略版本号比较,强制安装」。正常流程里传false就行,只有一种场景要传true:你在做内部测试,反复用同一个版本号覆盖安装。线上传true是很危险的,它会让降级包也能装进去,装完版本号倒退,下次启动又要更新,形成来回横跳。
安装成功回调里直接调plus.runtime.restart(),这是官方推荐路径。但实际线上有极小概率遇到重启后白屏,尤其是在低端 Android 机型上,原因是重启时旧 webview 还没完全销毁。稳妥一点的做法是延迟一小段时间再重启:
setTimeout(() => { plus.runtime.restart() }, 300)300ms 是经验值,太短没效果,太长用户会觉得卡。另外重启前要把「已提示过的版本号」写进本地存储,防止用户重启之后因为网络抖动又拉到一个旧清单,再次弹窗。存储的 key 用wgt_ignored_version,值是版本名称,检测逻辑里先读它,相等就跳过,直到服务端下发的版本号变了才清除。
提示:iOS 端的热更行为受平台规则约束,实际项目里通常只用于修复级别的小改动,涉及功能新增一律走全量发版。这部分按各自团队的合规要求执行,不要照搬 Android 的策略。
4. APK 覆盖安装:Android 7.0 之后的 FileProvider 与权限链
热更包解决不了原生层面的改动,该走全量还是得走。APK 的下载和安装比 wgt 麻烦,麻烦在 Android 从 7.0 开始收紧了文件共享,从 8.0 开始收紧了「安装未知来源应用」的权限,两条限制叠在一起,就是「下载完了点安装毫无反应」这个经典现象。
4.1 什么时候必须走 APK 全量
判断依据不是「wgt 装失败了就换 APK」,而是改动本身的性质。新增原生插件、调整 SDK 配置、修改权限清单、更换启动图、升级 uni-app 编译器大版本,这五类必须全量。还有一种隐性情况:wgt 包体积超过 10MB 之后,下载失败率和安装失败率都会明显上升,尤其是弱网地区的用户,这时候把资源打进 APK 反而更省事。
全量安装的触发条件要写进清单逻辑里,别让客户端自己判断。服务端在apk节点下给出url、size、md5、force,客户端只做三件事:下载、校验、调起安装器。判断逻辑集中在一处,出问题好复现。
还有一点容易被忽略:APK 的versionCode必须大于当前设备上已安装的版本,包括热更后的版本。如果用户热更到了 2.4.0 的 wgt,而服务端下发的 APK 是 2.4.0 但versionCode没涨,覆盖安装会被系统拦下来。所以 wgt 和 APK 的版本号要统一规划,APK 的 versionCode 永远是这一批改动的上界。
4.2 FileProvider 与安装未知来源权限的配置链
Android 7.0 之后,应用之间共享文件必须走FileProvider,直接传file://开头的 URI 会抛FileUriExposedException。HBuilderX 云端打包的默认基座里已经带好了这套配置,但如果你做过离线打包、改过 AndroidManifest、或者用了自定义基座,就必须自己核对一遍。
<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <application> <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> </application>REQUEST_INSTALL_PACKAGES是 Android 8.0 引入的权限,没有它,安装器调起来了也会立刻被系统关掉,用户看到的就是「闪一下就没了」。maxSdkVersion="28"是给存储权限加的上界,Android 10 之后分区存储生效,再申请这个权限没有意义,反而会在应用商店审核时被挑出来。
file_paths.xml里要覆盖 APK 的实际存放目录:
<?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="download" path="Download/" /> <files-path name="internal" path="update/" /> <cache-path name="cache" path="." /> </paths>external-files-path对应应用外部私有目录,files-path对应内部私有目录,cache-path对应缓存目录。三个都写上不浪费,能省掉后面「为什么安装器说找不到文件」的排查。如果权限没配全,plus.runtime.install的失败回调里给出的信息通常很含糊,只说「安装失败」,不看 logcat 基本定位不到。
云端打包的项目,这些配置默认已经处理好,你要做的只是在 manifest.json 的「App权限配置」里勾上安装未知应用的权限。离线打包的项目,务必在每次升级 HBuilderX 大版本之后重新对一遍 AndroidManifest,官方基座的配置会变。
4.3 安装失败对照表与签名校验
APK 的安装失败比 wgt 更难查,因为错误发生在系统安装器里,JS 侧拿到的只是一个笼统的失败。把下面这张表贴在团队文档里,能省掉大量重复问答:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 点安装毫无反应 | 缺少 REQUEST_INSTALL_PACKAGES | 补权限并重新打包 |
| 提示「解析包时出现问题」 | 下载文件不完整或不是 APK | 校验 size 和 md5,检查 CDN |
| 提示「应用未安装」 | 签名与已安装版本不一致 | 统一签名文件,卸载重装验证 |
| 提示「版本降级」 | versionCode 未递增 | 提升 versionCode 后重新打包 |
| 安装器闪一下就消失 | FileProvider 路径没覆盖到 | 补 file_paths.xml 条目 |
| 安装成功但打开是旧版 | 设备上有多个同包名应用 | 检查是否有测试包共存 |
签名这一条值得单独说。Android 要求覆盖安装的 APK 签名与已安装版本完全一致,包括证书、密钥算法、有效期。换过签名文件的包,用户只能先卸载再装,而卸载会丢本地数据。所以签名文件从项目第一天起就要归档,交接时作为一个交付物,别等到发版前才发现签名找不到了。
调起安装的代码和 wgt 类似,只是force传true,并且要在下载完成后做一次可安装性预检:
/** * 下载并安装 APK * @param {object} apk { url, size, md5 } */ async function installApk(apk) { const file = await downloadApk(apk) // 预检:文件存在且大小合理 const ok = await new Promise((resolve) => { plus.io.resolveLocalFileSystemURL(file.path, (entry) => { entry.file((f) => resolve(f.size > 1024 * 1024), () => resolve(false)) }, () => resolve(false)) }) if (!ok) { uni.showModal({ title: '安装包异常', content: '请稍后重试', showCancel: false }) return } plus.runtime.install( file.path, { force: true }, () => { console.log('[apk] installer launched') }, (err) => { console.error('[apk] install failed', JSON.stringify(err)) uni.showModal({ title: '安装失败', content: '请检查是否允许安装未知来源应用', showCancel: false }) } ) }force: true在这里是必须的,因为 APK 覆盖安装本身就是「强制」语义。预检里的 1MB 阈值是个经验下限,正常 APK 不可能小于这个数,小于说明下载到的是错误页。resolveLocalFileSystemURL的回调里只判断存在性和大小,不做全量读取,避免大文件阻塞主线程。
5. 避坑与排查:四条熬夜换来的记录
更新链路的问题有个共同特征:本地怎么测都好,一上量就出事。下面四条是实际项目里反复踩过的,按「现象 → 原因 → 解决」写清楚。
5.1 热更装成功了,但页面还是旧的
现象:日志显示plus.runtime.install成功回调已触发,应用也重启了,但用户看到的还是旧页面,版本号也没变。
原因:最常见的是打 wgt 包时忘了改versionName,包内版本和线上一致,安装时被版本校验静默跳过,回调却仍然返回成功。第二种可能是下载到的文件其实是个 HTML 错误页,.wgt后缀是下载器按命名规则加的,内容根本不是 zip,安装器解压失败但不报错。第三种是应用跑在自定义基座上,基座包名和正式包不同,热更装到了另一个应用里。
解决:打包前把版本号改动做成清单里的第一步,改完在「关于」页核对;下载完成后强制校验大小和文件头,wgt 本质是 zip,前两个字节应该是PK,读一下文件头就能判断;验证热更一律用正式签名的包,不用自定义基座。查到问题之后,把已提示版本写进本地存储并加一个「版本号 + 文件大小」的联合 key,避免缓存命中错误状态。
5.2 更新提示反复弹,重启完还是弹
现象:用户点「立即更新」、重启、进入首页,几秒后更新弹窗又来了,循环三次以上。
原因:客户端读到的当前版本号没有及时刷新。热更安装成功后,如果应用没有真正重启(比如用户在重启前杀掉了进程),内存里缓存的appVersion还是旧值,下次启动时服务端下发的仍是同一个新版本,比较结果依然是「需要更新」。另一种情况是服务端清单被 CDN 缓存,用户已经更新到最新版,清单还停在旧数据上。
解决:安装成功后写入pending_version标记,启动时先读标记,如果标记里的版本等于服务端下发的版本,直接清除标记并跳过弹窗,给系统一次完整的冷启动机会。清单接口加时间戳参数并设置Cache-Control: no-cache。判断逻辑里加一条保险:连续两次提示同一版本且安装都成功,第三次直接跳过并上报埋点,宁可漏更一次,也别把用户锁在弹窗里。
5.3 APK 下载完,点安装没任何反应
现象:进度条走到 100%,日志显示下载成功,点「立即安装」之后界面无变化,也没有任何错误提示。
原因:Android 8.0 之后缺少REQUEST_INSTALL_PACKAGES权限,系统会静默拦截安装请求。Android 7.0 之后如果 FileProvider 的file_paths.xml没有覆盖 APK 的实际目录,安装器拿到 URI 之后无法读取文件,同样表现为无反应。还有一种是plus.runtime.install传了相对路径而不是绝对路径,内部转 URI 失败。
解决:先用plus.io.convertLocalFileSystemURL把路径转成绝对路径再传;核对 AndroidManifest 里的权限和 provider 配置,离线打包项目每次升级 HBuilderX 后重新对一遍;在失败回调里把JSON.stringify(err)完整打出来,而不是只弹一句「安装失败」。真机排查时连上电脑看 logcat,关键字搜FileUriExposed和SecurityException,比在 JS 层猜快得多。
5.4 覆盖安装之后,版本反而倒退了
现象:用户装了新 APK,打开一看版本号比之前热更的还低,然后又被提示更新一次。
原因:wgt 的版本号跑在了 APK 前面。比如 APK 是 2.3.0(versionCode 230),热更把资源更到了 2.4.0,这时候服务端下发 2.3.1 的 APK,versionCode231 大于 230,系统允许覆盖安装,装完之后应用版本回到 2.3.1,比之前的 2.4.0 低。
解决:版本号统一由一个地方管理,APK 的versionCode永远是同批改动的最大值。具体做法是每次发版先定 APK 版本,wgt 版本只能小于等于 APK 版本,且 wgt 只用于「APK 版本内的增量修复」。清单接口里加一条校验:如果apk.latestVersionCode小于等于当前设备已热更到的版本,服务端直接返回不更新,把矛盾挡在服务端。
6. 上线前跑一遍这套自检,比事后看日志便宜
链路写完只是一半,真正决定线上稳定的是发版前那十几分钟的核对。我现在的习惯是把下面这段检查做成一个内部页面,挂在测试包的「设置」里,每次发版前手动点一遍,比对着文档逐条确认靠谱得多。
/** * 发版前自检:在测试包里调用,输出到控制台 */ function preflightCheck() { const sys = uni.getSystemInfoSync() const report = { platform: sys.platform, appid: plus.runtime.appid, versionName: sys.appVersion, versionCode: sys.appVersionCode, // 1. 基座是否为正式签名(自定义基座会返回 false) isReleaseBase: plus.runtime.appid && !sys.appVersion.includes('debug'), // 2. 下载目录是否可写 downloadDirWritable: false, // 3. 安装权限是否已授予(仅 Android) installPermission: true } // 探测下载目录 plus.io.resolveLocalFileSystemURL('_doc/', (entry) => { entry.getDirectory('update', { create: true }, () => { report.downloadDirWritable = true console.log('[preflight]', JSON.stringify(report, null, 2)) }, () => { console.log('[preflight] 目录创建失败', JSON.stringify(report, null, 2)) }) }, () => { console.log('[preflight] 私有目录不可用', JSON.stringify(report, null, 2)) }) }这段代码做了三件事:把当前运行环境的版本信息固化下来,方便和服务端清单对照;探测_doc/update/目录能否创建,提前暴露存储权限问题;标记基座类型,防止拿调试基座去验证正式热更。getDirectory的create: true参数会在目录不存在时自动创建,正好模拟真实下载路径。
自检之后还有几条硬性检查,用清单形式过一遍:
- 服务端清单里的
latestVersionCode是否大于线上所有已知版本,包括灰度组的 - wgt 包的
versionName是否比线上高一个修订号,且没超过 APK 的versionCode - APK 的签名是否和线上包一致,用
apksigner verify或安装到已有旧版的设备上验证 - 静态资源服务器是否对
.wgt和.apk返回了正确的Content-Type,.wgt建议application/octet-stream,避免被浏览器或 CDN 当成文本处理 - 灰度比例是否从 1% 起步,观察一小时无异常再放到 10%
一个具体技巧:把「更新检查」和「更新执行」彻底分开。检查只在启动和手动触发时发生,执行只在用户确认后发生,中间不允许有任何自动跳转。见过太多项目把这两件事揉在一起,启动时检查到更新直接开始下载,用户流量被吃掉几十兆还不知道发生了什么,投诉率飙升。
还有一个经验值:wgt 包的体积控制在 3MB 以内,超过这个数就把静态资源拆出去走 CDN 引用。下载耗时和失败率不是线性增长的,5MB 以上的包在弱网下的失败率会突然抬头,而失败重试又会进一步放大流量成本。
从那以后我每次发版,不管改动多小,都会先在测试机上把「旧版 → 热更 → 重启 → 校验版本 → 再热更一次」「旧版 → APK 覆盖 → 校验版本」两条路径各走一遍,确认没有鬼打墙的弹窗、没有版本倒挂、没有安装器静默失败。这套流程花不了二十分钟,比线上出事后翻日志、回滚、写复盘便宜太多。希望帮到你。
本文还有配套的精品资源,点击获取