探索Android开源框架 - 11. 热修复原理

5,894 阅读11分钟

热修复技术介绍

  • 重新发布版本代价大,成本高,不及时,用户体验差,对此有几种解决方案:
  1. Hybird:原生+H5混合开发,缺点是人工成本搞,用户体验不如纯原生方案好;
  2. 插件化:移植成本高,对老代码的改造费时费力,而且无法动态修改;
  3. 热修复技术,将补丁上传到云端,app可以直接从云端下来补丁直接应用;
  • 热修复技术对于国内开发者来说是一个比较实用的功能,可以解决如下问题:
  1. 发布新版本代价较大,用户下载安装成本高;
  2. 版本更新的效率问题,需要较长时间来完成版本覆盖;
  3. 版本更新的升级率问题,不升级版本的用户得不到修复,强更又比较暴力。
  4. 小而重要的功能,需要短时间内完成版本覆盖,比如节日活动。
  • 热修复的优势:无需发版,用户无感知,修复成功率高,用时短;
百家争鸣的热修复框架

热修复技术原理

  • 热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复

代码修复:

  • 代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案

1. 类加载方案

  • 类加载方案需要重启App后让ClassLoader重新加载新的类,因为类是无法被卸载的,要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
优点:
  • 不需要太多的适配;
  • 实现简单,没有诸多限制;
缺点
  • 需要APP重启才能生效(冷启动修复);
  • dex插桩:Dalvik平台存在插桩导致的性能损耗,Art平台由于地址偏移问题导致补丁包可能过大的问题;
  • dex替换:Dex合并内存消耗在vm head上,可能OOM,导致合并失败
  • 虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,强制防止类被打上标志会影响性能;
Dex分包
  • 类加载方案基于Dex分包方案,而Dex分包方案主要是为了解决65536限制和LinearAlloc限制:
  1. 65536限制:DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用 65535个方法;
  2. LinearAlloc限制:DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小,安装时提示INSTALL_FAILED_DEXOPT;
  • Dex分包方案: 打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex。主要有两种方案,分别是Google官方方案、Dex自动拆包和动态加载方案。
ClassLoader
  • 在本系列的上一篇文章探索Android开源框架 - 10. 插件化原理中有讲过java中的ClassLoader(加载jar文件和Class文件,本质是加载Class文件), android中的ClassLoader(加载dex文件和apk文件), 双亲委派机制,以及ClassLoader如何加载插件中的类,其实热修复中代码修复的类加载方案也是使用的同样的原理;
几种不同的实现:
  1. 将补丁包放在Element数组的第一个元素得到优先加载(QQ空间的超级补丁和Nuwa)
  2. 将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组(饿了么的Amigo);
  3. 将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素(微信Tinker)
  4. Sophix:dex的比较粒度在类的维度,并且 重新编排了包中dex的顺序,classes.dex,classes2.dex..,可以看作是 dex文件级别的类插桩方案,对旧包中的dex顺序进行打破重组

2. 底层替换方案

  • 其思想来源于Xposed框架,完美诠释了AOP编程,直接在Native层修改原有类(不需要重启APP),由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,因为这破坏原有类的结构(引起索引变化), 虽然限制多,但时效性好,加载轻快,立即见效;
优点
  • 实时生效,不需要重新启动,加载轻快
缺点
  • 兼容性差,由于 Android 系统每个版本的实现都有差别,所以需要做很多的兼容。
  • 开发需要掌握 jni 相关知识, 而且native异常排查难度更高
  • 由于无法新增方法和字段,无法做到功能发布级别
几种不同的实现:
  1. 采用替换ArtMethod结构体中的字段,这样会有兼容问题,因为手机厂商的修改 以及 android版本的迭代可能会导致底层ArtMethod结构的差异,导致方法替换失败;(AndFix)
  2. 同时使用类加载和底层替换方案,针对小修改,在底层替换方案限制范 围内,还会再判断所运行的机型是否支持底层替换方案,是就采用底层替换(替换整个ArtMethod结构体,这样不会存在兼容问题),否则使用类加载替换;(Sophix)

3. Instant Run方案

Instant Run新特性的原理就是当进行代码改动之后,会进行增量构建,也就是仅仅构建这部分改变的代码,并将这部分代码以补丁的形式增量地部署到设备上,然后进行代码的热替换,从而观察到代码替换所带来的效果。其实从某种意义上讲,Instant Run和热修复在本质上是一样的。

