Android熱修復(fù)技術(shù)初探(三):動態(tài)加載外部資源

前面已經(jīng)介紹了Android平臺上的幾種ClassLoader,這幾種ClassLoader都有各自的使用場景,有了這些基礎(chǔ)知識之后,才能更好地理解以及探究Android熱修復(fù)技術(shù)。首先我們來探究怎么動態(tài)加載外部資源。

1. 動態(tài)加載外部資源

在Android中,資源文件一般指定義在res資源文件夾中的各種文件,常用到的有字符串資源strings.xml、顏色資源colors.xml、drawable文件等。動態(tài)加載外部資源的目標(biāo),是從一個外部的apk文件中加載資源文件,該apk文件可以是從網(wǎng)絡(luò)下載的,可以是存在于手機(jī)存儲目錄中的等等。

可以想象這樣一種使用場景,當(dāng)你的APP需要具有換膚功能,用戶只需要下載符合你規(guī)范的apk文件(包含皮膚的資源圖片文件等),使用動態(tài)加載資源的方式,加載你下載的apk文件中的資源圖片文件,就能輕松實現(xiàn)換膚功能,這樣用戶不需要升級APP,只需要下載他喜歡的皮膚apk文件就可以了,極大地提高了應(yīng)用的靈活性。

2. 實現(xiàn)思路

PathClassLoader只能加載手機(jī)里已經(jīng)安裝的apk文件,只有DexClassLoader能加載任意目錄(有讀寫權(quán)限)的apk文件。所以我們考慮先使用DexClassLoader來加載外部的apk文件,再通過該ClassLoader去加載特定的類,最后通過反射來調(diào)用類里的方法,從而獲取外部資源

3. 實現(xiàn)案例

首先,我們需要有2個工程:一個是宿主工程,用來加載外部資源;另一個是插件工程,用來提供外部資源。

3.1 插件工程

我們定義一個字符串資源、一個顏色資源、一個圖片資源,然后創(chuàng)建一個類來讀取這些資源。

  1. 字符串資源定義
<string name="content_plugin">插件APK資源里的文本內(nèi)容</string>
  1. 顏色資源定義
<color name="color_from_plugin">#66</color>
  1. 在圖片文件夾里放一個名為test.png的圖片
  2. 創(chuàng)建讀取資源文件的類及方法
package com.hjy.plugin;
import android.content.Context;
import android.graphics.drawable.Drawable;

public class Utils {

    /**
     * 直接返回文本字符串
     *
     * @return
     */
    public static String getTextFromPlugin() {
        return "插件APK類里的文本內(nèi)容";
    }

    /**
     * 讀取資源文件里的文本字符串
     *
     * @param context
     * @return
     */
    public static String getTextFromPluginRes(Context context) {
        return context.getResources().getString(R.string.content_plugin);
    }

    public static Drawable getDrawableFromPlugin(Context context) {
        return context.getResources().getDrawable(R.mipmap.test);
    }

    public static int getColorFromPlugin(Context context) {
        return context.getResources().getColor(R.color.color_from_plugin);
    }

}

該類提供了幾個靜態(tài)方法,分別來讀取包里的字符串、顏色、圖片。

編譯好該插件工程后,我們將生成的apk文件命名為plugin-debug.apk,將該apk文件復(fù)制到手機(jī)SD卡根目錄,可使用命令"adb push plugin-debug.apk /mnt/sdcard/plugin-debug.apk
"
。不一定要放到SD卡根目錄,可以是手機(jī)上的任何存儲目錄,只要具有讀寫權(quán)限即可,我這里只是為了演示方便而已,下面都將以該目錄為準(zhǔn)。

3.2 宿主工程

我們創(chuàng)建一個宿主工程,加載插件工程生成的apk文件,并顯示出插件里的資源。

public class MainActivity extends AppCompatActivity {

    private Button mBtnTest;
    private TextView mTvText1;
    private TextView mTvText2;
    private ImageView mIvImg;

    private DexClassLoader mCustomClassLoader;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBtnTest = findViewById(R.id.btn_test);
        mTvText1 = findViewById(R.id.tv_text1);
        mTvText2 = findViewById(R.id.tv_text2);
        mIvImg = findViewById(R.id.iv_image);

