文章内容
本文整理自享学团队直播课《热修复(一):常用热修复解决方案》。
一、为什么一定要有热修复
线上版本出了逻辑错误,或者直接导致崩溃的 bug,传统做法是重新打包发布一个新版本。这条路的成本并不在打包本身,而在用户什么时候愿意升级。
微信是国内升级率最高的应用之一。可即便是它,一个新版本也要 10 天以上才能覆盖到 50% 的用户。剩下的用户在干嘛?在等,等你下个版本强制更新,或者干脆一直用老版本。你说发一个新版本就能解决,用户不升,等于没解决。
热修复补齐的就是这一段空档。它让应用在用户几乎无感知的情况下,把线上代码里存在的 bug 自动修掉。不用重新下包,不用重新安装。
二、一次热修复的完整闭环
先看一个能跑通的工程例子:
app:热修复效果的测试 Demo。buildSrc:Gradle 插件源码,负责在构建时生成补丁包。patch-hack:用来插桩进入所有工程代码的类。patch-lib:运行期真正执行热修复的依赖库。
Demo 的代码很简单。MainActivity 里调用 Utils.test(),而 Utils.test() 故意抛了一个异常:
package com.enjoy.enjoyfix;// 引入日志工具import android.util.Log;// Utils 工具类,故意埋了一个 bugpublic class Utils { // 日志用的 TAG 常量 private static final String TAG = "Utils"; // test 方法:线上版本一调用就崩 public static void test() { // 修复前这行日志被注释掉了 // Log.d(TAG, "哈哈哈哈"); // 主动抛异常,模拟线上崩溃 throw new IllegalStateException("出错啦!!!!!!!!!!!!!"); }}package com.enjoy.enjoyfix;// 导入 Activity 基类import android.app.Activity;// 入口 Activitypublic class MainActivity extends Activity { // 覆盖父类的 onCreate @Override protected void onCreate(Bundle savedInstanceState) { // 调用父类初始化 super.onCreate(savedInstanceState); // 加载布局 setContentView(R.layout.activity_main); // 调用 Utils.test(),至此应用必然崩溃 Utils.test(); }}这里 Utils 是开了混淆的。打进 APK 之后,类名和方法名都被混淆成一模一样的短名。启动应用,一点悬念都没有,屏幕一闪就崩溃。
修复的流程是:先把 Utils.test() 改回正常逻辑,然后展开右侧的 Gradle 面板,任务路径是 EnjoyFix > app > Tasks > patch > patchDebug,双击它。这个任务会读你修改后的代码,打出一个补丁包。产物默认放在 app/build/patch/debug/ 下,里面有两个东西:
app/build/patch/debug/├── hex.txt├── mapping.txt # 备份的混淆 mapping,下次生成补丁时保持混淆名一致└── patch.jar # 补丁包本体patch.jar 虽然以 jar 结尾,实际内容是一个 dex 文件。用 jadx-gui 打开,里面就一个类,修复后的 Utils 又被混淆成和原来一模一样的短名:
package com.enjoy.enjoyfix;// 导入日志工具import android.util.Log;// 这是混淆后的 Utils,方法也是混淆后的短名public class a { // 修复后的 test 方法 public static void a() { // 打印日志,而不是抛异常 Log.d("Utils", "哈哈哈哈"); }}补丁包生成之后,要把它放到设备上。本地演示是用 Android Studio 的 Device File Explorer 手动把 patch.jar 上传到 /sdcard/。
之后在 Application.attachBaseContext 里调用库的安装方法:
package com.enjoy.enjoyfix;// 导入补丁库import com.enjoy.patch.EnjoyFix;// 导入文件类型import java.io.File;// 自定义 Applicationpublic class MyApplication extends Application { // attachBaseContext 在 onCreate 之前回调,是注入补丁的最佳时机 @Override protected void attachBaseContext(Context base) { // 先执行父类初始化 super.attachBaseContext(base); // 补丁库入口:传入 Application 和补丁文件,库内部完成安装 EnjoyFix.installPatch(this, new File("/sdcard/patch.jar")); }}再次启动应用,就不崩了。如果把补丁删掉再启动,又崩。热修复的闭环就是这样。

