Android熱修復技術初探(三):動態加載外部資源

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

1. 動態加載外部資源

在Android中,資源文件一般指定義在res資源文件夾中的各種文件,常用到的有字符串資源strings.xml、顏色資源colors.xml、drawable文件等。動態加載外部資源的目標,是從一個外部的apk文件中加載資源文件,該apk文件可以是從網絡下載的,可以是存在于手機存儲目錄中的等等。

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

2. 實現思路

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

3. 實現案例

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

3.1 插件工程

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

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

public class Utils {

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

    /**
     * 讀取資源文件里的文本字符串
     *
     * @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);
    }

}

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

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

3.2 宿主工程

我們創建一個宿主工程,加載插件工程生成的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);

        //優化后的dex文件輸出目錄,應用必須具備讀寫權限
        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();
        }
    }
}

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

3.3 執行效果

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

3.4 異常分析

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

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

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

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

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

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

    /**
     * 1.重新創建一個AssetManager資源管理器,通過反射調用addAssetPath()方法,可以加載插件apk中的資源。
     * <br/>
     * 2.依賴第一步創建的AssetManager,重新創建一個Resources對象,該Resources對象包含了插件apk中的資源。
     * <br/>
     * 3.插件apk中的資源是通過Context.getResources()來獲取的,因此需要重寫Context的getResources()方法,返回前面創建的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");

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

5. 其他問題

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

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

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

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

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

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

    /**
     * 通過資源名反射獲取資源id
     * 
     * @param pkgName 包名
     * @param type 資源類型,如:string, mipmap, drawable等
     * @param resName 資源名稱
     * @return 資源id
     */
    private int getResId(String pkgName, String type, String resName) {
        //構造R文件內部類的類名
        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. 小結

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

參考文章

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容