在 Android 开发中,我们经常会遇到这样一个场景:
APP 已经安装在几十台、几百台设备上。 每次修一个 Bug,都要重新打 APK、上传群聊、通知用户下载、重新安装。
如果这是普通消费者 APP,Google Play、应用商店通常负责版本分发。
但如果你的 APP 运行在:
- 门店 PAD
- 收银机
- 广告机
- 工业平板
- 自助终端
- IoT 控制设备
- 企业内部设备
事情就不一样了。
这些设备通常数量可控,而且都运行在中国大陆。我们完全可以自己搭一套:
APK OTA 更新系统
也就是:
服务器发布新版 APK,客户端自动发现、下载并安装。
一、APK OTA 到底是什么?
OTA 是:
Over The Air
直译就是:
通过网络进行更新。
所以所谓 APK OTA,本质非常简单:
服务器上有:
APP 1.2.0.apk
↓
PAD 当前运行:
APP 1.1.0
↓
PAD 发现:
服务器版本更高
↓
下载 APK
↓
安装
↓
APP 变成 1.2.0
它并不是什么神秘技术。
甚至可以简单理解成:
我们自己做了一个迷你版应用商店。
二、为什么不直接用 Shorebird?
Flutter 开发者可能第一时间会想到 Shorebird。
Shorebird 做的是 Flutter Code Push。
比如:
if (score >= 10) {
showWin();
}
改成:
if (score >= 9) {
showWin();
}
理论上不用重新安装整个 APK,只下发一个小 Patch。
这确实很漂亮。
但它解决的是:
Dart 代码
↓
增量 Patch
↓
Flutter Runtime 加载
而 APK OTA 解决的是:
整个 Android APP
↓
下载 APK
↓
Android 安装新版
两者不是一个层面的技术。
Shorebird
┌────────────────────┐
│ Flutter Base APK │
└─────────┬──────────┘
│
│ Patch
▼
┌────────────────────┐
│ 新 Dart 代码 │
└────────────────────┘
优点:
Patch 小
更新快
无需重新安装 APK
缺点:
Runtime 复杂
受服务基础设施影响
Native 修改无法 Patch
Assets 修改有限制
APK OTA
旧 APK
1.1.0
│
│ 下载新版
▼
1.2.0.apk
│
│ Android 安装
▼
新 APP
优点:
Flutter 可以改
Kotlin 可以改
Assets 可以改
Manifest 可以改
Plugin 可以升级
Flutter SDK 可以升级
几乎没有代码层面的限制。
对企业 PAD 来说,APK OTA 往往比 Dart 热更新更实际。
三、APK OTA 的整体架构
我们先看完整流程。
开发者
│
│
flutter build apk
│
▼
┌──────────────┐
│ app-release │
│ .apk │
└───────┬──────┘
│
│ 上传
▼
┌──────────────┐
│ OSS / COS │
│ 国内 CDN │
└───────┬──────┘
│
│
Version API
│
▼
┌─────────────────┐
│ Android PAD │
│ │
│ 当前:1.1.0 │
└────────┬────────┘
│
检查版本
│
▼
发现 1.2.0
│
下载
│
▼
SHA256 校验
│
▼
安装
│
▼
重启
如果你能理解这张图,APK OTA 已经理解了一半。
四、整个系统只需要三个东西
实际上,最小版本只需要:
1. APK 存储
2. 版本接口
3. Android 更新器
五、第一部分:APK 存哪里?
最简单的方法:
使用国内对象存储。
比如:
阿里云 OSS
腾讯云 COS
华为云 OBS
七牛云
假设我们上传:
https://download.example.com/app/app-1.2.0.apk
目录甚至可以设计成:
/app/
├── 1.0.0/
│ └── app.apk
├── 1.1.0/
│ └── app.apk
└── 1.2.0/
└── app.apk
客户端不需要知道目录结构。
服务器只需要告诉客户端:
最新 APK 在哪里。
六、第二部分:版本接口
这是 OTA 的核心。
客户端启动的时候请求:
GET /api/app/version
服务器返回:
{
"versionName": "1.2.0",
"versionCode": 12,
"apkUrl": "https://download.example.com/app/app-1.2.0.apk",
"forceUpdate": false,
"sha256": "8bc2a4..."
}
什么意思?
大白话翻译一下:
最新版本:
1.2.0
内部版本号:
12
APK 下载地址:
https://...
是不是强制升级:
不是
APK 指纹:
8bc2a4...
七、为什么必须有 versionCode?
Android 项目通常有:
versionName
versionCode
Flutter 对应:
version: 1.2.0+12
其中:
1.2.0
是:
versionName
而:
12
是:
versionCode
判断升级时,推荐:
永远比较 versionCode。
例如:
当前:
1.9.9 + 99
服务器:
2.0.0 + 100
只需要:
100 > 99
就知道:
有新版本。
不要自己拿:
"1.10.0"
和:
"1.9.0"
做字符串大小比较。
否则迟早翻车。
八、客户端如何检查更新?
流程基本就是:
APP 启动
│
▼
GET /app/version
│
▼
服务器 versionCode = 12
│
▼
本地 versionCode = 11
│
▼
12 > 11 ?
│
├── NO ──→ 正常启动
│
└── YES
│
▼
有新版本
Flutter 中,可以通过:
package_info_plus
读取:
version
buildNumber
伪代码:
final info = await PackageInfo.fromPlatform();
final localVersionCode =
int.parse(info.buildNumber);
final remote = await api.getLatestVersion();
if (remote.versionCode > localVersionCode) {
showUpdateDialog();
}
核心就这一句话:
remoteVersionCode > localVersionCode
九、发现新版本以后怎么办?
这里有三种策略。
策略 A:提示用户更新
最普通。
┌──────────────────────────┐
│ 发现新版本 1.2.0 │
│ │
│ 修复比赛倒计时异常 │
│ 优化设备连接稳定性 │
│ │
│ [以后再说] [立即更新] │
└──────────────────────────┘
适合:
普通 Android APP
策略 B:强制更新
服务器返回:
{
"forceUpdate": true
}
客户端:
发现新版
↓
不允许关闭弹窗
↓
必须更新
↓
才能继续使用
例如:
接口协议发生重大变化
旧版本已经无法兼容后端
存在严重 Bug
这时候就需要强更。
策略 C:后台下载
企业 PAD 最适合这种。
APP 正常运行
│
▼
后台检查
│
▼
发现新版
│
▼
后台下载 APK
│
▼
下载完成
│
▼
等待安全时间
例如:
比赛进行中
↓
先不更新
比赛结束
↓
提示:
系统已完成更新准备
[立即安装]
用户甚至感觉不到几十 MB APK 是什么时候下载的。
十、为什么不能比赛过程中直接安装?
假设你的 APP 正在进行:
玩家 A
↓
第 3 回合
↓
倒计时 8 秒
↓
WebSocket 在线
↓
设备正在上传数据
突然:
安装 APK
Android 会结束当前 APP 进程。
结果:
比赛中断
WebSocket 断开
页面状态丢失
所以 OTA 必须有一个非常重要的概念:
安全更新时机
例如:
APP 是否空闲?
│
├── 首页 → 可以更新
│
├── 设置页 → 可以更新
│
├── 登录页 → 可以更新
│
└── 比赛中 → 禁止更新
可以封装:
bool get canInstallUpdate {
return !battleRunning &&
!practiceRunning;
}
这比“下载 APK”本身重要得多。
十一、APK 下载怎么做?
客户端拿到:
apkUrl
以后:
HTTP 下载
↓
保存到本地
例如:
/data/user/0/xxx/files/update/
或者 Android 外部私有目录。
流程:
https://download.example.com/app.apk
↓
0%
↓
37%
↓
78%
↓
100%
↓
app-1.2.0.apk
UI 可以展示:
正在下载新版
██████████████░░░ 76%
38 MB / 50 MB
十二、APK 下载完成不能马上安装
因为还有一个重要步骤:
文件校验
假设服务器告诉客户端:
{
"sha256": "AD7384C..."
}
客户端下载完成以后:
APK
↓
计算 SHA256
↓
AD7384C...
↓
和服务器比较
如果:
一致
说明 APK 没损坏。
如果:
不一致
直接删除。
为什么需要 SHA256?
因为下载过程可能:
断网
文件损坏
CDN 异常
下载不完整
文件被替换
没有校验的话:
50MB APK
下载了:
49.7MB
你直接调用安装。
然后 Android:
解析安装包失败
门店就开始找你。
所以:
下载
↓
SHA256
↓
安装
顺序不能乱。
十三、Android 如何安装 APK?
普通 Android APP 不能悄无声息地随便安装 APK。
通常需要:
Intent
↓
Package Installer
↓
Android 安装确认页
大概:
┌───────────────────────────┐
│ 是否要更新此应用? │
│ │
│ [取消] [安装] │
└───────────────────────────┘
用户点击:
安装
Android 才更新 APP。
十四、为什么旧 APP 可以被新 APK 覆盖?
Android 判断:
这是同一个 APP 吗?
主要看:
packageName
+
签名
例如:
旧版:
com.example.shooting
新版:
com.example.shooting
同时:
旧 APK 签名 = A
新 APK 签名 = A
就可以覆盖更新。
如果签名不同
例如:
旧版:
release.keystore A
新版:
release.keystore B
Android 会直接说:
安装失败
签名不一致
所以:
release keystore 非常重要。
一定要长期保存。
不要:
换电脑
↓
找不到 keystore
↓
重新生成一个
否则以前安装的几十台设备都无法覆盖升级。
十五、门店 PAD 能不能静默安装?
这里开始进入企业 Android 设备管理领域。
普通 APP:
下载 APK
↓
弹系统安装页
↓
用户确认
这是 Android 的安全限制。
但是企业设备如果被配置成:
Device Owner
MDM 管理设备
系统应用
企业专用设备
就有机会通过 Android 企业设备管理能力实现更加自动化的安装流程。
例如:
PAD
↓
MDM
↓
后台下载 APK
↓
管理员策略安装
↓
用户无需手动操作
于是就变成:
门店晚上关店
↓
PAD 自动发现 1.2.0
↓
下载
↓
安装
↓
第二天打开
↓
已经是 1.2.0
这才是企业 PAD OTA 最舒服的形态。
不过需要注意:
普通第三方 Android APP,不能仅仅因为自己调用了 PackageInstaller 就获得真正意义上的任意静默安装权限。
要实现无人值守更新,需要结合:
Device Owner
MDM
系统权限
厂商设备管理接口
具体能力取决于设备管理方式和 Android 版本。
十六、版本接口应该再高级一点
做到生产阶段,不建议只返回:
{
"versionCode": 12
}
可以设计成:
{
"versionCode": 12,
"versionName": "1.2.0",
"apkUrl": "https://cdn.example.com/app-1.2.0.apk",
"fileSize": 52428800,
"sha256": "xxx",
"forceUpdate": false,
"minVersionCode": 9,
"releaseNote": [
"修复比赛倒计时异常",
"修复设备偶发断连",
"优化首页性能"
]
}
这样客户端可以展示:
1.2.0
更新内容:
• 修复比赛倒计时异常
• 修复设备偶发断连
• 优化首页性能
大小:50 MB
十七、为什么还需要 minVersionCode?
例如服务器:
{
"versionCode": 20,
"minVersionCode": 17
}
意思:
最新版本:
20
最低允许:
17
客户端如果:
18
可以:
稍后更新
但如果客户端:
15
已经低于:
17
那就:
强制更新
逻辑:
local < minVersion
↓
强制升级
local >= minVersion
&&
local < latest
↓
可选升级
这是非常实用的版本策略。
十八、灰度更新
千万不要:
1.2.0 发布
↓
200 台 PAD
↓
全部更新
正确做法:
1.2.0
│
▼
内部测试设备
│
▼
5 台
│
▼
20 台
│
▼
全量设备
这就是:
灰度发布
服务器可以返回:
设备 ID
门店 ID
Channel
例如:
PAD-TEST-001
→ internal
门店测试设备
→ beta
正式门店
→ stable
版本接口:
GET /app/version
?deviceId=PAD001
&channel=stable
服务器决定给哪一个版本。
十九、Channel 是什么?
可以理解成:
不同设备走不同更新轨道。
例如:
APP
│
┌──────────┼───────────┐
▼ ▼ ▼
internal beta stable
│ │ │
开发机 测试店 正式店
发布:
1.3.0
先:
internal
验证。
然后:
beta
最后:
stable
这样一旦 1.3.0 出 Bug,只影响少数设备。
二十、一定要考虑回滚
更新系统最容易被忽略的不是:
怎么升级
而是:
怎么降级。
例如:
1.2.0
↓
发布
↓
发现严重 Bug
服务器马上:
stable latest → 1.1.9
但是这里有一个问题:
Android 通常不允许:
versionCode 12
↓
安装 versionCode 11
直接降级。
所以实际生产中更推荐:
坏版本:
1.2.0 + 12
回滚修复版:
1.1.9-hotfix + 13
也就是说:
功能回滚
≠
versionCode 倒退
你仍然发布一个:
versionCode 更大的 APK
只是在代码层面回到了稳定实现。
例如:
12
↓
出问题
13
↓
代码 revert 到上一稳定版本
这是 Android OTA 中非常重要的一个原则。
二十一、APK OTA 最大的优点
有人会说:
每次下载几十 MB,不如 Dart Patch 高级。
确实。
但你换个角度:
假设:
APK = 80MB
门店:
Wi-Fi = 100Mbps
理论上:
几十秒
就下载完成。
而且:
后台下载
用户甚至感觉不到。
但你获得的是:
Flutter Dart
✅
Kotlin
✅
Java
✅
Assets
✅
Manifest
✅
权限
✅
Native Plugin
✅
Flutter SDK
✅
第三方 SDK
✅
这就是:
用几十 MB 的网络流量,换极低的运行时复杂度。
在企业 PAD 上通常非常划算。
二十二、APK OTA 最大的问题
当然它也不是完美方案。
1. 文件比较大
Patch:
500KB
APK:
80MB
差距明显。
2. 安装会重启 APP
所以:
正在比赛
不能更新。
3. 普通设备需要安装确认
想做到真正无人值守:
需要 Device Owner / MDM
4. 服务端需要自己维护
需要:
版本接口
OSS
CDN
设备信息
发布策略
不过这些都属于普通 Web 开发范畴,比修改 Dart Runtime 简单得多。
二十三、推荐的完整架构
如果做成正式系统,我推荐:
CI/CD
│
Flutter Build
│
▼
app-release.apk
│
┌───────────┴────────────┐
│ │
▼ ▼
OSS/COS Release Server
│ │
│ Version Metadata
│ │
└──────────┬─────────────┘
│
国内 CDN
│
▼
┌──────────────┐
│ 门店 PAD │
└──────┬───────┘
│
Version API
│
▼
有新版本?
│ │
NO YES
│ │
│ 下载
│ │
│ SHA256
│ │
│ 安全状态?
│ │ │
│ NO YES
│ │ │
│ 等待 安装
│ │
└────────┴──→ APP
二十四、Flutter 项目推荐分层
不要把 OTA 写在:
main.dart
里面塞一堆逻辑。
推荐:
lib/
└── update/
├── app_update_service.dart
├── update_api.dart
├── update_model.dart
├── apk_downloader.dart
├── apk_installer.dart
└── update_state.dart
例如:
AppUpdateService
负责整个编排:
检查
↓
比较版本
↓
下载
↓
校验
↓
判断安全时机
↓
安装
二十五、推荐状态机
OTA 本身最好也设计成状态机。
IDLE
│
▼
CHECKING
│
├── NO UPDATE ──→ IDLE
│
▼
AVAILABLE
│
▼
DOWNLOADING
│
▼
VERIFYING
│
├── ERROR ──→ FAILED
│
▼
READY_TO_INSTALL
│
▼
INSTALLING
对应 Dart:
enum UpdateState {
idle,
checking,
available,
downloading,
verifying,
readyToInstall,
installing,
failed,
}
这样 UI 很容易处理。
例如:
downloading
→ 显示进度条
failed
→ 显示重新下载
readyToInstall
→ 显示立即更新
二十六、建议记录设备版本
如果设备只有几十台,你可能一开始觉得没必要。
但设备变成:
100 台
以后,你一定会问:
哪些 PAD 还没升级?
所以客户端可以定期上报:
{
"deviceId": "PAD001",
"versionCode": 12,
"versionName": "1.2.0",
"androidVersion": "15",
"model": "TB331FC"
}
后台马上可以看到:
PAD001 1.2.0 在线
PAD002 1.2.0 在线
PAD003 1.1.8 ⚠️
PAD004 1.2.0 在线
PAD005 1.0.5 ⚠️
这已经不仅是:
APP 更新
而是:
设备运维平台。
二十七、推荐最终做成四层更新体系
真正成熟以后,我不建议所有东西都通过 APK 更新。
可以分成四层:
APP 变化
│
┌────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Remote Config Server Data APK OTA
更完整一点:
L0 Remote Config
比赛时间
开关
阈值
配置
│
▼
L1 Server Driven
Banner
文案
规则
运营内容
│
▼
L2 Web 内容
商城
活动
帮助
复杂运营页面
│
▼
L3 APK OTA
Flutter代码
Native代码
Plugin
Assets
SDK
例如:
“比赛倒计时从 15 秒改成 12 秒”
如果是配置:
Remote Config
根本不用发 APK。
如果:
WebSocket 状态机有 Bug
才:
APK OTA
这会大幅减少升级频率。
二十八、这套东西实际上比“热更新”更重要
很多团队一听到:
热更新
就马上研究:
Runtime Patch
AOT
动态代码
但对于企业设备来说,更重要的问题其实是:
我能不能知道设备是什么版本?
能不能自动发现新版?
能不能后台下载?
能不能控制更新时间?
能不能灰度?
能不能失败重试?
能不能知道哪台升级失败?
能不能快速恢复?
这些解决了以后:
APK 是 5MB
还是:
APK 是 80MB
很多时候反而没那么重要。
二十九、一个现实中的发布流程
开发者提交:
fix: 修复比赛倒计时异常
↓
CI:
flutter test
↓
构建:
flutter build apk --release
↓
生成:
app-1.3.2+42.apk
↓
计算:
SHA256
↓
上传:
阿里云 OSS
↓
Release Server:
internal = 42
stable = 41
↓
测试 PAD:
自动升级 42
↓
测试完成:
stable = 42
↓
正式门店:
后台下载
↓
比赛结束:
安装
整个流程:
Developer
│
▼
Git
│
▼
CI/CD
│
▼
APK
│
▼
Internal
│
▼
Test
│
▼
Stable
│
▼
门店 PAD
这已经是一套非常成熟的 ToB Android 发布体系。
三十、最终理解
如果要用一句最简单的话解释 APK OTA:
客户端定期问服务器:“有新版吗?”服务器回答:“有,这里下载。”客户端把 APK 下载下来,通过 Android 自己的安装机制完成升级。
它没有修改 Flutter Runtime。
没有动态执行 Dart。
没有复杂 AOT Patch。
技术栈非常普通:
HTTP
JSON
APK
SHA256
Android PackageInstaller
OSS
CDN
但工程价值非常高。
特别是:
Android 专用 PAD
+
国内网络
+
门店设备
+
不走应用商店
+
设备数量可控
这种场景。
相比重新造一个 Flutter 热更新 Runtime:
国内自建 APK OTA 往往不是“退而求其次”,而是更符合业务场景的工程方案。
它解决的不是:
“怎么炫技地替换一段 Dart。”
而是:
“怎么稳定地管理几十、几百、甚至几千台 Android 设备上的 APP 版本。”
一旦站在这个角度看,OTA 就不再只是一个“更新功能”。
它实际上是整个企业 Android 设备运维体系的入口。