文章目录
  1. 1. 1. 热修复面临的问题
  2. 2. 2. 从问题说起–热修复即时生效
    1. 2.1. 2.1. 虚拟机调用方法原理
    2. 2.2. 2.2 兼容性问题
    3. 2.3. 2.3. 访问权限问题
    4. 2.4. 2.4. 反射调用方法
    5. 2.5. 2.5. 即时生效的限制
  3. 3. 3. 从问题说起–so库热修复
    1. 3.1. 3.1 so库加载原理
    2. 3.2. 3.2 so库热修复方案
      1. 3.2.1. 3.2.1 方法热修复实时生效
      2. 3.2.2. 3.2.2 冷启动修复生效
      3. 3.2.3. 3.2.3 如何感知客户端abi
  4. 4. 4. 从问题说起–资源热修复
    1. 4.1. 4.1 Instant Run 方案
    2. 4.2. 4.2 sophix阿里百川
  5. 5. 5. 参考

热修复这一技术虽说在我个人看来属于旁门左道, 但对它的研究可以极好地拓展自己对Android底层实现以及版本间差异的直观认识. 并且人难免犯错, 有时候一些严重的线上问题, 确实只能通过这种旁门左道的方式来减少损失.
我已经写过几个跟热修复有关的文章, 那时候对热修复的认识还停留在ClassLoader上, 本文借着对sophix热修复框架的研究, 从头梳理一下热修复所涉及的方方面面.

1. 热修复面临的问题

  • 即时生效
  • 静态函数
  • 非静态函数
  • 静态成员
  • 非静态成员
  • 非代码资源
  • 编译优化技术JIT
  • 编译优化技术AOT
  • Dalvik与Art, 不同的虚拟机

Android上的热修复方案无可避免要面对以上的几种或全部问题, 本文将一一给出sophix的解答, 当然这些解答都是基于我自己的理解. –这意味着本文是个难读的长文.

为了节省篇幅和聚焦重点, 本文不会详细对比sophix与其他热修复框架的优劣, 只着重讲解sophix背后的热修复思想. –在我看来, 这是个很”笨重”的思想, 完全与优雅沾不上边, 甚至有些”dirty”, 但是读过众多代码的话就会发现, 哪怕是被誉为天才的程序员, 在追求极致的路上也无法做到既高效又优雅, 总会牺牲一部分.

2. 从问题说起–热修复即时生效

在native层整个替换ArtMethod结构体, 将方法指针指向补丁包中的方法地址, 从而达到即时生效的目的.

市面上有不少开源的热修复工具, 其中具有代表性的是腾讯的Tinker系列, 之前我也写过. 它们的热修复原理是利用Java ClassLoader类加载顺序, 让补丁类代替原有类, 从而达成热修复目的. 但是基于ClassLoader的热修复方案都有一个明显的缺陷, 那就是不能即时生效, 原因是类加载器只有在虚拟机启动的时候才会去查找类, 在此之后类对象在内存中就固定了, 这注定了所有基于类加载器的热修复方案只有在虚拟机重启时才能生效.

想要让热修复即时生效, 很自然想到需要修改内存. 怎么修改内存, 这里就是热修复框架的技巧了.

sophix是阿里家热修复框架的名字, 它的前身是andfix. 出于数据保密要求, 我无法详细讲它的实现, 这里只能从原理上讲一讲这个热修复流派不同于T家的修复思路(我所讲的内容均已有官方披露).

基本上, 它是在方法的粒度上, 从内存着手, 实现热修复实时生效.

2.1. 虚拟机调用方法原理