注意这里的代价:改完代码要重新执行构建任务、重新启动应用,补丁才生效。它不是即时的。这个 demo 用的是类替换:把修复后的类打包成 dex,让它顶掉原包里同名、同混淆名的旧类。同时工程里的 patch-hack 模块,是插桩进所有工程代码的桩,思路和后面讲的 Robust 编译期插桩同源,这也是为什么改完代码必须重新构建。
三、四种HotFix主流方案
市面热修复方案很多,最知名的是 Tinker、QZone、AndFix、Robust 四个,分别来自腾讯微信、腾讯 QQ 空间、阿里支付宝、美团。下面这张表是课程的原始对比表,我在 Tinker 的 GitHub 上核实过它的来源:
| Tinker | QZone | AndFix | Robust | |
|---|---|---|---|---|
| 类替换 | yes | yes | no | no |
| So 替换 | yes | no | no | no |
| 资源替换 | yes | yes | no | no |
| 全平台支持 | yes | yes | yes | yes |
| 即时生效 | no | no | yes | yes |
| 性能损耗 | 较小 | 较大 | 较小 | 较小 |
| 补丁包大小 | 较小 | 较大 | 一般 | 一般 |
| 开发透明 | yes | yes | no | no |
| 复杂度 | 较低 | 较低 | 复杂 | 复杂 |
| gradle 支持 | yes | no | no | no |
| Rom 体积 | 较大 | 较小 | 较小 | 较小 |
| 成功率 | 较高 | 较高 | 一般 | 最高 |
从这张表能读出几个结论。
- 第一,只有 Tinker 同时支持 So 替换和资源替换,也只有 Tinker 有官方 gradle 支持。
- 第二,「开发透明」这一列讲的是业务侧要不要手动加注解,Tinker、QZone 对业务开发透明,AndFix、Robust 则需要开发者在要修复的方法上手动打注解,这直接决定了使用成本。此外,阿里手机淘宝团队联合阿里云在 AndFix 的升级版「百川 Hotfix」基础上继续做了 Sophix,它是在 AndFix 基础上优化,再叠加一层类似 QZone / Tinker 的 dex 插装,属于混合方案,收费且不开源。
四、类替换和方法替换
热修复本质是拿一份新代码,替换掉设备上那份有 bug 的代码。替换的粒度有两种:
- 类替换 / dex 替换:整个类层面的替换。把修复后的某个类打包成 dex,在应用启动早期装进类加载器,让系统以后加载这个类时优先用新定义。类一旦被加载进内存就会缓存,所以必须在进程还没加载该类之前就把它装进去,也就是要重启。
- 方法替换:只替换某一个方法的实现。可以用 native 层的办法直接改方法结构体,也可以用字节码插桩的办法让方法执行时走一条「代理跳转」。方法还在,只是指向变了,所以运行时可以即时切。
AndFix 和 Robust 都是方法替换这一支,一个偏 native,一个纯 Java。
五、AndFix:在 Native 层替换方法实现
AndFix 是支付宝团队推出的。先看它的使用方式:在修复后的那个方法上打一个 @MethodReplace 注解:
// 注解表示补丁中此方法会替换掉 Apk 中哪个方法@MethodReplace(clazz = "com.enjoy.Utils", method = "test")// 这是修复后的 test 方法,会替换掉原包里的 testpublic void test() { // 调用父类初始化,方法体任意 super.onCreate(paramBundle); // 修复后的实现省略 // ...}注解提供了两个参数:类的全限定名和方法名。构建补丁包时,插件只把打了注解的方法打进补丁;设备拿到补丁后,用 ClassLoader 加载这个 dex,再用反射逐个读取注解的值,就能知道「补丁里的这个方法,要去替换原包里的哪个类的哪个方法」。
真正替换发生在 native 层。Java 反射里拿到一个方法,我们管它叫 Method;到了 ART 运行时,它对应一个 C++ 结构体 ArtMethod(Dalvik 下是对应的 Method 结构体)。这个结构体记录了方法的各种信息,其中最关键的几个字段是方法真正执行时的入口地址。
Method art: ArtMethod dvm: Method替换动作就是把原方法对应的 ArtMethod,和补丁方法对应的 ArtMethod 拿出来,把原方法结构体里的字段,逐项复制成补丁方法结构体的字段,尤其是指向方法实现入口的那些字段。

