插件化-资源处理
写的比较长,可以选择跳过前面2节,直接从0x03实例分析开始。如有错误,请不吝指正。
0x00 aapt编译流程
在之前的Apk编译打包过程分析中,我们使用了一个google提供的一个工具,aapt。主要有两个用途,第一,在编译代码之前通过aapt生成R.java文件。第二,在编译完成代码之后,通过aapt打包所有的资源生成apk。下面我们来简单分析一下aapt是如何进行这两项工作的。
aapt需要干什么?
aapt主要需要做以下这些事情:
1.编译xml文件
编译成二进制的xml文件会加快运行时的解析速度。
2.生成R.java/R.txt
aapt在编译的过程中分析收集所有的资源文件,除了assets文件夹下的文件外,其他所有的资源都会分配一个唯一的id,并写入R文件中,这样我们就可以通过R.xx.xxxx的方法在代码中访问资源了。
3.生成resource.arsc
arsc文件其实就是一份资源文件的索引,因为在资源中除了文本形式的xml文件以外还有很多非文本类型的资源,例如图片,要获取这些资源,我们需要通过arsc找到他们的存储路径。
Aapt的编译流程
如果我们略过aapt实际运行时复杂的流程,那么资源处理可以简化成以下过程
其中涉及到了两个关键的数据结构,AaptAssets
和ResourceTable
,从源码中可以得到这两个结构以及相关类的UML图,如下:
上面的图可能有些抽象,我们通过一个例子看一下,假设res
目录下有一下资源:
-drawable
-a.png
-drawable-xhdpi
-a.png
-drawable-xxhdpi
-a.png
-layout
-main_layout.xml
-detail_layout.xml
-values
-attrs.xml
-strings.xml
那么,在收集资源阶段AaptAssets的结构类似下面的json字符串
AaptAssets的结构示意图接下来,aapt为每一个收集到资源分配资源id,这个过程中有以下几个地方需要注意:
- values文件下的资源会被编译成最终值放入resource.arsc。例如colors.xml中,
<color name="background">#2888e5</color>
会被编译成类似[typeoffset:0x02,keyoffset:0x12,value:0xff2888e5]
这样的三元组用来在resource.arsc中定位它的值 - 类似drawable这样的非文本类型的资源,它的值是它在res目录下的相对路径,并不是它的二进制值
- attr中的内容会被编译成带有层级的结构,类似
[typeoffset:0x01,keyoffset:0x14,parentStart:0x3f,valueoffset=0x0f2]
我们还是通过上面提到的例子来看一下ResourceTable的结构,如图
ResourceTable的结构示意图我们知道一个资源的id是一个32位整数,其中前8位是packageID,之后的8位是typeID,最后16位是具体资源的ID,也就是如下的形式:
0xPPTTEEEE
其中,packageID和typeID在resources.arsc中是确实存在的,但是具体资源ID在arsc中并不存在,它的含义其实是某一个具体资源在当前package,当前Type下的index值。packageId的取值范围是[0x01,0x7f],其中,0x01是系统资源的packageId,应用程序的packageId始终是0x7f,这个特性需要重点关注一下,因为接下来的一些黑科技就是从这里诞生的。typeID虽然在arsc文件中真是存在,但是不要认为它的取值是一个枚举,它和具体资源id一样,表示一个资源类型在当前package下的index值,它的值是从1开始的。
分配完资源id后,接下来要对所有的xml文件进行编译,这里的xml文件不包括values中的文件。编译的具体过程,这里略过不谈,只简单的分析一下思路,大致是这样的,首先读取一个xml文件,在内存中,这个xml文件以一棵树的形式存在,然后遍历这棵树,将所有具有资源id的属性名和属性值换成id,然后收集所有的字符串,写入字符串常量池,xml中对字符串的引用全部换成对常量池的索引,之后压平这棵将树,最后将处理后的结构写入文件就完成了一个xml文件的编译,具体的过程当然要比这个复杂很多,但是我们要理解这样做的原因,第一,通过字符串常量池处理所有的字符串能够过滤重复的字符串,减少xml的体积。第二,对于有资源id的属性名和属性值直接替换成id之后能够加快运行时对xml文件的解析。对xml文件的处理有点类似dex文件压缩class文件的方式,都是抽取重复的内容,然后通过索引来引用这些重复的内容。
将所有的xml文件都编译完成后,就可以输出resources.arsc 和 R.java 文件了,由于在之前的步骤中,我们已经将所有的资源保存在了ResourceTable这个结构中,那么接下来只需要遍历这个结构,按照资源出现的顺序将最终的资源id写入R.java,然后按照arsc文件的结构把ResourceTable中的内容写入文件就得到了resource.arsc文件。需要注意一点,arsc文件的结构并不是aapt定义的,而是由Android系统源码中的AssetManager定义,因为这份文件最终是由AssetManager来读取和解析的。
0x01 resources.arsc 结构分析
我们通过ResourceTypes.h 的定义来分析arsc的结构,为什么不通过aapt创建它的过程来分析呢,因为aapt的代码实在太难读。。。
arsc文件的结构如图所示
arsc文件结构其中ResTable_typeSpec结构描述了资源的类型,例如drawable,layout。
ResTable_type结构描述了不同相同类型的资源在不同的维度下的配置,这里的维度指的就是Android平台提供的资源适配机制,比如drawable-xhdpi,drawable-xxhdpi描述的是屏幕密度,layout-v19,layout-v21描述的是系统版本,Android系统一共提供了18个维度来进行资源适配,具体的内容可以参考文档。AssetManager会根据实际运行时的设备信息匹配到最合适的资源。entry结构描述的是具体的资源项,在不同的 ResTable_type
下的一组entry是同名资源在不同维度下的不同文件。理论上每一个
ResTable_type
下包含有相同个数的entry,但是实际上并不会这样,因为我们往往只针对部分资源做了不同维度的区分,这意味着每一个type下的entry数组是不等长的,对于这样的情况,AssetManager有一套机制来对维度进行剪裁,具体算法可以参考文档。
注意一点,值字符串常量池没有根据包名进行区分,所有包中的资源的值字符串都会进入这个区域。而其他的区域是根据包名进行区分的,但是很奇怪的一点是,在我的测试中,无论是使用gradle进行构建,还是直接使用aapt打包,都无法做到在resource.arsc中包含多个package。使用gradle时只包含一个包这个很好理解,因为gradle在调用aapt之前已经将多个包中的资源进行合并,aapt接受到的参数中只有一个包。但是使用aapt的--auto-overlay和--extra-packages参数依然只包含一个包,让我很困惑,后续还会继续阅读aapt的代码查找原因。
对于编译时资源文件的处理就分析到这,省略了很多细节,对具体的细节感兴趣的话,可以参考老罗的系列博客,写的很详细。
0x02 运行时资源寻找过程
首先我们回想一下在Activity中我们是如何获取定义在strings.xml文件中的字符串的,就是以下方法
//MainActivity.java
String str = getResources().getString(R.string.app_string_2_1);
我们来分析一下调用链,
首先getResources()方法是在Context中定义的抽象方法,Context的继承关系如图所示:
在Activity中的getResources()方法会走到ContextWrapper的实现上,而ContextWrapper顾名思义它只是一个包装类,最终的调用是ContextWrapper的实际类ContextImpl中的方法。
ContextImpl中getResources()方法返回了它的成员变量mResource,我们看一下ContextImpl的构造函数,其中mResources被第一次赋值是通过下面的函数调用
Resources resources = packageInfo.getResources(mainThread);
packageInfo是一个LoadedApk类型的参数,mainThread是ActivityThread类型的参数,mainThread就是当前Apk运行的主进程类,我们继续看LoadedApk中的方法,
public Resources getResources(ActivityThread mainThread) {
if (mResources == null) {
mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
}
return mResources;
继续往下走到AcitvityThread中,
/**
* Creates the top level resources for the given package.
*/
Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs,
String[] libDirs, int displayId, Configuration overrideConfiguration,
LoadedApk pkgInfo) {
return mResourcesManager.getTopLevelResources(resDir, splitResDirs, overlayDirs, libDirs,
displayId, overrideConfiguration, pkgInfo.getCompatibilityInfo(), null);
mResourceManager是一个ResourceManager类型的成员变量,当我们戳开ResourceManager的代码时,惊喜的发现这个类是一个单例,然后定位到getTopLevelResources方法
这个方法有点长,我删减了一些不太关键的逻辑
ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfiguration, scale, token);
Resources r;
WeakReference<Resources> wr = mActiveResources.get(key);
r = wr != null ? wr.get() : null;
if (r != null && r.getAssets().isUpToDate()) {
return r;
}
AssetManager assets = new AssetManager();
if (resDir != null) {
if (assets.addAssetPath(resDir) == 0) {
return null;
}
}
r = new Resources(assets, dm, config, compatInfo, token);
WeakReference<Resources> wr = mActiveResources.get(key);
Resources existing = wr != null ? wr.get() : null;
if (existing != null && existing.getAssets().isUpToDate()) {
r.getAssets().close();
return existing;
}
mActiveResources.put(key, new WeakReference<Resources>(r));
return r;
最终我们找到了Resources对象创建的地方,接下来我们看获取到Resources后如何找到对应id的资源,在Resources中定位到getString(int id)方法:
@NonNull
public String getString(@StringRes int id) throws NotFoundException {
final CharSequence res = getText(id);
if (res != null) {
return res.toString();
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}
接着往下,
public CharSequence getText(@StringRes int id) throws NotFoundException {
CharSequence res = mAssets.getResourceText(id);
if (res != null) {
return res;
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}
注意到寻找资源的调用是有AssetManager来执行的。这个AssetManager对象是在ResourceManager中创建并传递给Resources中的,java层的AssetManager只是Native层AssetManager的一个代理,其中初始化,获取资源的方法都是native实现,java层的AssetManager通过持有Native对象的内存地址来和Native对象进行通信。我们再来看ResourceManager中对AssetManager的使用方式,发现ResourceManager只为AssetManager设置了资源路径,这个路径实际就是Apk文件的路径。
分析到这里,我们其实已经找到了在运行时注入资源的方式,有两个思路,第一,当我们需要加载插件中的资源时,替换掉当前Context的ContextImpl中的Resource对象。第二,由于ResourceManager是一个单例类,并且持有了当前App的Resource缓存,那么我们直接在App启动时手动替换掉ResourceManager中的Resource缓存,就可以在当前App中添加插件的资源,并且全局有效。
0x03 实例分析
下面我们来实验一下刚刚得到思路,这里我们采用第一个思路,也就是只替换当前Context的Resource对象。
创建plugin
首先创建接口
//ILib.java
public interface ILib {
String getLibString();
}
接口的实现类
//LibComponent.java
public class LibComponent implements ILib{
private Context context;
public LibComponent(Context context){
this.context = context;
}
@Override
public String getLibString() {
Log.e("lib_plugin",Integer.toHexString(R.string.app_1_string));
return context.getString(R.string.app_1_string);//输出的内容是"111111ypp1"
}
}
创建host
App启动时将Asset目录下的插件拷贝到App的存储目录
//HostApplication.java
private void installPluginApk(){
new Thread(new Runnable() {
@Override
public void run() {
try {
InputStream inputStream = getResources().getAssets().open("lib_plugin.apk");
File apkFile = new File(getFilesDir(),"lib_plugin.apk");
OutputStream out = new FileOutputStream(apkFile);
byte[] buf = new byte[1024];
int len;
while((len=inputStream.read(buf))>0){
out.write(buf,0,len);
}
inputStream.close();
out.close();
installSuccess.set(true);
}catch (IOException e){
e.printStackTrace();
installSuccess.set(false);
}
}
}).start();
}
修改当前Context的Resource,注入插件的资源
//MainActivity.java
private void injectContextResource(){
AssetManager assetManager = ReflectAccelerator.newAssetManager();
String[] paths = new String[2];
paths[0] = getPackageResourcePath();
paths[1] = getFilesDir()+File.separator+"lib_plugin.apk";
ReflectAccelerator.addAssetPaths(assetManager,paths);
Resources base = getResources();
DisplayMetrics displayMetrics = base.getDisplayMetrics();
Configuration configuration = base.getConfiguration();
Resources injectResource = new Resources(
assetManager,
displayMetrics,
configuration
);
ReflectAccelerator.setResources(getBaseContext(),injectResource);
}
创建ClassLoader加载插件中的类
//MainActivity.java
private String getLibText(){
File source = new File(getFilesDir()+File.separator+"lib_plugin.apk");
DexClassLoader cl = new DexClassLoader(
source.getAbsolutePath(),
this.getCacheDir().getPath(),
null,
getClassLoader()
);
Class libPlugin = null;
//省略异常处理
libPlugin = cl.loadClass("com.haizhi.oa.restest.LibComponent");
Class[] paramTypes = new Class[]{
Context.class
};
Constructor constructor = libPlugin.getConstructor(paramTypes);
injectContextResource();//注入资源
ILib iLib = (ILib)constructor.newInstance(getBaseContext());
String res = iLib.getLibString();
return res;
}
反射工具类
//ReflectAccelerator.java
public static AssetManager newAssetManager() {
AssetManager assets;
try {
assets = AssetManager.class.newInstance();
} catch (InstantiationException e1) {
e1.printStackTrace();
return null;
} catch (IllegalAccessException e1) {
e1.printStackTrace();
return null;
}
return assets;
}
public static int[] addAssetPaths(AssetManager assets, String[] paths) {
if (sAssetManager_addAssetPaths_method == null) {
sAssetManager_addAssetPaths_method = getMethod(AssetManager.class,
"addAssetPaths", new Class[]{String[].class});
}
if (sAssetManager_addAssetPaths_method == null) return null;
return invoke(sAssetManager_addAssetPaths_method, assets, new Object[]{paths});
}
public static void setResources(Context context, Resources resources) {
if (sContextImpl_mResources_field == null) {
sContextImpl_mResources_field = getDeclaredField(
context.getClass(), "mResources");
if (sContextImpl_mResources_field == null) return;
}
setValue(sContextImpl_mResources_field, context, resources);
}
资源冲突的现象
实际运行一下,结果如下:
呃,非常的尴尬,没有按照我们设想的那样输出"111111ypp1",这是为什么呢?
猜测一下原因,我们在注入资源的时候放入了两个path路径,
paths[0] = getPackageResourcePath();
paths[1] = getFilesDir()+File.separator+"lib_plugin.apk";
path[0]是宿主的资源,path[1]是插件资源,假如宿主和插件的资源id相同,由于宿主的资源路径在插件的前面,那么AssetManager会首先命中宿主的资源,于是返回了宿主的资源。
我们调整一下path的顺序,验证一下我们的猜测。
paths[1] = getPackageResourcePath();
paths[0] = getFilesDir()+File.separator+"lib_plugin.apk";
调整顺序后,正确返回了插件的资源。
对比一下插件和宿主的R.java文件,我们发现插件中R.string.app_1_string的资源id是0x7f040000,在宿主中,同样id对应的资源为R.layout.abc_action_bar_title_item 它的值是res/layout/abc_action_bar_title_item,再次验证了我们的猜测。
如何解决资源冲突
要解决资源冲突,目前有很多插件化框架都提出了自己的解决方案
- 宿主和插件隔离
我们在加载插件Activity时,只在当前上下文注入插件的资源,这样宿主和插件之间是完全隔离的,也就无所谓资源id冲突了。
- 通过public.xml锁死宿主资源id
在编译宿主时,手动指定宿主中所有资源的id,然后在编译插件时,通过在public.xml中设定padding,避免分配到宿主的资源id
- 修改aapt,增加packageId参数
在前文的分析中,我们知道资源id是通过0xPPTTEEEE的形式指定的,如果在编译插件资源时,指定插件的packageId不是0x7f,而是指定的值,那么即使TTEEEE重复,也能保证整个资源id不重复。
- 修改resource.arsc
这种方案和第三种是同样的原理,都是修改packageId,只是是从resources.arsc文件出发。
下面我们尝试一下直接修改resource.arsc的方案。由于需要修改aapt生成的R.java文件,因此我们不使用gradle构建,使用appt,javac,dx手动打包。
首先修改R.java,指定packageId为0x7e
然后修改resource.arsc中的packageId
解释一下这几个值的含义:
Chunk_Type 由于resource.arsc中的内容是分块的,chunk_type表示当前数据块的类型。
Header_Size 每一种类型的数据块都有一个头部,header_size表示当前数据块头部的大小
Chunk_Size 当前数据块的大小
Package_ID 当前package的ID
理论上要修改packageid不仅仅需要修改resources.arsc中的packageid,还要修改所有编译后的xml中的相关内容,这里我们先考虑最简单的情况,在插件资源中只包含values类型。我们将编译好的resource.arsc文件中的0x7f000000修改为0x7e000000。
然后按照正常的步骤打包插件。在宿主中运行,R.string.app_1_string对应的资源id变为0x7e040002,同时正确输出了"111111ypp1"。
0x04 总结
上述的分析其实只回答了我们一个问题,为什么我们在做插件化开发的时候,要对资源id进行特殊的处理。除开宿主和插件隔离的方案,无论是携程的实现还是Small的实现,都采用了手动分配PP段,保证资源id不重复。
我对这个问题的理解是这样的,宿主和插件,插件和插件之间进行资源共享对于插件化开发而言并不是必须的,假设宿主和插件之间隔离,带来的问题是同样的资源会在多个插件中重复出现,导致应用的整体体积膨胀,但是我们可以规避复杂的资源id处理部分,不做,就不会有bug。
如果我们整体采用Small作为我们的插件化框架,就必然要接受Small对于所有插件共享同一个classloader,共享同一个AssetManager的方案,同时要接受Small提供的一系列资源处理的gradle插件,那么Small会做的处理就不仅仅是分配PP段,同时还会对插件进行资源裁剪,过滤掉重复的资源,对依赖裁剪,过滤掉重复的依赖,这些事情当然是非常好的,但是作为一个新的开源框架,直接应用到生产环境是有风险的,我们要对Small的代码有足够深入的理解才能保证在出bug时能够及时修复。
综上,我建议前期我们的插件化开发可以采用宿主和插件隔离的方案规避资源id的问题,把重点放在插件间的通信上,同时确保能够兼容nuwa的热修复框架以及deepLink的schema跳转。这样的方案我们有可能遇到以下的问题,
- 根据Small的wiki,有可能会遇到Activity 主题相关的一系列问题,这个我需要尝试一下。
- 从现有代码剥离插件的时候需要将模块自有的资源和依赖的公共资源一起剥离到插件工程。这个我可以尝试通过脚本分析代码对资源的引用关系,一定程度上做到自动化。
- apk体积膨胀。由于公共资源在插件中被多次打包,会带来apk体积膨胀的问题,这个问题在这种方案下是必然的,也许只能接受。
网友评论
但是我测试的结果是先path[1],然后是path[0],我再添加了一个apk插件,然后先找path[2],请问一下你这里是怎么得出结论的?