Android 插件化中资源错乱的解决方案
摘要
本文介绍了 Android 插件化框架中,插件使用宿主资源时资源错乱的问题,以及错乱的原因、业界通用解决方案、我们提出的优化方案。
本文将按照如下顺序,循序渐进地进行讲解:
- 简单介绍 Android 插件化中资源部分的动态化。
- 简单介绍 Android 中的资源的一些基础知识、使用方式及其编译原理。
- 介绍插件化场景下出现的资源错乱问题及业界通用的解决方案。
- 介绍一种新的方案——免资源固定方案,用于解决资源错乱问题。
- 单独介绍一下免资源固定方案中的一个技术点:修改 apk 中的资源文件。
1. Android 插件化中资源的动态化
Android 发展了这么多年,市面上涌现出许多插件化/热修复框架,无论是插件化还是热修复,都是为了实现对主apk以外内容的动态化,这些内容包括 dex(class)、res(资源)、so(动态库)等。对于每一种内容,业界都有许多实现方案,尽管方案各不相同,但底层原理都差不多,网上也有许多文章和开源项目可以学习参考。
名词解释
宿主:直接安装到用户手机上的 App,宿主中的代码在宿主安装到用户手机上的那一刻就定死了,不能再改变了(热修复也只是让错误的逻辑不走而已,并没有改变原有的代码)。
插件:独立于宿主之外的一个文件。需要被宿主动态加载的 class、res、so 等的集合。(热修复中这部分通常称为 patch,这里为了方便,就叫插件吧)
java?代码:为了描述方便,apk 中的 dex 在编译前一律称为 java 代码,编译后一律称为 dex(这个说法不准确,不要被我误导了,一般为java / kotlin- > class- > dex )
说到 Android 资源的动态化,思路都大同小异:
- 为每个插件创建一个 Resources 或者把插件的资源路径添加到宿主 AssetManager,从而可以顺利的加载到插件资源。
- 插件编译时通过配置 aapt2 参数对插件中资源 id 的 packageId 部分进行修改,保证插件与宿主资源 id 不冲突。
- 对于插件中使用到的宿主资源,利用 aapt2 参数进行资源固定,保证宿主升级后插件使用到的宿主资源 id 不变。
aapt2 的出现使资源固定、packageId 修改变得容易了很多!
尽管 Android 资源的动态化技术已经十分成熟,但是在实践过程中还是有许多不足,比如“资源固定”就经常被业务同学吐槽。
2. Android 中的资源介绍
在介绍资源固定之前,首先简单介绍一下 Android 中资源相关的基础知识。
2.1 ?Android 中的资源 id
Android 代码在编译成 apk 之后,每个资源都对应一个唯一的资源 id,资源 id 是一个 8 位的 16 进制 int 值 0xPPTTEEEE :
- PP :前两位是 PackageId 字段,系统资源是 01,宿主资源 id 是 7f,其他如厂商自定义的皮肤包、webview 插件资源包会占用 02、03......,因此 App 资源和系统资源永远不会冲突。市面上的插件框架为了保证插件和宿主资源不冲突,通常会把插件资源的 PP 改为其他值,如 7e、7d。
- TT :中间两位是 TypeId 字段,表示资源的类型,如 anim、drawable、string 等,这块没有严格的对应关系,通常是按照字母顺序分配 type 值。
- EEEE :最后四位是 EntryId 字段,用于区分同一个 PackageId、同一个 TypeId 下不同 name 的资源,通常也是按照字母顺序进行分配的。
注意:
- 资源 id 的分配默认是按资源的字母排序进行的,也就是说,当新增一个 name 为 a 的资源,重新编译之后,a 后面的同类型的资源 id 值都会被改变。
- aapt2 中提供了参数可以对资源 id 分配方式进行干预,aapt2 会优先按照参数中配置的对应关系分配 id,这个技术我们称之为资源固定,也是目前插件化框架在解决资源错乱问题中用的最多的技术。
2.2 ?Android 中的资源使用方式
Android 中使用资源通常有两种方式:
- 在 java 代码中通过 R 的内部类进行访问,具体语法为:
[<package_name>].R.<resource_type>.<resource_name>
- 在 xml 中通过符号使用,具体语法为:
@[<package_name>:]<resource_type>/<resource_name>
xml 中也可以通过 ? 代替 @ 的形式引用样式属性。也可以引入自定义属性,如 android:layout_width 。这两种用法不影响下文的介绍。
那么这两种方式有什么区别呢?
从代码书写的角度来说,都是通过一个资源名称(resource_name)来访问资源。我们反编译一下 apk,看看编译后是什么样的。
分别在项目 app module、library module、xml 中编写如下代码
我们反编译一下 apk,看看这三种代码在 apk 中是如何表现的。
可以发现 appTest 方法和 xml 中的资源变成了数字(0x7f0e0069),libTest 方法中的资源依旧是通过 Lcom/bytedance/lib/R$string;->test 访问的
结论:
- 主 module 中引用的资源被编译成了数值;
- 子 module、aar 中通过 R 的内部类间接引用数值;
- xml 中的资源 id 全部编译成了数值。(看上图中 xml 的属性—— lay out_width 等依旧是字符串,其实它背后也是资源 id 数值,这块的字符串其实是没有用的,甚至在一些包体积优化中可以直接去掉)。
那么为什么 libTest 方法中是通过 field 引用,而 appTest 中就变成数字了呢?
2.3 Android 中资源编译的简单流程
假设有一个工程,只有一个 app module,通过 maven 仓库依赖若干三方 aar,项目编译时的简化流程如下图:
- 下载三方 aar;
- 将 app module 和三方 aar 中的资源经过 aapt2 进行编译、链接,最终生成R.jar和ap_
- R.jar 包含了最终打入 apk 的所有 R.class,每个依赖对应一个。aapt2 也会默认按照字母排序为每个资源分配唯一的 id 值。注意:新增删除一个资源都会导致它后面的资源 id 改变。aapt2 允许通过配置干预 id 的分配。
- ap_ 文件中包含了所有编译好的资源文件。
- App module 的 java 文件与 R.jar 一起被 javac 编译。由于 R.jar 中的 field 都是 final,因此 app module 中通过 R 引用的资源全部被内联成了数值。而三方 aar 中由于已经是 class,无需进行编译,因此依旧是通过 R 引用来使用资源;
- 最后把 app module 编译出来的 .class、三方 aar 中的 .class 转成 dex,与 ap_ 一起压缩到 apk 中。
因此就很容易理解为啥 libTest 中依旧是通过 R 来使用资源,而 appTest 中通过数值直接引用(被内联)。
libTest module 虽然被 app module 通过源码依赖,但是在资源编译这块其实是类似的,这里不展开介绍。
2.4 总结
Android 中的资源的无论是通过 java 代码使用还是 xml 使用,最终都是通过资源 id 值进行查找的。
把 apk 拖到 as 中,查看 resources.arsc 文件,可以看到它里面包含了 apk 中所有资源的 id 索引,以及该资源名对应的真正资源或值。很容易想到,App 运行起来也是通过资源 id 值经过这个资源表来查找真正的资源内容。
3. 插件使用宿主资源
3.1 插件如何使用宿主资源
想象一下,我们想要把 App 的直播功能做成一个插件动态下发,直播功能所需要的大部分资源都在直播插件中,但是总有一些资源来自宿主,如一些通用的 UI 组件中包含的资源(support/androidx 库)等。
那么,假设宿主中有一张图片名为 icon,直播插件中的 xml 通过 @drawable/icon 引用了这张图片,同时也在代码中通过 R.drawable.icon 引用了它,实际直播插件中是没有 icon 这张图片的,它存在于宿主中。宿主编译完后,按照前面的知识点,宿主中的 icon 对应的数值被编译成 0x7f010001。
插件本身也是一个 apk,根据前面介绍的知识点,插件编译完成后,xml 中的 @drawable/icon 会编成一个数值(0x7f010001),java 代码中的 R.drawable.icon 也会直接或间接编成一个数值(0x7f010001)。当这个插件运行在宿主上,按照前面的介绍,插件会去查找 0x7f010001,发现可以找到,这样就正确的使用了宿主资源。
插件编译时我们会做一些处理,使插件中可以引用到宿主 id。
3.2 插件使用宿主资源有什么问题
前文介绍过,新增或删除一个资源都可能导致其他许多资源的 id 被改变。
我们的宿主编译出来后 icon 为 0x7f010001,基于已有的宿主编译出一个插件后,插件中引用的 icon 也是 0x7f010001,此时没什么问题。
宿主迭代后,新增了一个新的资源 aicon,按照前面介绍的资源 id 分配规则,新版本的宿主中 aicon 的 id 值为 0x7f010001,icon 的 id 值被分配为 0x7f010002。老版本的插件下发到新版本的宿主上时依旧会通过 0x7f010001去宿主中找 icon,自然就找错了。运气好一点可能只是图片展示异常,运气不好点可能就直接 crash 了。
3.3 如何解决这类问题
为了解决这个问题,业界目前有一个通用、稳定的方案——资源固定。宿主编译时通过 aapt2 提供的参数对插件使用到的资源进行固定,使宿主每次打包时这些资源的值永远不发生改变。
资源固定方案的弊端:
- 一个插件对应一个宿主的情况:
- 必须把宿主的所有资源都进行固定。如果只固定插件使用的资源,当一个宿主有两个插件时,两个插件各自给宿主固定自己需要的资源,在代码合并时,很容易引发冲突,因为资源固定的值是不允许重复的;
- 当宿主接入多个涉及到资源固定的框架,如:插件化、资源热修复、游戏重打包框架等,这些框架之间进行资源固定时也需要考虑统一固定,这个成本是很高的;
- 资源固定提高了宿主接入框架的成本。
- 一个插件运行在多个宿主的情况:
当一个插件想要运行在多个宿主上,就需要每个宿主针对该插件的资源使用情况进行资源固定。一旦某个宿主已经对某个资源进行了固定,导致其与该插件要求的资源固定产生冲突,插件就需要对该宿主进行妥协,根据该宿主已有的资源固定重新生成固定规则。这样就无法实现一个插件在多个宿主上运行。我们目前有一个需求:同一个插件需要在上千个宿主上运行,如果不能解决这个问题,可能需要打成百上千个插件出来,很明显是不合理的;
资源固定提高了宿主接入框架的成本。
为了解决上述的问题,我们研究了一套新的方案解决资源错乱问题。
4. 免资源固定方案
同一个版本的插件运行在不同版本甚至不同的 App 上时,插件的代码是固定的,而宿主中的资源 id 是会改变的,为了解决资源错乱问题,当前的思路是保证宿主每次出新版本时资源 id 不变。那么有没有办法在不约束宿主的情况下,让插件始终跟宿主的资源 id 保持一致呢?
由于插件打包时,宿主是未知的,并且对于一个插件跑在多个宿主的情况,宿主也是多样的。所以没法指定让插件把 id 打成满足宿主的样子,而前文也介绍过,插件中引用宿主?id?的地方都是常量。那怎么办呢?
是否可以在插件运行到宿主上时,动态修改插件中的内容,实现插件与宿主 id 值匹配的效果。
比如插件中使用了宿主的资源 icon,对应的 id 值为 0x7f010001。当该插件运行在一个 icon 为 0x7f010002的宿主上时,由于运行时资源查找都是通过 id 值进行的,此时我们只能知道插件是在找一个 id 为 0x7f010001 的资源。通过某些手段,如果我们可以把 0x7f010001 映射成 icon 这个字符串,然后利用 Android 系统提供的Resources#getIdentifier方法,动态获取到当前宿主中 icon 对应的资源 id,即可保证插件加载到正确的资源。
这个工作需要在插件编译时、运行时分别做一些工作配合完成实现。
4.1 ?插件编译时工作
本小节内容基于 agp4.1 介绍,各个版本有些许差异,但总体思路大同小异。
前面介绍了,插件使用宿主资源主要有两种情况:1.通过 java 代码 2.通过 xml。
4.1.1 处理 java 代码中引用宿主的资源
java 代码在编译成 class 之后,对于引用宿主资源 id 的代码,有的会编译成数值,有的依旧是通过 R 引用。对于后者,我们可以很容易找出来,对于前者就有些困难了,因为单纯去扫描 class 中 0x7f 开头的数字,很容易误判,把一个无意义的数字也当作资源 id 处理。
前面讲了为什么 class 中的资源 id 会内联成数值,那我们不让它内联不就好了吗?只需要在编译过程中处理 R.jar,移除 class 中所有的 final 字段,就可以保证插件中引用宿主的资源 id 全部通过 R 进行引用。
这块需要对 agp 的工作流程、gradle plugin 的开发有一定的了解,用到了 asm 字节码修改技术和 agp 提供的 transform api,不了解的同学可以单独查一下,这块就不详细介绍了。
简单来说就是通过这两项技术,可以在编译 apk 时,对 class 文件进行修改。
开始实践
- 由于 R.jar 是在 processResourcesTask 中生成的,因此可以写一个 gradle plugin,在 processResourcesTask 的 doLast 中获取到 R.jar,修改 R.jar 中的字节码,将 field 中的 id 为 0x7f 开头的字段的 final 修饰符全部移除。这样就可以保证插件 class 中所有引用宿主资源的地方都不会被内联成数值;
- 经过第一步的处理,插件中引用的宿主资源全部通过 R.xx.xx 来引用,但插件 R 中的数值依旧是无法与宿主对应的。因此我们继续写一个 transform,扫描出插件中通过 R 引用资源的地方,利用 asm 将其从原来的 R 引用修改为方法调用。插件运行时,原本类似 R.drawable.test 的代码不再是获取一个常量数值,而是调用一个方法,内部动态计算当前宿主中对应的值。?
总结:
以上,通过编译时的一些处理,即可解决插件 java 代码中引用宿主资源时免资源固定的问题。
- 优点:无需资源固定。
- 缺点:
- 插件中的部分资源不进行内联,会使包体积有非常微小的增加,但是问题不大;
- 插件引用宿主资源由原来的常量变成了方法调用,执行效率降低,不过这块可以通过缓存来解决。同时插件化本身就是一项黑科技技术,有时候牺牲一些性能,解决一个问题还是非常值得的。
4.1.2 处理 xml 代码中引用宿主的资源
xml 中引用宿主资源的问题仅靠编译时是无法解决的,因为 xml 不像 java 代码一样可以执行逻辑,前面介绍了,xml 在编译结束后,资源全部编成了数值,而我们在编译时又无法知道未来运行在哪个宿主,值为多少。所以修改 xml 中资源id的工作只能搬到运行时去搞。当然也需要在编译时做一些事情,辅助运行时的修改操作。
运行时我们需要修改 apk 的?xml 中 0x7f 开头的资源,将其数值改为对应当前宿主的正确数值,而通过 xml,我们只能拿到一个数值,因此我们可以在插件编译时收集插件 xml 中使用的宿主资源所在的 xml 文件以及它们所对应的资源 name,运行时借助前文提到的mapRes方法即可获取到需要被修改后的值。
开始实践
前文介绍过,aapt2 编译/链接后会生成一个 ap_ 文件,这个文件中包含了最终会进入插件中的所有编译后的资源(包括各种 xml、resources.arsc、AndroidManifest.xml ),我们只需要分析这些文件中引用的 0x7f 开头的资源,根据 R.txt(aapt2生成的一个文件)找到对应的资源名,将资源名、id 值、所在文件记录到一个文件中,一并打包进插件 apk 中。
至于如何扫描这些文件中 0x7f 的资源,我们在不同阶段使用了不同方式,大家可以自行选择:
- 使用 aapt2 命令 dump 文件信息,分析 dump 后的文本内容(我们编译时是这么做的,简单粗暴、性能较差、不够优雅);
- 根据文件格式分析对文件内容进行解析,找到 0x7f 开头的资源(比较优雅,效率也高,我们运行时是这样做的)。
总结:
以上,便生成了一个文件,内部存储了插件 xml 中使用到的宿主资源的信息。大概长下面这样:
前文一直在说 xml 中使用的宿主资源,看上面这个配置文件发现 fileNames 中怎么会有 resoureces.arsc ?它明明不是 xml 文件?
其实 Android 资源编译之后,values 相关的一些资源文件都不存在了,会直接进入到 resources.arsc 中,layout 这类文件还存在,resoureces.arsc 中 layout 指向的正是各种 layout.xml,而 string 等 value 类型的资源指向的是一个真实的内容。感兴趣的同学可以通过 Android Studio 打开 apk,观察一下 resources.arsc 中的结构。
4.2 插件安装时的工作
前面介绍了在插件编译时,给 java 代码中插入了一些逻辑,实现了插件动态根据宿主环境获取资源 id 的效果。但是 xml 编译完之后,资源 id 都直接编译成了数字,xml 中也无法插入逻辑,因此我们只能在插件运行前,根据宿主环境进行修改。
插件在宿主中运行前都有一个插件安装的过程,类似于 apk 在 Android 系统中的安装,因此只需要在每次插件安装前,或者宿主升级后,根据编译时生成的配置文件,结合 mapRes 方法,对插件中的 xml、resources.arsc 文件进行修改即可。
确定了修改时机和修改内容,接下来就要详细介绍怎么修改这些文件了。
5. 修改 apk 中的资源文件
5.1 如何修改 xml、arsc 文件
Android 中的 layout、drawable、AndroidManifest 等文件在编译成 apk 后,不再是常规的 xml 文件了,而是新的一种文件格式 Android Binary XML,我们这里称之为 axml。那么如何修改 axml 文件呢?
所有的文件都有自己的文件格式,程序在读取文件时都是读的 byte 数组,然后根据文件格式解析 byte 数组中每一个元素的含义。因此我们只需要了解了 axml 的文件格式,按照规范解析这个文件,在 byte 数组中找到其中表示资源 id 的位置,将原本的资源 id 根据 resMap 方法映射出新的值,然后修改 byte 数组中对应的部分。(非常幸运,我们这里修改的只是 axml 文件中的一个 8 位 16 进制数,这个修改不会导致文件中内容的长度、偏移等信息改变,因此直接替换对应部分的 byte 数组即可。)
resources.arsc 是 apk 的资源索引表,里面记录了 apk 中所有的资源,对于 values 类型的资源,资源对应的内容会全部进入到 resources.arsc 中,因此我们也需要对这个文件进行修改(如一个 style 的 parent 是宿主资源,我们就需要修改它)。修改的方法和 xml 类似,只需要按照规范解析 byte 数组,找到要修改内容的偏移量,替换即可。
关于 axml、arsc 的文件格式,网上有很多文章介绍,这里就不详细叙述了。
Apktool 是一款强大、开源的逆向工具,它可以把 apk 反编译成源码,那它肯定也有读取 apk 中 axml、arsc 的代码,不然怎么输出一个可以编辑的 xml 源码文件?所以我们可以直接去扒 apktool 中读取 axml、arsc 的代码,当读取到 axml 中属于宿主的 id 时,记录一下 byte 数组的偏移量,直接替换对应位置的 byte 子数组。
aapt2 为我们提供了 dump 资源内容的能力,可以帮助我们直接用“肉眼”去看 axml、arsc 的内容,借助这个工具可以让我们很方便的确认修改内容,验证修改是否生效。以 30.0 版本的 build-tools 中的 aapt2 为例,它的命令为aapt2 dump apk路径 --file 资源路径?。后面不跟--file 资源路径,会直接 dump arsc。
以下是 dump 出来的 arsc,可以看到最后一个 style 的 parent 是一个 0x7f 开头的宿主资源。
以下是 dump 出来的 activity_plugin1.xml,可以看到 TextView 中引用了一个宿主中的资源作为 backgroud。
5.2 修改 apk 中的 xml/arsc 文件
以上我们知道了如何修改一个 axml、arsc 文件。插件安装时我们拿到的是 apk 文件,那么如何修改 apk 中的 axml、arsc 文件呢?
5.2.1 重压缩方式修改
Apk 其实就是一个 zip 文件,修改 apk 中的文件内容,首先想到的最简单的方法就是读取 zipFile 里面的文件,修改之后重压缩。
java 为我们提供了一套操作 zipFile 的 api,我们可以轻松的将 zip 文件中的内容读取到内存,在内存中修改之后利用 ZipOutputStream 重新写入到新的 zipFile 中。
代码实现非常简单。修改成功后,测试发现是可行的,那我们的第一步就算是成功了,说明运行时动态修改插件的路子是行的通的。
窃喜之于,发现修改过程十分耗时。以公司的直播插件为例(直播插件大约 30 MB,属于比较大的插件了),在 9.0 及其以上的设备上耗时约 8s,在 7~8 的设备耗时大约 20~40s,在 7.x 以下设备大约耗时 10~20s。尽管插件安装是在后台进行,适当的增加一些时间是可以接受的,但是几十秒的耗时很明显不可以接受。那我们只能想别的办法了。
关于各个版本的耗时差异:
Android7.0 开始,官方使用 ZLIB 来提供 Deflater、Inflater 的实现,优化了解压压缩算法速度(可以查看 Deflater.java、Inflater.java 的注释)。但是 7.x/8.x 的 ZipFileInputStream 在读取数据时有一个 8192 的 BUFSIZE 限制( 8.x 之后移除了这个限制),导致在读取数据时循环次数增多,效率反而下降。
7.0 开始,ZipFileInpugStream 在读取数据时是通过 native 方法 ZipFile_read 进行的。以下是 android8.0 和 android9.0 中 ZipFile_read 的部分代码。
5.2.2 直接修改 apk 的 byte 数组
Apk 其实就是一个 zip 文件,关于 zip 文件的介绍可以参考 Zip 的官方文档。
简单总结一下,zip 文件是由数据区、中央目录记录区、中央目录尾部区组成(高版本的 zip 文件增加了新的内容)。
- 中央目录尾部区:通过尾部区我们可以知道 zip 包中文件的数目、中央目录记录区的位置等信息;
- 中央目录记录区:通过尾部区我们可以快速找到中央目录记录区中的每一条文件记录,这些记录主要描述了 zip 包中文件的基本属性如文件名、文件大小、是否压缩、压缩后的大小、文件在数据区中的偏移等;
- 数据区:数据区用来存放文件真实的内容,根据中央目录记录区记录的内容,可以快速在数据区找到对应的文件元数据以及文件的真实数据(如果压缩,则是压缩后的数据)。
开始干活
了解了 zip 文件的格式后,我们只需要按照文件格式协议,在 apk 中找到我们需要修改的文件数据在 apk 中的偏移量,然后结合前面修改 axml/arsc 文件的方式,直接修改对应的 byte 数组即可。借助 java 为我们提供的 RandomAccessFile 工具,我们可以快速的文件的任意位置进行读取/写入。
修改过程中发现,apk 中的 xml 文件大部分是被压缩的( res/xml 目录下的一般不压缩),这就导致我们从 apk 中拿出来的 byte 数组是 axml 被压缩后的数据,我们要对这段数据进行修改,需要先利用 Deflate 算法对它进行解压( zip 文件中一般都是用的 Deflate 算法),然后进行修改再压缩,但是经过我们修改后,可能重新压缩出来的数据就与修改前的数据长度不匹配了,如果是缩短还好,修改一下文件元数据即可,如果文件长度变长可能会导致后面文件的偏移量都要改变,牵一发而动全身。
好在插件的打包过程我们是可以侵入的,前面介绍“插件编译时工作”时,我们在编译时拿到了需要修改的文件,因此我们只需要控制 apk 打包时不要对这些文件进行压缩(事实上 Android Target30 也要求 arsc 文件不进行压缩)。这样就很简单的解决了问题,当然会导致插件包体积的增加。
最终测试在直播插件中,开启这个功能会导致包体积增加 20kb,对于接近 30mb 体积的直播插件来说,这个增量是可以接受的,而且也不会影响宿主包体积。(这个增量取决于插件有多少 xml 使用了宿主资源,一般插件的增量应该都是小于直播插件的。)
改造完成后,经测试,直播插件在各个版本手机上修改时长大约在 300~700ms 之间,修改速度提升了 10~90 倍。大部分插件也比直播插件小,耗时可以保证在 100ms 之内。同时这个修改过程仅在插件第一次安装或者宿主升级时做,并且是在后台完成,所以是完全可以接受的。