uni-app APP 版本更新:从「能用」到「可靠」的实战记录
背景
项目是一个基于 uni-app + 若依(RuoYi) 的巡检应用,需要实现 APP 端版本检测与自动更新。服务端通过 REST API 管理版本信息(版本号、APK 下载地址、更新类型等),APP 启动时调用接口判断是否需要升级。
一开始觉得「不就是比个版本号嘛」,结果踩了三个坑,修修补补后整理成这篇记录。
一、版本信息的来源
uni-app 中,版本信息写在项目根目录的 manifest.json 里:
json
{
"name": "XXXX平台-测试版",
"versionName": "1.0.0",
"versionCode": "100"
}
打包 APK 时这些字段会被编译进原生配置,运行时通过 plus.runtime.getProperty() 读取:
javascript
plus.runtime.getProperty(plus.runtime.appid, (info) => {
console.log(info.version) // "1.0.0"
console.log(info.versionCode) // "100"
})
服务端同样维护这两个字段。APP 调用 GET /api/app/version 拿到最新版本,和本地对比判断是否需要更新。
二、坑一:用 versionName 比较版本号
最初写法
javascript
// 原始实现
_needUpdate(remote, localVersion) {
const remoteVersion = remote.versionName // "1.0.1"
return this._compareVersion(remoteVersion, localVersion) > 0
}
_compareVersion(remote, local) {
const r = (remote || '0').split('.').map(n => parseInt(n, 10) || 0)
const l = (local || '0').split('.').map(n => parseInt(n, 10) || 0)
const len = Math.max(r.length, l.length)
for (let i = 0; i < len; i++) {
const rv = r[i] || 0
const lv = l[i] || 0
if (rv > lv) return 1
if (rv < lv) return -1
}
return 0
}
问题
"1.10.0" vs "1.9.0" 这种场景下,语义化比较会正确得出 1 > 1 => 比第二位 10 > 9。虽然逻辑正确,但:
- 代码冗余,十几行只为了做个版本比较
versionCode本身就是设计来做版本比较的------它是递增整数 ,一行>就完事- Android 原生的版本比较用的就是 versionCode,何必再造轮子
改后
javascript
_getLocalVersionCode() {
return new Promise((resolve) => {
plus.runtime.getProperty(plus.runtime.appid, (info) => {
const vc = parseInt((info && info.versionCode) || '0', 10)
resolve(isNaN(vc) ? 0 : vc)
})
})
}
_needUpdate(remote, localVersionCode) {
const remoteCode = remote.versionCode
if (remoteCode == null) return false
if (!localVersionCode) return true // 拿不到本地版本,保守处理
return remoteCode > localVersionCode // 就这一行
}
删除 _compareVersion 整个方法,比较逻辑从 20 行缩减到 1 行。
三、坑二:服务端 versionCode 与实际 APK 不一致导致死循环更新
场景还原
存在这样一个风险场景:本地 App 真实 versionCode=100。打包新版本 APK 时,忘记修改 manifest.json,APK 内部实际 versionCode 依旧是 100;上传服务端录入版本数据时填成 versionCode=101。客户端调用
/api/app/version拿到服务端返回 101,对比本地 100 满足更新条件,会正常下载 APK。但是 APK 本身真实版本号没有提升,Android 系统不允许覆盖安装,更新直接失败。
补充:这个 bug 根因一句话
服务端存储的 versionCode 和 APK 包内真实 versionCode 不一致,客户端只信任服务端返回值,没有二次校验安装包本身版本,造成逻辑判断和系统行为割裂。
ini
本地 APP: versionCode=100 (manifest.json 打包时写的)
↓
GET /api/app/version → 服务端返回 versionCode=101
↓ ↑
101 > 100 → 需要更新 │ 管理员在后台表单里手动填的 101
↓ │
下载 APK,覆盖安装 │ 但这包其实是 1.0.1,
↓ │ 打包时 manifest.json 里忘记改,
新安装的 APP: versionCode=100 ←────┘ versionCode 还是 100
↓
下次启动检查: 服务器 101 > 本地 100 → 又弹更新
↓
用户再次下载安装 → 还是 100 → 死循环 💥
根因 :服务端数据库里的 versionCode 是管理员手动填写 的,而 APK 包内的真实 versionCode 是打包时 manifest.json 决定的,两个值没有任何校验机制。
解决方案:两层防御
第一层:客户端 localStorage 防重复弹窗
核心思路:记录「已提示过的服务端 versionCode」,同一版本绝不弹第二次。
javascript
const STORAGE_KEY_LAST_PROMPTED_VC = 'APP_LAST_PROMPTED_VERSION_CODE'
_shouldPrompt(remote, isManual) {
const remoteCode = remote.versionCode
const lastPrompted = this._getLastPromptedVC()
// 这个版本号已经提示过用户了 → 跳过
if (lastPrompted === remoteCode) {
if (isManual) {
uni.showToast({ title: '当前已是最新版本', icon: 'none' })
}
return false
}
// 没提示过:记录并放行
this._setLastPromptedVC(remoteCode)
return true
}
_getLastPromptedVC() {
return parseInt(uni.getStorageSync(STORAGE_KEY_LAST_PROMPTED_VC), 10) || 0
}
_setLastPromptedVC(versionCode) {
uni.setStorageSync(STORAGE_KEY_LAST_PROMPTED_VC, String(versionCode))
}
自动检查和手动检查都走这个判断,区别在于:自动检查静默跳过,手动检查跳过后给个 toast 提示。
效果:
| 时间 | 本地 VC | 服务端 VC | lastPrompted | 结果 |
|---|---|---|---|---|
| 首次检查 | 100 | 101 | 无 | 弹窗更新 |
| 安装后(APK 实际还是 100) | 100 | 101 | 101 | 跳过 ✅ |
| 真正新版本 1.0.2 | 100 | 102 | 101 | 正常弹窗 ✅ |
第二层:服务端自动提取 APK 真实 versionCode(根治)
客户端只能防死循环,从根本上解决问题需要服务端在上传 APK 时自动解析 真实的 versionCode。
Maven 依赖:
xml
<dependency>
<groupId>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>2.6.10</version>
</dependency>
工具类:
java
package com.ruoyi.common.utils;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import java.io.File;
public class ApkInfoUtil {
public static ApkVersionInfo extractVersion(String apkFilePath) throws Exception {
try (ApkFile apkFile = new ApkFile(new File(apkFilePath))) {
ApkMeta meta = apkFile.getApkMeta();
ApkVersionInfo info = new ApkVersionInfo();
info.setVersionCode(meta.getVersionCode().intValue());
info.setVersionName(meta.getVersionName());
return info;
}
}
// getter / setter 省略
public static class ApkVersionInfo {
private int versionCode;
private String versionName;
}
}
修改上传接口:
java
@PostMapping("/upload")
public AjaxResult upload(@RequestParam("file") MultipartFile file) {
// 1. 保存文件
String tempPath = saveToTemp(file);
// 2. ★ 解析 APK 真实版本信息
ApkVersionInfo info = ApkInfoUtil.extractVersion(tempPath);
// 3. 移动到存储路径
String apkUrl = moveToStorage(tempPath, info);
// 4. 返回时携带解析出的真实值,前端直接拿来填表单
AjaxResult result = AjaxResult.success("上传成功");
result.put("apkUrl", apkUrl);
result.put("versionCode", info.getVersionCode());
result.put("versionName", info.getVersionName());
return result;
}
新增/编辑接口兜底:
java
@PostMapping
public AjaxResult add(@RequestBody AppVersion appVersion) {
// 以 APK 文件解析值为准,覆盖前端传值
ApkVersionInfo info = ApkInfoUtil.extractVersion(getFullPath(appVersion.getApkUrl()));
appVersion.setVersionCode(info.getVersionCode());
appVersion.setVersionName(info.getVersionName());
return toAjax(appVersionService.insertAppVersion(appVersion));
}
流程对比:
ini
改前(危险):
管理员填表单 versionCode=101 → 存入数据库
APK 实际 versionCode=100 → 数据库里是 101
两份数据不一致 → 死循环更新
改后(安全):
上传 APK → 服务端解析: versionCode=100, versionName="1.0.1"
→ 返回给前端填入表单(只读)
→ 保存时再次以 APK 解析值为准写入数据库
→ 数据库存的就是 100,永远和 APK 一致 ✓
四、APK 上传安全
除了版本号一致性问题,上传环节还有这些需要防范的点:
| 优先级 | 防御项 | 位置 | 说明 |
|---|---|---|---|
| P0 | 文件大小限制 | 双端 | 防止超大文件拖垮服务,建议 200MB |
| P0 | 文件名防路径穿越 | 服务端 | ../../../etc/passwd 这种攻击 |
| P0 | 权限校验 riskperson:appVersion:add |
服务端 | 不是谁都能上传 |
| P1 | 文件头魔数校验 | 服务端 | APK 本质是 ZIP,头两字节固定为 PK(0x50 0x4B) |
| P1 | 扩展名白名单 .apk |
双端 | 前端限制选择器 + 后端二次校验 |
| P2 | 上传频率限制 | 服务端 | 同一用户每分钟最多 3 次 |
| P2 | APK 签名校验 | 服务端 | 确认来源可信 |
关键代码片段:
java
// 文件头魔数校验:防止将病毒文件改名成 virus.apk 上传
private static boolean isValidApk(byte[] header) {
return header.length >= 2 && header[0] == 0x50 && header[1] == 0x4B;
}
// 文件名防路径穿越
String filename = Paths.get(originalFilename).getFileName().toString()
.replaceAll("[^a-zA-Z0-9._-]", "_");
五、更新流程:直达最新版本
有人可能会问:用户本地是 1.0.1(versionCode=101),服务端存了 1.0.2~1.0.6 多个版本,用户更新时是逐个升级还是一步到位?
答案:一步到位,直接下载最新 APK。
sql
GET /api/app/version
│
▼
服务端: ORDER BY version_code DESC LIMIT 1 → 返回 VC=106 那条
│
▼
用户直接下载 1.0.6 完整包,覆盖安装
这跟 Google Play 和 App Store 的逻辑一致------APK 是完整包,不是增量补丁,不需要逐版本升级。1.0.6 已经包含了 1.0.2~1.0.6 的所有累积改动。
需要注意:
/api/app/version必须用ORDER BY versionCode DESC而不是ORDER BY id DESC或create_time DESC,否则补录旧版本时会返回错误的最新版本。
六、完整更新流程图
ini
┌──────────────────────────────────────────────────────┐
│ APP 启动 / 手动检查 │
└──────────────────────┬───────────────────────────────┘
│
┌────────────▼────────────┐
│ plus.runtime.getProperty │ ← 读取本地 versionCode
│ 本地 VC = 100 │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ GET /api/app/version │ ← 服务端返回最新
│ 服务端 VC = 106 │ (ORDER BY versionCode DESC LIMIT 1)
└────────────┬────────────┘
│
┌────────▼────────┐
│ 106 > 100 ? │
└───┬─────────┬───┘
│ No │ Yes
▼ ▼
┌──────────┐ ┌──────────────────────┐
│ 无需更新 │ │ 有 wgtUrl? │
└──────────┘ └───┬──────────────┬────┘
│ Yes │ No
▼ ▼
┌────────────┐ ┌──────────────────────────┐
│ 静默下载 wgt │ │ _shouldPrompt() 检查 │
│ 热更新 │ │ 同一 VC 是否已提示过? │
└────────────┘ └───┬──────────────────┬───┘
│ 已提示 │ 未提示
▼ ▼
┌────────────┐ ┌──────────────┐
│ 跳过/tips │ │ 记录 VC 到 │
└────────────┘ │ localStorage │
│ 弹出更新弹窗 │
└──────┬───────┘
│
┌──────▼───────┐
│ 下载 APK │
│ 调用系统安装 │
│ plus.runtime │
│ .install() │
└──────────────┘
七、总结
| 问题 | 方案 | 效果 |
|---|---|---|
| versionName 比较代码冗余 | 改用 versionCode 整数比较 | 代码从 20 行缩减到 1 行 |
| 服务端 VC 与 APK 真实 VC 不一致 | 客户端 localStorage 防重复弹窗 | 同一版本绝不死循环 |
| 同上(根治) | 服务端 apk-parser 自动提取真实 VC | 数据库存储值永远准确 |
| APK 上传安全 | 文件大小/MIME/路径穿越/权限等多层校验 | 防御各种攻击 |
两句话总结:
- versionCode 做比较,versionName 做展示,各司其职
- 不要相信任何手动填写的数据------APK 的真实版本信息应该由服务端从文件里解析出来