AndFix 源码里,ART 4.4 对应的实现文件是 art_method_replace_4_4.cpp,函数名是 replace_4_4,它接收两个 jobject:src 是原方法,dest 是补丁方法。核心逻辑摘录如下:
// 取出原方法的 ArtMethodart::mirror::ArtMethod *smeth = (art::mirror::ArtMethod *) env->FromReflectedMethod(src);// 取出补丁方法的 ArtMethodart::mirror::ArtMethod *dmeth = (art::mirror::ArtMethod *) env->FromReflectedMethod(dest);// 原方法的类信息替换为补丁方法的类信息smeth->declaring_class_ = dmeth->declaring_class_;// 方法的 code_item 偏移指向补丁方法smeth->code_item_offset_ = dmeth->code_item_offset_;// 编译后的入口指向补丁方法smeth->entry_point_from_compiled_code_ = dmeth->entry_point_from_compiled_code_;// 解释执行的入口同样指向补丁方法smeth->entry_point_from_interpreter_ = dmeth->entry_point_from_interpreter_;关键在最后两行:把原方法 ArtMethod 里指向方法体的入口地址,改成指向补丁方法。这样下一次调用 test(),实际执行的就是补丁里的实现。
有点绕的地方是复制方向:smeth(原方法)是接收方,dmeth(补丁方法)是来源方。AndFix 把「有 bug 的原方法结构体」整个改写成「补丁方法结构体」,所以原方法跑起来就执行补丁代码。这也能顺带解释它为什么不好维护:ArtMethod 里的字段名在不同 Android 版本会变,比如 4.4 是 entry_point_from_compiled_code_,5.0 之后又换了别的名字,你每适配一个版本就要重新对一遍字段复制逻辑。
AndFix 的修复力度是方法级,而且不支持新增类、不支持新增方法、不支持新增属性,它只能改已有方法的实现。它为什么用 Native?因为「在运行期动态替换一个方法的实现」这件事,纯 Java 层做不到。Java 只能获取一个 Method 对象,改不了它内部指向的入口地址。只有拿到 native 层的 ArtMethod 结构体,才能改这个指针。
这也是它停更的原因。ArtMethod 结构体的成员在不同手机厂商、不同 Android 版本里会被调整(字段名变、增删字段)。兼容成本极高,所以 AndFix 停在几年前的版本,没有继续维护。
六、Robust:纯 Java 的字节码插桩
AndFix 靠 native 换入口,美团 Robust 则完全用 Java 实现了即时生效,没有任何 C++ 代码。原理来自美团公开的技术博客,参考了 Android Studio 3.5 的 Instant Run 热发布。
场景仍然是逻辑 bug:假设有个 State 类,getIndex() 本应返回 106,但线上版本返回了 100。
Robust 的做法是在编译期动手。它的 Gradle 插件会遍历你的每个类:
- 给这个类插入一个静态成员
ChangeQuickRedirect,类型是一个接口。 - 对每个方法,在方法体最前面插入一段判断:
if (ChangeQuickRedirect != null) { ... }。不为空就走补丁实现,为空则走原逻辑。
改造之后,代码会长成这样:

