Contents

App 升级系统设计实战:从版本管理到增量更新与热修复

为什么升级系统是 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 增量差分包地址 有则走增量,无则走全量

二、更新检查机制:什么时候查、怎么查

三种检查时机

  1. 启动时检查:App 冷启动时请求服务端,有更新则弹窗。最常见但体验最差——用户刚打开 App 就被弹窗打断。
  2. 进入前台时检查:App 从后台回到前台时检查。比启动时更自然,但要注意节流,避免频繁请求。
  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
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 的「增量更新」通常指两种:

  1. 资源差分:只下载变化的资源文件(图片、JS bundle、配置文件),不动二进制。React Native / Flutter 的热更新就是这种模式。
  2. 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。主流方案是:

  1. JSPatch / Rollout.io:通过 JavaScript 引擎动态下发代码。但苹果 2026 年的政策仍然严格限制动态下发可执行代码,JSPatch 已无法过审。
  2. React Native 热更新:因为 JS bundle 不算「可执行代码」(它是解释执行的脚本),可以过审。用 CodePush 等工具推送新 JS bundle。
  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
// 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
    }
}

这段代码做了什么URLSessionDownloadTaskcancel 方法会回调一个 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 升级系统涉及六个核心模块:

  1. 版本管理:语义化版本号 + 服务端配置表,版本比对用整数比较不用字符串比较
  2. 更新检查:启动/前台检查 + 节流策略 + 灰度发布的哈希取模
  3. 增量更新:bsdiff 差分算法,下载 2MB 而不是 50MB,合成后必须 MD5 校验
  4. 热修复:Android 用 Dex 替换(Tinker/Robust),iOS 用资源替换(避免碰可执行代码)
  5. 下载策略:断点续传(HTTP Range)+ 网络环境判断 + 电量检查
  6. 安全与回滚:三道校验(MD5 + 签名 + 防降级)+ 崩溃自回滚(阈值 3 次)

一句话总结:升级系统的核心不是「弹窗提醒更新」,而是「用最小的代价、最安全的方式、把正确的版本推给正确的人」。每一环——从版本号比对到崩溃回滚——都是在为「万一出问题」做兜底。