場(chǎng)景
前段時(shí)間,產(chǎn)品又提出了新的需求,要把a(bǔ)pp的主題換成圣誕節(jié)的主題,過(guò)后再換回來(lái)。一種思路就是跟夜間模式那樣,準(zhǔn)備多套主題資源放在app內(nèi)的資源文件夾內(nèi),切換時(shí)調(diào)用不用的主題即可,但這樣無(wú)疑增加了app的包體積,而且如果有新的主題資源包要加進(jìn)來(lái),用戶又得更新整個(gè)app,這樣的更新方式肯定是不好的,這種情況下我們可以考慮另外一種思路,動(dòng)態(tài)加載資源主題包的apk文件。
先看看最終實(shí)現(xiàn)的效果對(duì)比:

原理
動(dòng)態(tài)加載apk有兩種方式:
- 一種是將資源主題包的apk安裝到手機(jī)上再讀取apk內(nèi)的資源,這種方式的原理是將宿主app和插件app設(shè)置相同的sharedUserId,這樣兩個(gè)app將會(huì)在同一個(gè)進(jìn)程中運(yùn)行,并可以相互訪問(wèn)內(nèi)部資源了。
- 一種是不用安裝資源apk的方式。其原理是通過(guò)DexClassLoader類(lèi)加載器去加載指定路徑下的apk、dex或者jar文件,反射出R類(lèi)中相應(yīng)的內(nèi)部類(lèi)然后根據(jù)資源名來(lái)獲取我們需要的資源id,然后根據(jù)資源id得到對(duì)應(yīng)的圖片或者xml文件。
實(shí)現(xiàn)
無(wú)論是哪種方式,我們都需要新的資源包,我們新建一個(gè)android工程,把需要更換的新圖片和xml資源文件放在這個(gè)工程對(duì)應(yīng)的目錄下,注意,文件名必需和宿主app內(nèi)對(duì)應(yīng)的文件名相同,因?yàn)楹竺娣瓷涫歉鶕?jù)資源名去找資源id。然后將這個(gè)工程打包成apk并使用跟宿主app相同的簽名文件簽名,在app啟動(dòng)的Activity中需要加一個(gè)檢查是否有新的資源包和是否需要?jiǎng)h掉資源包的接口(需要后臺(tái)人員配合寫(xiě)接口),如果有就下載apk,至于安裝apk和不安裝apk這兩種方式哪種更好,我覺(jué)得安裝apk這種方式不太友好,即使我們可以做到安裝后在桌面上沒(méi)有啟動(dòng)圖標(biāo),但還是有一個(gè)安裝的過(guò)程,對(duì)用戶來(lái)說(shuō),可能不知道這是什么東西,以為又安裝了什么新應(yīng)用,所以我會(huì)使用不安裝apk來(lái)更新這種方式,這里也還是要記錄下安裝apk方式是怎么做的。
準(zhǔn)備資源包
新建工程Skin-Plugin,將要更換的圖片或者xml文件放在對(duì)應(yīng)的drawable文件夾內(nèi),在AndroidManifest.xml中增加shareUserId,然后打包成apk文件。如果是不需要安裝apk的,就不用設(shè)置shareUserId了。
shareUserId這個(gè)值可以隨意設(shè)定,但是必須和宿主app里面的設(shè)置為相同才行。

我這里只更新幾個(gè)icon圖標(biāo)和底部tab的selector資源。

