APP版本管理全链路 --- 从零到一实现若依框架下的APP更新系统
一、背景与需求
在若依(RuoYi)框架下,为APP实现版本管理功能,包括:
- APP端:自动检查更新,提示用户下载新版本
- Web管理端:超管在后台管理版本信息、上传APK
二、方案选型:数据库配置 vs 硬编码
| 方案 | 优点 | 缺点 |
|---|---|---|
| 硬编码 | 简单 | 每次更新需改代码、重启服务 |
| 数据库配置 ✅ | 动态管理,无需重启 | 需要建表、写CRUD |
选择数据库方案,建表适配 PostgreSQL:
SQL
sql
CREATE TABLE sys_app_version (
id BIGSERIAL PRIMARY KEY,
version_code INT NOT NULL,
version_name VARCHAR(50) NOT NULL,
apk_url VARCHAR(500),
wgt_url VARCHAR(500),
update_type VARCHAR(20) DEFAULT 'suggest',
update_log TEXT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
三、踩坑记录
坑1:静态资源访问 401
上传APK到 static/app/ 后,浏览器访问返回401。
原因 :Spring Security 拦截了 /static/** 路径。
修复 :在 SecurityConfig 中放行:
Java
scss
.antMatchers("/static/**").permitAll()
坑2:静态资源访问 404
放行后仍404。
原因 :Spring Boot 默认不映射 /static/** 到 classpath:/static/。
修复 :在 ResourcesConfig 中显式配置:
Java
arduino
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
坑3:返回的 apkUrl 中IP/端口不对
上传后返回的URL里IP是客户端IP而非服务器IP。
原因 :误用了 request.getRemoteHost() / getRemotePort()。
修复 :改用 request.getServerName() / getServerPort()。
坑4:补录旧版本导致返回错误版本
/api/app/version 用 ORDER BY id DESC 取最新版本,补录旧版本时id更大,导致返回旧版本。
修复 :改为 ORDER BY version_code DESC。
XML
ini
<select id="selectLatestVersion" resultMap="SysAppVersionResult">
<include refid="selectAppVersionVo"/>
order by version_code desc limit 1
</select>
坑5:apk-parser 依赖 NoClassDefFoundError
apk-parser 加在 ruoyi-common 模块,但运行时找不到类,报 NoClassDefFoundError: net/dongliu/apk/parser/ApkFile。
原因 :依赖只在 ruoyi-common 声明,但运行时打包在 ruoyi-admin 模块,依赖未传递进最终JAR。
修复 :在 ruoyi-admin/pom.xml 也显式声明依赖,确保打包进最终JAR:
XML
xml
<!-- APK文件解析 -->
<dependency>
<groupId>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>2.6.10</version>
</dependency>
四、方案二:服务端自动解析APK真实versionCode
问题
前端手动填写 versionCode 容易出错,比如填101实际却是102。
解决
引入 net.dongliu:apk-parser:2.6.10,上传APK时自动解析真实版本信息。
① 添加Maven依赖 (ruoyi-admin/pom.xml):
XML
xml
<!-- APK文件解析 -->
<dependency>
<groupId>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>2.6.10</version>
</dependency>
如果工具类放在
ruoyi-common模块,ruoyi-common/pom.xml也需要一份,确保编译通过。
② 工具类 ApkInfoUtil.java:
Java
java
package com.ruoyi.common.utils;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
public class ApkInfoUtil {
private static final Logger log = LoggerFactory.getLogger(ApkInfoUtil.class);
public static ApkVersionInfo extractVersion(String apkFilePath) throws IOException {
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;
} catch (Exception e) {
log.error("APK文件解析失败: {}", apkFilePath, e);
throw new IOException("APK文件解析失败: " + e.getMessage(), e);
}
}
public static class ApkVersionInfo {
private int versionCode;
private String versionName;
// getter / setter 省略...
}
}
③ 上传接口 保存文件后立即解析,返回真实版本信息:
JSON
css
{
"code": 200,
"msg": "上传成功",
"apkUrl": "/static/app/2026/07/29/patrolApp-1.0.2.apk",
"versionCode": 102,
"versionName": "1.0.2"
}
④ 新增/编辑接口 兜底覆盖------即使前端传错,也以APK解析值为准:
Java
ini
private void fillVersionFromApk(SysAppVersion appVersion) {
if (StringUtils.isEmpty(appVersion.getApkUrl())) return;
String absolutePath = System.getProperty("user.dir")
+ "/ruoyi-admin/src/main/resources" + appVersion.getApkUrl();
ApkInfoUtil.ApkVersionInfo info = ApkInfoUtil.extractVersion(absolutePath);
appVersion.setVersionCode(info.getVersionCode());
appVersion.setVersionName(info.getVersionName());
}
流程图
Plain Text
bash
前端上传 APK
│
▼
POST /upload
│ ① 保存至 static/app/
│ ② apk-parser 解析真实 versionCode / versionName
│ ③ 返回 apkUrl + versionCode + versionName
▼
前端拿到 versionCode(填入表单只读展示)
│
▼
POST /appVersion(新增)/ PUT /appVersion(编辑)
│ fillVersionFromApk() 再次解析 APK 兜底
│ 确保数据库存的永远 = APK 真实值
▼
数据库: versionCode = 102 ✓
五、前端防御方案:同版本不反复弹窗
问题:用户点了"暂不更新"后,每次打开APP都弹窗。
方案 :localStorage 记录"已提示过的 versionCode",同版本跳过。
JavaScript
ini
_shouldPrompt(remote, isManual) {
const key = 'APP_LAST_PROMPTED_VERSION_CODE';
const lastPrompted = uni.getStorageSync(key);
const localVC = plus.runtime.versionCode;
if (remote.versionCode <= localVC) return false; // 无需更新
if (remote.versionCode === lastPrompted) {
if (isManual) uni.showToast({ title: '当前已是最新版本' });
return false; // 已提示过,跳过
}
uni.setStorageSync(key, remote.versionCode); // 记录本次
return true;
}
效果:
| 时间线 | 本地VC | 服务端VC | lastPrompted | 结果 |
|---|---|---|---|---|
| T1 自动检查 | 100 | 101 | 无 | 弹窗 ✅ |
| T2 用户安装(实际VC=100) | 100 | 101 | 101 | --- |
| T3 下次自动检查 | 100 | 101 | 101 | 101==101 → 跳过 ✅ |
| T4 真正新版本 | 100 | 102 | 101 | 102≠101 → 弹窗 ✅ |
六、最终架构
Plain Text
sql
┌──────────────────────────────────────────────────────┐
│ Web管理端(超管) │
│ │
│ 上传APK → apk-parser解析versionCode/versionName │
│ → 写入数据库 sys_app_version │
│ 列表/新增/编辑/删除 → CRUD │
└──────────────────────┬─────────────────────────────────┘
│
sys_app_version 表
│
┌──────────────────────┴─────────────────────────────────┐
│ APP端 │
│ │
│ GET /api/app/version → ORDER BY version_code DESC │
│ → 对比本地 versionCode │
│ → localStorage 防重复弹窗 │
│ → 下载 APK 覆盖安装 │
└──────────────────────────────────────────────────────┘
七、关键设计决策
- APK存
static/app/而非profile/upload/,利用静态资源映射直接提供下载,无需额外接口 - versionCode 以服务端解析为准,不信任前端传值,保证数据一致性
ORDER BY version_code DESC而非id DESC,防止补录旧版本干扰- 前端 localStorage 防重复弹窗,同一版本只在首次提示,用户体验友好
- apk-parser 依赖需在
ruoyi-admin模块显式声明 ,否则运行时打包不进去,报NoClassDefFoundError