在程序开发中, 很大一部分问题可以通过修改方法实现来解决, 因此如果一个热修复框架可以完成方法粒度的热修复, 理论上这个框架就可以成立. 我们可以从这个角度入手, 基于虚拟机调用方法的过程, 导出sophix的修复方案.
在虚拟机载入类之后, 每个类的每个方法, 都对应一个内存地址, 我们都知道一个方法在内存中存在的形式就是一个地址以及这个地址指向的指令块. 在虚拟机角度, 会对方法做一个结构抽象, 这里不说具体的字段, 只说存在这么个结构体, 我们姑且称为VMMethod, 它描述了虚拟机方法的基本信息, 例如起始地址, 所属类.
由于Android的JIT, AOT等各种优化手段的存在, class中定义的某个方法, 其成为机器码后, 在内存中的地址并不固定. 比如Art虚拟机可以通过解释执行的方式把Dex指令拿出来, 逐条解释执行, 也可以先把Dex指令编译成机器码, 之后每次运行直接运行机器码而不依赖原来的Dex文件. 这两种模式下, 同一个方法的入口地址是不同的, 体现在VMMethod里就是两个不同的字段. 根据不同的执行模式, 虚拟机会从不同的字段去寻找方法入口.
那么, 如果把方法入口地址从旧地址换到新地址, 不就可以实现方法替换了吗? 是的, 基本原理就是这样, 但没有那么简单. 一个VMMethod除了方法入口外, 方法调用还依赖其他的字段. 在热修复时, 不能仅仅替换方法入口, 新方法中对VMMethod的依赖也需要可用才行.
那么把整个VMMethod都替换掉, 不就可以了吗? Andfix就是这么做的, 事实证明完全可以, 但这么做有个非常严重的问题, 就是接下来要讲的兼容性问题.

2.2 兼容性问题

VMMethod这个抽象结构的具体字段在虚拟机版本变迁中以及厂商定制中很可能会有变化, 而热修复框架必然是基于官方开源的系统源码进行开发, 即便它能够兼容所有官方版本的VMMethod, 也无法保证能兼容大千世界各种手机厂商的魔改rom.

例如, 厂商在方法结构上删除或增加了某个字段, 跟官方开源的方法结构不一致了, 那么你做替换时原本想替换的字段, 可能就错位成了另一个字段, 带来不可预知的内存错误.

sophix解决了这个兼容问题. 它是怎么解决的呢? 它基于一个更稳定的假设. 事实上, 我们做开发都是基于某个假设的(或者说标准): 系统源码与官方一致, 编码协议一致, 字节码格式一致… Andfix基于的假设是VMMethod结构, 而sophix基于一个更稳定的假设: 虚拟机总是通过VMMethod来识别和使用方法, 并且方法总是连续存储在一个数组中. 在这样的假设下, 我们只需要把旧方法的VMMethod, 替换成新方法的VMMethod, 不就可以达成修复的目的了吗? –反正在同一个虚拟机上, 旧方法和新方法的VMMethod结构肯定是一样的, 这样也就规避了VMMethod结构变化导致的兼容问题, 使得同一套热修复可以应用在不同的系统版本上.

替换底层方法, 理论上看起来可行, 可是实现时有个困难: 从旧方法的起始地址开始, 我们应该替换多大的内存范围呢? 作为系统开发者, 我只要sizeof(VMMethod)就可以拿到方法结构的大小, 因为这是系统编译期就可以决定的值, 但是作为上层开发者, 我们并不能拿到VMMethod的具体实现, 一旦尺寸计算失误, 可能会破坏掉整个类.

深入虚拟机代码, 我们可以发现虚拟机对类方法的定义(VMMethod), 以及填充类对象的过程. 类的方法有direct方法和virtual方法。direct方法包含static方法和所有不可继承的对象方法, 而virtual方法就是所有可以继承的对象方法了. 通过观察源码, 我们发现方法是存在一个连续空间的数组中的, 那么我们只要人工构造两个相邻的方法, 并计算一下起始地址差, 这不就是一个VMMethod的大小了吗? 于是这个问题也解决了.

2.3. 访问权限问题

旧方法和新方法本质上是存在于两个不同类中的不同方法, 而我们知道方法调用时是有可见性限制的, 那么直接这么替换方法指针, 不会面临权限问题吗?

要回答这个问题, 我们仍需要深入虚拟机汇编码. 这里省略汇编码, 直接说结论: 调用同一个类下的其他方法时, 不做权限检查, 但调用其他类中的方法时会有权限检查.

这也是可以理解的: 如果要调用的方法本来就在我所处的类中, 那不管它是什么修饰符, 我都可以访问它, 自然就不必检查权限了.

而要解决调用其他类方法的问题, 我们就要搞清楚它的权限检查机制. 查阅虚拟机源码后(此处省略数百字), 我们发现它会检查调用方和被调用方是否同一个ClassLoader实例. 在这里, 调用方就是旧方法所在的类, 而被调用方就是新方法所在的类. 因此, 我们只要把新方法所在类的ClassLoader设置成旧方法所在类的ClassLoader, 就可以成功欺骗虚拟机, 通过包权限检查.

2.4. 反射调用方法

