摘要
本文介绍了 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 之内。一起这个修正进程仅在插件第一次装置或者宿主晋级时做,并且是在后台完成,所以是完全能够承受的。
欢迎参加 AppHealth 团队
AppHealth 是字节跳动的一个客户端技能专家团队,专心于动态化、App 功用、稳定性、构建等方向。旨在经过对操作系统内核、虚拟机、东西链、编译器等方向的深度优化和建造,为字节跳动内部几十款 App(包含抖音、头条、飞书等)供给职业抢先的终端体验优化才能,助力公司事务的高效、高质开展。扫码投递,或发送简历到邮箱:xuekai.xk@bytedance.com