1. 程式人生 > >動態載入資源簡析和實踐

動態載入資源簡析和實踐

本文所引用的原始碼為Android 6.0版本

Resources建立過程

getResources()呼叫過程

在Activity中我們經常使用getResources()來獲取Resources。拿到這個物件之後,我可以通過它獲取apk中的各種資源。

先看看getResources()的呼叫過程:

Activity的內部並沒有這個方法,它是Activity的父類ContextThemeWrapper的方法。
ContextThemeWrapper位置:android-6.0.0_r1/frameworks/base/core/java/android/view/ContextThemeWrapper.java

getResources()方法程式碼:

    public Resources getResources() {
        if (mResources != null) {
            return mResources;
        }
        if (mOverrideConfiguration == null) {
            mResources = super.getResources();
            return mResources;
        } else {
            ...//這種情況暫不考慮
} }

如果存在Resources物件,則直接返回。如果不存在,先呼叫了父類的方法獲取,然後返回。

super.getResources()呼叫的是父類CotextWrapper的getResources()方法。
CotextWrapper位置:android-6.0.0_r1/frameworks/base/core/java/android/content/ContextWrapper.java

    Context mBase;

    public Resources getResources()
    {
        return mBase.getResources();
    }

返回的mBase的Resource物件,mBase是ContextImpl物件,通過attachBaseContext(Context base)方法設定的。ContextImpl直接繼承了ContextWrapper。

ContextImpl的getResources()方法:

    public Resources getResources() {
        return mResources;
    }

而ContextImpl物件是在ActivityThread執行建立handleLaunchActivity(ActivityClientRecord r, Intent customIntent)
方法建立獲取啟動Activity時建立的。

Context建立

ActivityThread位置:android-6.0.0_r1/frameworks/base/core/java/android/app/ActivityThread.java
建立和設定ContextImpl物件的相關程式碼:

    private Context createBaseContextForActivity(ActivityClientRecord r, final Activity activity) {
        ...
        //建立了ContextImpl物件
        ContextImpl appContext = ContextImpl.createActivityContext(
                this, r.packageInfo, displayId, r.overrideConfig);
        appContext.setOuterContext(activity);
        Context baseContext = appContext;

        ...
        return baseContext;
    }

    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {

        ...

        try {
            ...

            if (activity != null) {
                //建立mBase
                Context appContext = createBaseContextForActivity(r, activity);
                ...
                //將值設定給建立的Activity
                activity.attach(appContext, this, getInstrumentation(), r.token,
                        r.ident, app, r.intent, r.activityInfo, title, r.parent,
                        r.embeddedID, r.lastNonConfigurationInstances, config,
                        r.referrer, r.voiceInteractor);

                ...//執行Activity的onCreate(),onStart()等方法
            }
            ...

        } catch (SuperNotCalledException e) {
            throw e;

        } catch (Exception e) {
            ...
        }

        return activity;
    }


    private void handleLaunchActivity(ActivityClientRecord r, Intent customIntent) {
        ...

        //建立和啟動Activity
        Activity a = performLaunchActivity(r, customIntent);

        ...
    }

ActivityThread內建立了ContextImpl物件,通過Activity的attach()方法將值傳遞給Activity。

Activity位置:android-6.0.0_r1/frameworks/base/core/java/android/app/Activity.java
繼續看Activity的attach()方法

    final void attach(Context context, ActivityThread aThread,
            Instrumentation instr, IBinder token, int ident,
            Application application, Intent intent, ActivityInfo info,
            CharSequence title, Activity parent, String id,
            NonConfigurationInstances lastNonConfigurationInstances,
            Configuration config, String referrer, IVoiceInteractor voiceInteractor) {
        //通過父類ContextThemeWrapper的方法將值傳遞給CotextWrapper
        attachBaseContext(context);
        ...
    }

執行attachBaseContext(context);這行程式碼後,便能在Activity中操作Context的相關方法。於是能呼叫getResources()獲取資源。
看了這麼多程式碼,但是Resources是如何被建立的並沒有介紹到。其實它是在ContextImpl.createActivityContext()方法中建立的。

Resources建立

ContextImpl位置:android-6.0.0_r1/frameworks/base/core/java/android/app/ContextImpl.java

ContextImpl.createActivityContext()方法中呼叫了私有的構造方法建立物件。

    static ContextImpl createActivityContext(ActivityThread mainThread,
            LoadedApk packageInfo, int displayId, Configuration overrideConfiguration) {
        if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
        return new ContextImpl(null, mainThread, packageInfo, null, null, false,
                null, overrideConfiguration, displayId);
    }

    private ContextImpl(ContextImpl container, ActivityThread mainThread,
            LoadedApk packageInfo, IBinder activityToken, UserHandle user, boolean restricted,
            Display display, Configuration overrideConfiguration, int createDisplayWithId) {
        ...

        Resources resources = packageInfo.getResources(mainThread);
        if (resources != null) {
            if (displayId != Display.DEFAULT_DISPLAY
                    || overrideConfiguration != null
                    || (compatInfo != null && compatInfo.applicationScale
                            != resources.getCompatibilityInfo().applicationScale)) {
                resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
                        packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(),
                        packageInfo.getApplicationInfo().sharedLibraryFiles, displayId,
                        overrideConfiguration, compatInfo);
            }
        }
        mResources = resources;

        ...
    }