以上类替换的方式, 基本可以满足大部分情况, 但是如果代码中有通过反射的方式调用经过热修复的非静态方法, 则会因为反射时校验不通过而无法成功, 抛出异常. 原因是非静态方法通过Method.invoke(object, params)反射调用时, 会检查object这个接收的实例对象, 是否是Method所属类的实例, 而通过热修复替换掉的Method, 其所属类是新方法所在类, 与object所属的旧方法所在类是两个不同的类对象, 因此无法通过检查.

静态方法不存在这个问题, 因为静态方法不检查接收的实例对象.

方法替换无法解决上述问题, 因此我们需要另辟蹊径, 例如通过冷启动方式解决.

2.5. 即时生效的限制

上述热修复一直都在说旧方法替换成新方法, 实际上就算仅仅是方法粒度的热修复, 当然也远远不止替换这一种需求场景. 我们往往还需要新增或删除方法或字段.

但是上述热修复方案, 无法支持方法或字段的新增或删除. 原因就是上述热修复方法的基本假设: 方法总是连续存储在一个数组中. 事实上, 不仅方法, 字段也是按固定顺序排列在内存结构中的, 如果我们新增或删除了方法, 则相当于打乱了原有的索引顺序. 原先的类对象数据结构中的方法索引, 就无法指向正确的内存地址, 从而造成不可预知的后果.

换句话说, 只要是引起原有类的结构变化的修改, 都不能通过上述即时生效的方案进行热修复. 如果完全新增一个原来不存在的类, 则没有这个限制.

3. 从问题说起–so库热修复

与class method类似的思想, 将补丁so重新load一遍, 用补丁的方法替换原来load的方法, 从而达到即时生效的热修复效果.

3.1 so库加载原理

so(shared object)库, 我在之前的JNI相关文章里已经讲过, 这里简单回顾一下so库的加载原理.
在Android中, 目前都是通过文件名的方式找到so文件, 通过mmap方式加载到内存的. 加载完so文件后, 系统会调用JNI_OnLoad方法, 将so中动态注册的方法注册到虚拟机维护的方法数组中, 这样就完成了native方法的动态映射.
调用native方法时, 首先会判断是否动态映射, 然后会按照静态映射规则构造静态映射方法名, 查找对应的方法, 如果还没映射则进行静态映射, 当然前提是so已经load到内存中. 如果都没有找到, 就会抛出异常了.

静态注册和动态注册的方法, 在映射时机上的区别就在于, 静态注册的方法是在第一次调用时完成映射, 而动态注册的方法则是在so载入内存后通过回调JNI_OnLoad主动完成方法映射.

3.2 so库热修复方案

对so载入原理有了基本认识, 接下来在不同条件下探索一下如何进行热修复.

3.2.1 方法热修复实时生效

要让方法实时生效, 必然需要从内存载入上让补丁so代替原来so发挥作用, 这就需要区别对待静态注册和动态注册的方法.
先来看动态注册.
很自然的我们想到, 既然动态注册是回调JNI_OnLoad后完成的方法映射, 那么我们只要让补丁so再执行一次JNI_OnLoad, 不就可以覆盖先注册的方法的映射了吗?
动态注册修复
实际上是否可行呢? 实测发现, 在Art虚拟机上可以实时生效, 但Dalvik不能实时生效.
loadLibrary调用的是以下两个native方法:

  • dlopen():返回给我们一个动态链接库的句柄
  • dlsym(): 通过一个dlopen得到的动态连接库句柄,来查找一个symbol

首先来看下Dalvik虚拟机下面dlopen的实现, 源码在/bionic/linker/dlfcn.cpp文件, 方法调用链路:dlopen-> do_dlopen -> find_library -> find_library_internal.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static soinfo* find_library_internal(const char* name) {
soinfo* si = find_loaded_library(name);
if (si != NULL) { //so库已经加载过
if (si->flags & FLAG_LINKED) {
return si; //直接返回该so库的句柄
}
DL_ERR("OOPS: recursive link to \"%s\"", si->name);
return NULL;
}

TRACE("[ '%s' has not been loaded yet. Locating...]", name);
si = load_library(name); //so库从未加载过, load_library执行加载
if (si == NULL) {
return NULL;
}
return si;
}