AndroidManifest配置如上圖所示,需要注意的是,讓app不在桌面上生成應(yīng)用圖標(biāo),需要將啟動(dòng)activity去掉下面的過(guò)濾配置:
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
去掉上述配置后這個(gè)工程是無(wú)法執(zhí)行Run操作了,但是不要緊,不影響打包成apk。
加載安裝的apk
前面說(shuō)過(guò)要提供一個(gè)接口下載新的資源包,下載后自動(dòng)安裝,我們?cè)谑褂眠@些資源的地方去檢查資源apk有沒(méi)有安裝,如果有,就加載資源包中的資源,將檢查apk是否安裝的方法寫(xiě)到工具類(lèi)中,這里需要傳入資源app的包名。
/**
* apk是否已安裝
* @param packageName
* @return true已經(jīng)安裝,false未安裝或者已經(jīng)卸載。
*/
public static boolean checkApkInstalled(Context context, String packageName) {
if (packageName == null || "".equals(packageName)) {
return false;
}
try {
ApplicationInfo info = context.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_UNINSTALLED_PACKAGES);
return true;
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
檢查到安裝了插件apk后,需要?jiǎng)?chuàng)建一個(gè)插件apk內(nèi)的上下文對(duì)象,因?yàn)橹挥胁寮pk的上下文對(duì)象才能獲取到它的Resourece對(duì)象,從而通過(guò)插件上下文獲取資源id。
//獲取對(duì)應(yīng)插件中的上下文,通過(guò)它可得到插件的Resource
Context pluginContext = this.createPackageContext(packageName, CONTEXT_IGNORE_SECURITY | CONTEXT_INCLUDE_CODE);
//獲取資源id
int resId = pluginContext.getResources().getIdentifier(......);
加載未安裝的apk
同樣的這種方式也要提供一個(gè)資源包,用戶啟動(dòng)app時(shí)在后臺(tái)靜默下載插件apk文件,保存到指定的路徑下。我們要加載這個(gè)插件,就需要一個(gè)插件的類(lèi)加載器,而不是宿主app的類(lèi)加載器,這時(shí)候只能去手動(dòng)構(gòu)建DexClassLoader,再通過(guò)類(lèi)加載器,反射出R類(lèi)中相應(yīng)的內(nèi)部類(lèi)進(jìn)而獲取我們需要的資源id。
/**
* 加載apk獲得內(nèi)部資源id
* @param context
* @param pluginPath apk路徑
*/
public static int getResId(Context context, String pluginPath, String apkPackageName, String resName) {
try {
//在應(yīng)用安裝目錄下創(chuàng)建一個(gè)名為app_dex文件夾目錄,如果已經(jīng)存在則不創(chuàng)建
File optimizedDirectoryFile = context.getDir("dex", Context.MODE_PRIVATE);
// 構(gòu)建插件的DexClassLoader類(lèi)加載器,參數(shù):
// 1、包含dex的apk文件或jar文件的路徑,
// 2、apk、jar解壓縮生成dex存儲(chǔ)的目錄,
// 3、本地library庫(kù)目錄,一般為null,
// 4、父ClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(pluginPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
//通過(guò)使用apk自己的類(lèi)加載器,反射出R類(lèi)中相應(yīng)的內(nèi)部類(lèi)進(jìn)而獲取我們需要的資源id
Class<?> clazz = dexClassLoader.loadClass(apkPackageName + ".R$drawable");
Field field = clazz.getDeclaredField(resName);//得到名為resName的這張圖片字段
return field.getInt(R.id.class);//得到圖片id
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
其中第二個(gè)參數(shù)是插件apk的全路徑,文件名必需是帶.apk,第三個(gè)參數(shù)是插件apk的包名,第四個(gè)參數(shù)是資源名。
/**
* 獲取插件apk的包名
* @param context
* @param pluginPath 插件apk的絕對(duì)路徑
* @return
*/
public static String getPluginPackagename(Context context, String pluginPath) {
PackageManager pm = context.getPackageManager();
PackageInfo pkgInfo = pm.getPackageArchiveInfo(pluginPath, PackageManager.GET_ACTIVITIES);
if (pkgInfo != null) {
ApplicationInfo appInfo = pkgInfo.applicationInfo;
String pkgName = appInfo.packageName;//包名
return pkgName;
}
return null;
}
只有資源id還不夠,還需要插件apk的Resources對(duì)象,因?yàn)橹挥兴拍芨鶕?jù)資源id獲取到對(duì)應(yīng)的資源。
/**
* 獲取對(duì)應(yīng)插件的Resource對(duì)象
* @param context
* @param pluginPath 插件apk的路徑,帶apk名
* @return
*/
public static Resources getPluginResources(Context context, String pluginPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 反射調(diào)用方法addAssetPath(String path)
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
// 將插件Apk文件添加進(jìn)AssetManager
addAssetPath.invoke(assetManager, pluginPath);
// 獲取宿主apk的Resources對(duì)象
Resources superRes = context.getResources();
// 獲取插件apk的Resources對(duì)象
Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return mResources;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
獲取插件的Resources對(duì)象其實(shí)就是用反射的方式調(diào)用了AssetManager類(lèi)的addAssetPath方法,這個(gè)方法的目的是將插件apk里的資源都加載到AssetManager對(duì)象中進(jìn)行管理,然后來(lái)構(gòu)建插件apk的Resources。至于為什么要用反射,看看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) {
int res = addAssetPathNative(path);
return res;
}
這里有個(gè)注解@hide,表示即使它是public的,但是外界仍然無(wú)法訪問(wèn)它的,因?yàn)閍ndroid sdk導(dǎo)出的時(shí)候會(huì)自動(dòng)忽略隱藏的api,因此只能通過(guò)反射來(lái)調(diào)用。
// 根據(jù)資源名去加載新的資源
String pluginPath = Environment.getExternalStorageDirectory().toString() + "/dynamicload/download/skin-plugin.apk";
if (item.getResName() != null) {
Drawable drawable = Util.getPluginResources(mContext, pluginPath).getDrawable(Util.getResId(mContext, pluginPath, Util.getPluginPackagename(mContext, pluginPath), item.getResName()));
imageView.setImageDrawable(drawable);
}
至此就完成了動(dòng)態(tài)加載插件apk資源,當(dāng)我們需要切換回原來(lái)的資源時(shí),只需要將資源包刪除即可,或者重新構(gòu)建一個(gè)資源包,讓用戶去下載,由于我們是運(yùn)行時(shí)加載,所以當(dāng)更換了資源包時(shí),第一次打開(kāi)只是去下載這個(gè)插件資源包,再次打開(kāi)時(shí)才會(huì)去加載。
代碼下載地址:
https://github.com/shenhuniurou/BlogDemos/tree/master/DynamicLoadDemo