AssetManager的 mResources成员是一个ResTable
结构体。
class ResTable
{
mutable Mutex mLock; //互斥锁,用于多进程间互斥操作
status_t mError;
ResTable_config mParams;
Vector<Header*> mHeaders; //表示所有resources.arsc原始数据,这就等同于所有通过addAssetPath加载进来的路径的资源ID信息
Vector<PackageGroup*> mPackageGroups //资源包的实体,包含所有加载进来的package id所对应的资源
uint8_t mPackageMap[256]; //索引表,表示0~255的package id,每个元组分别存放该ID所属PackageGroup在mPackageGroups中的index
uint8_t mNextPackageId;
}
一个Android进程只包含一个ResTable,ResTable的成员变量mPackageGroups
就是所有解析过的资源包的集合。
任何一个资源包中都含有resources.arsc
,它记录了所有资源的ID分配情况,以及资源中的所有字符串。这些信息是以二进制数的方式存储的。底层的AssetManager做的事就是解析这个资源文件,然后把相关信息存储到 mPackageGroups里面。
============================================================================
整个resources.arsc文件,实际上是由一个个ResChunk
(以下简称chunk)拼接起来的。从头文件开始,每个chunk的头部都是一个ResChunk_header
结构,他指示了这个chunk的大小和数据类型。
struct ResChunk_header
{
uint16_t type;
uint16_t headerSize;
uint32_t size;
}
通过ResChunk_header
中的type成员,可以知道这个chunk是什么类型,从而就可以知道应该如何解析这个chunk。
解析完一个chunk后,从这个 chunk + size的位置开始,就可以得到下一个 chunk的起始位置,这样就可以依次读取完整个文件的数据内容。
一般来说,一个 resources.arsc里面包含若干个package,不过默认情况下,由打包工具AAPT打包出来的包只有一个package。这个package里包含了App中的所有资源信息。
资源信息主要是指每个资源的名称以及它对应的编号。我们知道,Android中的每个资源,都有它唯一的编号。编号是一个32位数字,用十六进制来表示就是 0xPPTTEEEE。PP为package id,TT为type id,EEEE为entry id。
他们代表什么?在resources.arsc里是以怎样的方式记录呢?
-
对于package id,每个package对应的是类型为
RES_TABLE_PACKAGE_TYPE
的 ResTable结构体,ResTable_package结构体的ID成员变量就表示它的package id -
对于type id,每个type对应的类型为
RES_TABLE_TYPE_SPEC_TYPE
的ResTable_typeSpec结构体。它的ID成员变量就是type id。但是该 type id具体对应什么类型,是需要到package chunk的 Type String Pool去解析得到的。比如 Type String Pool中依次有attr、drawable、mipmap、layout字符串,所以attr的id就是1,drawable就是2,以此类推。 -
对于entry id,每个entry表示一个资源项,资源项是按照排列的先后顺序自动被标记编号的。也就是说,一个type里按位置出现的第一个资源项其entry id为0,依次类推。因此我们因此我们是无法直接指定entry id的,只能够根据排布顺序决定。资源项之间是紧密排布的,没有空隙,但是可以指定资源项为 ResTable_type::NO_ENTRY来填入一个空资源。
举个例子:
我们随便找个带资源APK,用AAPT解析一下,看到其中的一行是:
spc resource 0x7f040019 com.rikkathworld.hotfix:layout/activity_main flags=0x00000000
表示 activity_main这个资源的编号是 0x7f040019,其中package id是 0x7f,资源类型ID是0x04(即layout类型),而0x04类型的第0x0019个资源项就是activity_main这个资源。
=============================================================================
默认由Android SDK编出来的APK,是由APPT工具进行打包的,其资源包的package id就是 0x7f
系统的资源包,也就是framework-res.jar,package id为0x01
在走到App的第一行代码之前,系统就已经帮我们构造好一个已经添加了安装包资源的AssetManager了。
// ResourcesManager.java
protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
AssetManager assets = new AssetManager();
if (key.mResDir != null) { // 1
if (assets.addAssetPath(key.mResDir) == 0) { // 2
Log.e(TAG, "failed to add asset path " + key.mResDir);
return null;
}
}
…
return assets;
}
注释1:if语句 mResDir,指的就是安装包APK。
注释2:用新建的AssetManager调用 addAssetPath()
去解析这个APK下的资源
因此这个AssetManager里就已经包含了系统资源包以及App的安装包,就是package id为0x01的framework-res.jar中的资源和package id为0x7f的App安装包资源。
如果此时直接在原有AssetManager上继续 addAssetPath的完整补丁包的话,由于补丁包里面的package id也是0x7f,就会使得同一个package id的包被加载两次。这会有怎样的问题呢?
在Android L之后,这是没问题的,它会默默地把后来的包添加到之前的包同一个PackageGroup下面。
而在解析的时候,会与之前的包比较同一个type id所对应的类型,如果该类型下的资源项数目和之前添加过的不一致,会打出一条warning log,但是仍旧加入到该类型的TypeList中。
但是在获取这个资源的时候呢?
在获取某个Type的资源的时候,它会从前往后遍历,也就是说先得到原有安装包里的资源,除非后面的资源的config比前面更详细才会覆盖。而针对于同一个config而言,补丁中的资源就永远无法生效了。所以在Android L以上的版本,在原有的AssetManager上加入补丁包,是没有任何作用的,补丁中的资源无法生效。
而在Android4.4及以下的版本,addAssetPath只是把补丁包的路径添加到了 mAssetPath中,而真正解析的资源包的逻辑是在App第一次执行AssetManager::getResTbale()
的时候:
//Android4.4_r1 frameworks/base/libs/androidfw/AssetManager.cpp
const ResTable* AssetManager::getResTable(bool required) const
{
// mResources已经存在,直接返回不再往下走
//如果已经执行过一次完整的该方法,以后都会直接return,所以补丁包来了也解析不了。
ResTable* rt = mResources;
if (rt) {
return rt;
}
…
const size_t N = mAssetPaths.size();
for (size_t i=0; i<N; i++) {
//真正解析package的地方
…
}
return rt;
}
而在执行到加载补丁代码的时候,getResTable已经执行过了无数次。这是因为就算我们之前没有做过任何资源相关的操作,Android framework里的代码也会多次调用到那里。所以,以后即使是 addAssetPath,也只是添加到了mAssetPath,并不会发生解析。因而补丁包里面的资源是完全不生效的!(注意看代码中的注释)
所以像Instant Run这种方案,一定需要一个全新的AssetManager,再加入完整的新资源包,替换到原有的AssetManager。
================================================================================
一个好的资源修复方案是怎样的呢?
首先,补丁包要足够的小,像直接下发完整的补丁包肯定是不行的,很占用空间
而像有些方案,是先进行bsdiff,对资源包做差量处理,然后下发差量包,在运行时合成完整包再进行加载。这样确实减小了包的体积,却在运行时多了合成的操作,耗费了运行时间和内存。合成后的包也是完整的包,仍旧会占用磁盘空间。
而如果不采用类似Instant Run的方案,市面上许多实现方案是自己修改APPT,在打包时将补丁包资源进行重新编号。这样就会涉及修改Android SDK工具包,既不利于集成也无法很好地对将来的APPT版本进行升级。
Sophix的方案,简单来说,是构造了一个package id为0x66的资源包,这个包里只包含改变了的资源项,直接在原有的AssetManager中的addAssetPath这个包就可以了。
真的这么简单?
是的,由于补丁包的package id为0x66,不与目前已经加载的0x7f冲突,因此直接加入已有的AssetManager中就可以直接使用了。补丁包里面的资源,只包含原有包里面没有而新的包有的新增资源,以及原有内容发生了改变的资源。
面对资源改变包含的 增加、减少、修改这三种情况,我们分别是如何处理的呢?
-
对于新增资源,直接加入补丁包,然后新代码里直接引用就可以了,没什么好说的
-
对于减少资源,我们只要不使用它就行了,因此不用考虑这种情况,它也不影响补丁包
-
对于修改资源,比如替换了一张图片之类的情况。我们把它视为新增资源,在打入补丁的时候,代码在引用处也会做相应修改,也就是直接把原来使用旧资源ID的地方变为新ID。
用下图来说明补丁包的情况:
图中绿线表示新增资源,红线表示内容发生修改的资源,黑线表示内容没有变化,但是ID发生改变的资源,×表示删除的资源。
可以看到,新的资源包与旧资源包相比,新增了 holo_grey
和dropdn_item2
资源,新增的资源被加入到了补丁包中,并分配了0x66开头的资源id。
而新增的两个资源导致了在它们所属的type中跟在它们之后的资源id发生了位移。比如holo_light,id由0x7f020002变为0x7f020003,而abc_dialog由 0x7f030004变为了 0x7f030003。新资源插入的位置是随机的,这与每次APPT打包时解析XML的顺序有关。发生位移的资源不会加入到补丁包中,但是在补丁包的代码中会调整ID的引用处。
比如说在代码里,我们是这么写的:
imageView.setImageResource(R.drawable.holo_light);
这个R.drawable.holo_light是一个int值,它的值时AAPT指定的,对于开发者透明,即使点进去,也会直接跳到对应 res/drawable/holo_light.png,无法查看这int。不过可以通过反编译工具,看到它的真实值时0x7f020002,所以这行代码等价于:
imageView.setImageResource(0x7f020002);
而当打出了一个新包后,对开发者而言,holo_light的图片内容没有改变,代码引用处也没有改变,但是新包里面,同样是这句话,由于新资源的插入导致ID改变,对于R.drawable.holo_light的引用已经变成了:
imageView.setImageResource(0x7f020003);
但实际上这种情况并不属于资源改变,更不属于代码的改变,所以我们在对比新旧代码之前,会把新包里面的这行代码修正回原来的资源ID。
imageView.setImageResource(0x7f020002);
然后进行后续代码的对比。这样后续代码对比时就不会被检测到发生了改变。
而对于内容发生改变的资源(类型为layout的activity_main,这可能是我们修改了activity_main.xml的文件内容。还有类型为string的no,可能是我们修改了这个字符串的值),他们都会被加入到补丁包中,并重新编号为新ID。
而相应的代码,也会发生改变,比如:
setContentView(R.layout.activity_main);
//实际上就是下面的
setContentView(0x7f030000);
在生成对比新旧代码之前,我们会把新包里面的这行代码变为:
setContentView(0x66020000);
这样,在进行代码对比时,会使得这行代码所在函数被检测到发生了改变。于是相应的代码修复会在运行时发生,这样就引用到了正确的新内容资源。
对于删除的资源,不会影响补丁包。
这很好理解,既然资源被删除了,就说明新的代码中也不会用到它,那资源放在那里没人用,就相当于不存在了。
可以看到,对于type0x01的所有资源项都没有发生改变,所以整个type0x01资源都没有加入到补丁包中。这也使得后面的type的id都往前移了一位。因此Type String Pool中的字符串也要进行修正,这样才能使得0x01的type指向drawable,而不是原来的attr。
所以我们可以看到,所谓简单,指的是运行时应用补丁变得简单了。
而真正复杂的地方在于构造补丁。我们需要把新旧两个资源包解开,分别解析其中的resources.arsc文件,对比新旧的不同,并将他们重新打成带有新package id的新资源包。这里补丁包是指定的package id只要不是0x7f和0x01就行,可以是仍以0x7f以下的数字,我们默认把它指定为0x66。
构造这样的补丁资源包,需要对整个resources.arsc的结构十分了解,要对二进制数形式的一个一个chunk进行解析分类,然后再把补丁信息一个一个重新组装成二进制数形式的chunk。这里面很多工作与AAPT做的类似,实际上开发打包工具的时候也是产考了很多AAPT和系统加载资源的代码。
=======================================================================================
对于Android L以后的版本,直接在原有AssetManager上应用补丁就行了,并且由于用的是原来的AssetManager,所以原先大量的反射修改替换操作就完全不需要了,大大提高了补丁加载的效率。
但之前提到过,在Android KK和以下的版本,addAssetPath是不会加载资源的,必须重新构造一个新的AssetManager并加入补丁包中,再换掉原来的。那么我们不就又要和Instant Run一样,做一大堆兼容版本和反射替换的工作了吗?
对于这种情况,我们也找到了更优雅的方式,不需要如此的大费周章。
在AssetManager源码里面,有一个有趣的东西:
// AssetManager.java
…
private native final void destroy();
…
明显,这个是用来AssetManager并释放资源的函数,我们来看看它具体做了什么把。
static void android_content_AssetManager_destroy(JNIEnv* env, jobject clazz)
{
AssetManager* am = (AssetManager*) (env->GetIntField(clazz, gAssetManagerOffsets.mObject));
if (am != null) {
delete am;
env->SetIntField(clazz, gAssetManagerOffsets, 0);
}
}
结尾
- 腾讯T4级别Android架构技术脑图;查漏补缺,体系化深入学习提升
- 一线互联网Android面试题含详解(初级到高级专题)
这些题目是今年群友去腾讯、百度、小米、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。并且大多数都整理了答案,熟悉这些知识点会大大增加通过前两轮技术面试的几率
有Android开发3-5年基础,希望突破瓶颈,成为架构师的小伙伴,可以关注我
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!
ets.mObject));
if (am != null) {
delete am;
env->SetIntField(clazz, gAssetManagerOffsets, 0);
}
}
结尾
- 腾讯T4级别Android架构技术脑图;查漏补缺,体系化深入学习提升
[外链图片转存中…(img-JAdHtZRh-1715110606083)]
- 一线互联网Android面试题含详解(初级到高级专题)
这些题目是今年群友去腾讯、百度、小米、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。并且大多数都整理了答案,熟悉这些知识点会大大增加通过前两轮技术面试的几率
[外链图片转存中…(img-1zptMjRK-1715110606084)]
有Android开发3-5年基础,希望突破瓶颈,成为架构师的小伙伴,可以关注我
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!