static soinfo *find_loaded_library(const char *name) {
soinfo *si;
const char *bname;

// TODO: don't use basename only for determining libraries
// http://code.google.com/p/android/issues/detail?id=6670
bname = strrchr(name, '/');
bname = bname ? bname + 1 : name;

for (si = solist; si != NULL; si = si->next) {
if (!strcmp(bname, si->name)) {
return si;
}
}
return NULL;
}

可以看到, 加载补丁so的时候, 在Dalvik虚拟机上是以basename(而非完整的so路径)作为key来查询是否已经加载过了, 如果表中存在这个basename, 则返回已经加载的句柄. 于是, 加载补丁so时也返回了原so的句柄.
知道原因后, 我们尝试将补丁so的basename重命名, 问题解决.

但是这样无法修复静态注册的方法.
前面说过, 静态注册的方法是在第一次调用时才做映射, 那么如果加载补丁so时, 要修复的静态注册方法已经被调用过了, 则不会再次被映射. 幸运的是, JNI API提供了注销Native方法的接口.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static jint UnregisterNatives(JNIEnv* env, jclass jclazz) {
ClassObject* clazz = (ClassObject*) dvmDecodeIndirectRef(ts.self(), jclazz);
dvmUnregisterJNINativeMethods(clazz);
return JNI_OK;
}
/*
* Un-register all JNI native methods from a class.
*/
void dvmUnregisterJNINativeMethods(ClassObject* clazz){
unregisterJNINativeMethods(clazz->directMethods, clazz->directMethodCount);
unregisterJNINativeMethods(clazz->virtualMethods, clazz->virtualMethodCount);
}
static void unregisterJNINativeMethods(Method* methods, size_t count){
while (count != 0) {
count--;
Method* meth = &methods[count];
if (!dvmIsNativeMethod(meth))
continue;
if (dvmIsAbstractMethod(meth)) /* avoid abstract method stubs */
continue;

dvmSetNativeFunc(meth, dvmResolveNativeMethod, NULL); //meth->nativeFunc重新指向dvmResolveNativeMethod
}
}

UnregisterNatives函数会把jclazz所在类的所有native方法都重新指向为dvmResolveNativeMethod, 所以调用UnregisterNatives之后不管是静态注册还是动态注册的native方法在加载补丁so的时候都会重新做映射. 这里有个难点就是找到这个正确的jclazz对象, 这里假设我们知道哪个类对象里的native方法需要做修复. 但是测试发现, 在补丁so库重命名的前提下, Java层native方法可能映射到原so库的方法, 也可能映射到补丁so库的新方法.

首先静态注册的native方法之前从未执行(或者调用了unregisterJNINativeMethods注销方法), 首先尝试解析该方法. 该方法将指向meth->nativeFunc = dvmResolveNativeMethod, 那么真正运行该方法的时候, 实际上执行的是dvmResolveNativeMethod函数. 这个函数主要完成java层native方法和native层方法的映射逻辑.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void dvmResolveNativeMethod(const u4* args, JValue* pResult,
const Method* method, Thread* self) {
ClassObject* clazz = method->clazz;
.....
/* now scan any DLLs we have loaded for JNI signatures */
void* func = lookupSharedLibMethod(method); //调用lookupSharedLibMethod方法, 拿到so库文件对应的native方法函数指针.
if (func != NULL) {
/* found it, point it at the JNI bridge and then call it */
dvmUseJNIBridge((Method*) method, func);
(*method->nativeFunc)(args, pResult, method, self);
return;
}
.....

dvmThrowUnsatisfiedLinkError("Native method not found", method);
}

static void* lookupSharedLibMethod(const Method* method){
return (void*) dvmHashForeach(gDvm.nativeLibs, findMethodInLib,
(void*) method);
}

int dvmHashForeach(HashTable* pHashTable, HashForeachFunc func, void* arg){
int i, val, tableSize;
tableSize = pHashTable->tableSize;

for (i = 0; i < tableSize; i++) {
HashEntry* pEnt = &pHashTable->pEntries[i];
if (pEnt->data != NULL && pEnt->data != HASH_TOMBSTONE) {
val = (*func)(pEnt->data, arg);
if (val != 0)
return val;
}
}
return 0;
}

gDvm.nativeLibs是一个全局变量, 它是一个hashtable, 存放着整个虚拟机实例加载so库的SharedLib结构指针. 该变量作为参数传递给dvmHashForeach函数进行hashtable遍历. 执行findMethodInLib函数看是否找到对应的native函数指针, 如果找到就直接短路return, 不再继续查找.

