Android 热修复(一):常用热修复解决方案

文章内容

本文整理自享学团队直播课《热修复(一):常用热修复解决方案》。

一、为什么一定要有热修复

线上版本出了逻辑错误,或者直接导致崩溃的 bug,传统做法是重新打包发布一个新版本。这条路的成本并不在打包本身,而在用户什么时候愿意升级。

微信是国内升级率最高的应用之一。可即便是它,一个新版本也要 10 天以上才能覆盖到 50% 的用户。剩下的用户在干嘛?在等,等你下个版本强制更新,或者干脆一直用老版本。你说发一个新版本就能解决,用户不升,等于没解决。

热修复补齐的就是这一段空档。它让应用在用户几乎无感知的情况下,把线上代码里存在的 bug 自动修掉。不用重新下包,不用重新安装。

二、一次热修复的完整闭环

先看一个能跑通的工程例子:

  • app:热修复效果的测试 Demo。
  • buildSrc:Gradle 插件源码,负责在构建时生成补丁包。
  • patch-hack:用来插桩进入所有工程代码的类。
  • patch-lib:运行期真正执行热修复的依赖库。

Demo 的代码很简单。MainActivity 里调用 Utils.test(),而 Utils.test() 故意抛了一个异常:

java
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("出错啦!!!!!!!!!!!!!");    }}
java
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/ 下,里面有两个东西:

plain text
app/build/patch/debug/├── hex.txt├── mapping.txt   # 备份的混淆 mapping,下次生成补丁时保持混淆名一致└── patch.jar     # 补丁包本体

patch.jar 虽然以 jar 结尾,实际内容是一个 dex 文件。用 jadx-gui 打开,里面就一个类,修复后的 Utils 又被混淆成和原来一模一样的短名:

java
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 里调用库的安装方法:

java
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 注解:

java
// 注解表示补丁中此方法会替换掉 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 结构体)。这个结构体记录了方法的各种信息,其中最关键的几个字段是方法真正执行时的入口地址。

plain text
Method    art: ArtMethod    dvm: Method

替换动作就是把原方法对应的 ArtMethod,和补丁方法对应的 ArtMethod 拿出来,把原方法结构体里的字段,逐项复制成补丁方法结构体的字段,尤其是指向方法实现入口的那些字段。

AndFix 在 native 层替换方法实现

AndFix 源码里,ART 4.4 对应的实现文件是 art_method_replace_4_4.cpp,函数名是 replace_4_4,它接收两个 jobject:src 是原方法,dest 是补丁方法。核心逻辑摘录如下:

cpp
// 取出原方法的 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 插件会遍历你的每个类:

  1. 给这个类插入一个静态成员 ChangeQuickRedirect,类型是一个接口。
  2. 对每个方法,在方法体最前面插入一段判断:if (ChangeQuickRedirect != null) { ... }。不为空就走补丁实现,为空则走原逻辑。

改造之后,代码会长成这样:

Robust 的字节码插桩与运行期重定向

java
// 插件插入的静态成员,默认 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」:

java
// 实现了 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 接口,里面重写了修复后的逻辑:

java
// 实现了 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 里,遍历时第一个就命中它。

Android ClassLoader 的 Element 数组与类替换

热修复就利用了这个顺序。假设要修的 Key 类本来在 classes.dex 里,那我打一个补丁 dex,里面放一个修复后的 Key 类,再把它插入到 Element[] 数组的最前面。这样下次加载 Key 时,先命中补丁 dex 里的新 Key,直接拿去用;classes.dex 里那个有 bug 的旧 Key 虽然还在原地,却再也没有被加载的机会。这里先表述下结构:

java
// 伪代码:说明「插到最前面」这一步动了什么// 1. BaseDexClassLoader 持有 pathList,类型是 DexPathList// 2. DexPathList 持有一个 dexElements 字段,类型是 Element[]// 3. Element[] 按 classes.dex、classes2.dex… 有序排列// 4. 热修复要做的:构造补丁 dex 对应的 Element,拼成新数组放在 [0],写回 dexElements

QQ 空间基于的是 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 这对工具。:

bash
# 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 patch

bsdiff 是与格式无关的算法,底层关注的是二进制,所以对任意文件都能算,哪怕拿一个 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 的完整流程:

Tinker 基于差分包的热修复流程

  1. 拿到 base APK 和新 APK。
  2. 用 DexDiff 对比两者,对 classes.dex 生成差分包。
  3. 用户下载差分包,用 base APK 里的 classes.dex 与差分包合成,得到修复后的新 dex。
  4. 把合成后的新 dex,通过类加载机制插入 ClassLoader 的 Element[] 数组最前面。

第 4 步和 QZone 一模一样。所以 Tinker 跟 QZone 在运行期修复原理上并无区别,区别只在于:Tinker 在「生成补丁」「合成补丁」上多做了差分和合成两步,换来的是一个更小的补丁包。

十、小结

把四种方案放一起,差异对比:

  • AndFix:native 层改方法结构体入口,方法级、即时生效。但依赖 NDK、不支持新增类/方法/属性,且 ArtMethod 结构体跨厂商版本不稳定,已基本停更。
  • Robust:纯 Java 字节码插桩,方法级、即时生效。每类每方法都插一段判断,换来 APK 变大,支持新增方法。
  • QZone:类替换,把修复类打成 dex 插到 ClassLoader 最前。实现最简单,但要重启,补丁也不省。
  • Tinker:在类替换的基础上,把补丁做成差分包,用 DexDiff 压缩补丁体积。原理与 QZone 相同,多了差分与合成,补丁更小。

四种方案各有取舍:

  • 即时生效的两个要靠 native 或体积来付代价;
  • 类替换的两个要重启,但换来的是稳定、兼容。

本文由 AgentWorkFlow 生成

觉得不错?给作者点个赞吧

分类:Android系统

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

Android 热修复(一):常用热修复解决方案 · Yinchen 的博客