LoadedApk用來記錄載入的apk的資訊。packageInfo.getResources(mainThread)這句程式碼最終還是會呼叫ResourcesManager的getTopLevelResources()方法來獲取Resources物件,與第二個if程式碼塊中呼叫的為同一個方法。

packageInfo.getResources(mainThread)呼叫的方法:

LoadedApk位置:android-6.0.0_r1/frameworks/base/core/java/android/app/LoadedApk.java

    //LoadedApk的getResources()方法
    public Resources getResources(ActivityThread mainThread) {
        if (mResources == null) {
            mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
                    mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
        }
        return mResources;
    }

    //ActivityThread的getTopLevelResources()方法
    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());
    }

如果mResources不存在,則呼叫ActivityThread的getTopLevelResources()獲取,並賦值給mResources。然後返回該值。ActivityThread的getTopLevelResources()方法內又直接呼叫了ResourcesManager的getTopLevelResources()方法。

繼續檢視ResourcesManager的getTopLevelResources()方法。
ResourcesManager位置:android-6.0.0_r1/frameworks/base/core/java/android/app/ResourcesManager.java

    Resources getTopLevelResources(String resDir, String[] splitResDirs,
            String[] overlayDirs, String[] libDirs, int displayId,
            Configuration overrideConfiguration, CompatibilityInfo compatInfo) {
        ...
        ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfigCopy, scale);
        Resources r;
        synchronized (this) {
            ...
            WeakReference<Resources> wr = mActiveResources.get(key);
            r = wr != null ? wr.get() : null;
            ...
            //如果快取中有,且沒有過期,則返回查詢到的Resources物件
            if (r != null && r.getAssets().isUpToDate()) {
                //
                return r;
            }
        }

        //建立Resosurces物件

        //首先,建立AssetManager物件,並將加resDir,splitResDirs等目錄中的資源
        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 (resDir != null) {
            //載入該目錄的資源到AssetManager物件,非0為載入成功
            if (assets.addAssetPath(resDir) == 0) {
                return null;
            }
        }
        ...//使用addXxxPath(resDir)方法載入splitResDirs,overlayDirs和libDirs目錄資源

        ...//設定Configuration等引數

        //建立Resources物件
        r = new Resources(assets, dm, config, compatInfo);


        synchronized (this) {
            //再次從快取中查詢
            WeakReference<Resources> wr = mActiveResources.get(key);
            Resources existing = wr != null ? wr.get() : null;
            //存在且沒有過期,則返回查詢到的Resources物件
            if (existing != null && existing.getAssets().isUpToDate()) {
                // Someone else already created the resources while we were
                // unlocked; go ahead and use theirs.
                //釋放AssetManager
                r.getAssets().close();
                return existing;
            }

            // XXX need to remove entries when weak references go away
            //存入快取集合
            mActiveResources.put(key, new WeakReference<>(r));
            ...
            return r;
        }
    }

先建立ResourcesKey物件,然後從快取mActiveResources查詢。如果有符合的,則直接返回。如果無,則先建立AssetManager物件,並使用該物件載入相關目錄的資源,然後在建立Resources物件。並不會直接將剛建立的Resources物件快取和返回,而是再次從快取中查詢。如果有符合的,則釋放建立的。沒有,則執行快取和返回建立的Resources物件。

在ContextImpl的構造方法內,執行Resources resources = packageInfo.getResources(mainThread);這行程式碼便能獲取到資源物件。一般情況下,if (resources != null)程式碼塊中的if語句不會進入。至此,Resources的建立過程就分析完了。

Resources獲取資源

getString()獲取字串過程

一,Resources呼叫過程

呼叫Resources的getString()方法。

    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));
    }

getString()方法內部呼叫了getText()方法,而getText()方法內部又是通過mAssets的getResourceText()方法來獲取字元序列。mAssets便是之前getTopLevelResources()方法中建立的AssetManager物件。

二,AssetManager呼叫過程

AssetManager位置:android-6.0.0_r1/frameworks/base/core/java/android/content/res/AssetManager.java

AssetManager的getResourceText()方法

    /*package*/ final CharSequence getResourceText(int ident) {
        synchronized (this) {
            TypedValue tmpValue = mValue;
            int block = loadResourceValue(ident, (short) 0, tmpValue, true);
            if (block >= 0) {
                if (tmpValue.type == TypedValue.TYPE_STRING) {
                    return mStringBlocks[block].get(tmpValue.data);
                }
                //非字串情況,暫不分析
                return tmpValue.coerceToString();
            }
        }
        return null;
    }

