查看原文
其他

Android插件化中资源错乱的解决方案

薛凯 字节跳动终端技术 2022-08-07

👆点击上方蓝字关注我们!

本文介绍了Android插件化框架中,插件使用宿主资源时资源错乱的问题,以及错乱的原因、业界通用解决方案、我们提出的优化方案。

本文将按照如下顺序,循序渐进地进行讲解:

  • 简单介绍Android插件化中资源部分的动态化。

  • 简单介绍Android中资源的一些基础知识、使用方式及其编译原理。

  • 介绍插件化场景下出现的资源错乱问题及业界通用的解决方案。

  • 介绍一种新的方案——免资源固定方案,用于解决资源错乱问题。

  • 单独介绍一下免资源固定方案中的一个技术点:修改apk中的资源文件。

一、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资源的动态化技术已经十分成熟,但是在实践过程中还是有许多不足,比如“资源固定”就经常被业务同学吐槽。

二、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的属性——layout_width等依旧是字符串,其实它背后也是资源id数值,这块的字符串其实是没有用的,甚至在一些包体积优化中可以直接去掉,后续抖音会发一篇相关的文章介绍该技术)

那么为什么libTest方法中是通过field引用,而appTest中就变成数字了呢?

2.3 Android中资源编译的简单流程

假设有一个工程,只有一个app module,通过maven仓库依赖若干三方aar,项目编译时的简化流程如下图:

  1. 下载三方aar。
  2. 将app module和三方aar中的资源经过aapt2进行编译、链接,最终生成R.jar和ap_。
  • R.jar包含了最终打入apk的所有R.class,每个依赖对应一个。aapt2也会默认按照字母排序为每个资源分配唯一的id值。注意:新增删除一个资源都会导致它后面的资源id改变。aapt2允许通过配置干预id的分配。
  • ap_文件中包含了所有编译好的资源文件。
  1. App module的java文件与R.jar一起被javac编译。由于R.jar中的field都是final,因此app module中通过R引用的资源全部被内敛成了数值。而三方aar中由于已经是class,无需进行编译,因此依旧是通过R引用来使用资源。
  2. 最后把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.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提供的参数对插件使用到的资源进行固定,使宿主每次打包时这些资源的值永远不发生改变。

资源固定方案的弊端:

  1. 一个插件对应一个宿主的情况:

  • 必须把宿主的所有资源都进行固定。如果只固定插件使用的资源,当一个宿主有两个插件时,两个插件各自给宿主固定自己需要的资源,在代码合并时,很容易引发冲突,因为资源固定的值是不允许重复的。

  • 当宿主接入多个涉及到资源固定的框架,如:插件化、资源热修复、游戏重打包框架等,这些框架之间进行资源固定时也需要考虑统一固定,这个成本是很高的。

  • 资源固定提高了宿主接入框架的成本

  1. 一个插件运行在多个宿主的情况

  • 当一个插件想要运行在多个宿主上,就需要每个宿主针对该插件的资源使用情况进行资源固定。一旦某个宿主已经对某个资源进行了固定,导致其与该插件要求的资源固定产生冲突,插件就需要对该宿主进行妥协,根据该宿主已有的资源固定重新生成固定规则。这样就无法实现一个插件在多个宿主上运行。我们目前有一个需求:同一个插件需要在上千个宿主上运行,如果不能解决这个问题,可能需要打成百上千个插件出来,很明显是不合理的。

  • 资源固定提高了宿主接入框架的成本

为了解决上述的问题,我们研究了一套新的方案解决资源错乱问题。

四、免资源固定方案

同一个版本的插件运行在不同版本甚至不同的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文件进行修改。
开始实践
  1. 由于R.jar是在processResourcesTask中生成的,因此可以写一个gradle plugin,在processResourcesTask的doLast中获取到R.jar,修改R.jar中的字节码,将field中的id为0x7f开头的字段的final修饰符全部移除。这样就可以保证插件class中所有引用宿主资源的地方都不会被内联成数值。

  2. 经过第一步的处理,插件中引用的宿主资源全部通过R.xx.xx来引用,但插件R中的数值依旧是无法与宿主对应的。因此我们继续写一个transform,扫描出插件中通过R引用资源的地方,利用asm将其从原来的R引用修改为方法调用。插件运行时,原本类似R.drawable.test的代码不再是获取一个常量数值,而是调用一个方法,内部动态计算当前宿主中对应的值。

总结:

以上,通过编译时的一些处理,即可解决插件java代码中引用宿主资源时免资源固定的问题。

  • 优点:无需资源固定。

  • 缺点:1. 插件中的部分资源不进行内联,会使包体积有非常微小的增加,但是问题不大。2. 插件引用宿主资源由原来的常量变成了方法调用,执行效率降低,不过这块可以通过缓存来解决。同时插件化本身就是一项黑科技技术,有时候牺牲一些性能,解决一个问题还是非常值得的。

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的资源,我们在不同阶段使用了不同方式,大家可以自行选择:1. 使用aapt2命令dump文件信息,分析dump后的文本内容(我们编译时是这么做的,简单粗暴、性能较差、不够优雅) 2. 根据文件格式分析对文件内容进行解析,找到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文件进行修改即可。

确定了修改时机和修改内容,接下来就要详细介绍怎么修改这些文件了。

五、修改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(Apktool - A tool for reverse engineering 3rd party, closed, binary Android apps.)是一款强大、开源的逆向工具,它可以把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中。

代码实现非常简单。修改成功后,测试发现是可行的,那我们的第一步就算是成功了,说明运行时动态修改插件的路子是行的通的。

窃喜之于,发现修改过程十分耗时。以公司的直播插件为例(直播插件大约30MB,属于比较大的插件了),在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文件的格式后,我们只需要按照文件格式协议,在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之内。同时这个修改过程仅在插件第一次安装或者宿主升级时做,并且是在后台完成,所以是完全可以接受的。

目前Android插件化已上线至火山引擎应用开发套件MARS,并免费开放给各位开发者使用,欢迎大家体验。


火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式整体研发解决方案,助力企业研发模式升级,降低企业研发综合成本。

👇 点击阅读原文,了解更多产品信息。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存