在虚拟机中大量使用到了hashtable这个数据结构, hashtable的实现源码在dalvik/vm/Hash.h和dalvik/vm/Hash.cpp文件中, 有兴趣可以自行查看源码. hashtable的遍历和插入都是在dvmHashTableLookup方法中实现, 简单说下Java的hashtable和Dalvik的hashtable的异同点:

  • 共同点: 两者底层都是数组实现, hashtable容量如果超过默认值都会进行扩容, 都是对key进行hash计算然后跟hashtable的长度进行取模作为bucket.
  • 不同点: Dalvik虚拟机下hashtable put/get操作实现方法, 实际上要比Java的Hashmap实现简单一些. Java Hashmap的put实现需要处理hash冲突的情况, 一般情况下会通过在冲突节点上新增一个链表处理冲突. get实现则会遍历这个链表通过equals方法比较value是否一致进行查找. Davlik的hashtable的put实现上(doAdd=true)只是简单的把指针下移直到下一个空节点. get实现(doAdd=false)首先根据hash值计算出bucket位置, 然后通过cmpFunc函数比较值是否一致, 不一致则指针下移, 这个过程实际就是数组遍历.

知道了Davlik下hashtable的实现原理, 那我们再来看下前面提到的: 补丁so库重命名的前提下, 为什么Java层native方法可能映射到原so库的方法也可能映射到补丁so库的新方法? 事实上, 这跟静态方法在hashtable上的顺序有关, 因为先找到的函数指针会生效, 而重命名后的补丁so的方法, 既有可能在原方法前面, 也有可能在其后面. 一张图说明情况
静态注册修复

小结一下. 补丁so实时生效的条件如下:

  • 为了兼容Dalvik下动态注册方法的实时生效, 补丁so需要单独命名, 与原so不重名
  • so库静态注册的方法要实时生效, 需要在补丁so中先把静态注册方法注销, 强制虚拟机重新映射
  • 原so和补丁so会同时存在于内存中
  • 补丁so不能新增动态注册方法, 否则会因为找不到对应的Java方法而报NoSuchMethodError

3.2.2 冷启动修复生效

从上一节可以看到, 让so实时生效的限制条件较多. 如果换个思路, 允许冷启动后补丁生效, 又会如何呢?
这里又有两种方案, 一是我们自己接管loadLibrary方法, 启动时先从特定路径查找补丁so, 找到则加载补丁so, 否则加载原so. 这个方法的优点是普适性较好, 缺点是so的载入过程我们自己要有控制权, 如果是已经编译打包好的库里的so, 要做修复就无能为力了.
接管接口调用

为了让冷启动修复能对打包好的第三方so也有修复能力, 我们考虑另一种方案: 与dex修复类似, 将补丁so的路径通过反射的方式插入到so路径数组中原so路径前面, 让补丁so优先加载. 这种方式的缺点是, 不同的系统版本中, 载入so的具体方式可能不同, 需要根据系统版本做适配兼容.
反射注入

3.2.3 如何感知客户端abi

我们知道, 客户端有不同的cpu架构, 对应apk里有不同的abi, 一般来说客户端虚拟机只会在某个特定目录下寻找so, 那么我们在进行so热修复时, 也需要考虑客户端具体用的什么cpu.
如何感知客户端的abi呢?
如何选择abi

  • sdk>=21, 直接反射拿到ApplicationInfo对象的primaryCpuAbi即可
  • sdk<21, 由于此时不支持64位, 所以直接把Build.CPU_ABI, Build.CPU_ABI2作为primaryCpuAbi即可

4. 从问题说起–资源热修复

与代码不同, 资源在Android中最后都是被分类打包好的, 并以一个id作为资源指针(我们知道, 编译时会生成一个R文件, 里面就是各种资源对应的编号), 资源映射信息则存储在resources.arsc文件中.
资源文件的格式定义在frameworks\base\include\androidfw\ResourceTypes.h, 网上有很多关于这个格式的说明, 这里就不一一讲解了. 我们只要知道, 资源文件信息都在这个文件里就够了.
资源热修复, 就是在不需重新安装App的前提下, 利用补丁包的形式将App中的资源更新的技术.
arsc资源格式

4.1 Instant Run 方案

