国内自建 APK OTA

高桥凉介
分享互动规则

在 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 设备运维体系的入口。

评论 0

支持 @用户名 提醒对方(需为站内已注册用户名);回复仅支持一层楼中楼。

登录后发表评论、回复与 @ 提及。

举报

举报会匿名发送给管理员审核。

  • 暂无评论,来发表第一条。

码谱 · The Digital Atelier · 技术内容社区

下载 Android 版

下载完成后,点击通知栏中的安装包完成安装