【Android进阶笔记】热修复(代码、资源、动态链接库)

2,137 阅读8分钟

1. 热修复

1.1. 作用

  • 严重的Bug,需要立即解决,而不用重新打包上架。
  • 解决版本升级率不高,Bug会一直影响不升级版本的用户。
  • 实现小功能短时间版本覆盖,如节日活动。

1.2. 主流热修复框架

1.2.1. 主流框架

派系框架
阿里系AndFix⚠️、Dexposed⚠️、HotFix⚠️、Sophix
腾讯系Tinker、超级补丁、QFix
知名公司美团Robust饿了么Amigo⚠️、蘑菇街Aceso⚠️
其他RocooFix⚠️、Nuwa⚠️、AnoleFix⚠️

1.2.2. 框架对比

特性SophixTinker超级补丁Robust
即时生效支持不支持不支持不支持
方法替换支持支持支持支持
类替换支持支持支持不支持
类结构修改支持支持不支持不支持
资源替换支持支持支持不支持
so替换支持支持不支持不支持
补丁包大小较小较小较大一般
性能损耗较小较大较大较小
侵入式打包无侵入侵入侵入侵入


2. 代码修复

代码修复主要有3中方案:类加载方案、底层替换方案、Instant Run方案。

2.1. 类加载方案

采用类加载方案的主要是 Tinker、超级补丁、QFix、Amigo 和 Nuwa 等。

2.1.1. Dex 分包机制

【方法数 65536 限制】

由于 DVM 指令集的方法调用指令 invoke-kind 索引为16bits,即最多能引用 65535 个方法。因此,当一个 dex 文件中法方法数超过 65535 个时,就会抛出编译期异常:com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536

【LinearAlloc 限制】

DVM 中的 LinearAlloc 是一个固定的缓存区,当方法数超出了缓存区的大小时会报错。因此,在安装应用时可能会提示 INSTALL_FAILED_DEXOPT

【分包方案】

  • 解决 65536 限制和 LinearAlloc 限制。
  • 打包时将应用代码分成多个 Dex,将应用启动时必须用到的类和这些类的直接引用类放到主 Dex 中,其他代码放到次 Dex 中。
  • 当应用启动时先加载主 Dex,等到应用启动后再动态地加载次 Dex,从而缓解了主 Dex 的 65536 限制和 LinearAlloc 限制。

2.1.2. 类加载

在 Android 的类加载过程中,一个重要环节是调用 DexPathListfindClass 方法。

// 每一个dex文件,都对应一个Element元素,并有序排列
private Element[] dexElements;

