普遍的实现方式
目前市面上很多资源热修复都参考了 Instant Run 的实现,首先看下 Instant Run 是怎么做到资源热修复的:
创建一个新的 AssetManager (AssetManager.class.getConstructor().newInstance()),并通过反射调用 addAssetPath 添加 sdcard 上的新资源包
反射所有 Activity 中 AssetManager 的引用处,全部换成刚才新建的 newAssetManager
得到 Resource 的弱引用集合,把它们的 AssetManager 成员替换成 newAssetManager
总体来说就是2步:构造一个新的 AssetManager ,并通过反射调用 addAssetPath ,这样就得到一个含有所有新资源的 AssetManager;找到所有引用到原有 AssetManager 的地方,通过反射把引用处替换成 newAssetManager 。
资源文件的格式
我们随便找个带资源的APK,用 AAPT 解析以下,可以看到内容大概是这样的:
aapt d resources App-debug.apk
…
spec resource 0x7f040019 com.taobao.demo:layout/activity_main:flags=0x00000000
这就表示,activity_main.xml 这个资源编号是 0x7f040019, 其中packageid 是 0x7f , 资源类型id 是 0x04 ,在Type String Pool 中对应的正是 layout 类型,而 0x04 类型的 第 0x0019 个资源就是 activity_main 这个资源。
运行时资源的解析
默认由 Android SDK 编出来的 APK 是由 AAPT 工具打包的,其资源包的 package id 就是 0x7f 。在走到 App第一行代码之前,系统就已经帮我们构建好一个已经添加了安装包资源的 AssetManager 了,即包含了 package id 为0x01 的 framework-res.jar 中资源和 package id 为 0x7f 的 App 安装包资源。
如果补丁包中资源的 package id 也是 0x7f ,就会使得同一个 package id 的包被加载2次,怎么解决呢?
在Android L 之后这是没问题的,因为它会默默把后来的包添加到之前的包的同一个 PackageGroup 下面,仍旧会加入到该类型的 TypeList 中,只是会打出一个 warning log。但是,使用时获取某个 Type 资源时,会从前往后遍历,也就是说先得到原有安装包里的资源,补丁包中的资源永远无法生效了。所以,在Android L 以上的版本,在原有的 AssetManager 上加入补丁包,是没有任何作用的。
而在 Android 4.4 以下版本,addAssetPath 只是把补丁包的路径添加到了 mAssetPath 中,这时候早已经错过真正解析资源包时间了。
以上解释了为什么像 Instant Run 这种方案,一定需要一个全新的 AssetManager ,然后再加入完整的新资源包,替换原有的 AssetManager。
另辟蹊径的资源修复方案
一个好的资源修复方案,首先补丁包要足够小,直接下发完整的补丁包肯定是不行的。目前主要有以下方案:
对资源包做差量处理,在运行时合成完整包,虽然减少了包体积,但是运行时的合成操作耗费了运行事件和内存。
自己修改 AAPT ,在打补丁包时对资源重新编号,这样会涉及修改 Android SDK 工具包,不利于集成,也无法很好地对将来的 AAPT 升级。
我们的方案:构造一个package id 为 id 0x66 的资源包,它只包含改变了的资源项,直接在原有 AssetManager 中 addAssetPath 这个包即可。补丁包的资源,只包含原有包没有而新报里面有的新增资源,以及发生了改变的资源。对于增加、减少、修改这3种情况,我们要如何处理呢?
新增资源直接假如补丁包
减少资源,只要不使用这个资源就好了
修改资源,比如替换了一张图片,那么将其视为新增资源,在打入补丁包的时候,代码引用处也做相应修改,把原来使用的旧资源id的地方变为新的 id
一张图说明下这些情况(绿线表示新增资源,红线表示发生修改的资源,黑线表示内容没有变化,但是id发生了变化的,x表示删除了的资源):
新增的资源及其导致的id偏移
新资源插入的位置是随机的,这与每次 AAPT 打包时解析 XML 的顺序有关。
所以,新增的资源导致它们所属的 type 中跟在它们之后的资源 id 发生了位移,发生位移的资源不会加入补丁包中,但是在补丁包的代码中会调整 id 的引用处,如下所示:
1 | imageView.setImageResource(R.drawable.holo) |
R.drawable.holo 是一个int 值,它的值是 AAPT 指定的,可以用反编译工具可以看到它的真实值:
1 | imageView.setImageResource(0x7f020002) |
打出一个新包时,对于开发者而言,holo 的图片内容没变,代码引用处也没变,但是在新包里面,同样这句话,由于新资源插入导致 id 改变,所以引用实际变成了:
1 | imageView.setImageResource(0x7f020003) |
但这种情况不属于资源改变,更不属于代码改变,所以,我们在对比新旧代码之前,会把新包里面的这行代码修正为原来的id:
1 | imageView.setImageResource(0x7f020002) |
内容发生改变的资源
内容发生改变,比如 activity_main.xml 文件内容改变了,也可能我们修改了string 类型的值,他们都会加入到补丁包中,并重新编号,相应代码也会改变:
1 | setContentView(R.layout.activity_main) |
实际上就是:
1 | setContentView(0x7f030000) |
在生成新旧代码对比之前,我们会把新包里面的这行代码改为:
1 | setContentView(0x66020000) |
这样,新旧代码对比时,会检测到这行代码发生了改变,于是相应的代码修复会在运行时发生,这样就得到了正确的新内容资源。
删除了的资源
不影响补丁包,就不多言
对于type的影响
上面说的所谓简单,值得是运行时应用补丁变得简单了。真正复杂的地方在于构造补丁。
更优雅地替换 AssetManager
分2种情况:
对于 Android L 以后的版本,直接在原有 AssetManager 上应用补丁就行了,并且由于是应用原来的 AssetManager ,与 Instant Run 方案比,省略了大量的反射和替换操作,提升了加载补丁的效率
之前提过,在Android KK 和以下版本,addAssetPath 是不会加载资源的。我们对原有的 AssetManager 先进行析构,再重构的时候将补丁包资源也加入,用的还是原来的 AssetManager,同样避免了反射和替换操作(Instant Run 方案还是必须重新构造一个新的 AssetManager 并加入补丁包,再替换原来的)
意料之外的资源问题
在加载完补丁之后,如果做了 new WebView() 操作,就会发现找不到新资源的问题,这是因为 WebView 初始化时可能会构造新的 ResourceImpl ,替换掉了原先的 ResourceImpl ,从而把补丁资源给一起丢掉了。其他的就不展开。
小结
对比市面上的方案,我们的方案优势:
不侵入打包,直接对比新旧资源即可产生补丁资源(对比修改 AAPT)
不必下发完整包,补丁中只有变动的资源(对比 Instant Run、Amigo等方式)
不需要在运行时合成完整包,不占用运行时的计算和内存资源(对比Tinker)
唯一需要注意的是,因为对新资源的引用是在新代码中,所有资源修复需要代码修复的支持的
可能的问题:查找旧 id 的时候,是直接对 int 值替换,所以会找到 0x7f?????? ,如果开发者也使用了 0x7f?????? 这样的数字,就会导致数字会被错误地转换。