        //優(yōu)化后的dex文件輸出目錄,應(yīng)用必須具備讀寫權(quán)限
        String optimizedDirectory = getDir("dex", MODE_PRIVATE).getAbsolutePath();
        mCustomClassLoader = new DexClassLoader("/mnt/sdcard/plugin-debug.apk", optimizedDirectory, null, getClassLoader());

        mBtnTest.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loadResFromPluginApk();
            }
        });
    }

    private void loadResFromPluginApk() {
        try {
            Class clazz = mCustomClassLoader.loadClass("com.hjy.plugin.Utils");

            //加載插件里類中定義的字符串資源
            Method method = clazz.getMethod("getTextFromPlugin", new Class[]{});
            String text = (String) method.invoke(null);
            mTvText1.setText(text);

            //加載插件里的字符串資源
            method = clazz.getMethod("getTextFromPluginRes", Context.class);
            text = (String) method.invoke(null, MainActivity.this);
            mTvText2.setText(text);

            //加載插件里的顏色資源
            method = clazz.getMethod("getColorFromPlugin", Context.class);
            int color = (int) method.invoke(null, MainActivity.this);
            mTvText2.setTextColor(color);

            //加載插件里的圖片資源
            method = clazz.getMethod("getDrawableFromPlugin", Context.class);
            Drawable drawable = (Drawable) method.invoke(null, MainActivity.this);
            mIvImg.setImageDrawable(drawable);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

代碼很簡單,就是自己構(gòu)造了一個DexClassLoader對象,通過該ClassLoader去加載插件里Utils類,然后通過反射調(diào)用Utils類里的各個方法。其中/mnt/sdcard/plugin-debug.apk對應(yīng)的就是插件apk在手機(jī)中的存儲地址,根據(jù)實際情況而定。

3.3 執(zhí)行效果

我們先運(yùn)行插件工程,將插件apk傳入手機(jī)里面。然后再運(yùn)行宿主工程,點擊測試按鈕開始動態(tài)加載資源。很遺憾的是,這并沒有達(dá)到我們的預(yù)期效果,你只會看到第一個TextView有文本顯示,其內(nèi)容為"插件APK類里的文本內(nèi)容",第二個TextView顯示的文本并不是插件工程里定義的,第三個ImageView的內(nèi)容為空,并且控制臺可以看到拋出了android.content.res.Resources$NotFoundException異常,也就是資源未找到。

3.4 異常分析

從執(zhí)行結(jié)果中可以看到,在宿主工程中反射調(diào)用Utils類的方法時,只有第一個方法返回成功,后面幾個方法執(zhí)行都出現(xiàn)異常,到這里是不是有點沮喪了,第一個方法能正確返回內(nèi)容,這說明插件apk已經(jīng)被正確的加載了,但是為什么后面的幾個都失敗了呢?

別急,我們來看看第一個方法與其他的有什么差別。第一個方法為getTextFromPlugin(),沒帶任何參數(shù),直接返回的是一個固定的字符串,第二個方法為getTextFromPluginRes(Context context),帶有一個參數(shù)Context,通過Context去獲取資源,由此我們斷定問題是不是就出在這里。

在Android中,apk中的資源都是通過Resources對象來獲取的,我們在反射調(diào)用后面幾個方法時,Context參數(shù)傳入的是MainActivity.this,這個是宿主工程的Context,因此加載插件apk資源用的實際是宿主的Resources對象,但是宿主的Resources對象目前并不能訪問插件apk的資源,所以會出現(xiàn)資源找不到的異常。

4. 訪問外部資源的正確姿勢

上面這個例子中可以分析出,從宿主工程中的Context對象獲取到的Resources對象,無法加載插件apk中的資源文件,只需要解決該問題,那么我們的動態(tài)加載資源就大功告成了。

通過Context.getResources()方法,可以獲取到Resources對象,所以我們需要重寫宿主工程的getResources()方法,重新創(chuàng)建一個能讀取插件apk資源的Resources對象,在宿主工程的MainActivity類中,需要完善的代碼如下:

    /**
     * 1.重新創(chuàng)建一個AssetManager資源管理器,通過反射調(diào)用addAssetPath()方法,可以加載插件apk中的資源。
     * <br/>
     * 2.依賴第一步創(chuàng)建的AssetManager,重新創(chuàng)建一個Resources對象,該Resources對象包含了插件apk中的資源。
     * <br/>
     * 3.插件apk中的資源是通過Context.getResources()來獲取的,因此需要重寫Context的getResources()方法,返回前面創(chuàng)建的Resources對象。
     * 
     * @param dexPath 插件路徑
     */
    protected void loadPluginResource(String dexPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method method = assetManager.getClass().getMethod("addAssetPath", String.class);
            method.invoke(assetManager, dexPath);
            mAssetManager = assetManager;
        } catch (Exception e) {
            e.printStackTrace();
        }

        Resources resource = getResources();
        mResources = new Resources(mAssetManager, resource.getDisplayMetrics(), resource.getConfiguration());

        mTheme = mResources.newTheme();
        mTheme.setTo(super.getTheme());
    }

    @Override
    public AssetManager getAssets() {
        return mAssetManager != null ? mAssetManager : super.getAssets();
    }

    @Override
    public Resources getResources() {
        return mResources != null ? mResources : super.getResources();
    }

    @Override
    public Resources.Theme getTheme() {
        return mTheme != null ? mTheme : super.getTheme();
    }

在onCreate()中加入初始化代碼:

loadPluginResource("/mnt/sdcard/plugin-debug.apk");

這里的關(guān)鍵代碼是用了AssetManager的addAssetPath()方法,這是一個隱藏的方法,所以需要采用反射來調(diào)用。重新運(yùn)行宿主工程,一切OK,插件apk中的字符串、顏色、圖片都能正確加載了,動態(tài)加載資源到此就初步完成了。

5. 其他問題

5.1 宿主工程能正確加載自己工程里的資源嗎?

答案是否定的,原因是宿主工程MainActivity類中的Resources對象是我們新建的,它只綁定了插件apk中的資源,可以寫段測試代碼試試看:

System.out.println(getString(R.string.app_name));

你會發(fā)現(xiàn)打印出來的是插件apk的app_name,訪問本工程其他的資源文件也會出現(xiàn)異常。到這里是不是很頭疼,本來以為能動態(tài)加載外部apk的資源文件了,結(jié)果發(fā)現(xiàn)本工程的資源文件無法正常加載,本末倒置了,那怎么解決這個問題呢?既然我們知道資源文件是通過Resources對象來加載,那我們只需要在插件工程里,將Context參數(shù)改成Resources,然后在宿主工程反射調(diào)用插件apk的方法時,只傳入自己構(gòu)造的Resources參數(shù)即可,完全沒必要重寫宿主工程MainActivity類里的getResources()方法,這樣避免了宿主工程原本的Resources被污染破壞。

5.2 通過反射獲取插件工程的資源id

我們這個例子中,插件工程的幾個方法是獲取固定的資源文件,如果有很多資源文件,那豈不是要寫很多對應(yīng)的方法,這顯然不是我們想要的,同樣我們可以通過反射來獲取資源的id,這要宿主工程調(diào)用插件工程的方法時,只需要傳入資源名稱即可。

    /**
     * 通過資源名反射獲取資源id
     * 
     * @param pkgName 包名
     * @param type 資源類型,如:string, mipmap, drawable等
     * @param resName 資源名稱
     * @return 資源id
     */
    private int getResId(String pkgName, String type, String resName) {
        //構(gòu)造R文件內(nèi)部類的類名
        String className = pkgName + ".R$" + type;
        try {
            Class clazz = mCustomClassLoader.loadClass(className);
            Field field = clazz.getField(resName);
            field.setAccessible(true);
            Integer id = (Integer) field.get(null);
            return id;
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return 0;
    }

通過反射獲取插件apk里的字符串資源content_plugin,代碼如下:

int resId = getResId("com.hjy.plugin","string", "content_plugin");
System.out.println(mResources.getString(resId));

這樣是不是靈活了很多。

6. 小結(jié)

本文只是初步探究了怎么去動態(tài)加載外部資源,但這是管中窺豹,有很多問題還沒有解決,不過當(dāng)了解這些之后,談到這些話題的時候就不會覺得那么高深莫測了。

參考文章

Android應(yīng)用程序資源管理器(Asset Manager)的創(chuàng)建過程分析

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容