uni-app APP 版本更新二

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。虽然逻辑正确,但:

  1. 代码冗余,十几行只为了做个版本比较
  2. versionCode 本身就是设计来做版本比较的------它是递增整数 ,一行 > 就完事
  3. 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 DESCcreate_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/路径穿越/权限等多层校验 防御各种攻击

两句话总结:

  1. versionCode 做比较,versionName 做展示,各司其职
  2. 不要相信任何手动填写的数据------APK 的真实版本信息应该由服务端从文件里解析出来
相关推荐
红尘伴蝶舞6 小时前
H5游戏多进程隔离:如何解决Android内存激增与进程冻结问题
架构·app·android studio
熊猫钓鱼>_>2 天前
2026 鸿蒙全栈开发实战:从新能力落地到多设备上架的完整路径
华为·架构·app·harmonyos·arkts·鸿蒙·运营
ZZH_AI项目交付7 天前
同一套前摄心率算法,为什么 iPhone 16 Pro 稳,iPhone XR 会失准?
ios·app·ai编程
凡泰AI8 天前
如何借助MCP打通企业APP内部服务:从统一调用到小程序承接
小程序·uni-app·app·mpaas·mcp·小程序容器
私人珍藏库13 天前
[Android] 会计快题库 -财会职称考试刷题学习
android·人工智能·学习·app·软件·多功能
私人珍藏库13 天前
[Android] SimpleLive-聚合抖B虎鱼网络直播
智能手机·app·工具·软件·多功能
私人珍藏库13 天前
[Android] 抖音短视频 手表版-适配手机 极简体积无广告
智能手机·app·工具·软件·多功能
ZZH_AI项目交付14 天前
端侧大模型跑进 iPhone XR:我为什么没把它做成聊天助手
人工智能·app·ai编程
私人珍藏库14 天前
[Android] Stay浏览器-无限制访问任何网站
智能手机·app·工具·软件·多功能