Instant Run实现了资源的热修复生效, 目前很多资源热修复方案也是仿照它实现的. 那么它是怎么实现资源热修复的呢?
Instant Run资源热修复的核心代码是这个monkeyPatchExistingResources方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@com/android/tools/fd/runtime/MonkeyPatcher.java

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

if (externalResourceFile == null) {
return;
}

try {
// %% Part 1. 创建一个新的AssetManager,并通过反射调用addAssetPath添加/sdcard上的新资源包.
// 这样就构造出了一个带新资源的AssetManager
// Create a new AssetManager instance and point it to the resources installed under
// /sdcard
AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
mAddAssetPath.setAccessible(true);
if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) {
throw new IllegalStateException("Could not create new AssetManager");
}

// Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
// in L, so we do it unconditionally.
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager);

// %% Part 2. 反射得到Activity中AssetManager的引用处,全部换成刚才新构建的newAssetManager
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources();

try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}
... ...

pruneResourceCaches(resources);
}
}

// %% Part 3. 得到Resources的弱引用集合,把他们的AssetManager成员替换成newAssetManager
// Iterate over all known Resources objects
Collection<WeakReference<Resources>> references;
if (SDK_INT >= KITKAT) {
// Find the singleton instance of ResourcesManager
Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance");
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null);
try {
Field fMActiveResources = resourcesManagerClass.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
@SuppressWarnings("unchecked")
ArrayMap<?, WeakReference<Resources>> arrayMap =
(ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);
//noinspection unchecked
references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager);
}
} else {
Class<?> activityThread = Class.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);
@SuppressWarnings("unchecked")
HashMap<?, WeakReference<Resources>> map =
(HashMap<?, WeakReference<Resources>>) fMActiveResources.get(thread);
references = map.values();
}
for (WeakReference<Resources> wr : references) {
Resources resources = wr.get();
if (resources != null) {
// Set the AssetManager of the Resources instance to our brand new one
try {
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
mResourcesImpl.setAccessible(true);
Object resourceImpl = mResourcesImpl.get(resources);
Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
implAssets.setAccessible(true);
implAssets.set(resourceImpl, newAssetManager);
}

resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}}

总体思路是分为两步:

  1. 构造一个新的AssetManager, 通过反射调用addAssetPath, 将包含补丁资源的完整资源包路径加入到新的AssetManager中.
  2. 找到原应用中引用AssetManager的所有地方, 通过反射替换成新的AssetManager.

这里面关键的步骤自然是addAssetPath方法调用. 这个方法最终会调用Native方法, 对传入的资源包中的resource.arsc文件进行解析. 调用路径大概是这样addAssetPath -> jni:: android_content_AssetManager_addAssetPath -> AssetManager::addAssetPath -> AssetManager::appendPathToResTable -> ResTable::add -> ResTable::addInternal -> ResTable::parsePackage.
最后对文件进行解析的部分, 可以参考上述的文件格式定义.
举个例子,我们随便找个带资源的apk, 用aapt解析一下, 看到其中的一行是:

1
2
3
4
5
$ aapt d resources app-debug.apk

... ...
spec resource 0x7f040019 com.taobao.patch.demo:layout/activity_main: flags=0x00000000
... ...

这就表示, activity_main.xml这个资源的编号是0x7f040019, 它的package id0x7f, 资源类型的id为0x04, Type String Pool里的第四个字符串正是layout类型, 而0x04类型的第0x0019个资源项就是activity_main这个资源.

默认由Android SDK编出来的apk, 是由aapt工具进行打包的, 其资源包的package id就是0x7f.

系统的资源包, 也就是framework-res.jar, package id0x01.

在走到app的第一行代码之前, 系统就已经帮我们构造好一个已经添加了系统资源和安装包资源的AssetManager了.

如果我们不替换AssetManager, 而是在原来的AssetManager上直接添加补丁资源包路径, 会发生什么呢?
由于默认情况下, 补丁包的package id也是0x7f, 这就使得同一个package id的资源会被加载两次(原包一次, 补丁包一次).
在Android L之后, 这样直接添加是没问题的, 系统会默默地把后来的包添加到之前的包的同一个PackageGroup下面.
在资源解析的时候, 会与之前的包比较同一个type id所对应的类型, 如果该类型下的资源项数目和之前添加过的不一致, 会打出一条warning log, 但是仍旧加入到该类型的TypeList中. 这就完成了资源添加.
但是在获取某个类型的资源的时候, 是从前往后遍历资源列表, 也就是说会先访问原安装包中的资源, 除非后面资源的config比前面更详细, 对于相同config的资源, 靠后位置的补丁包资源就无法载入了. 也就是说, 这种补丁包直接添加到原AssetManager的方式, 在Android L以上无法生效.

