深入探索Android热修复技术原理读书笔记 —— 资源热修复技术

该系列文章: 

深入探索Android热修复技术原理读书笔记 —— 热修复技术介绍

深入探索Android热修复技术原理读书笔记 —— 代码热修复技术

普遍的实现方式

Android资源的热修复,就是在app不重新安装的情况下,利用下发的补丁包 直接更新本app中的资源。

目前市面上的很多资源热修复方案基本上都是参考了 Instant Run的实现。 

简要说来,Instant Run中的资源热修复分为两步:

  1. 构造一个新的 AssetManager,并通过反射调用 addAssetPath,把这个完 整的新资源包加入到 AssetManager 中。这样就得到了一个含有所有新资源 AssetManager。

  2. 找到所有之前引用到原有 AssetManager 的地方,通过反射,把引用处替换 AssetManager。

一个 Android 进程只包含一个 ResTable, ResTable 的成员变量 mPackageGroups 就是所有解析过的资源包的集合。任何一个资源包中都含有 resources.arsc,它记录了所有资源的 id 分配情况以及资源中的所有字符串。这些信息是以二进制方式存储的。底层的 AssetManager 做的事就是解析这个文件,然后把相关信息存储到 mPackageGroups 里面。

2 资源文件的格式

整个 resources.arse 文件,实际上是由一个个 ResChunk (以下简称 chunk) 拼接起来的。从文件头开始,每个 chunk 的头部都是一个 ResChunk_header 构,它指示了这个 chunk 的大小和数据类型。

通过ResChunk_header中的type成员,可以知道这个chunk是什么类型, 从而就可以知道应该如何解析这个chunko

解析完一个 chunk 后,从这个 chunk + size 的位置开始,就可以得到下一个 chunk 起始位置,这样就可以依次读取完整个文件的数据内容。

一般来说,一个 resources.arsc 里面包含若干个package,不过默认情况下, 由打包工具 aapt 打出来的包只有一个 package。这个 package 里包含了 app 中的 所有资源信息。

资源信息主要是指每个资源的名称以及它对应的编号。我们知道,Android 中的每个资源,都有它唯一的编号。编号是一个 32 位数字,用十六进制来表示就是0xPPTTEEEE。PP 为 package id, TT type id, EEEE entry id。

它们代表什么?在 resources.arse 里是以怎样的方式记录的呢?

  • 对于 package id,每个 package 对应的是类型为 RES_TABLE_PACKAG E_ TYPE ResTable_package 结构体,ResTable_package 结构体的 id 成员变量就表示它的 package id。

  • 对于 type id,每个type对应的是类型为 RES_TABLE_TYPE_SPEC_ TYPE 的 ResTable_typeSpec 结构体。它的id成员变量就是type id。但是,该 type id 具体对应什么类型,是需要到package chunk 里的 Type String Pool 中去解析得到的。比如 Type String Pool 中依次有 attr drawablex mipmap、layout 字符串。就表示 attr 类型的 type id 1, drawable 类型的 type id 2, mipmap 类型的 type id 3, layout 类型的 type id 为 4。所以,每个 type id 对应了 Type String Pool里的字符顺序 所指定的类型。

  • 对于 entry id,每个 entry 表示一个资源项,资源项是按照排列的先后顺序 自动被标机编号的。也就是说,一个 type 里按位置出现的第一个资源项,其 entry id 为0x0000,第二个为 0x0001,以此类推。因此我们是无法直接指定 entry id 的,只能够根据排布顺序决定。资源项之间是紧密排布的,没有空隙,但是可以指定资源项为 ResTable_type::NO_ENTRY 来填入一个空资源。

举个例子,我们随便找个带资源的 apk,用 aapt 解析一下,看到其中的一行是:

$ aapt d resources app-debug.apk
 ......
 spec resource 0x7f040019 com.taobao.patch.demo:layout/activity_main: flags=0x00000000
 ......

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

运行时资源的解析

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

系统的资源包,也就是 framework-res.jar, package id 0x01。

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

 

因此,这个 AssetManager里就已经包含了系统资源包以及 app 的安装包,就 package id 0x01 framework-res.jar 中的资源和 package id 0x7f app 安装包资源。

如果此时直接在原有 AssetManager 上继续 addAssetPath 的完整补丁包的 话,由于补丁包里面的 package id 也是 0x7f,就会使得同一个 package id 的包被 加载两次。这会有怎样的问题呢?

在 Android L 之后,这是没问题的,他会默默地把后来的包添加到之前的包的同—个 PackageGroup 下面。

而在解析的时候,会与之前的包比较同一个 type id 所对应的类型,如果该类型 下的资源项数目和之前添加过的不一致,会打出一条 warning log,但是仍旧加入到该类型的 TypeList 中。