首先使用native方法loadResourceValue()獲取基本資訊,block為id為ident的字串在mStringBlocks陣列中的位置,tmpValue.data記錄其在StringBlock中的位置。
StringBlock中有兩個屬性來儲存字元序列:mStrings,為字元序列CharSequence[]型別;mSparseStrings,為SparseArray<CharSequence>型別。在StringBlock的get()中會先從mStrings,如果不存在,則從mSparseStrings中查詢。如果還未查詢到,則呼叫native方法nativeGetString(long obj, int idx)去獲取。

在AssetManager的makeStringBlocks()方法中對mStringBlocks進行初始化。

    /*package*/ final void makeStringBlocks(StringBlock[] seed) {
        final int seedNum = (seed != null) ? seed.length : 0;
        final int num = getStringBlockCount();
        mStringBlocks = new StringBlock[num];
        if (localLOGV) Log.v(TAG, "Making string blocks for " + this
                + ": " + num);
        for (int i=0; i<num; i++) {
            if (i < seedNum) {
                mStringBlocks[i] = seed[i];
            } else {
                mStringBlocks[i] = new StringBlock(getNativeStringBlock(i), true);
            }
        }
    }

getStringBlockCount()是native方法,用於獲取資源包中字串塊的數量,然後建立該數量的StringBlock陣列。
這個方法在addAssetPath(String path),addOverlayPath(String idmapPath)和addAssetPaths(String[] paths)方法中被呼叫,而這個三個方法又在Resources被建立時被呼叫。ResourcesManager的getTopLevelResources()方法中AssetManager物件載入resDir, splitResDirs, overlayDirs和libDirs目錄資源時呼叫到這3個addXxxPath()方法。

getDrawable()獲取圖片過程

只分析獲取png圖片,且不分析Resources中圖片快取情況。

呼叫Resources的getDrawable()

    public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
        final Drawable d = getDrawable(id, null);
        ...
        return d;
    }

    public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme) throws NotFoundException {
        ...
        final Drawable res = loadDrawable(value, id, theme);
        ...
        return res;
    }

    Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
        ...

        Drawable dr;
        if (cs != null) {
            ...
        } else if (isColorDrawable) {
            ...
        } else {
            dr = loadDrawableForCookie(value, id, null);
        }

        ...

        return dr;
    }

getDrawable()最終會呼叫到loadDrawable()方法,這裡我沒有分析從快取獲取和快取情況,只分析從AssetManager中獲取資源流。loadDrawable()中又呼叫了loadDrawableForCookie()方法。

繼續檢視loadDrawableForCookie()方法:

    private Drawable loadDrawableForCookie(TypedValue value, int id, Theme theme) {
        ...
        try {
            if (file.endsWith(".xml")) {
                ...
            } else {
                final InputStream is = mAssets.openNonAsset(
                        value.assetCookie, file, AssetManager.ACCESS_STREAMING);
                dr = Drawable.createFromResourceStream(this, value, is, file, null);
                is.close();
            }
        } catch (Exception e) {
            ...
        }
        ...

        return dr;
    }

呼叫了mAssets.openNonAsset()獲取資源流,然後將流轉換為Bitmap,再生成Drawable物件。

小結

getString()和getDrawable()獲取的資源並不由Resources提供,Resources只是對AssetManager做了一層封裝。真正獲取資源的操作是由AssetManager執行的。通過分析Resources的建立過程,我們知道可以通過addAssetPath(String path),addOverlayPath(String idmapPath)和addAssetPaths(String[] paths)這三個方法向AssetManager新增資源包,AssetManager會根據路徑去載入和解析資源包,也就是說我們可以通過這3個方法動態的載入資源。

AssetManager動態載入apk

查詢入口

