Android外掛化原理和實踐 (四) 之 合併外掛中的資源
我們繼續來學習Android外掛化相關知識,還是要圍繞著三個根本問題來展開。在前面兩章中已經講解過第一個根本問題:在宿主中如何去載入外掛以及呼叫外掛中類和元件程式碼。Demo中使用了Service來演示,因為還沒有解決載入外掛中資源的問題,用Activity不好展示。所以本文將要從資源的載入機制講起,然後進一步介紹AssetManager類,最後就是為解決第二個根本問題,就是在宿主載入外掛後如何解決資源讀取問題做準備。
1 資源載入機制
1.1 資源分類
Android中資原始檔分為兩類:
一類是在res目錄下存放的可編譯資原始檔。比如layout、drawable、string等,它們在編譯時會被系統自動在R.java中生成資原始檔的十六進位制值。所以訪問此類資源,如要獲取一個字串,那就使用程式碼:
String str = context.getResources().getString(R.string.XX);即可。
而另一類是assets目錄下存放的原始資原始檔。Apk在編譯時不會編譯此類資原始檔,要訪問此類資源只能通過AssetManager類open方法來獲取,AssetManager類又可以通過context.getResource().getAssets()方法獲取。所以歸根到底還是離不開Resource類。
1.2 Resources和 AssetManager
我們比較熟悉的Resources類對外的提供getLayout、getDrawable、getString等方法,其實都是間接呼叫了AssetManager類的方法,然後AssetManager再向系統讀取資源。
就拿context.getResource().getString方法來看:
Resources.java
public String getString(@StringRes int id) throws NotFoundException { return getText(id).toString(); } public CharSequence getText(@StringRes int id) throws NotFoundException { CharSequence res = mResourcesImpl.getAssets().getResourceText(id); if (res != null) { return res; } throw new NotFoundException("String resource ID #0x" + Integer.toHexString(id)); }
在程式碼中mResourcesImpl.getAssets()返回的就是AssetManager類物件。
AssetManager如此重要,那就來看看它到底是何方神聖。其實在App啟動時會進行載入資源,在那時會把當前apk路徑傳入給AssetManager類的addAssetPath方法,然後經過AssetManager內部的NDK方法和一系列邏輯後,AssetManager和Resources就能夠訪問當前apk的所有資源,因為apk打包時會在R.java中生成一個十六進位制值,和生成一個resources.arsc檔案,此檔案是一個Hash表,對資源的十六進位制值是對應的。其流程可見《Android應用程式啟動詳解(二)之Application和Activity的啟動過程》中介紹的建立Applicatoin過程中有下面程式碼:
LoadedApk.java
public Application makeApplication(boolean forceDefaultAppClass, Instrumentation instrumentation) {
……
try {
……
// 關鍵程式碼1
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
// 關鍵程式碼2
app = mActivityThread.mInstrumentation.newApplication(cl, appClass, appContext);
appContext.setOuterContext(app);
} catch (Exception e) {
……
}
……
}
我們當時只往關鍵程式碼2中往下介紹,今天就在此補充關鍵程式碼1中的資源載入流程,請往下看程式碼:
ContextImpl.java
static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo) {
if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, 0,
null);
// 關鍵程式碼
context.setResources(packageInfo.getResources());
return context;
}
這裡有程式碼:context.setResources(),所以我們平時可以通過context.getResources()來獲得資源,繼續看setResources方法中的引數
LoadedApk.java
public Resources getResources() {
if (mResource s == null) {
……
// 關鍵程式碼
mResources = ResourcesManager.getInstance().getResources(null, mResDir,
splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),
getClassLoader());
}
return mResources;
}
這裡又回到了LoaderApk類,繼續往下看關鍵程式碼
ResourcesManager.java
public @Nullable Resources getResources(@Nullable IBinder activityToken,
@Nullable String resDir,
@Nullable String[] splitResDirs,
@Nullable String[] overlayDirs,
@Nullable String[] libDirs,
int displayId,
@Nullable Configuration overrideConfig,
@NonNull CompatibilityInfo compatInfo,
@Nullable ClassLoader classLoader) {
try {
Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesManager#getResources");
final ResourcesKey key = new ResourcesKey(
resDir,
splitResDirs,
overlayDirs,
libDirs,
displayId,
overrideConfig != null ? new Configuration(overrideConfig) : null, // Copy
compatInfo);
classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
// 關鍵程式碼
return getOrCreateResources(activityToken, key, classLoader);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
}
}
從關鍵程式碼行中呼叫的getOrCreateResources方法名可猜到,資源的獲取或建立就是此方法,繼續往下看
private @Nullable Resources getOrCreateResources(@Nullable IBinder activityToken,
@NonNull ResourcesKey key, @NonNull ClassLoader classLoader) {
……
// 關鍵程式碼1
// If we're here, we didn't find a suitable ResourcesImpl to use, so create one now.
ResourcesImpl resourcesImpl = createResourcesImpl(key);
if (resourcesImpl == null) {
return null;
}
synchronized (this) {
……
final Resources resources;
if (activityToken != null) {
resources = getOrCreateResourcesForActivityLocked(activityToken, classLoader,
resourcesImpl, key.mCompatInfo);
} else {
// 關鍵程式碼2
resources = getOrCreateResourcesLocked(classLoader, resourcesImpl, key.mCompatInfo);
}
return resources;
}
}
這裡先提一下關鍵程式碼2,因為最終 Resources 的建立邏就在裡頭:
private @NonNull Resources getOrCreateResourcesLocked(@NonNull ClassLoader classLoader,
@NonNull ResourcesImpl impl, @NonNull CompatibilityInfo compatInfo) {
……
// Create a new Resources reference and use the existing ResourcesImpl object.
Resources resources = compatInfo.needsCompatResources() ? new CompatResources(classLoader)
: new Resources(classLoader);
resources.setImpl(impl);
……
return resources;
}
可能看到程式碼中通過了new CompatResources或者 new Resources來建立了Resources物件。現在我們回頭來看看getOrCreateResources方法的關鍵程式碼1:
private @Nullable ResourcesImpl createResourcesImpl(@NonNull ResourcesKey key) {
final DisplayAdjustments daj = new DisplayAdjustments(key.mOverrideConfiguration);
daj.setCompatibilityInfo(key.mCompatInfo);
// 關鍵程式碼
final AssetManager assets = createAssetManager(key);
……
return impl;
}
protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
AssetManager assets = new AssetManager();
// resDir can be null if the 'android' package is creating a new Resources object.
// This is fine, since each AssetManager automatically loads the 'android' package
// already.
if (key.mResDir != null) {
// 關鍵程式碼
if (assets.addAssetPath(key.mResDir) == 0) {
Log.e(TAG, "failed to add asset path " + key.mResDir);
return null;
}
}
if (key.mSplitResDirs != null) {
for (final String splitResDir : key.mSplitResDirs) {
// 關鍵程式碼
if (assets.addAssetPath(splitResDir) == 0) {
Log.e(TAG, "failed to add split asset path " + splitResDir);
return null;
}
}
}
if (key.mOverlayDirs != null) {
for (final String idmapPath : key.mOverlayDirs) {
assets.addOverlayPath(idmapPath);
}
}
if (key.mLibDirs != null) {
for (final String libDir : key.mLibDirs) {
if (libDir.endsWith(".apk")) {
// 關鍵程式碼
// Avoid opening files we know do not have resources,
// like code-only .jar files.
if (assets.addAssetPathAsSharedLibrary(libDir) == 0) {
Log.w(TAG, "Asset path '" + libDir +
"' does not exist or contains no resources.");
}
}
}
}
return assets;
}
流程到這就能看到,最終是呼叫了AssetManager類的addAssetPath方法傳入各種資源目錄來對其進行載入:
AssetManager.java
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
return addAssetPathInternal(path, false);
}
到這就不打算繼續往下看原始碼了,因為已經達到了我們的目的。addAssetPath方法是一個hide不對外開放的方法,先來翻譯一下它的註釋,意思大概是:新增一組額外的資源,可以是目錄或zip檔案。從註釋意思我們就更加確定addAssetPath方法就是我們要找的新增外掛資源的方法。雖然此方法是不對外開放,但是我們可以通過反射來把外掛apk的路徑傳入這個方法,那麼就可以把外掛中資添也新增到這個資源池中去了。
2 合併外掛中的資源
既然已經清楚了App資源的載入機制,現在就來看看反射這個過程應該怎樣做了。所以步驟如下:
- 建立一個新的 AssetManager 物件,並將宿主和外掛的資源都能過addAssetPath方法塞入
- 通過新的AssetManager物件來創建出一個新的Resources物件
- 將新的Resources物件替換ContextImpl中的mResources變數、LoadedApk變數裡的mResources變數 以及 至空mThem變數
所以按照上述步驟通過程式碼實現如下:
private static void loadPluginResources(Application application, String apkName)
throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException, NoSuchFieldException {
// 建立一個新的 AssetManager 物件
AssetManager newAssetManagerObj = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
// 塞入原來宿主的資源
addAssetPath.invoke(newAssetManagerObj, application.getBaseContext().getPackageResourcePath());
// 塞入外掛的資源
File optDexFile = application.getBaseContext().getFileStreamPath(apkName);
addAssetPath.invoke(newAssetManagerObj, optDexFile.getAbsolutePath());
// ----------------------------------------------
// 建立一個新的 Resources 物件
Resources newResourcesObj = new Resources(newAssetManagerObj,
application.getBaseContext().getResources().getDisplayMetrics(),
application.getBaseContext().getResources().getConfiguration());
// ----------------------------------------------
// 獲取 ContextImpl 中的 Resources 型別的 mResources 變數,並替換它的值為新的 Resources 物件
Field resourcesField = application.getBaseContext().getClass().getDeclaredField("mResources");
resourcesField.setAccessible(true);
resourcesField.set(application.getBaseContext(), newResourcesObj);
// ----------------------------------------------
// 獲取 ContextImpl 中的 LoadedApk 型別的 mPackageInfo 變數
Field packageInfoField = application.getBaseContext().getClass().getDeclaredField("mPackageInfo");
packageInfoField.setAccessible(true);
Object packageInfoObj = packageInfoField.get(application.getBaseContext());
// 獲取 mPackageInfo 變數物件中類的 Resources 型別的 mResources 變數,,並替換它的值為新的 Resources 物件
// 注意:這是最主要的需要替換的,如果不需要支援外掛執行時更新,只留這一個就可以了
Field resourcesField2 = packageInfoObj.getClass().getDeclaredField("mResources");
resourcesField2.setAccessible(true);
resourcesField2.set(packageInfoObj, newResourcesObj);
// ----------------------------------------------
// 獲取 ContextImpl 中的 Resources.Theme 型別的 mTheme 變數,並至空它
// 注意:清理mTheme物件,否則通過inflate方式載入資源會報錯, 如果是activity動態載入外掛,則需要把activity的mTheme物件也設定為null
Field themeField = application.getBaseContext().getClass().getDeclaredField("mTheme");
themeField.setAccessible(true);
themeField.set(application.getBaseContext(), null);
}
上述方法的呼叫時機就是在執行完外掛dex合併後呼叫即可,但是你會發現,就算呼叫了此方法後使得外掛中的資源能和宿主中的資源合併成功了,但還是沒有解決到問題。知道為什麼啊?那是因為宿主和外掛的資源id衝突了。關於如何解決資源id衝突的問題,我們留到下一遍文章來解決。