本文介绍了 Android 插件化框架中,插件使用宿主资源时资源错乱的问题,以及错乱的原因、业界通用解决方案、我们提出的优化方案。
本文将按照如下顺序,循序渐进地进行讲解:
Android 发展了这么多年,市面上涌现出许多插件化/热修复框架,无论是插件化还是热修复,都是为了实现对主apk以外内容的动态化,这些内容包括 dex(class)、res(资源)、so(动态库)等。对于每一种内容,业界都有许多实现方案,尽管方案各不相同,但底层原理都差不多,网上也有许多文章和开源项目可以学习参考。
名词解释
宿主:直接安装到用户手机上的 App,宿主中的代码在宿主安装到用户手机上的那一刻就定死了,不能再改变了(热修复也只是让错误的逻辑不走而已,并没有改变原有的代码)。
插件:独立于宿主之外的一个文件。需要被宿主动态加载的 class、res、so 等的集合。(热修复中这部分通常称为 patch,这里为了方便,就叫插件吧)
java 代码:为了描述方便,apk 中的 dex 在编译前一律称为 java 代码,编译后一律称为 dex(这个说法不准确,不要被我误导了,一般为java / kotlin- > class- > dex )
说到 Android 资源的动态化,思路都大同小异:
aapt2 的出现使资源固定、packageId 修改变得容易了很多!
尽管 Android 资源的动态化技术已经十分成熟,但是在实践过程中还是有许多不足,比如“资源固定”就经常被业务同学吐槽。
在介绍资源固定之前,首先简单介绍一下 Android 中资源相关的基础知识。
Android 代码在编译成 apk 之后,每个资源都对应一个唯一的资源 id,资源 id 是一个 8 位的 16 进制 int 值 0xPPTTEEEE :
注意:
Android 中使用资源通常有两种方式:
[<package_name>].R.<resource_type>.<resource_name>
@[<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 访问的
结论:
那么为什么 libTest 方法中是通过 field 引用,而 appTest 中就变成数字了呢?
假设有一个工程,只有一个 app module,通过 maven 仓库依赖若干三方 aar,项目编译时的简化流程如下图:
3 . App module 的 java 文件与 R.jar 一起被 javac 编译。由于 R.jar 中的 field 都是 final,因此 app module 中通过 R 引用的资源全部被内联成了数值。而三方 aar 中由于已经是 class,无需进行编译,因此依旧是通过 R 引用来使用资源;
4 . 最后把 app module 编译出来的 .class、三方 aar 中的 .class 转成 dex,与 ap_ 一起压缩到 apk 中。
因此就很容易理解为啥 libTest 中依旧是通过 R 来使用资源,而 appTest 中通过数值直接引用(被内联)。
libTest module 虽然被 app module 通过源码依赖,但是在资源编译这块其实是类似的,这里不展开介绍。
Android 中的资源的无论是通过 java 代码使用还是 xml 使用,最终都是通过资源 id 值进行查找的。
把 apk 拖到 as 中,查看 resources.arsc 文件,可以看到它里面包含了 apk 中所有资源的 id 索引,以及该资源名对应的真正资源或值。很容易想到,App 运行起来也是通过资源 id 值经过这个资源表来查找真正的资源内容。
想象一下,我们想要把 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。
前文介绍过,新增或删除一个资源都可能导致其他许多资源的 id 被改变。
我们的宿主编译出来后 icon 为 0x7f010001,基于已有的宿主编译出一个插件后,插件中引用的 icon 也是 0x7f010001,此时没什么问题。
宿主迭代后,新增了一个新的资源 aicon,按照前面介绍的资源 id 分配规则,新版本的宿主中 aicon 的 id 值为 0x7f010001,icon 的 id 值被分配为 0x7f010002。老版本的插件下发到新版本的宿主上时依旧会通过 0x7f010001去宿主中找 icon,自然就找错了。运气好一点可能只是图片展示异常,运气不好点可能就直接 crash 了。
为了解决这个问题,业界目前有一个通用、稳定的方案——资源固定。宿主编译时通过 aapt2 提供的参数对插件使用到的资源进行固定,使宿主每次打包时这些资源的值永远不发生改变。
资源固定方案的弊端:
2 . 一个插件运行在多个宿主的情况:
为了解决上述的问题,我们研究了一套新的方案解决资源错乱问题。
同一个版本的插件运行在不同版本甚至不同的 App 上时,插件的代码是固定的,而宿主中的资源 id 是会改变的,为了解决资源错乱问题,当前的思路是保证宿主每次出新版本时资源 id 不变。那么有没有办法在不约束宿主的情况下,让插件始终跟宿主的资源 id 保持一致呢?
由于插件打包时,宿主是未知的,并且对于一个插件跑在多个宿主的情况,宿主也是多样的。所以没法指定让插件把 id 打成满足宿主的样子,而前文也介绍过,插件中引用宿主 id 的地方都是常量。那怎么办呢?
是否可以在插件运行到宿主上时,动态修改插件中的内容,实现插件与宿主 id 值匹配的效果。
比如插件中使用了宿主的资源 icon,对应的 id 值为 0x7f010001。当该插件运行在一个 icon 为 0x7f010002的宿主上时,由于运行时资源查找都是通过 id 值进行的,此时我们只能知道插件是在找一个 id 为 0x7f010001 的资源。通过某些手段,如果我们可以把 0x7f010001 映射成 icon 这个字符串,然后利用 Android 系统提供的Resources#getIdentifier
方法,动态获取到当前宿主中 icon 对应的资源 id,即可保证插件加载到正确的资源。
这个工作需要在插件编译时、运行时分别做一些工作配合完成实现。
本小节内容基于 agp4.1 介绍,各个版本有些许差异,但总体思路大同小异。
前面介绍了,插件使用宿主资源主要有两种情况:1.通过 java 代码 2.通过 xml。
java 代码在编译成 class 之后,对于引用宿主资源 id 的代码,有的会编译成数值,有的依旧是通过 R 引用。对于后者,我们可以很容易找出来,对于前者就有些困难了,因为单纯去扫描 class 中 0x7f 开头的数字,很容易误判,把一个无意义的数字也当作资源 id 处理。
前面讲了为什么 class 中的资源 id 会内联成数值,那我们不让它内联不就好了吗?只需要在编译过程中处理 R.jar,移除 class 中所有的 final 字段,就可以保证插件中引用宿主的资源 id 全部通过 R 进行引用。
这块需要对 agp 的工作流程、gradle plugin 的开发有一定的了解,用到了 asm 字节码修改技术和 agp 提供的 transform api,不了解的同学可以单独查一下,这块就不详细介绍了。
简单来说就是通过这两项技术,可以在编译 apk 时,对 class 文件进行修改。
开始实践
总结:
以上,通过编译时的一些处理,即可解决插件 java 代码中引用宿主资源时免资源固定的问题。
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 的资源,我们在不同阶段使用了不同方式,大家可以自行选择:
总结:
以上,便生成了一个文件,内部存储了插件 xml 中使用到的宿主资源的信息。大概长下面这样:
前文一直在说 xml 中使用的宿主资源,看上面这个配置文件发现 fileNames 中怎么会有 resoureces.arsc ?它明明不是 xml 文件?
其实 Android 资源编译之后,values 相关的一些资源文件都不存在了,会直接进入到 resources.arsc 中,layout 这类文件还存在,resoureces.arsc 中 layout 指向的正是各种 layout.xml,而 string 等 value 类型的资源指向的是一个真实的内容。感兴趣的同学可以通过 Android Studio 打开 apk,观察一下 resources.arsc 中的结构。
前面介绍了在插件编译时,给 java 代码中插入了一些逻辑,实现了插件动态根据宿主环境获取资源 id 的效果。但是 xml 编译完之后,资源 id 都直接编译成了数字,xml 中也无法插入逻辑,因此我们只能在插件运行前,根据宿主环境进行修改。
插件在宿主中运行前都有一个插件安装的过程,类似于 apk 在 Android 系统中的安装,因此只需要在每次插件安装前,或者宿主升级后,根据编译时生成的配置文件,结合 mapRes 方法,对插件中的 xml、resources.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。
以上我们知道了如何修改一个 axml、arsc 文件。插件安装时我们拿到的是 apk 文件,那么如何修改 apk 中的 axml、arsc 文件呢?
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 的部分代码。
Apk 其实就是一个 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 之内。同时这个修改过程仅在插件第一次安装或者宿主升级时做,并且是在后台完成,所以是完全可以接受的。
Copyright© 2013-2019