在获取某个 Type 的资源时,会从前往后遍历,也就是说先得到原有安装包里 的资源,除非后面的资源的 config 比前面的更详细才会发生覆盖。而对于同一个 config 而言,补丁中的资源就永远无法生效了。所以在 Android L 以上的版本,在原有 AssetManager 上加入补丁包,是没有任何作用的,补丁中的资源无法生效。

而在 Android 4.4 及以下版本,addAssetPath 只是把补丁包的路径添加到 mAssetPath中,而真正解析的资源包的逻辑是在app第一次执行 AssetManager::getResTable 的时候。

而在执行到加载补丁代码的时候,getResTable 已经执行过了无数次了。这是因为就算我们之前没做过任何资源相关操作,Android framework 里的代码也会多 次调用到那里。所以,以后即使是addAssetPath,也只是添加到了 mAssetPath, 并不会发生解析。所以补丁包里面的资源是完全不生效的!

所以,像 Instant Run 这种方案,一定需要一个全新的 AssetManager 时,然后再加入完整的新资源包,替换掉原有的 AssetManager。

另辟蹊径的资源修复方案

而一个好的资源热修复方案是怎样的呢?

首先,补丁包要足够小,像直接下发完整的补丁包肯定是不行的,很占用空间。

而像有些方案,是先进行 bsdiff,对资源包做差量,然后下发差量包,在运行时 合成完整包再加载。这样确实减小了包的体积,但是却在运行时多了合成的操作,耗费了运行时间和内存。合成后的包也是完整的包,仍旧会占用磁盘空间。

而如果不采用类似 Instant Run 的方案,市面上许多实现,是自己修改aapt, 在打包时将补丁包资源进行重新编号。这样就会涉及到修改 Android SDK 工具包, 即不利于集成也无法很好地对将来的aapt 版本进行升级。

针对以上几个问题,一个好的资源热修复方案,既要保证补丁包足够小,不在 运行时占用很多资源,又要不侵入打包流程。我们提出了一个目前市面上未曾实现 的方案。

简单来说,我们构造了一个 package id 为 0x66 的资源包,这个包里只包含改变了的资源项,然后直接在原有 AssetManager 中 addAssetPath 这个包。然后就可以了。真的这么简单?

没错!由于补丁包的 package id 为 0x66,不与目前已经加载的 0x7f 冲突,因 此直接加入到已有的 AssetManager 中就可以直接使用了。补丁包里面的资源,只包含原有包里面没有而新的包里面有的新增资源,以及原有内容发生了改变的资源。

而资源的改变包含增加、减少‘ 修改这三种情况,我们分别是如何处理的呢?

  • 对于新增资源,直接加入补丁包,然后新代码里直接引用就可以了,没什么好说的。

  • 对于减少资源,我们只要不使用它就行了,因此不用考虑这种情况,它也不影响补丁包。

  • 对于修改资源,比如替换了一张图片之类的情况。我们把它视为新增资源, 在打入补丁的时候,代码在引用处也会做相应修改,也就是直接把原来使用旧资源 id 的地方变为新 id。

用一张图来说明补丁包的情况,是这样的:

图中绿线表示新增资源。红线表示内容发生修改的资源。黑线表示内容没有变 化,但是 id 发生改变的资源。x 表示删除了的资源。

4.1 新增的资源及其导致 id 偏移

可以看到,新的资源包与旧资源包相比,新增了 holo_grey 和 dropdn_item2 资源,新增的资源被加入到 patch 中。并分配了 0x66 开头的资源 id。

而新增的两个资源导致了在它们所属的 type 中跟在它们之后的资源 id 发生了 位移。比如 holojight, id 0x7f020002 变为 0x7f020003, abc_dialog 0x7f030004 变为 0x7f030003。新资源插入的位置是随机的,这与每次 aapt 打包 时解析 xml 的顺序有关。发生位移的资源不会加入 patch,但是在 patch 的代码中会调整 id 的引用处。

比如说在代码里,我们是这么写的

imageView.setImageResource(R.drawable.holo_light);

这个 R.drawable.holojight 是一个 int 值,它的值是 aapt 指定的,对于开发者 透明,即使点进去,也会直接跳到对应 res/drawable/holo_light.png,无法查看。不过可以用反编译工具,看到它的真实值是 0x7f020002。所以这行代码其实等价于:

imageView.setImageResource(0x7f020002);

而当打出了一个新包后,对开发者而言,holojight 的图片内容没变,代码引用处也没变。但是新包里面,同样是这句话,由于新资源的插入导致的 id 改变,对于 R.drawable.holojight 的引用已经变成了:

imageView.setImageResource(0x7f020003);

但实际上这种情况并不属于资源改变,更不属于代码的改变,所以我们在对比新旧代码之前,会把新包里面的这行代码修正回原来的 id。

imageView.setImageResource(0x7f020002);

然后再进行后续代码的对比。这样后续代码对比时就不会检测到发生了改变。

4.2 内容发生改变的资源