// 插件插入的静态成员,默认 null 走原逻辑public static ChangeQuickRedirect changeQuickRedirect;// getIndex 方法体被插件改造,头部多了一个分支判断public long getIndex() { // 第一层判断:是否装了补丁 if (changeQuickRedirect != null) { // PatchProxy 判断这个方法是否真的被 patch if (PatchProxy.isSupport(new Object[0], this, changeQuickRedirect, false)) { // 分发到补丁实现,返回 106 return ((Long) PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)).longValue(); } } // 没有补丁时走原实现 return 100L;}注意返回类型是 long,插桩代码也不是直接调 changeQuickRedirect.accessDispatch(...)。中间隔了一层 PatchProxy,它负责拼出「哪个类的哪个方法」的签名,再转调 changeQuickRedirect。所以判断是两层:changeQuickRedirect != null 只是说明装了补丁,PatchProxy.isSupport(...) 才决定这个方法是否真的被 patch,而不是装了补丁类的所有方法都会被劫持。
补丁包里是两个类。一个是 PatchesInfoImpl,告诉运行时「哪个类需要被 patch」:
// 实现了 PatchesInfo 接口,返回需要 patch 的类信息public class PatchesInfoImpl implements PatchesInfo { // 返回补丁类的清单 public List<PatchedClassInfo> getPatchedClassesInfo() { // 创建结果列表 List<PatchedClassInfo> patchedClassesInfos = new ArrayList<PatchedClassInfo>(); // 记录被 patch 的类(d 是 State 混淆后的名字)和对应的补丁类 PatchedClassInfo patchedClass = new PatchedClassInfo("com.meituan.sample.d", StatePatch.class.getCanonicalName()); // 加入列表 patchedClassesInfos.add(patchedClass); // 返回清单 return patchedClassesInfos; }}另一个是 StatePatch,实现了 ChangeQuickRedirect 接口,里面重写了修复后的逻辑:
// 实现了 ChangeQuickRedirect 接口,是修复逻辑的载体public class StatePatch implements ChangeQuickRedirect { // isSupport:判断这个方法是否该走补丁实现 @Override public boolean isSupport(String methodSignature, Object[] paramArrayOfObject) { // 把签名按冒号切开,取混淆后的方法名 String[] signature = methodSignature.split(":"); // 长整型 getIndex() 混淆后叫 a if (TextUtils.equals(signature[1], "a")) { // 该走补丁 return true; } // 其他方法不处理 return false; } // accessDispatch:真正返回修复后的值 @Override public Object accessDispatch(String methodSignature, Object[] paramArrayOfObject) { // 切分签名 String[] signature = methodSignature.split(":"); // 命中的是 getIndex() if (TextUtils.equals(signature[1], "a")) { // 返回修复后的结果 return 106; } // 未命中返回空 return null; }}要点是:ChangeQuickRedirect 接口有两个方法 isSupport 和 accessDispatch;补丁类匹配的是混淆后的方法名(getIndex() 混淆成 a),不是源代码里的 getIndex;StatePatch 是补丁类名。
设备拿到补丁包后:用 ClassLoader 加载补丁包里的这两个类,先按 PatchesInfoImpl 找到要 patch 的类,再反射创建 StatePatch 实例,反射赋给 State.changeQuickRedirect。这一步做完,你再调用 getIndex(),两层判断都命中,返回的就是 106。
这套机制很像一个代理:方法被包了一层,执行时由 ChangeQuickRedirect 决定走原实现还是补丁实现。Robust 还支持新增方法,用 @Add 注解把新方法打进补丁包(与 @Modify 成对)。它内部的字节码重写用的是 ASM 或 Javassist 这类工具,这一页的红字关键词是:即时生效、注解、插桩、代理。
Robust 的代价也很清晰:每个类多一个静态成员,每个方法多一段 if 判断。你没法提前知道哪个方法会出 bug,所以只能每个都插。插到所有方法里,dex 自然会变大,整个 APK 也会跟着增大,用体积换取即时生效。
七、类加载:为什么「替换类」要重启
AndFix、Robust 都即时生效,因为它们改的是方法;QZone、Tinker 走的是类替换,所以非即时。这一类方案的根基是 Android 的类加载机制。
先说方法数限制这个背景。早期 Android 系统在一个 dex 里用两个字节的 short 给方法编号,short 最大 65535。应用方法数一旦超过 65535,编号就表示不了往下走。谷歌为此推出 Multidex,把一个 APK 拆成多个 dex:classes.dex、classes2.dex、classes3.dex……
Android 的 ClassLoader 和 Java 的 ClassLoader 不是一回事。Java 的加载器加载 .class 文件;Android 的加载器加载的是 dex。Android 的 BaseDexClassLoader 里有一个 pathList,类型是 DexPathList,DexPathList 内部又持有一个 Element[] 数组,它的每个 Element 对应 APK 里的一个 dex,内部再用一个 DexFile 包装起来。你的 APK 里有两个 dex,这个数组就有两个元素。
真正加载一个类时,ClassLoader 会遍历这个数组,对每个 Element 里的 DexFile 去找目标类,找到就返回,后面不再看。这个数组是有序的:classes.dex 在最前面,classes2.dex 次之,classes3.dex 再次,按名字排的。所以一个类如果在 classes.dex 里,遍历时第一个就命中它。

