Android系統使用了ClassLoader機制來進行Activity等組件的加載;apk被安裝之後,APK文件的代碼以及資源會被系統存放在固定的目錄(比如/data/app/package_name/1.apk)系統在進行類加載的時候,會自動去這一個或者幾個特定的路徑來尋找這個類;但是系統並不知道存在於插件中的Activity組件的信息,插件可以是任意位置,甚至是網絡,系統無法提前預知,因此正常情況下系統無法加載我們插件中的類;因此也沒有辦法創建Activity的對象,更不用談啓動組件了。這個時候就需要使用動態加載技術了,關於Activity如何插件化,後面系列在說,本文講了一個應用程序換膚的故事,雖然老套,但是對於理解動態加載技術很實用,讀完之後你可以知道如何解決插件之中的資源加載問題。
關於類加載器,請看深入探討 Java 類加載器
一、動態加載dex的技術
Android使用Dalvik虛擬機加載可執行程序,所以不能直接加載基於class的jar,而是需要將class轉化爲dex字節碼,從而執行代碼。優化後的字節碼文件可以存在一個.jar中,只要其內部存放的是.dex即可使用。
我們現在要實現的一個需求是:如何調用一個非本應用的java程序,如下:
app 與loutillib兩個模塊沒有任何的依耐關係,在Module App中,我們想調用Loutillib中的LogUitl輸出一條log。LogUitl如下,so easy。
public class LogUitl {
public static final String TAG="LogUitl";
private void printLog(){
Log.e(TAG,"這是來自另外一個dex中的log");
}
}
所以我們要在運行時把LogUitl動態加載到app這個進程中, Android支持動態加載的兩種方式是:DexClassLoader和PathClassLoader,DexClassLoader可加載jar/apk/dex,且支持從SD卡加載;PathClassLoader只能加載已經安裝在Android系統內APK文件( /data/app 目錄下),其它位置的文件加載的時候都會出現 ClassNotFoundException。 因爲 PathClassLoader 會去讀取 /data/dalvik-cache 目錄下的經過 Dalvik 優化過的 dex 文件,這個目錄的 dex 文件是在安裝 apk 包的時候由 Dalvik 生成的,沒有安裝的時候,自然沒有生成那個文件。
這裏我們用DexClassLoader來加載,LogUitl所生成的dex文件。首先用gradle打出LogUtil的jar包。
task makeJar(type:Copy){
delete 'build/libs/log.jar'
from('build/intermediates/bundles/release/')
into('build/libs/')
include('classes.jar')
rename ('classes.jar', 'log.jar')
exclude('test/','BuildConfig.class','R.class')
exclude{it.name.startsWith('R$');}
}
makeJar.dependsOn(build)
注意,這個jar還不能被加載,這個是基於class的jar,Dalvik虛擬機加載的是dex字節碼,所以需要將class轉化爲dex字節碼。這個需要用到dx命令,這個可以在Android\sdk\build-tools\23.0.0中找到,把log.jar拷貝到這個目錄下,執行
dx --dex --output=new_log.jar log.jar
在執行
adb push new_log.jar sdcard/
把這個new_log放進SDCARD中,這樣dex的準備工作就OK了。以下是用DexClassLoader動態加載的代碼。
public class MainActivity extends Activity {
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(newBase);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void start(View view) {
//dex解壓釋放後的目錄
final File dexOutPutDir = getDir("dex", 0);
//dex所在目錄
final String dexPath = Environment.getExternalStorageDirectory().toString() + File.separator + "new_log.jar";
//第一個參數:是dex壓縮文件的路徑
//第二個參數:是dex解壓縮後存放的目錄
//第三個參數:是C/C++依賴的本地庫文件目錄,可以爲null
//第四個參數:是上一級的類加載器
DexClassLoader classLoader=new DexClassLoader(dexPath,dexOutPutDir.getAbsolutePath(),null,getClassLoader());
try {
final Class<?> loadClazz = classLoader.loadClass("zhangwan.wj.com.logutillib.LogUitl");
final Object o = loadClazz.newInstance();
final Method printLogMethod = loadClazz.getDeclaredMethod("printLog");
printLogMethod.setAccessible(true);
printLogMethod.invoke(o);
} catch (Exception e) {
e.printStackTrace();
}
}
}
執行結果:
發現成功的調用了printLog方法。有上面的基礎,現在實現一個難一點的,如何給應用程序換膚,這個難體現在資源加載上。通常各種各樣的皮膚都是一個個的apk文件,當用戶需要哪個皮膚,就下載到本地,然後動態加載,但是當宿主程序調起未安裝的皮膚插件apk的時候,插件中以R開頭的資源都不能被訪問,程序會拋出異常,無法找到某某id所對應的資源。這是因爲加載資源都是通過Resourse來實現的,Resource對象是由Context得到的,我們知道一個app的工程的資源文件都會隱射到R文件中,而這個R文件的包名則是這個應用的包名,所以一個包名一般對應一個Context。宿主與皮膚插件的包名是不一樣的,所以宿主Context找不到皮膚插件的資源。
二、應用換膚
1、皮膚程序準備
-
sky.apk
-
children.apk
準備兩個apk,sky.apk中有一張名字爲skin_one的背景圖,顯示的是藍色的天空;children.apk中也有一張名字爲skin_one的背景圖,顯示的是一個小孩。將這兩個apk都push到SD裏面,兩套皮膚準備完成。
2、資源加載問題怎麼解決
通過分析系統資源加載瞭解到,系統是通過ContextImpl中的getAssets與getResources加載資源的
/**
* Returns an AssetManager instance for the application's package.
* <p>
* <strong>Note:</strong> Implementations of this method should return
* an AssetManager instance that is consistent with the Resources instance
* returned by {@link #getResources()}. For example, they should share the
* same {@link Configuration} object.
*
* @return an AssetManager instance for the application's package
* @see #getResources()
*/
public abstract AssetManager getAssets();
/**
* Returns a Resources instance for the application's package.
* <p>
* <strong>Note:</strong> Implementations of this method should return
* a Resources instance that is consistent with the AssetManager instance
* returned by {@link #getAssets()}. For example, they should share the
* same {@link Configuration} object.
*
* @return a Resources instance for the application's package
* @see #getAssets()
*/
public abstract Resources getResources();
ContextImpl中,也就是說,只要實現這兩個方法,就可以解決資源問題了。不饒彎子了,直接上代碼,解釋請移步Android動態加載技術三個關鍵問題詳解。
/**
* 獲取AssetManager 用來加載插件資源
* @param pFilePath 插件的路徑
* @return
*/
private AssetManager createAssetManager(String pFilePath) {
try {
final AssetManager assetManager = AssetManager.class.newInstance();
final Class<?> assetManagerClazz = Class.forName("android.content.res.AssetManager");
final Method addAssetPathMethod = assetManagerClazz.getDeclaredMethod("addAssetPath", String.class);
addAssetPathMethod.setAccessible(true);
addAssetPathMethod.invoke(assetManager, pFilePath);
return assetManager;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
//這個Resources就可以加載非宿主apk中的資源
private Resources createResources(String pFilePath){
final AssetManager assetManager = createAssetManager(pFilePath);
Resources superRes = this.getResources();
return new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
}
3、動態加載皮膚apk
public class MainActivity extends Activity {
private TextView mSkinTv;
private boolean mChange=false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_proxy);
mSkinTv= (TextView) findViewById(R.id.skin_bg);
}
/**
* 獲取未安裝apk的信息
* @param context
* @param pApkFilePath apk文件的path
* @return
*/
private String getUninstallApkPkgName(Context context, String pApkFilePath) {
PackageManager pm = context.getPackageManager();
PackageInfo pkgInfo = pm.getPackageArchiveInfo(pApkFilePath, PackageManager.GET_ACTIVITIES);
if (pkgInfo != null) {
ApplicationInfo appInfo = pkgInfo.applicationInfo;
return appInfo.packageName;
}
return "";
}
public void switchSkin(View view) {
String skinType="";
if(!mChange){
skinType= "sky.apk";
mChange=true;
}else {
skinType= "children.apk";
mChange=false;
}
final String path = Environment.getExternalStorageDirectory() + File.separator + skinType;
final String pkgName = getUninstallApkPkgName(this, path);
dynamicLoadApk(path,pkgName);
}
private void dynamicLoadApk(String pApkFilePath,String pApkPacketName){
File file=getDir("dex", Context.MODE_PRIVATE);
//第一個參數:是dex壓縮文件的路徑
//第二個參數:是dex解壓縮後存放的目錄
//第三個參數:是C/C++依賴的本地庫文件目錄,可以爲null
//第四個參數:是上一級的類加載器
DexClassLoader classLoader=new DexClassLoader(pApkFilePath,file.getAbsolutePath(),null,getClassLoader());
try {
final Class<?> loadClazz = classLoader.loadClass(pApkPacketName + ".R$drawable");
//插件中皮膚的名稱是skin_one
final Field skinOneField = loadClazz.getDeclaredField("skin_one");
skinOneField.setAccessible(true);
//反射獲取skin_one的resousreId
final int resousreId = (int) skinOneField.get(R.id.class);
//可以加載插件資源的Resources
final Resources resources = createResources(pApkFilePath);
if (resources != null) {
final Drawable drawable = resources.getDrawable(resousreId);
mSkinTv.setBackground(drawable);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
執行效果
到此換膚成功!