Android资源的热修复,就是在app不重新安装的情况下,利用下发的补丁包直接更新本app中的资源。
我们在开发阿里云移动热修复(Sophix)的过程中,对Android资源的加载原理做了深入的探究,最终在资源修复方法上取得了突破性进展!新的资源修复方法不论是在使用便捷性、补丁包大小以及运行时效率方面,相比其他实现都有巨大的优势。
目前市面上的很多资源热修复方案基本上都是参考了Instant Run的实现。
首先,我们简单来看一下Instant Run是怎么做到资源热修复的。
Instant Run资源热修复的核心代码就是这个monkeyPatchExistingResources方法:
简要说来,Instant Run中的资源热修复分为两步,
1、构造一个新的AssetManager,并通过反射调用addAssetPath,把这个完整的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。
2、找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为AssetManager。
其实仔细看可以发现,大量代码都是在处理兼容性问题和找到所有AssetManager的引用处。真正的实现逻辑其实很简单。
这其中的重点,自然是addAssetPath这个函数。现在我们来看一下它的底层实现逻辑。
以Android 6.0为例,addAssetPath最终调用到了native方法。
Java层的AssetManager只是个包装,真正关于资源处理的所有逻辑,其实都位于native层由C++实现的AssetManager。
执行addAssetPath就是解析这个格式,然后构造出底层数据结构的过程。整个解析资源的调用链是:
public final int addAssetPath(String path)
android_content_AssetManager_addAssetPath
AssetManager::addAssetPath
AssetManager::appendPathToResTable
ResTable::add
ResTable::addInternal
ResTable::parsePackage
解析的细节比较繁琐,就不细细说明了,有兴趣的可以一层层追下去。
大致过程就是,通过传入的资源包路径,先得到其中的resources.arsc,然后解析它的格式,存放在底层的AssetManager的mResources成员中。
AssetManager的mResources成员是一个ResTable结构体:
一个Android进程只包含一个ResTable,ResTable的成员变量mPackageGroups就是所有解析过的资源包的集合。任何一个资源包中都含有resources.arsc,它记录了所有资源的id分配情况以及资源中的所有字符串。这些信息是以二进制方式存储的。底层的AssetManager做的事就是解析这个文件,然后把相关信息存储到mPackageGroups里面。
整个resources.arsc文件,实际上是由一个个ResChunk(以下简称chunk)拼接起来的。从文件头开始,每个chunk的头部都是一个ResChunk_header结构,它指示了这个chunk的大小和数据类型。
通过ResChunk_header中的type成员,可以知道这个chunk是什么类型,从而就可以知道应该如何解析这个chunk。
解析完一个chunk后,从这个chunk + size的位置开始,就可以得到下一个chunk起始位置,这样就可以依次读取完整个文件的数据内容。
一般来说,一个resources.arsc里面包含若干个package,不过默认情况下,由打包工具aapt打出来的包只有一个package。这个package里包含了app中的所有资源信息。
资源信息主要是指每个资源的名称以及它对应的编号。我们知道,Android中的每个资源,都有它唯一的编号。
编号是一个32位数字,用十六进制来表示就是0xPPTTEEEE。PP为package id,TT为type id,EEEE为entry id。
它们代表什么?在resources.arsc里是以怎样的方式记录的呢?
对于package id,每个package对应的是类型为RES_TABLE_PACKAGE_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、drawable、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解析一下,看到其中的一行是:
这就表示,activity_main.xml这个资源的编号是0x7f040019。它的package id是0x7f,资源类型的id为0x04,Type String Pool里的第四个字符串正是layout类型,而0x04类型的第0x0019个资源项就是activity_main这个资源。
系统的资源包,也就是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中。
但是在get这个资源的时候呢?
在获取某个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发生改变的资源。×表示删除了的资源。
可以看到,新的资源包与旧资源包相比,新增了holo_grey和dropdn_item2资源,新增的资源被加入到patch中。并分配了0x66开头的资源id。
而新增的两个资源导致了在它们所属的type中跟在它们之后的资源id发生了位移。比如holo_light,id由0x7f020002变为0x7f020003,而abc_dialog由0x7f030004变为0x7f030003。新资源插入的位置是随机的,这与每次aapt打包时解析xml的顺序有关。发生位移的资源不会加入patch,但是在patch的代码中会调整id的引用处。
比如说在代码里,我们是这么写的
这个R.drawable.holo_light是一个int值,它的值是aapt指定的,对于开发者透明,即使点进去,也会直接跳到对应res/drawable/holo_light.png,无法查看。不过可以用反编译工具,看到它的真实值是0x7f020002。所以这行代码其实等价于:
而当打出了一个新包后,对开发者而言,holo_light的图片内容没变,代码引用处也没变。但是新包里面,同样是这句话,由于新资源的插入导致的id改变,对于R.drawable.holo_light的引用已经变成了:
但实际上这种情况并不属于资源改变,更不属于代码的改变,所以我们在对比新旧代码之前,会把新包里面的这行代码修正回原来的id。
然后再进行后续代码的对比。这样后续代码对比时就不会检测到发生了改变。
而相应的代码,也会发生改变,比如,
实际上也就是
在生成对比新旧代码之前,我们会把新包里面的这行代码变为
这样,在进行代码对比时,会使得这行代码所在函数被检测到发生了改变。于是相应的代码修复会在运行时发生,这样就引用到了正确的新内容资源。
这很好理解,既然资源被删除了,就说明新的代码中也不会用到它,那资源放在那里没人用,就相当于不存在了。
可以看到,由于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和系统加载资源的代码。
但之前提到过,在Android KK和以下版本,addAssetPath是不会加载资源的,必须重新构造一个新的AssetManager并加入patch,再换掉原来的。那么我们不就又要和Instant Run一样,做一大堆兼容版本和反射替换的工作了吗?
对于这种情况,我们也找到了更优雅的方式,不需要再如此地大费周章。
在AssetManager的源码里面,有一个有趣的东西。
明显,这个是用来销毁AssetManager并释放资源的函数,我们来看看它具体做了什么吧。
可以看到,首先,它析构了native层的AssetManager,然后把java层的AssetManager对native层的AssetManager的引用设为空。
native层的AssetManager析构函数会析构它的所有成员,这样就会释放之前加载了的资源。
而现在,java层的AssetManager已经成为了空壳。我们就可以调用它的init方法,对它重新进行初始化了!
这同样是个native方法,
这样,在执行init的时候,会在native层创建一个没有添加过资源,并且mResources没有初始化的的AssetManager。然后我们再对它进行addAssetPath,之后由于mResource没有初始化过,就可以正常走到解析mResources的逻辑,加载所有此时add进去的资源了!
这个方案的实现代码如下:
这里需要注意的地方是mStringBlocks。它记录了之前加载过的所有资源包的String Pool,因此很多时候访问字符串是通过它来找到的。如果不进行重新构造,在后面使用到它时就会导致崩溃。
由于我们是直接对原有的AssetManager进行析构和重构,所有原先对AssetManager对象的引用是没有发生改变的,这样,就不需要像Instant Run那样进行繁琐的修改了。
顺带一提,类似Instant Run的完整替换资源的方案,在替换AssetManager这一步,也可以采用我们这种方式进行替换,省时省力又省心。
总结一下,相比于目前市面上的资源修复方式,我们提出的资源修复的优势在于:
不侵入打包,直接对比新旧资源即可产生补丁资源包。(对比修改aapt方式的实现)
不必下发完整包,补丁包中只包含有变动的资源。(对比Instanat Run、Amigo等方式的实现)
不需要在运行时合成完整包。不占用运行时计算和内存资源。(对比Tinker的实现)
唯一有个需要注意的地方就是,因为对新的资源的引用是在新代码中,所有资源修复是需要代码修复的支持的。也因此所有资源修复方案必然是附带代码修复的。而之前提到过,本方案在进行代码修复前,会对资源引用处进行修正。而修正就是需要找到旧的资源id,换成新的id。查找旧id时是直接对int值进行替换,所以会找到0x7f??????这样的需要替换id。但是,如果有开发者使用到了0x7f??????这样的数字,而它并非资源id,可是却和需要替换的id数值相同,这就会导致这个数字被错误地替换。
但这种情况是极为罕见的,因为很少会有人用到这样特殊的数字,并且还需要碰巧这数字和资源id相等才行。即使出现,开发者也可以用拼接的方式绕过这类数字的产生。所以基本可以不用担心这种情况,只是需要注意它的存在。
这套资源修复方案目前已经完全集成进阿里云移动热修复(Sophix),值得一提的是,结合Sophix提供的代码热替换机制,资源也可以做到补丁下发即时生效,无需重启APP!如果对代码热替换的技术的实现细节有兴趣,可以看这篇文章,其中实现了兼容性极好的Java方法的Native热替换。
另外,不同于阿里Hotfix1.X版本笨拙的命令行操作,新的补丁工具实现了图形界面,使用起来更加方便快捷。
最后,展示一下这个工具的界面。轻松一键,即可完美生成补丁。
猛戳「阅读原文」,开始体验阿里云移动热修复(Sophix) !