热修复就利用了这个顺序。假设要修的 Key 类本来在 classes.dex 里,那我打一个补丁 dex,里面放一个修复后的 Key 类,再把它插入到 Element[] 数组的最前面。这样下次加载 Key 时,先命中补丁 dex 里的新 Key,直接拿去用;classes.dex 里那个有 bug 的旧 Key 虽然还在原地,却再也没有被加载的机会。这里先表述下结构:
// 伪代码:说明「插到最前面」这一步动了什么// 1. BaseDexClassLoader 持有 pathList,类型是 DexPathList// 2. DexPathList 持有一个 dexElements 字段,类型是 Element[]// 3. Element[] 按 classes.dex、classes2.dex… 有序排列// 4. 热修复要做的:构造补丁 dex 对应的 Element,拼成新数组放在 [0],写回 dexElementsQQ 空间基于的是 dex 分包方案。把 BUG 方法修复以后,放到了一个单独的 dex 补丁文件,让程序运行期间加载 dex 补丁,执行修复后的方法。在 Android 中所有我们运行期间需要的类都是由 ClassLoader 进行加载,因此让 ClassLoader 加载全新的类替换掉出现 Bug 的类即可完成热修复。
注意:这不是替换,是插入。旧 dex 始终还在数组里。用补丁里没有的类(比如 A、B),会继续往回找原数组,所以只有被修复的那个类被「遮蔽」了,其余类完全不受影响。也正因为只在启动早期插进去才有效,且每次进程启动都要重新注入一次,所以类替换方案必然要求重启应用才能生效。
八、QZone:最朴素的类替换
QZone(QQ 空间)方案就是把上面这套机制用最直接的方式实现:你把要修复的类打包成一个 dex,运行时把它插到 ClassLoader 的 Element[] 数组最前面。它不关心差分、不关心合成,也不关心补丁体积,修复逻辑落在哪个类,就把那个类打成 dex。
所以 QZone 的运行期修复原理,和 Tinker 一模一样,都是把修复后的 dex 插到数组最前面。两者差别只在补丁的生成与合成环节。
九、Tinker:把类替换再往前推一步
Tinker 最大的不同,在于它把补丁包做成了差分包,而不是一个完整的 dex 包。
什么叫差分包?你手上有一份有 bug 的 base APK(比如 1.0),修复之后打出一份新 APK(2.0)。理论上可以让用户重新下载整个 2.0 包,这叫全量更新。但更省流量的做法是:算出一份「差异」,用户只下载差异,再用自己手里的 1.0 和这份差异拼出 2.0。这就是增量更新。
计算这种二进制的差异,常用的是 bsdiff / bspatch 这对工具。:
# bspatch 的 usage 说明# 用 1.0 和 2.0 的差异生成补丁包 patchbsdiff 1.0.txt 2.0.txt patch# 用 1.0 和差分包合成出 new.txt,结果与 2.0 一致bspatch 1.0.txt new.txt patchbsdiff 是与格式无关的算法,底层关注的是二进制,所以对任意文件都能算,哪怕拿一个 txt 和一个 mp4 去 diff,它也能算出差异。但它对 dex 的效果不稳定,测试下来生成补丁包的大小时高时低。而这种场景里,补丁越小越好:越小用户下载越快,覆盖周期越短。
所以腾讯没直接用 bsdiff 处理 dex。Tinker 的差分算法经过了「BsDiff → DexMerge → DexDiff」的演进,最终自己实现了一套 DexDiff,深入研究了 dex 格式,让产物比 bsdiff 显著更小、大小更稳定。不过 Tinker 也没有完全抛弃 bsdiff,对 .so 和资源做差分包时,用的仍是 bsdiff:
Tinker 通过计算对比指定的 Base Apk 中的 dex 与修改后的 Apk 中的 dex 的区别,补丁包中的内容即为两者差分的描述。运行时将 Base Apk 中的 dex 与补丁包进行合成,重启后加载全新的合成后的 dex 文件。
Tinker 的完整流程:

- 拿到 base APK 和新 APK。
- 用 DexDiff 对比两者,对
classes.dex生成差分包。 - 用户下载差分包,用 base APK 里的
classes.dex与差分包合成,得到修复后的新 dex。 - 把合成后的新 dex,通过类加载机制插入 ClassLoader 的
Element[]数组最前面。
第 4 步和 QZone 一模一样。所以 Tinker 跟 QZone 在运行期修复原理上并无区别,区别只在于:Tinker 在「生成补丁」「合成补丁」上多做了差分和合成两步,换来的是一个更小的补丁包。
十、小结
把四种方案放一起,差异对比:
AndFix:native 层改方法结构体入口,方法级、即时生效。但依赖 NDK、不支持新增类/方法/属性,且ArtMethod结构体跨厂商版本不稳定,已基本停更。Robust:纯 Java 字节码插桩,方法级、即时生效。每类每方法都插一段判断,换来 APK 变大,支持新增方法。QZone:类替换,把修复类打成 dex 插到 ClassLoader 最前。实现最简单,但要重启,补丁也不省。Tinker:在类替换的基础上,把补丁做成差分包,用 DexDiff 压缩补丁体积。原理与 QZone 相同,多了差分与合成,补丁更小。
四种方案各有取舍:
- 即时生效的两个要靠 native 或体积来付代价;
- 类替换的两个要重启,但换来的是稳定、兼容。
本文由 AgentWorkFlow 生成