Andorid KitKat以下, AssetManager解析资源的时机稍有不同. addAssetPath只是把资源路径添加到成员变量中, 只有在app第一次执行AssetManager::getResTable的时候才进行资源包解析. 也即是说, 我们调用addAssetPath添加补丁资源包, 并不会触发资源包解析动作. 而AssetManager::getResTable方法, 可能此时已经被Android Framework调用过无数次了. 所以新添加的补丁资源包, 根本不会解析到内存中.

综上, 以Instant Run为例的资源热修复方案, 必须要用一个全新的AssetManager来替换原来的AssetManager, 才能达到热修复效果.

4.2 sophix阿里百川

和其他热修复一样, 一个好的资源热修复方案, 应该有以下几个目标:

  • 补丁包足够小. 下发完整资源包的方式, 当然也能完成布热修复, 但肯定不是一个好选择.
  • 补丁方式简单, 兼容性强. 有些方案是用diff工具打出补丁包, 再在本地合成一个完整资源包. 这样在本地合成这一步就较为复杂, 且增加客户端负担.
  • 避免侵入正常打包流程. 有些方案是修改aapt工具, 通过给补丁包资源重新编号的方式, 完成补丁包生成. 这样涉及到修改aapt, 并不通用, 还涉及aapt升级的问题.

对于Android L以上的系统. 我们可以构造一个package id0x66(只要在0x7f之前, 在0x01之后, 理论上都行)的补丁资源包, 这个包里只包含改变了的资源项. 然后将这个资源包通过addAssetPath添加到原包的AssetManager中, 理论上就可以了.

改变了的资源有以下情况:

  • 新增资源. 新增资源, 直接放到补丁包中就可以了, 因为只有补丁包的代码才可能引用到新增资源.
  • 删除资源. 删除资源, 只要补丁包中不引用资源就可以了, 不用考虑主动删除.
  • 资源修改. 例如资源替换, 这个可以视作新增资源, 只要把代码里原有的引用该资源id的地方, 全部替换为引用新资源id即可.

resource_patch
绿色: 新增资源;
红色: 内容改变的资源;
黑色: 资源id发生改变的资源;
✘: 删除的资源;

图里的id值存在错误, 将就看吧.

  • 新增资源. 可以看到新增的资源会导致资源所在type里, 它后面的资源id发生位移. 由于新增资源插入的相对位置是随机的, 所以哪些资源会发生id位移也跟具体的aapt解析xml顺序有关. id发生位移的资源, 在补丁包的代码里对其的引用, 需要相应恢复原来的id. 例如图中对于同一个资源R.drawable.holo_light, 原包id是0x7f020002, 补丁包中的代码里, 引用id会变成0x7f020003, 实际上这个资源并没发生改变, 因此在生成代码补丁包的过程中, 新包对这个资源的引用应该修正恢复成0x7f020002再来进行新旧包比较, 这样避免将没有发生实质改变(仅仅id改变)的资源引用也计算到代码补丁包中.
  • 删除资源. 删除资源由于在新包中不会有引用, 故不影响补丁包的生成.
  • 资源修改. 对于内容发生改变的资源(类型为layout的activity_main, 这可能是我们修改了activity_main.xml的文件内容), 它们都会被加入到patch中, 并重新编号为新id. 这样一来, 原来代码中setContentView(R.layout.activity_main);(实际上等价于setContentView(0x7f030000);), 在对比新旧代码生成代码补丁之前, 我们应该把新包里面的这行代码的资源id替换为补丁id, 即setContentView(0x66020000);. 这样, 在进行代码对比时, 会检测到这行代码所在函数发生了改变, 于是生成相应的修复代码, 引用到补丁包中正确的新资源.

由于type0x01的所有资源项都没有变化, 所以整个type0x01资源都没有加入到patch中. 这也使得后面的type的id都往前移了一位. 因此资源文件中Type String Pool中的字符串也要进行修正, 这样才能使得0x01的type指向drawable, 而不是原来的attr.

