为什么升级系统是 App 的生命线
你打开一个 App,弹出「发现新版本,是否更新?」你点了「以后再说」。第二天再打开,它不弹了——但你用的旧版本有个登录接口刚改了字段名,你再也登录不上了。
这就是升级系统没做好的代价。App 升级系统不是「弹个对话框提醒更新」这么简单,它是一条从版本管理、更新检测、差分包生成、断点续传下载、安全校验到灰度发布和回滚的完整链路。
打个比方:升级系统就像汽车的 OTA 远程升级。特斯拉可以在你睡觉时把刹车距离缩短 2 米——这背后是版本比对、差分包、安全校验、回滚保护一整套机制。App 的升级系统也是同样的道理,只是规模更小。
下面我们从六个核心模块,拆解一套完整的 App 升级系统设计。
一、版本管理:地基没打好,后面全乱
语义化版本号
小贴士:语义化版本号(Semantic Versioning)的格式是 主版本.次版本.修订号,比如 2.1.3。主版本号变了意味着不兼容的大改,次版本号变了意味着加了新功能但向下兼容,修订号变了只是修 bug。
版本号是整个升级系统的「身份证」。所有更新判断、灰度策略、强制升级逻辑都依赖它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
// Swift:版本号解析与比对
struct AppVersion: Comparable {
let major: Int
let minor: Int
let patch: Int
// 从字符串解析版本号,如 "2.1.3" → (2, 1, 3)
init?(_ versionString: String) {
let parts = versionString.split(separator: ".")
guard parts.count >= 3,
let major = Int(parts[0]),
let minor = Int(parts[1]),
let patch = Int(parts[2]) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
// Comparable 协议要求:定义小于比较
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}
}
// 使用示例:判断是否需要升级
let current = AppVersion("2.1.3")!
let latest = AppVersion("2.2.0")!
if current < latest {
print("有新版本可用:\(latest)")
}
|
为什么这样设计:把版本号拆成三个整数比较,而不是用字符串比较。因为字符串比较 "2.10.0" < "2.9.0" 会得到 true(字符串按字符逐位比,“1” < “9”),这是经典 bug。
服务端版本配置表
服务端维护一张版本配置表,客户端启动时请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"version": "2.2.0",
"versionCode": 220,
"minRequiredVersion": "2.0.0",
"updateMode": "optional",
"downloadUrl": "https://cdn.example.com/app/v2.2.0/full.apk",
"patchUrl": "https://cdn.example.com/app/v2.2.0/patch_from_2.1.3.zip",
"patchSize": 2097152,
"fullSize": 52428800,
"md5": "a1b2c3d4e5f6...",
"changelog": "1. 修复登录闪退\n2. 新增深色模式",
"forceUpdate": false,
"grayConfig": {
"enabled": true,
"percentage": 10,
"whiteList": ["device_id_001", "device_id_002"]
}
}
|
这段配置做了什么:服务端告诉客户端三件事——最新版本是什么(version)、最低不能低于哪个版本(minRequiredVersion,低于就强制升级)、这次更新是可选还是强制(updateMode)。灰度配置控制只有 10% 的用户收到这个版本,白名单设备可以直接体验。
| 字段 |
作用 |
示例 |
minRequiredVersion |
低于此版本 → 强制升级 |
2.0.0,当前 1.9.0 就必须升级 |
updateMode |
optional / forced / silent |
silent 用于静默热修复 |
grayConfig |
灰度发布控制 |
先放 10% 用户,没问题再加量 |
patchUrl |
增量差分包地址 |
有则走增量,无则走全量 |
二、更新检查机制:什么时候查、怎么查
三种检查时机
- 启动时检查:App 冷启动时请求服务端,有更新则弹窗。最常见但体验最差——用户刚打开 App 就被弹窗打断。
- 进入前台时检查:App 从后台回到前台时检查。比启动时更自然,但要注意节流,避免频繁请求。
- 服务端推送:服务端通过长连接或推送通知客户端有新版本。实时性最好,但需要维护推送通道。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
// Swift:带节流的前台检查策略
class UpdateChecker {
private let minCheckInterval: TimeInterval = 3600 // 最少间隔1小时
private var lastCheckTime: Date?
func checkOnForeground() {
let now = Date()
// 节流:距离上次检查不到1小时,跳过
if let last = lastCheckTime,
now.timeIntervalSince(last) < minCheckInterval {
return
}
lastCheckTime = now
Task {
await checkForUpdate()
}
}
private func checkForUpdate() async {
guard let url = URL(string: "https://api.example.com/app/version") else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let info = try JSONDecoder().decode(VersionInfo.self, from: data)
await MainActor.run {
self.handleUpdateInfo(info)
}
} catch {
// 静默失败:检查更新失败不应该影响用户使用
print("版本检查失败:\(error)")
}
}
private func handleUpdateInfo(_ info: VersionInfo) {
let current = AppVersion(Bundle.main.appVersion)!
let latest = AppVersion(info.version)!
// 当前版本低于最低要求 → 强制升级
if let minReq = AppVersion(info.minRequiredVersion),
current < minReq {
showForceUpdateAlert(info)
return
}
// 有新版本且非强制 → 询问用户
if current < latest {
showOptionalUpdateAlert(info)
}
}
}
|
为什么静默失败:更新检查是「锦上添花」的功能,不能因为检查接口挂了就让用户用不了 App。所有更新检查失败都应该 catch 住,最多打个日志。
灰度发布策略
生活比喻:灰度发布就像饭店上新菜——先给 10% 的客人免费试吃,没人拉肚子再推广到所有客人。如果有人吃坏了,立刻停售,影响范围只有 10%。
灰度发布的核心逻辑在服务端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// Go:灰度发布判断逻辑
func ShouldReceiveUpdate(deviceID string, grayConfig GrayConfig) bool {
// 白名单用户直接放行(内部测试设备)
for _, id := range grayConfig.WhiteList {
if id == deviceID {
return true
}
}
if !grayConfig.Enabled {
return true // 灰度未开启,全量发布
}
// 用 deviceID 的哈希值取模,确保同一设备每次结果一致
// 这样用户不会「有时看到更新、有时看不到」
hash := crc32.ChecksumIEEE([]byte(deviceID))
return hash%100 < uint32(grayConfig.Percentage)
}
|
关键细节:用 deviceID 的哈希取模而不是随机数。因为随机数每次结果不同,用户第一次打开看到更新提示,第二次打开又看不到了,体验极差。哈希取模保证同一设备永远得到相同结果。
三、全量更新 vs 增量更新:50MB 还是 2MB 的选择
为什么需要增量更新
一个 50MB 的 App,改了一行代码,发了一个新版本。用户要重新下载 50MB——这浪费带宽、浪费时间、消耗用户流量。增量更新(也叫差分更新)的做法是:只下载新旧版本之间的差异部分(差分包),在本地合成新版本。
| 对比项 |
全量更新 |
增量更新 |
| 下载大小 |
完整 APK/IPA(~50MB) |
差分包(~2MB) |
| 服务端开销 |
只存一个包 |
每个旧版本一个差分包 |
| 客户端复杂度 |
下载即安装 |
下载+合成+校验 |
| 适用场景 |
大版本跨度 |
小版本迭代 |
bsdiff 差分算法
小贴士:bsdiff 是最经典的二进制差分算法,被 Android 的增量更新机制广泛使用。它的思路是:找出新旧文件中相同的部分(不用传)和不同的部分(需要传),生成一个很小的差分包。客户端用旧文件 + 差分包 = 新文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Kotlin(Android):客户端增量合成
// 这段代码演示如何用旧 APK + 差分包合成新 APK
fun applyPatch(oldApkPath: String, patchPath: String, newApkPath: String): Boolean {
val oldFile = File(oldApkPath)
val patchFile = File(patchPath)
val newFile = File(newApkPath)
if (!oldFile.exists() || !patchFile.exists()) {
return false
}
// 调用 native 方法执行 bspatch 合成
// bspatch 底层是 C 实现,通过 JNI 调用
val success = BspatchNative.applyPatch(
oldFile.absolutePath,
newFile.absolutePath,
patchFile.absolutePath
)
if (!success) return false
// ⚠️ 关键:合成后必须校验 MD5,确保合成正确
val newMd5 = calculateMd5(newFile)
val expectedMd5 = getExpectedMd5FromServer()
return newMd5 == expectedMd5
}
|
为什么必须校验 MD5:差分合成过程中如果出现任何字节错位,合成的 APK 就是损坏的。直接安装会导致崩溃。MD5 校验是最后一道防线——不对就放弃增量,回退到全量下载。
iOS 的特殊情况
iOS 因为沙盒限制,App 无法直接替换自身二进制。iOS 的「增量更新」通常指两种:
- 资源差分:只下载变化的资源文件(图片、JS bundle、配置文件),不动二进制。React Native / Flutter 的热更新就是这种模式。
- App Store 增量:iOS 系统层面自带 App Store 的增量下载机制(App Thin + Bitcode),开发者无需关心。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Swift(iOS):资源差分更新——只下载变化的 JS bundle
// 适用于 React Native / 自定义热更新方案
class ResourcePatcher {
func checkAndUpdateResources() async throws {
// 1. 请求服务端获取资源差分包信息
let manifest = try await fetchResourceManifest()
// 2. 比对本地资源版本
let localVersion = UserDefaults.standard.string(forKey: "resource_version") ?? "0"
guard localVersion < manifest.version else { return }
// 3. 下载差分包
let patchData = try await downloadPatch(url: manifest.patchUrl)
// 4. 解压并合并到本地资源目录
let resourceDir = getResourceDirectory()
try applyResourcePatch(patchData, to: resourceDir)
// 5. 更新版本号
UserDefaults.standard.set(manifest.version, forKey: "resource_version")
}
}
|
四、热修复:不改二进制,修线上 Bug
热修复 vs 增量更新的区别
很多人混淆这两个概念。简单说:
- 增量更新:替换整个安装包(APK/IPA),用户需要重新安装。
- 热修复:不重新安装 App,直接在运行时替换代码或资源,用户无感知。
生活比喻:增量更新就像换了一台新车——你得去 4S 店办手续;热修复就像给车刷了个新固件——你停一晚上,第二天启动就生效了。
Android 热修复方案
Android 热修复经历了三代方案:
| 代际 |
代表方案 |
原理 |
限制 |
| 第一代 |
AndFix |
方法替换 |
兼容性差,已停更 |
| 第二代 |
Tinker(微信) |
Dex 替换 |
需重启生效 |
| 第三代 |
Robust(美团) |
方法 Hook |
即时生效,但只能改方法体 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// Kotlin:热修复补丁加载框架
class HotFixManager(private val context: Context) {
fun loadPatch(patchPath: String): Boolean {
val patchFile = File(patchPath)
if (!patchFile.exists()) return false
// 1. 校验补丁签名——防止被篡改的补丁包
if (!verifyPatchSignature(patchFile)) {
return false
}
// 2. 加载补丁 Dex
try {
// Tinker 方案:替换 ClassLoader 中的 dexPathList
val classLoader = context.classLoader as? BaseDexClassLoader ?: return false
val dexPathListField = BaseDexClassLoader::class.java
.getDeclaredField("pathList")
dexPathListField.isAccessible = true
val pathList = dexPathListField.get(classLoader)
// 将补丁 Dex 插入到 dexElements 数组的最前面
// ClassLoader 加载类时按顺序查找,补丁中的类会优先于旧类
val dexElementsField = pathList.javaClass
.getDeclaredField("dexElements")
dexElementsField.isAccessible = true
// 合并补丁 Dex 到数组头部
val newElements = mergeDexElements(
patchDex = loadDexFromFile(patchFile),
originalElements = dexElementsField.get(pathList) as Array<*>
)
dexElementsField.set(pathList, newElements)
return true
} catch (e: Exception) {
return false
}
}
}
|
这段代码做了什么:Android 的类加载器有一个 dexElements 数组,加载类时按数组顺序查找。热修复的原理就是把补丁 Dex 放到数组最前面,这样补丁里的类会优先于原 App 里的同名类被加载——等于「替换」了有 Bug 的类。
为什么校验签名:如果攻击者伪造一个补丁包,里面包含恶意代码,App 就会在加载补丁时执行恶意逻辑。签名校验确保补丁包来自官方。
iOS 热修复的困境
iOS 因为系统限制,无法像 Android 那样替换底层 Dex。主流方案是:
- JSPatch / Rollout.io:通过 JavaScript 引擎动态下发代码。但苹果 2026 年的政策仍然严格限制动态下发可执行代码,JSPatch 已无法过审。
- React Native 热更新:因为 JS bundle 不算「可执行代码」(它是解释执行的脚本),可以过审。用 CodePush 等工具推送新 JS bundle。
- 资源替换:只替换图片、配置文件等静态资源,不涉及代码逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// Swift:iOS 资源热更新——安全的做法
// 只替换资源文件,不碰可执行代码
class SafeHotFix {
func applyResourceUpdate(_ update: ResourceUpdate) throws {
let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let resourceDir = documentsDir.appendingPathComponent("hot_resources")
// 1. 下载资源包
let zipData = try downloadResourcePackage(url: update.downloadUrl)
// 2. 校验签名(用本地公钥验签)
guard verifySignature(data: zipData, signature: update.signature) else {
throw HotFixError.signatureMismatch
}
// 3. 解压到临时目录
let tempDir = documentsDir.appendingPathComponent("hot_temp")
try unzip(zipData, to: tempDir)
// 4. 原子替换:先复制旧的资源目录做备份
let backupDir = documentsDir.appendingPathComponent("hot_backup")
if FileManager.default.fileExists(atPath: resourceDir.path) {
try? FileManager.default.removeItem(at: backupDir)
try FileManager.default.copyItem(at: resourceDir, to: backupDir)
}
// 5. 替换资源目录
try? FileManager.default.removeItem(at: resourceDir)
try FileManager.default.moveItem(at: tempDir, to: resourceDir)
// 6. 记录版本
UserDefaults.standard.set(update.version, forKey: "hot_resource_version")
}
}
|
为什么要原子替换和备份:如果替换过程中 App 崩了或被杀,资源目录可能处于半更新状态——一部分新资源一部分旧资源,导致各种奇怪 Bug。原子替换保证:要么全部成功,要么回退到旧版本。备份目录就是回退用的。
五、下载策略:断点续传与流量控制
用户在地铁上点了「更新」,下载到 60% 时网络断了。没有断点续传的话,下次得从头下载——用户体验极差。
断点续传实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
// Swift:支持断点续传的下载器
class ResumableDownloader {
private var task: URLSessionDownloadTask?
private var resumeData: Data?
private let fileManager = FileManager.default
// 开始或恢复下载
func download(from url: URL, to destination: URL) async throws {
// 如果有断点续传数据,从上次中断的位置继续
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
let session = URLSession(configuration: config)
if let resumeData = resumeData {
// 从断点继续
task = session.downloadTask(withResumeData: resumeData)
} else {
// 全新下载
task = session.downloadTask(with: url)
}
task?.resume()
}
// 暂停下载(保存断点数据)
func pause() {
task?.cancel { [weak self] data in
// cancel 回调会给出 resumeData,下次可以从此处继续
self?.resumeData = data
}
}
// 下载完成时校验文件完整性
func validateDownloadedFile(at path: URL, expectedMd5: String) -> Bool {
guard let fileData = try? Data(contentsOf: path) else { return false }
// 计算 MD5 并与服务端给定的比对
let hash = Insecure.MD5.hash(data: fileData)
let actualMd5 = hash.map { String(format: "%02x", $0) }.joined()
return actualMd5 == expectedMd5
}
}
|
这段代码做了什么:URLSessionDownloadTask 的 cancel 方法会回调一个 resumeData——它记录了已经下载到哪个字节位置。下次创建任务时传入这个 data,就会从断点继续,而不是从头下载。
小贴士:断点续传的原理是 HTTP 的 Range 请求头。客户端告诉服务端「给我从第 1048576 字节开始的数据」,服务端只返回后面的部分。resumeData 里就保存了这个偏移量信息。
下载策略矩阵
| 场景 |
策略 |
理由 |
| WiFi 环境 |
立即下载 |
不消耗用户流量 |
| 移动数据网络 |
询问用户 |
尊重用户流量选择 |
| 低电量 (<20%) |
延迟下载 |
避免下载消耗最后电量 |
| 强制更新 |
不询问,直接下载 |
用户没有选择权 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// Swift:根据网络环境选择下载策略
class DownloadPolicy {
func shouldAutoDownload() -> Bool {
// 1. 检查网络类型
let networkType = getCurrentNetworkType()
switch networkType {
case .wifi:
return true // WiFi 下自动下载
case .cellular:
// 移动网络:检查用户是否设置了「仅 WiFi 下载」
return UserDefaults.standard.bool(forKey: "allow_cellular_download")
case .none:
return false // 无网络
}
}
func shouldDelayDownload() -> Bool {
// 2. 检查电量
let batteryLevel = UIDevice.current.batteryLevel
// batteryLevel 为 -1 时表示未知(模拟器等)
if batteryLevel >= 0 && batteryLevel < 0.2 {
return true // 电量低于 20%,延迟下载
}
return false
}
private func getCurrentNetworkType() -> NetworkType {
// 用 NWPathMonitor 监听网络类型
// 返回 .wifi / .cellular / .none
return .wifi
}
}
|
六、安全校验与灰度回滚:最后一道防线
安全校验链路
完整的下载→安装链路需要三道安全校验:
1
2
3
|
下载完成 → MD5 校验(文件完整性)
→ 签名校验(来源可信)
→ 版本号校验(防降级攻击)
|
什么是降级攻击:攻击者搭建一个假的服务端,返回旧版本的安装包。用户「升级」后反而装了一个有已知漏洞的旧版本。版本号校验就是防止这种情况——新版本号必须大于当前版本号,否则拒绝安装。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
// Swift:三道安全校验
class UpdateSecurityValidator {
// 第一道:文件完整性校验
func validateIntegrity(fileUrl: URL, expectedMd5: String) -> Bool {
guard let data = try? Data(contentsOf: fileUrl) else { return false }
let hash = Insecure.MD5.hash(data: data)
let md5 = hash.map { String(format: "%02x", $0) }.joined()
return md5 == expectedMd5
}
// 第二道:签名校验
func validateSignature(fileUrl: URL, signature: Data, publicKey: P256.KeyAgreement.PrivateKey) -> Bool {
// 用非对称加密验证:服务端用私钥签名,客户端用公钥验签
// 确保安装包来自可信的服务端,而非中间人篡改
guard let data = try? Data(contentsOf: fileUrl) else { return false }
// 简化示意:实际用 SecKeyCreateSignature / CryptoKit 验签
return verifyEdDSA(data: data, signature: signature, publicKey: publicKey)
}
// 第三道:防降级校验
func validateVersion(newVersion: String, currentVersion: String) -> Bool {
guard let new = AppVersion(newVersion),
let current = AppVersion(currentVersion) else {
return false
}
// 新版本号必须严格大于当前版本
return new > current
}
// 完整校验链
func validate(fileUrl: URL, updateInfo: VersionInfo, currentVersion: String) -> Bool {
guard validateIntegrity(fileUrl: fileUrl, expectedMd5: updateInfo.md5) else {
print("校验失败:文件 MD5 不匹配")
return false
}
guard validateVersion(newVersion: updateInfo.version,
currentVersion: currentVersion) else {
print("校验失败:版本号异常(可能降级攻击)")
return false
}
// 签名校验可选(如果服务端下发了签名)
if let signature = updateInfo.signature {
guard validateSignature(fileUrl: fileUrl, signature: signature,
publicKey: getServerPublicKey()) else {
print("校验失败:签名不匹配")
return false
}
}
return true
}
}
|
灰度回滚机制
灰度发布最怕的是:新版本有严重 Bug,已经推给 10% 用户了,怎么快速回滚?
服务端回滚:最快的方案。服务端把版本配置表改回旧版本号,还没升级的用户就不会再收到新版本。已经升级的用户需要通过推送通知引导回退。
客户端自回滚:更高级的方案。客户端安装新版本后,如果连续崩溃 3 次以上,自动回退到旧版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// Kotlin(Android):客户端自动回滚
class CrashAwareRollback(private val context: Context) {
companion object {
private const val CRASH_THRESHOLD = 3
private const val PREF_CRASH_COUNT = "crash_count_since_update"
private const val PREF_LAST_VERSION = "last_installed_version"
}
fun onAppStart() {
val prefs = context.getSharedPreferences("rollback", Context.MODE_PRIVATE)
val currentVersion = getCurrentVersion()
val lastVersion = prefs.getString(PREF_LAST_VERSION, "")
// 版本变化说明刚更新过,重置崩溃计数
if (currentVersion != lastVersion) {
prefs.edit()
.putString(PREF_LAST_VERSION, currentVersion)
.putInt(PREF_CRASH_COUNT, 0)
.apply()
}
}
fun onCrash() {
val prefs = context.getSharedPreferences("rollback", Context.MODE_PRIVATE)
val count = prefs.getInt(PREF_CRASH_COUNT, 0) + 1
prefs.edit().putInt(PREF_CRASH_COUNT, count).apply()
// 崩溃次数达到阈值 → 触发回滚
if (count >= CRASH_THRESHOLD) {
rollbackToPreviousVersion()
}
}
private fun rollbackToPreviousVersion() {
// 1. 清除新版本的数据缓存
// 2. 重新安装备份的旧版本 APK(如果有备份)
// 3. 上报回滚事件到服务端,触发全量回滚
val backupApk = File(context.filesDir, "backup_previous.apk")
if (backupApk.exists()) {
// 通过 PackageInstaller 静默安装旧版本
installApk(backupApk)
reportRollbackEvent()
}
}
}
|
为什么阈值设为 3:设为 1 太敏感——偶尔一次崩溃可能是网络问题或内存不足,不代表版本有问题。设为 10 太迟钝——用户已经崩溃了 10 次早就卸载了。3 次是工程实践中比较合理的平衡点。
架构总览
把以上六个模块串起来,完整的 App 升级系统架构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
┌─────────────────────────────────────────────────────┐
│ 服务端 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 版本配置 │ │ 灰度控制 │ │ 差分包生成(bsdiff)│ │
│ │ 服务 │ │ 服务 │ │ 服务 │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────────┴──────────────────┘ │
│ │ │
│ ┌───────┴────────┐ │
│ │ CDN 分发 │ │
│ │ (全量包+差分包)│ │
│ └───────┬────────┘ │
└──────────────────────┼──────────────────────────────┘
│ HTTPS
┌──────────────────────┼──────────────────────────────┐
│ 客户端 │
│ │ │
│ ┌───────┬───────────┘ │
│ │ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ 更新检查 │───▶│ 灰度判断 │───▶│ 下载策略 │ │
│ │ │ (节流) │ │ (哈希取模)│ │ (WiFi/流量)│ │
│ │ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │ │
│ │ ┌────────────────────────┘ │
│ │ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ 断点续传 │───▶│ 安全校验 │───▶│ 安装/热修│ │
│ │ │ 下载 │ │ (MD5+签名)│ │ 复/回滚 │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ │ ┌──────────────────────────────────┘ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ 崩溃监控 + 自回滚 │ │
│ │ │ (阈值 3 次) │ │
│ │ └──────────────────┘ │
└───────────────────────────────────────────────────────┘
|
小结
一套完整的 App 升级系统涉及六个核心模块:
- 版本管理:语义化版本号 + 服务端配置表,版本比对用整数比较不用字符串比较
- 更新检查:启动/前台检查 + 节流策略 + 灰度发布的哈希取模
- 增量更新:bsdiff 差分算法,下载 2MB 而不是 50MB,合成后必须 MD5 校验
- 热修复:Android 用 Dex 替换(Tinker/Robust),iOS 用资源替换(避免碰可执行代码)
- 下载策略:断点续传(HTTP Range)+ 网络环境判断 + 电量检查
- 安全与回滚:三道校验(MD5 + 签名 + 防降级)+ 崩溃自回滚(阈值 3 次)
一句话总结:升级系统的核心不是「弹窗提醒更新」,而是「用最小的代价、最安全的方式、把正确的版本推给正确的人」。每一环——从版本号比对到崩溃回滚——都是在为「万一出问题」做兜底。