public Class<?> findClass(String name, List<Throwable> suppressed) {
    // 依次遍历dex文件数组
    for (Element element : dexElements) {
        // element.findClass内部会调用DexFile的loadClassBinaryName方法查找类
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        // 找到了这个类,就返回
        if (clazz != null) {
            return clazz;
        }
    }

    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

2.1.3. 修复方案

根据类的查找流程:

  • 将有 Bug 的类 Key.class 进行修改,再将 Key.class 打包成补丁包 Patch.dex
  • Patch.dex 放在 dexElements 数组的第一个元素。
  • 根据双亲委派,会首先找到 Patch.dex 中的 Key.class 会优先加载,而存在 Bug 的 Key.class 就不会被加载。

具体到实现细节上,不同的框架就有些差异了。

  • 超级补丁:将 Patch.dex 放到 dexElements 数组的第一个元素。
  • Tinker:将新旧 apk 做 diff,得到一个 patch.dex,再将 patch.dex 与手机 apk 中的 classes.dex 进行合并,生成 fix_classess.dex,再将 fix_classess.dex 放到 dexElements 数组的第一个元素。

2.2. 底层替换方案

采用底层替换方案的主要是 AndFix、Dexposed、HotFix 和 Sophix。

优点:不需要重启 APP,立即生效。

2.2.1. ArtMethod

Java 层的每个方法在 ART 中都对应着一个 ArtMethod 的结构体(包含 Java 方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等),只要把原方法的结构体内容替换为新的结构体内容,则在调用原来方法的时候,真正执行的指令是新方法的指令,就可以实现热修复。

art/runtime/art_method.h 文件中,定义了 ArtMethod 的结构体内容。

class ArtMethod FINAL {
    /* ... */
    GcRoot<mirror::Class> declaring_class_;
    std::atomic<std::uint32_t> access_flags_;
    uint32_t dex_method_index_;
    uint16_t method_index_;
    uint16_t hotness_count_;
    uint16_t imt_index_;

    struct PtrSizedFields {
        ArtMethod** dex_cache_resolved_methods_;
        void* data_;
        void* entry_point_from_quick_compiled_code_;
    } ptr_sized_fields_;
}

2.2.2. 修复方案

  • 方法①:将待修复的 java 方法对应的 ArtMethod 结构体中的每个字段进行替换。
  • 方法②:将待修复的 java 方法对应的 ArtMethod 结构体整个进行替换。

不同的框架采用了不同方案:

  • AndFix 采用方法①,不同版本和不同厂商 ArtMethod 可能不同,存在兼容问题,导致方法替换失败。
  • Sophix 采用方法②,不存在兼容问题 。

无论采用哪种方案,由于类加载后,类的结构和方法数量就已经固定了,因此该方案有以下不适场景:

  • 增加或减少方法和字段的个数。
  • 改变原有类的结构。

Sophix 结合了底层替换方案和类加载方案各自的优点,以底层替换方案为主,类加载方案为辅,在热部署无法使用的情况下,自动降级为冷部署。

2.3. Instant Run 方案

在 Android Studio 2.0 版本上,支持了一个新特性 Instant Run,实现了对代码修改的实时生效(热插拔)。

采用 Instant Run 方案的主要是 Robust 和 Aceso。

2.3.1. Instant Run 原理

在第一次构建 Apk 时:

  • 在每一个类中注入了一个 $change 的成员变量,它实现了 IncrementalChange 接口。
  • 在每一个方法的第一行,插入了一段判断执行逻辑。
public class TestActivity {
    // 注入一个类型为IncrementalChange的成员
    IncrementalChange localIncrementalChange = $change;
    
    public void onCreate(Bundle savedInstanceState){
        // 当localIncrementalChange不为null时,可能会执行到access$dispatch从而替换掉之前老的逻辑
        if (localIncrementalChange != null) {
            localIncrementalChange.access$dispatch(
                    "onCreate.(Landroid/os/Bundle;)V", new Object[] { this, paramBundle });
            return;
        }
        super.onCreate(savedInstanceState);
    }
}

当我们点击 Android Studio 的 InstantRun 按钮时:

  • 如果方法没有变化,则 $changenull,执行方法中的旧逻辑。
  • 如果方法有变化,则:
    • 动态生成替换类 TestActivity$overrideAppPatchesLoaderImpl 类。
    • AppPatchesLoaderImpl 类的 getPatchedClasses 方法会返回被修改的类的列表,根据这个列表,TestActivity 中的 $change 会被赋值为 TestActivity$override
    • 判断条件成立,access$dispatch() 方法会执行 TestActivity$override 类中的 onCreate 方法,从而实现对现有 onCreate 方法的修改。

2.3.2. 修复方案

Robust 为例

  • 在编译打包阶段对每个方法都自动的插入了一段代码。
  • 动态下发包含有 PatchesInfoImpl.javaPatch.javapatch.dex 到客户端,用 DexClassLoader 加载 patch.dex,反射拿到 PatchesInfoImpl.java 这个 class 并创建对象。
  • 然后通过这个对象的 getPatchedClassesInfo 方法,获得需要修复的 class 的混淆后名字,再反射得到当前运行环境中的该 class。
  • 其中的 changeQuickRedirect 字段赋值为用 patch.dex 中的 Patch.java 这个 class new 出来的对象。


3. 资源修复

很多热修复框架的资源修复都参考了 Instant Run 的资源修复原理。由于 Instant Run 不是 Android 的源码,需要反编译才能知道。

Instant Run 资源修复的核心逻辑在 MonkeyPatcher 类的 monkeyPatchExistingResources 方法中。

public class MonkeyPatcher {
    public static void monkeyPatchExistingResources(
        Context context, String externalResourceFile, Collection<Activity> activities) {

        if (externalResourceFile == null) {
            return;
        }

        try {
            // 反射创建新的AssetManager
            AssetManager newAssetManager = AssetManager.class.getConstructor(
                new Class[0]).newInstance(new Object[0]);
            Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
                "addAssetPath", new Class[]{String.class});
            mAddAssetPath.setAccessible(true);
            // 反射调用addAssetPath方法加载外部资源
            if (((Integer) mAddAssetPath.invoke(
                newAssetManager, new Object[]{externalResourceFile})).intValue() == 0) {
                throw new IllegalStateException("Could not create new AssetManager");
            }
            Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
                "ensureStringBlocks", new Class[0]);
            mEnsureStringBlocks.setAccessible(true);
            mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
            if (activities != null) {
                for (Activity activity : activities) {
                    Resources resources = activity.getResources();
                    try {
                        // 把Resources中的mAssets替换为newAssetManager
                        Field mAssets = Resources.class.getDeclaredField("mAssets");
                        mAssets.setAccessible(true);
                        mAssets.set(resources, newAssetManager);
                    } catch (Throwable ignore) {
                        /* ... */
                    }
                    // 获取Activity的主题
                    Resources.Theme theme = activity.getTheme();
                    try {
                        try {
                            // 把Resources.Theme中的mAssets替换为newAssetManager
                            Field ma = Resources.Theme.class.getDeclaredField("mAssets");
                            ma.setAccessible(true);
                            ma.set(theme, newAssetManager);
                        } catch (NoSuchFieldException ignore) {
                            /* ... */
                        }
                        /* ... */
                    } catch (Throwable e) {
                        /* ... */
                    }
                }
                Collection<WeakReference<Resources>> references = null;
                /* ...根据不同SDK版本,用不同方式得到Resources的弱引用集合 */
                for (WeakReference<Resources> wr : references) {
                    Resources resources = wr.get();
                    if (resources != null) {
                        try {
                            // 把每个Resources中的mAssets替换为newAssetManager
                            Field mAssets = Resources.class.getDeclaredField("mAssets");
                            mAssets.setAccessible(true);
                            mAssets.set(resources, newAssetManager);
                        } catch (Throwable ignore) {
                            /* ... */
                        }
                        resources.updateConfiguration(
                            resources.getConfiguration(), resources.getDisplayMetrics());
                    }
                }
            }
        } catch (Throwable e) {
            throw new IllegalStateException(e);
        }
    }
}

