本文介绍了 Android 插件化框架中,插件使用宿主资源时资源错乱的问题,以及错乱的原因、业界通用解决方案、我们提出的优化方案。
本文将按照如下顺序,循序渐进地进行讲解:
- 简单介绍 Android 插件化中资源部分的动态化。
- 简单介绍 Android 中的资源的一些基础知识、使用方式及其编译原理。
- 介绍插件化场景下出现的资源错乱问题及业界通用的解决方案。
- 介绍一种新的方案——免资源固定方案,用于解决资源错乱问题。
- 单独介绍一下免资源固定方案中的一个技术点:修改 apk 中的资源文件。
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 的代码不再是获取一个常量数值,而是调用一个方法,内部动态计算当前宿主中对应的值。