本文介绍了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,我的项目编译时的简化流程如下图:
⒈下载三方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中仍旧是通过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提供的参数对插件应用到的资源进行固定,使宿主每次打包时这些资源的值永远不产生扭转。
资源固定计划的弊病:
⒈一个插件对应一个宿主的状况:
- 必须把宿主的所有资源都进行固定。如果只固定插件应用的资源,当一个宿主有两个插件时,两个插件各自给宿主固定本人须要的资源,在代码合并时,很容易引发抵触,因为资源固定的值是不容许反复的。
- 当宿主接入多个波及到资源固定的框架,如:插件化、资源热修复、游戏重打包框架等,这些框架之间进行资源固定时也须要思考对立固定,这个老本是很高的。
- 资源固定进步了宿主接入框架的老本
⒉一个插件运行在多个宿主的状况
- 当一个插件想要运行在多个宿主上,就须要每个宿主针对该插件的资源应用状况进行资源固定。一旦某个宿主曾经对某个资源进行了固定,导致其与该插件要求的资源固定产生抵触,插件就须要对该宿主进行斗争,依据该宿主已有的资源固定从新生成固定规定。这样就无奈实现一个插件在多个宿主上运行。咱们目前有一个需要:同一个插件须要在上千个宿主上运行,如果不能解决这个问题,可能须要打成千盈百个插件进去,很显著是不合理的。
- 资源固定进步了宿主接入框架的老本
为了解决上述的问题,咱们钻研了一套新的计划解决资源错乱问题。
四、免资源固定计划
同一个版本的插件运行在不同版本甚至不同的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代码中援用宿主资源时免资源固定的问题。
- 长处:无需资源固定。
- 毛病: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之内。同时这个批改过程仅在插件第一次装置或者宿主降级时做,并且是在后盾实现,所以是齐全能够承受的。
我整顿了一些学习材料,外面包含Java根底、Android进阶、架构设计、NDK、音视频开发、跨平台、底层源码等技术,还有2022年一线大厂最新面试题集锦,都分享给大家,助大家学习路上乘风破浪~ 能力失去晋升,思维失去宽阔~ 有须要的能够点击下方链接收费获取。
链接:https://shimo.im/docs/R13j85m...