以上做法, 将运行时应用patch变的简单了, 真正复杂的地方在于构造patch. 我们需要把新旧两个资源包解开, 分别解析其中的resources.arsc文件, 对比新旧的不同, 并将它们重新打成带有新package id的补丁资源包. 构造这样的补丁资源包, 需要对整个resources.arsc的结构十分了解, 要对二进制形式的一个一个chunk进行解析分类, 然后再把补丁信息一个一个重新组装成二进制的chunk, 这是一个相当繁琐的工作, 好在网上已经有相关开源工具帮我们完成解析这一步. 这里面很多工作与aapt做的类似, 开发打包工具时可以参考aapt和系统加载资源的代码.

上面说了, 对于Android L以上的系统可以直接通过原有的AssetManager完成热修复, 但是Android Kitkat及以下版本, 并不会触发重新解析资源包的逻辑, 那我们应该怎么做呢? 难道要不可避免走上Instant Run的路子吗?
事实上, 我们可以主动原地置换AssetManager, 通过Java: AssetManager.destroy()方法将Native端资源析构, 再通过Java: AssetManager.init()方法创建一个全新Native实例, 此时这个全新实例会直接设置到当前AssetManager对象上, 这样我们就完成了AssetManager的原地置换, 得到了一个未经初始化的全新的AssetManager, 此时我们只需对它用补丁包进行addAssetPath, 之后由于mResource没有初始化过, 就可以正常走到解析mResources的逻辑, 加载所有已经add进去的资源了.

这个方案的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    Method initMeth = assetManagerMethod("init");
Method destroyMeth = assetManagerMethod("destroy");
Method addAssetPathMeth = assetManagerMethod("addAssetPath", String.class);

// %% 析构AssetManager
destroyMeth.invoke(am);

// %% 重新构造AssetManager
initMeth.invoke(am);

// %% 置空mStringBlocks
assetManagerField("mStringBlocks").set(am, null);

// %% 重新添加原有AssetManager中加载过的资源路径
for (String path : loadedPaths) {
LogTool.d(TAG, "pexyResources" + path);
addAssetPathMeth.invoke(am, path);
}

// %% 添加patch资源路径
addAssetPathMeth.invoke(am, patchPath);

// %% 重新对mStringBlocks赋值
assetManagerMethod("ensureStringBlocks").invoke(am);

}

private Method assetManagerMethod(String name, Class<?>... parameterTypes) {
try {
Method meth = Class.forName("android.content.res.AssetManager")
.getDeclaredMethod(name, parameterTypes);
meth.setAccessible(true);
return meth;
} catch (Exception e) {
LogTool.e(TAG, "assetManagerMethod", e);
return null;
}
}

private Field assetManagerField(String name) {
try {
Field field = mAssetManagerClass.getDeclaredField(name);
field.setAccessible(true);
return field;
} catch (Exception e) {
LogTool.e(TAG, "assetManagerField", e);
return null;
}
}

这里需要注意的地方是mStringBlocks, 它记录了之前加载过的所有资源包的String Pool, 因此很多时候访问字符串是通过它来找到的, 如果不进行重新构造, 在后面使用到它时就会导致崩溃.
资源修改必然伴随资源引用, 所以是无法脱离代码热修复而单独存在的.

5. 参考

主要参考了阿里云上的系列公开文档.
安卓热修复宝典
代码热替换
资源更新之新思路
Dalvik下冷启动修复的新探索

另外还参考了如下资料.

  1. 自己以前的系列文章
  2. 简书上的相关文章.
文章目录
  1. 1. 1. 热修复面临的问题
  2. 2. 2. 从问题说起–热修复即时生效
    1. 2.1. 2.1. 虚拟机调用方法原理
    2. 2.2. 2.2 兼容性问题
    3. 2.3. 2.3. 访问权限问题
    4. 2.4. 2.4. 反射调用方法
    5. 2.5. 2.5. 即时生效的限制
  3. 3. 3. 从问题说起–so库热修复
    1. 3.1. 3.1 so库加载原理
    2. 3.2. 3.2 so库热修复方案
      1. 3.2.1. 3.2.1 方法热修复实时生效
      2. 3.2.2. 3.2.2 冷启动修复生效
      3. 3.2.3. 3.2.3 如何感知客户端abi
  4. 4. 4. 从问题说起–资源热修复
    1. 4.1. 4.1 Instant Run 方案
    2. 4.2. 4.2 sophix阿里百川
  5. 5. 5. 参考