Instant Run打包逻辑
  • 接入Instant Run之后,与传统方式相比,在进行打包的时候会存在以下四个不同点
  1. manifest注入:InstantRun会生成一个自己的application,然后将这个application注册到manifest配置文件里面,这样就可以在其中做一系列准备工作,然后再运行业务代码;
  2. nstant Run代码放入主dex:manifest注入之后,会将Instant Run的代码放入到Android虚拟机第一个加载的dex文件中,包括classes.dex和classes2.dex,这两个dex文件存放的都是Instant Run本身框架的代码,而没有任何业务层的代码。
  3. 工程代码插桩——IncretmentalChange;这个插装里面会涉及到具体的IncretmentalChange类。
  4. 工程代码放入instantrun.zip;这里的逻辑是当整个App运行起来之后才回去解压这个包里面的具体工程代码,运行整个业务逻辑。
  • Instant Run在第一次构建apk时,使用ASM在每一个方法中注入了类似如下的代码 (ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能)
//$change实现了IncrementalChange这个抽象接口。
//当点击InstantRun时,如果方法没有变化则$change为null,就调用return,不做任何处理。
//如果方法有变化,就生成替换类,假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivity$override,
//这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法
//会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivity的$change设置为MainActivity$override
//因此满足了localIncrementalChange != null,会执行MainActivity$override的access$dispatch方法,
//access$dispatch方法中会根据参数”onCreate.(Landroid/os/Bundle;)V”执行MainActivity$override的onCreate方法,
//从而实现了onCreate方法的修改。
IncrementalChange localIncrementalChange = $change;
if (localIncrementalChange != null) {//2
    localIncrementalChange.access$dispatch(
            "onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
                    paramBundle });
    return;
}
被废弃的Instant Run

Android Studio 3.5 中一个显著变化是引入了 Apply Changes,它取代了旧的 Instant Run。Instant Run 是为了更容易地对应用程序进行小的更改并测试它们,但它会产生一些问题。为了解决这一问题,谷歌已经彻底删除了 Instant Run,并从根本上构建了 Apply Changes ,不再在构建过程中修改 APK,而是使用运行时工具动态地重新定义类,它应该比立刻运行更可靠和更快。

优点
  • 实时生效,不需要重新启动
  • 支持增加方法和类
  • 支持方法级别的修复,包括静态方法
  • 对每个产品代码的每个函数都在编译打包阶段自动的插入了一段代码,插入过程对业务开发是完全透明
缺点
  • 代码是侵入式的,会在原有的类中加入相关代码
  • 会增大apk的体积

资源修复:

  • 目前市面上大部分资源热修复方案基本都参考了Instant Run的实现, 其主要分两步:
  1. 创建新的AssetManager,并通过反射调用addAssetPath加载完整的新资源包;
  2. 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处 替换为新AssetManager;
  • 这里的具体原理可以参考章探索Android开源框架 - 10. 插件化原理中的资源加载部分;
  • Sophix: 构造了一个package id为0x66的资源包(原有资源包为 0x7f),此包只包含改变了的资源项,然后直接在原有的AssetManager中 addAssetPath这个包就可以了,不修改AssetManager的引用处,替换更快更安全

so库修复:

  • 主要是更新so,也就是重新加载so,主要用到了System的load和loadLibrary方法
  • System.load(""): 传入so在磁盘的完整路径,用于加载指定路径的so
@CallerSensitive
public static void load(String filename) {
    Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
  • System.loadLibrary(""):传入so名称,用于加载app安装后自动从apk包中复制到/data/data/packagename/lib下的so
@CallerSensitive
public static void loadLibrary(String libname) {
    Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
  • 最终都会调用到LoadNativeLibrary(),其主要做了如下工作:
  1. 判断so文件是否已经加载,若已经加载判断与class_Loader是否一样,避免so重复加载;
  2. 如果so文件没有被加载,打开so并得到so句柄,如果so句柄获取失败,就返回false,常见新的SharedLibrary,如果传入path对应的library为空指针,就将创建的SharedLibrary赋值给library,并将library存储到libraries_中;
  3. 查找JNI_OnLoad的函数指针,根据不同情况设置was_successful的值,最终返回该was_successful;
两种方案:
  1. 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载;
  2. 调用System.load方法来接管so的加载入口;

参考

我是今阳,如果想要进阶和了解更多的干货,欢迎关注微信公众号 “今阳说” 接收我的最新文章