资源热修复总结为两个步骤:

  • 反射创建新的 AssetManager 对象,反射调用 addAssetPath 方法加载外部的资源。
  • 将 AssetManager 类型的 mAssets 字段的引用全部替换为新创建的 AssetManager 对象。


4. 动态链接库修复

Android 的动态链接库主要是 so 库。

4.1. so 的加载

加载 so 主要用到了 System 类的 loadloadLibarary 方法。

public final class System {
    // 传入so的名字,会直接从系统的目录去加载so文件,
    // 系统的路径包括/data/data/${package_name}/lib、/system/lib、/vender/lib等
    public static void load(String filename) {
        Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
    }
    
    // 传入so的绝对路径,直接从这个路径加载自定义外部so文件
    public static void loadLibrary(String libname) {
        Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
    }
}

实际上这两个方法最后都调用 nativeLoad 这个 native 方法去加载 so 库,参数 fileName 为 so 库在磁盘中的完整路径名。

而 nativeLoad 会调用 LoadNativeLibrary 函数来实现 so 的加载:

  • 判断 so 是否被加载过,两次 ClassLoader 是否是同一个,避免 so 重复加载。
  • 打开 so 并得到 so 句柄,如果 so 句柄获取失败,就返回false。创建新的 SharedLibrary,如果传入 path 对应的 library 为空指针,就将新创建的 SharedLibrary 赋值给 library,并将 library 存储到 libraries_ 中。
  • 查找 JNI_OnLoad 的函数指针,根据不同情况设置 was_successful 的值,最终返回该 was_ successful。

4.2. 注册 Native 方法

4.2.1. 静态注册Native方法

通过 javah -jni 命令生成的包含 JNI 的头文件,接口的命名方式一般是 Java_<PackageName>_<ClassName>_<MethodName>,程序执行时系统会根据这种命名规则来调用对应的 Native 方法。

  • 注册方式方便简单。
  • JNI函数名过长,可读性差,不能灵活改变。

4.2.2. 动态注册

在加载函数库(.a或.so)的时候进行注册,即在 JNI_OnLoad 方法里进行注册。

  • 注册方式麻烦。
  • JNI函数名可自由命名。

4.3. 修复方案

  • 将 so 补丁插入到 NativeLibraryElement 数组的前部,让 so 补丁的路径先被返回和加载。
  • 调用 System.load 方法来接管 so 的加载入口。

Tinker 就是使用 System.load 方法加载自定义.so文件。

  • 使用 BSdiff 算法,比较新旧 so 文件,得到差分包 patch.so。
  • 使用 BSpatch 算法,将 patch.so 与应用的原始 so 文件合并,生成 fix.so。
  • 将 fix.so 保存到磁盘目录,再使用 System.load 加载该目录下的 so 文件。