插件化-資源處理
寫的比較長,可以選擇跳過前面2節(jié),直接從0x03實例分析開始。如有錯誤,請不吝指正。
0x00 aapt編譯流程
在之前的Apk編譯打包過程分析中,我們使用了一個google提供的一個工具,aapt。主要有兩個用途,第一,在編譯代碼之前通過aapt生成R.java文件。第二,在編譯完成代碼之后,通過aapt打包所有的資源生成apk。下面我們來簡單分析一下aapt是如何進(jìn)行這兩項工作的。
aapt需要干什么?
aapt主要需要做以下這些事情:
1.編譯xml文件
編譯成二進(jìn)制的xml文件會加快運行時的解析速度。
2.生成R.java/R.txt
aapt在編譯的過程中分析收集所有的資源文件,除了assets文件夾下的文件外,其他所有的資源都會分配一個唯一的id,并寫入R文件中,這樣我們就可以通過R.xx.xxxx的方法在代碼中訪問資源了。
3.生成resource.arsc
arsc文件其實就是一份資源文件的索引,因為在資源中除了文本形式的xml文件以外還有很多非文本類型的資源,例如圖片,要獲取這些資源,我們需要通過arsc找到他們的存儲路徑。
Aapt的編譯流程
如果我們略過aapt實際運行時復(fù)雜的流程,那么資源處理可以簡化成以下過程
其中涉及到了兩個關(guān)鍵的數(shù)據(jù)結(jié)構(gòu),AaptAssets
和ResourceTable
,從源碼中可以得到這兩個結(jié)構(gòu)以及相關(guān)類的UML圖,如下:
上面的圖可能有些抽象,我們通過一個例子看一下,假設(shè)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的結(jié)構(gòu)類似下面的json字符串
接下來,aapt為每一個收集到資源分配資源id,這個過程中有以下幾個地方需要注意:
- values文件下的資源會被編譯成最終值放入resource.arsc。例如colors.xml中,
<color name="background">#2888e5</color>
會被編譯成類似[typeoffset:0x02,keyoffset:0x12,value:0xff2888e5]
這樣的三元組用來在resource.arsc中定位它的值 - 類似drawable這樣的非文本類型的資源,它的值是它在res目錄下的相對路徑,并不是它的二進(jìn)制值
- attr中的內(nèi)容會被編譯成帶有層級的結(jié)構(gòu),類似
[typeoffset:0x01,keyoffset:0x14,parentStart:0x3f,valueoffset=0x0f2]
我們還是通過上面提到的例子來看一下ResourceTable的結(jié)構(gòu),如圖
我們知道一個資源的id是一個32位整數(shù),其中前8位是packageID,之后的8位是typeID,最后16位是具體資源的ID,也就是如下的形式:
0xPPTTEEEE
其中,packageID和typeID在resources.arsc中是確實存在的,但是具體資源ID在arsc中并不存在,它的含義其實是某一個具體資源在當(dāng)前package,當(dāng)前Type下的index值。packageId的取值范圍是[0x01,0x7f],其中,0x01是系統(tǒng)資源的packageId,應(yīng)用程序的packageId始終是0x7f,這個特性需要重點關(guān)注一下,因為接下來的一些黑科技就是從這里誕生的。typeID雖然在arsc文件中真是存在,但是不要認(rèn)為它的取值是一個枚舉,它和具體資源id一樣,表示一個資源類型在當(dāng)前package下的index值,它的值是從1開始的。
分配完資源id后,接下來要對所有的xml文件進(jìn)行編譯,這里的xml文件不包括values中的文件。編譯的具體過程,這里略過不談,只簡單的分析一下思路,大致是這樣的,首先讀取一個xml文件,在內(nèi)存中,這個xml文件以一棵樹的形式存在,然后遍歷這棵樹,將所有具有資源id的屬性名和屬性值換成id,然后收集所有的字符串,寫入字符串常量池,xml中對字符串的引用全部換成對常量池的索引,之后壓平這棵將樹,最后將處理后的結(jié)構(gòu)寫入文件就完成了一個xml文件的編譯,具體的過程當(dāng)然要比這個復(fù)雜很多,但是我們要理解這樣做的原因,第一,通過字符串常量池處理所有的字符串能夠過濾重復(fù)的字符串,減少xml的體積。第二,對于有資源id的屬性名和屬性值直接替換成id之后能夠加快運行時對xml文件的解析。對xml文件的處理有點類似dex文件壓縮class文件的方式,都是抽取重復(fù)的內(nèi)容,然后通過索引來引用這些重復(fù)的內(nèi)容。
將所有的xml文件都編譯完成后,就可以輸出resources.arsc 和 R.java 文件了,由于在之前的步驟中,我們已經(jīng)將所有的資源保存在了ResourceTable這個結(jié)構(gòu)中,那么接下來只需要遍歷這個結(jié)構(gòu),按照資源出現(xiàn)的順序?qū)⒆罱K的資源id寫入R.java,然后按照arsc文件的結(jié)構(gòu)把ResourceTable中的內(nèi)容寫入文件就得到了resource.arsc文件。需要注意一點,arsc文件的結(jié)構(gòu)并不是aapt定義的,而是由Android系統(tǒng)源碼中的AssetManager定義,因為這份文件最終是由AssetManager來讀取和解析的。
0x01 resources.arsc 結(jié)構(gòu)分析
我們通過ResourceTypes.h 的定義來分析arsc的結(jié)構(gòu),為什么不通過aapt創(chuàng)建它的過程來分析呢,因為aapt的代碼實在太難讀。。。
arsc文件的結(jié)構(gòu)如圖所示
其中ResTable_typeSpec結(jié)構(gòu)描述了資源的類型,例如drawable,layout。
ResTable_type結(jié)構(gòu)描述了不同相同類型的資源在不同的維度下的配置,這里的維度指的就是Android平臺提供的資源適配機(jī)制,比如drawable-xhdpi,drawable-xxhdpi描述的是屏幕密度,layout-v19,layout-v21描述的是系統(tǒng)版本,Android系統(tǒng)一共提供了18個維度來進(jìn)行資源適配,具體的內(nèi)容可以參考文檔。AssetManager會根據(jù)實際運行時的設(shè)備信息匹配到最合適的資源。entry結(jié)構(gòu)描述的是具體的資源項,在不同的 ResTable_type
下的一組entry是同名資源在不同維度下的不同文件。理論上每一個
ResTable_type
下包含有相同個數(shù)的entry,但是實際上并不會這樣,因為我們往往只針對部分資源做了不同維度的區(qū)分,這意味著每一個type下的entry數(shù)組是不等長的,對于這樣的情況,AssetManager有一套機(jī)制來對維度進(jìn)行剪裁,具體算法可以參考文檔。
注意一點,值字符串常量池沒有根據(jù)包名進(jìn)行區(qū)分,所有包中的資源的值字符串都會進(jìn)入這個區(qū)域。而其他的區(qū)域是根據(jù)包名進(jìn)行區(qū)分的,但是很奇怪的一點是,在我的測試中,無論是使用gradle進(jìn)行構(gòu)建,還是直接使用aapt打包,都無法做到在resource.arsc中包含多個package。使用gradle時只包含一個包這個很好理解,因為gradle在調(diào)用aapt之前已經(jīng)將多個包中的資源進(jìn)行合并,aapt接受到的參數(shù)中只有一個包。但是使用aapt的--auto-overlay和--extra-packages參數(shù)依然只包含一個包,讓我很困惑,后續(xù)還會繼續(xù)閱讀aapt的代碼查找原因。
對于編譯時資源文件的處理就分析到這,省略了很多細(xì)節(jié),對具體的細(xì)節(jié)感興趣的話,可以參考老羅的系列博客,寫的很詳細(xì)。
0x02 運行時資源尋找過程
首先我們回想一下在Activity中我們是如何獲取定義在strings.xml文件中的字符串的,就是以下方法
//MainActivity.java
String str = getResources().getString(R.string.app_string_2_1);
我們來分析一下調(diào)用鏈,
首先getResources()方法是在Context中定義的抽象方法,Context的繼承關(guān)系如圖所示:
在Activity中的getResources()方法會走到ContextWrapper的實現(xiàn)上,而ContextWrapper顧名思義它只是一個包裝類,最終的調(diào)用是ContextWrapper的實際類ContextImpl中的方法。
ContextImpl中g(shù)etResources()方法返回了它的成員變量mResource,我們看一下ContextImpl的構(gòu)造函數(shù),其中mResources被第一次賦值是通過下面的函數(shù)調(diào)用
Resources resources = packageInfo.getResources(mainThread);
packageInfo是一個LoadedApk類型的參數(shù),mainThread是ActivityThread類型的參數(shù),mainThread就是當(dāng)前Apk運行的主進(jìn)程類,我們繼續(xù)看LoadedApk中的方法,
public Resources getResources(ActivityThread mainThread) {
if (mResources == null) {
mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
}
return mResources;
繼續(xù)往下走到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類型的成員變量,當(dāng)我們戳開ResourceManager的代碼時,驚喜的發(fā)現(xiàn)這個類是一個單例,然后定位到getTopLevelResources方法
這個方法有點長,我刪減了一些不太關(guān)鍵的邏輯
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對象創(chuàng)建的地方,接下來我們看獲取到Resources后如何找到對應(yīng)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));
}
注意到尋找資源的調(diào)用是有AssetManager來執(zhí)行的。這個AssetManager對象是在ResourceManager中創(chuàng)建并傳遞給Resources中的,java層的AssetManager只是Native層AssetManager的一個代理,其中初始化,獲取資源的方法都是native實現(xiàn),java層的AssetManager通過持有Native對象的內(nèi)存地址來和Native對象進(jìn)行通信。我們再來看ResourceManager中對AssetManager的使用方式,發(fā)現(xiàn)ResourceManager只為AssetManager設(shè)置了資源路徑,這個路徑實際就是Apk文件的路徑。
分析到這里,我們其實已經(jīng)找到了在運行時注入資源的方式,有兩個思路,第一,當(dāng)我們需要加載插件中的資源時,替換掉當(dāng)前Context的ContextImpl中的Resource對象。第二,由于ResourceManager是一個單例類,并且持有了當(dāng)前App的Resource緩存,那么我們直接在App啟動時手動替換掉ResourceManager中的Resource緩存,就可以在當(dāng)前App中添加插件的資源,并且全局有效。
0x03 實例分析
下面我們來實驗一下剛剛得到思路,這里我們采用第一個思路,也就是只替換當(dāng)前Context的Resource對象。
創(chuàng)建plugin
首先創(chuàng)建接口
//ILib.java
public interface ILib {
String getLibString();
}
接口的實現(xiàn)類
//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);//輸出的內(nèi)容是"111111ypp1"
}
}
創(chuàng)建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();
}
修改當(dāng)前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);
}
創(chuàng)建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);
}
資源沖突的現(xiàn)象
實際運行一下,結(jié)果如下:
呃,非常的尷尬,沒有按照我們設(shè)想的那樣輸出"111111ypp1",這是為什么呢?
猜測一下原因,我們在注入資源的時候放入了兩個path路徑,
paths[0] = getPackageResourcePath();
paths[1] = getFilesDir()+File.separator+"lib_plugin.apk";
path[0]是宿主的資源,path[1]是插件資源,假如宿主和插件的資源id相同,由于宿主的資源路徑在插件的前面,那么AssetManager會首先命中宿主的資源,于是返回了宿主的資源。
我們調(diào)整一下path的順序,驗證一下我們的猜測。
paths[1] = getPackageResourcePath();
paths[0] = getFilesDir()+File.separator+"lib_plugin.apk";
調(diào)整順序后,正確返回了插件的資源。
對比一下插件和宿主的R.java文件,我們發(fā)現(xiàn)插件中R.string.app_1_string的資源id是0x7f040000,在宿主中,同樣id對應(yīng)的資源為R.layout.abc_action_bar_title_item 它的值是res/layout/abc_action_bar_title_item,再次驗證了我們的猜測。
如何解決資源沖突
要解決資源沖突,目前有很多插件化框架都提出了自己的解決方案
- 宿主和插件隔離
我們在加載插件Activity時,只在當(dāng)前上下文注入插件的資源,這樣宿主和插件之間是完全隔離的,也就無所謂資源id沖突了。
- 通過public.xml鎖死宿主資源id
在編譯宿主時,手動指定宿主中所有資源的id,然后在編譯插件時,通過在public.xml中設(shè)定padding,避免分配到宿主的資源id
- 修改aapt,增加packageId參數(shù)
在前文的分析中,我們知道資源id是通過0xPPTTEEEE的形式指定的,如果在編譯插件資源時,指定插件的packageId不是0x7f,而是指定的值,那么即使TTEEEE重復(fù),也能保證整個資源id不重復(fù)。
- 修改resource.arsc
這種方案和第三種是同樣的原理,都是修改packageId,只是是從resources.arsc文件出發(fā)。
下面我們嘗試一下直接修改resource.arsc的方案。由于需要修改aapt生成的R.java文件,因此我們不使用gradle構(gòu)建,使用appt,javac,dx手動打包。
首先修改R.java,指定packageId為0x7e
然后修改resource.arsc中的packageId
解釋一下這幾個值的含義:
Chunk_Type 由于resource.arsc中的內(nèi)容是分塊的,chunk_type表示當(dāng)前數(shù)據(jù)塊的類型。
Header_Size 每一種類型的數(shù)據(jù)塊都有一個頭部,header_size表示當(dāng)前數(shù)據(jù)塊頭部的大小
Chunk_Size 當(dāng)前數(shù)據(jù)塊的大小
Package_ID 當(dāng)前package的ID
理論上要修改packageid不僅僅需要修改resources.arsc中的packageid,還要修改所有編譯后的xml中的相關(guān)內(nèi)容,這里我們先考慮最簡單的情況,在插件資源中只包含values類型。我們將編譯好的resource.arsc文件中的0x7f000000修改為0x7e000000。
然后按照正常的步驟打包插件。在宿主中運行,R.string.app_1_string對應(yīng)的資源id變?yōu)?x7e040002,同時正確輸出了"111111ypp1"。
0x04 總結(jié)
上述的分析其實只回答了我們一個問題,為什么我們在做插件化開發(fā)的時候,要對資源id進(jìn)行特殊的處理。除開宿主和插件隔離的方案,無論是攜程的實現(xiàn)還是Small的實現(xiàn),都采用了手動分配PP段,保證資源id不重復(fù)。
我對這個問題的理解是這樣的,宿主和插件,插件和插件之間進(jìn)行資源共享對于插件化開發(fā)而言并不是必須的,假設(shè)宿主和插件之間隔離,帶來的問題是同樣的資源會在多個插件中重復(fù)出現(xiàn),導(dǎo)致應(yīng)用的整體體積膨脹,但是我們可以規(guī)避復(fù)雜的資源id處理部分,不做,就不會有bug。
如果我們整體采用Small作為我們的插件化框架,就必然要接受Small對于所有插件共享同一個classloader,共享同一個AssetManager的方案,同時要接受Small提供的一系列資源處理的gradle插件,那么Small會做的處理就不僅僅是分配PP段,同時還會對插件進(jìn)行資源裁剪,過濾掉重復(fù)的資源,對依賴裁剪,過濾掉重復(fù)的依賴,這些事情當(dāng)然是非常好的,但是作為一個新的開源框架,直接應(yīng)用到生產(chǎn)環(huán)境是有風(fēng)險的,我們要對Small的代碼有足夠深入的理解才能保證在出bug時能夠及時修復(fù)。
綜上,我建議前期我們的插件化開發(fā)可以采用宿主和插件隔離的方案規(guī)避資源id的問題,把重點放在插件間的通信上,同時確保能夠兼容nuwa的熱修復(fù)框架以及deepLink的schema跳轉(zhuǎn)。這樣的方案我們有可能遇到以下的問題,
- 根據(jù)Small的wiki,有可能會遇到Activity 主題相關(guān)的一系列問題,這個我需要嘗試一下。
- 從現(xiàn)有代碼剝離插件的時候需要將模塊自有的資源和依賴的公共資源一起剝離到插件工程。這個我可以嘗試通過腳本分析代碼對資源的引用關(guān)系,一定程度上做到自動化。
- apk體積膨脹。由于公共資源在插件中被多次打包,會帶來apk體積膨脹的問題,這個問題在這種方案下是必然的,也許只能接受。