设计热更新方案
问题
如何设计一个 Android 热更新(热修复)方案?
答案
主流方案对比
| 方案 | 原理 | 粒度 | 时效性 | 代表框架 |
|---|---|---|---|---|
| 类加载替换 | 修改 DexPathList,优先加载 patch.dex | 类级别 | 重启生效 | Tinker、QZone |
| 底层替换 | 替换 ArtMethod 指针 | 方法级别 | 即时生效 | AndFix、Sophix |
| 资源修复 | 替换 AssetManager 的资源路径 | 资源文件 | 重启生效 | Tinker |
| .so 修复 | 修改 System.loadLibrary 加载路径 | so 文件 | 重启生效 | Tinker |
类加载方案(Tinker 原理)
核心原理:Android 的 PathClassLoader 按 dexElements 数组顺序查找类。将修复后的 dex 插到数组前面,就能"覆盖"旧类:
// 简化版 DexPathList 注入
fun installPatchDex(context: Context, patchDexFile: File) {
val classLoader = context.classLoader
// 反射获取 pathList 字段
val pathListField = classLoader.javaClass.superclass!!
.getDeclaredField("pathList").apply { isAccessible = true }
val pathList = pathListField.get(classLoader)
// 反射获取 dexElements 数组
val dexElementsField = pathList.javaClass
.getDeclaredField("dexElements").apply { isAccessible = true }
val oldElements = dexElementsField.get(pathList) as Array<*>
// 把 patch dex 构造为 Element 并插到数组最前面
val patchElement = makeDexElement(patchDexFile)
val newElements = arrayOf(patchElement) + oldElements
dexElementsField.set(pathList, newElements)
}
安全校验
安全须知
热更新包必须做完整的安全校验,防止被篡改植入恶意代码:
- 签名校验 — 服务端用私钥签名 patch,客户端用公钥验签
- 完整性校验 — MD5/SHA256 校验文件完整性
- 版本校验 — patch 版本必须匹配当前 App 版本
- 回滚机制 — 加载失败自动删除 patch 并上报
设计要点
| 要点 | 方案 |
|---|---|
| 差量包 | BSdiff 生成差分包,客户端合成 |
| 灰度 | 按设备 ID hash 分桶,逐步放量 |
| 回滚 | patch 加载异常时自动清除 |
| 多补丁 | 支持叠加,按版本号顺序应用 |
| 监控 | 统计补丁下载率、应用成功率、崩溃率 |
Google Play 限制
Google Play 政策禁止通过非 Play 渠道更新可执行代码(dex、.so)。此方案主要用于国内应用市场分发的 App。
常见面试问题
Q1: Tinker 和 Sophix 的区别?
答案:
- Tinker(类加载方案):通过 dex 差分 + 合成,将修复后的完整 dex 插入 ClassLoader。优点是稳定可靠,缺点是需要重启才生效
- Sophix(混合方案):方法级别的修复用底层 ArtMethod 替换可即时生效;类结构变更则回退到类加载方案需重启。兼顾了时效性和兼容性
Q2: 为什么类加载方案需要重启才能生效?
答案:
因为一个类一旦被 ClassLoader 加载到内存,就会被缓存。即使修改了 dexElements 的查找顺序,已经加载过的类不会重新加载。只有重启进程后,所有类重新经过 ClassLoader 查找时,才会优先命中插入在前面的 patch dex 中的修复类。