一、普遍的实现方式
目前市面上的很多资源热修复方案基本上都是参考了 Instant Run的实现。
简要说来,Instant Run中的资源热修复分为两步:
1.构造一个新的 AssetManager,并通过反射调用 addAssetPath,把这个完 整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的 AssetManager。
2.找到所有之前引用到原有 AssetManager的地方,通过反射,把引用处替换 为 AssetManager。
一个 Android 进程只包含一个 ResTable, ResTable 的成员变量 mPackageGroups 就是所有解析过的资源包的集合。任何一个资源包中都含有 resources.arsc,它记录了所有资源的id分配情况以及资源中的所有字符串。这些信息是以二进制方式存储的。底层的AssetManager做的事就是解析这个文件,然后把相关信息存储到 mPackageGroups 里面。
二、资源文件的格式
整个 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.jpg,无法查看。不过可以用反编译工具,看到它的真实值是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这一步,也可以采用我们这种方式进行替换,省时省力又省心。
六、本章小结
总结一下,相比于目前市面上的资源修复方式,我们提出的资源修复的优势在于:
- 不侵入打包,直接对比新旧资源即可产生补丁资源包。(对比修改 aapt方式的 实现)
- 不必下发完整包,补丁包中只包含有变动的资源。(对比 Instanat Run,Amigo 等方式的实现)
- 不需要在运行时合成完整包。不占用运行时计算和内存资源。(对比 Tinker的 实现)
唯一有个需要注意的地方就是,因为对新的资源的引用是在新代码中,所有资源修复是需要代码修复的支持的。也因此所有资源修复方案必然是附带代码修复的。而 之前提到过,本方案在进行代码修复前,会对资源引用处进行修正。而修正就是需要 找到旧的资源id,换成新的id。查找旧 id 时是直接对 int值进行替换,所以会找到 0x7f ?????? 这样的需要替换 id。但是,如果有开发者使用到了 0x7f ??????这样的数字,而它并非资源id,可是却和需要替换的id数值相同,这就会导致这个数字 被错误地替换。
但这种情况是极为罕见的,因为很少会有人用到这样特殊的数字,并且还需要碰巧这数字和资源id相等才行。即使出现,开发者也可以用拼接的方式绕过这类数字的产生。所以基本可以不用担心这种情况,只是需要注意它的存在。
以上就是深入理解Android热修复技术原理之资源热修复技术的详细内容,更多关于Android资源热修复的资料请关注编程网其它相关文章!