而对于内容发生改变的资源(类型为 layout 的 activity_main,这可能是我们修 改了 activity_main.xml 的文件内容。还有类型为 string 的 no,可能是我们修改了这个字符串的值),它们都会被加入到 patch 中,并重新编号为新 id。而相应的代码,也会发生改变,比如,

setContentView(R.layout.activity_main); 

实际上也就是

setContentView(0x7f030000);

在生成对比新旧代码之前,我们会把新包里面的这行代码变为

setContentView(0x6 6020000);

这样,在进行代码对比时,会使得这行代码所在函数被检测到发生了改变。于是相应的代码修复会在运行时发生,这样就引用到了正确的新内容资源。

4.3 删除了的资源

对于删除的资源,不会影响补丁包。

这很好理解,既然资源被删除了,就说明新的代码中也不会用到它,那资源放在那里没人用,就相当于不存在了。

4.4 对于type的影响

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

所以我们可以看到,所谓简单,指的是运行时应用patch变的简单了。

而真正复杂的地方在于构造 patch 。我们需要把新旧两个资源包解开,分别解析 其中的 resources.arsc 文件,对比新旧的不同,并将它们重新打成带有新 package id 的新资源包。这里补丁包指定的 package id 只要不是 0x7f 和 0x01 就行,可以是 任意 0x7f 以下的数字,我们默认把它指定为 0x66。

构造这样的补丁资源包,需要对整个resources.arsc的结构十分了解,要对二 进制形式的一个一个 chunk 进行解析分类,然后再把补丁信息一个一个重新组装成 二进制的 chunk。这里面很多工作与 aapt 做的类似,实际上开发打包工具的时候也是参考了很多aapt和系统加载资源的代码。

更优雅地替换 AssetManager

对于 Android L 以后的版本,直接在原有 AssetManager 上应用 patch 就行 了。并且由于用的是原来的 AssetManager,所以原先大量的反射修改替换操作就 完全不需要了,大大提高了加载补丁的效率。

但之前提到过,在 Android KK 和以下版本,addAssetPath 是不会加载资源 的,必须重新构造一个新的 AssetManager 并加入 patch,再换掉原来的。那么我们不就又要和 Instant Run —样,做一大堆兼容版本和反射替换的工作了吗?

对于这种情况,我们也找到了更优雅的方式,不需要再如此地大费周章。

明显,这个是用来销毁 AssetManager 并释放资源的函数,我们来看看它具体做了什么吧。

可以看到,首先,它析构了 native 层的 AssetManager,然后把 java 层的 AssetManager native 层的 AssetManager 的引用设为空。

native 层的 AssetManager 析构函数会析构它的所有成员,这样就会释放之前加载了的资源。

而现在,java 层的 AssetManager 已经成为了空壳。我们就可以调用它的 init 方法,对它重新进行初始化了!

这同样是个native方法,

这样,在执行 init 的时候,会在 native 层创建一个没有添加过资源,并且 mResources 没有初始化的的 AssetManager。然后我们再对它进行 addAssetPath,之后由于 mResource 没有初始化过,就可以正常走到解析 mResources 逻辑,加载所有此时 add 进去的资源了

由于我们是直接对原有的 AssetManager 进行析构和重构,所有原先对 AssetManager 对象的引用是没有发生改变的,这样,就不需要像 Instant Run 样进行繁琐的修改了。

顺带一提,类似 Instant Run 的完整替换资源的方案,在替换 AssetManager 这一步,也可以采用我们这种方式进行替换,省时省力又省心。

6本章小结

总结一下,相比于目前市面上的资源修复方式,我们提出的资源修复的优势在于:

  • 不侵入打包,直接对比新旧资源即可产生补丁资源包。(对比修改 aapt 方式的 实现)
  • 不必下发完整包,补丁包中只包含有变动的资源。(对比 Instanat Run,Amigo 等方式的实现)
  • 不需要在运行时合成完整包。不占用运行时计算和内存资源。(对比 Tinker  实现)

唯一有个需要注意的地方就是,因为对新的资源的引用是在新代码中,所有资源修复是需要代码修复的支持的。也因此所有资源修复方案必然是附带代码修复的。而 之前提到过,本方案在进行代码修复前,会对资源引用处进行修正。而修正就是需要 找到旧的资源id,换成新的id。查找旧 id 时是直接对 int 值进行替换,所以会找到 0x7f ?????? 这样的需要替换 id。但是,如果有开发者使用到了 0x7f ?????? 这样的数字,而它并非资源id,可是却和需要替换的id数值相同,这就会导致这个数字 被错误地替换。

但这种情况是极为罕见的,因为很少会有人用到这样特殊的数字,并且还需要碰巧这数字和资源id相等才行。即使出现,开发者也可以用拼接的方式绕过这类数字的产生。所以基本可以不用担心这种情况,只是需要注意它的存在。