先看看AssetManager中最簡單的addAssetPath()方法:

    /**
     * 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) {
        synchronized (this) {
            int res = addAssetPathNative(path);
            makeStringBlocks(mStringBlocks);
            return res;
        }
    }

Not for use by applications.好像不能用這個方法。再Activity中使用getAssets().addAssetPath(“”),編輯器會提示錯誤,沒有這個方法。其實是這個方法被隱藏(hide)了。不能直接使用,那我們就間接使用,使用反射方式來間接使用這個方法。

建立工程測試

第一步:建立一個簡單的Android工程,命名為ResDemo,包名為com.plugin.test。保留strings.xml檔案,將Activity相關的資源和java檔案全部刪除,越簡單越好。AndroidManifest.xml檔案只需要保留application資訊。然後生成apk。

第二步:建立另一個的Android工程,命名為HostDemo,包名為com.plugin。

在MainActivity新增如下程式碼:


    private void testDynamicLoadResource(){
        try{
            AssetManager assetManager = getAssets();
            Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
            if(!method.isAccessible()){
                method.setAccessible(true);
            }
            //TODO 需要改為resApk實際存放的路徑
            String resPath = "/mnt/sdcard/res.apk";
            int cook = (int) method.invoke(assetManager, resPath);
            Log.i("Test", "cook = " + cook);
            int strId = getResources().getIdentifier("app_name", "string", "com.test.res");
            Log.i("Test", "app_name = " + getString(strId) );
        }catch (Exception e){
            e.printStackTrace();
        }
    }

**path一定要改為resApk實際存放的路徑。**getResources().getIdentifier()方法中的三個測試:第一個為資源名稱;第二個為資源型別,如:string,drawable等deng;第三個為資源apk的包名。
然後再onCreate()方法中呼叫testDynamicLoadResource()方法。

執行HostApk工程,檢視結果。日誌顯示’cook = 0’,後面直接丟擲異常。
異常資訊:

Caused by: android.content.res.Resources$NotFoundException: String resource ID #0x0
at android.content.res.Resources.getText(Resources.java:343)
at android.content.res.CoollifeUIResources.getText(CoollifeUIResources.java:110)
at android.content.res.Resources.getString(Resources.java:441)
at android.content.Context.getString(Context.java:382)

出現這種情況是因為resPath與resApk實際存放的路徑不一致,導致resApk未被載入到AssetManager。因而getResources().getIdentifier(“app_name”, “string”, “com.test.res”)獲取不到資源id。
如果cook資訊不為0,也出現這種異常,則可能為getIdentifier()方法中的引數錯誤。需要檢視名稱,型別和包名是否與ResDemo工程中的一種。

修改錯誤,再次執行。日誌顯示’cook = 4’,但是app_name顯示的資訊確不是ResApk。這是因為ResDemo中R.java檔案的資源id與HostDemo的資源id相同,導致去獲取HostDemo的資源。

這個錯誤是由資源id相同導致,只能修改aapt模組,新增自定義的資源id頭。因為應用預設的資源id頭為0x7f,如果我們將ResDemo的資源id頭改為比0x7f大,則就可以正確的獲取到資源。

修改aapt模組程式碼請閱讀修改aapt和自定義資源ID,使用命令構建apk請閱讀手工構建Android應用
使用修改過的aapt檔案生成R.java檔案和資源包,然後使用命令編譯java檔案和生成dex檔案,使用ApkBuidler將資源包和dex檔案合成Apk,對Apk檔案進行簽名。
然後替換之前的apk,執行HostDemo,在列印的日誌裡app_name = ResDemo。

在成功載入ResDemo.apk後,首先通過getResources().getIdentifier()獲取資源id,然後根據id載入資源,HostDemo.apk可以獲取ResDemo的任何資源。

在ResDemo工程新增一個繼承Activity的MainActivity,新增一個佈局檔案,在drawable和mipmap目錄個新增一張圖片,再在strings檔案新增一些字串。手工構建工程,生產新的resDemo.apk。
然後,在HostDemo工程的MainActivity中獲取ResDemo新增和字串和drawable中的圖片,再獲取ResDemo中的佈局檔案並在MainActivity中顯示。執行工程。

首先看看ResDemo執行截圖:

res_apk

再看HostDemo載入resDemo.apk後,動態獲取它的資源的顯示效果截圖:
(請忽略輸入框和按鈕上的文字,它們與本文所介紹的內容無關)
host_apk
圖中兩紅色框內的資源都是從resDemo.apk中獲取。動態載入的RelativeLayout與resDemo.apk顯示除高度不同,其它都一樣。

補充

這種方式只在Android 5.0及以上版本有效。Android 4.4及以下版本即便使用addAssetPath()方法新增,也無法獲取獲取到資源id,或根據資源id獲取資源。
原因貌似是並沒有將該路徑的apk的資源包解析並新增到native的AssetManager物件中。
Android 4.4及以下版本可以通過建立新的Resources物件,在通過反射方式將新建立的Resources物件設定給Activity的Context的mResources屬性。這樣就可以獲取資源包中的資源,可以通過get Application().getResources()獲取Host中的資源,但是不能載入Host中的layout檔案,在解析佈局xml時會出現獲取資源失敗異常。

總結

動態載入資源的步驟:

一,主工程需要通過反射呼叫AssetManager動態載入子apk中的資源,先獲取子apk中的資源id,然後通過id載入該資源。

二,子工程生成的資源id必須與主工程的不同,即在生產R.java檔案和資源包時需要指定資源id的頭。這一步驟需要自己修改android原始碼中的aapt和androidfw模組程式碼,並重新編譯生成可執行的aapt檔案,使用該檔案構建子apk。