前面已經介紹了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 插件工程
我們定義一個字符串資源、一個顏色資源、一個圖片資源,然后創建一個類來讀取這些資源。
- 字符串資源定義
<string name="content_plugin">插件APK資源里的文本內容</string>
- 顏色資源定義
<color name="color_from_plugin">#66</color>
- 在圖片文件夾里放一個名為test.png的圖片
- 創建讀取資源文件的類及方法
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. 小結
本文只是初步探究了怎么去動態加載外部資源,但這是管中窺豹,有很多問題還沒有解決,不過當了解這些之后,談到這些話題的時候就不會覺得那么高深莫測了。