概述
Android插件化顧名思義,就是把APP分成N多插件,可以随意對插件進行熱插拔。插件化帶來的好處是,減小了軟體耦合,同時開發人員可以子產品開發,提高了開發效率,而且線上bug可以通過更新插件方式快速修複。
一般情況下Android的插件就是一個單獨的apk,開發模式與Android原生應用沒有太大差別。我們要解決的問題是,如何在不安裝的情況下把這個apk運作起來。apk内主要包含class.dex和相關資源檔案,class.dex内部是Android虛拟機位元組碼。是以要想APP能運作,需要載入Android虛拟機位元組碼與插件apk中的資源。
本文寫了一個簡單的插件化架構,下載下傳位址https://github.com/pengyuntao/yuntao-plugin
本插件使用fragment來建構頁面,沒有實作service,receiver,provider等的動态加載,這裡隻是作為學習的例子,當然純界面應用也可以使用這種架構來分子產品開發,動态更新替換子產品。
該例子
代碼不多
,閱讀下文請
對照例子
。核心類隻有PluginInstallUtils,PluginHostActivity
兩個類
。host工程是宿主工程,plugin1,plugin2,plugin3是插件工程,pluginlib是依賴庫工程。運作時候請将三個插件工程打包成apk放在sdcard/yuntao-plugin目錄下,沒有請建立該目錄。
類加載
參考PluginInstallUtils類
要想實作動态加載,第一步需要實作加載插件中的類。JAVA提供了類加載器來加載jar中的類。但是Android識别的是dex檔案不同與class檔案,是以不能使用JAVA原生的類加載器,還好Android提供了用于加載dex中類的類加載器,DexClassLoader,PathClassLoader。
這兩者的差別在于DexClassLoader需要提供一個可寫的outpath路徑,用來釋放.apk包或者.jar包中的dex檔案。換個說法來說,就是PathClassLoader不能主動從zip包中釋放出dex,是以隻支援直接操作dex格式檔案,或者已經安裝的apk(因為已經安裝的apk在cache中存在緩存的dex檔案)。而DexClassLoader可以支援.apk、.jar和.dex檔案,并且會在指定的outpath路徑釋放出dex檔案。
看DexClassLoader構造方法的幾個參數
String dexPath 要加載的apk的絕對路徑
String optimizedDirectory apk解壓出來的目錄
String libraryPath native lib的路徑,可以為null
ClassLoader parent 父類加載器
是以我們可以使用以下代碼方式來加載一個apk中的類到記憶體中。
private DexClassLoader createDexClassLoader(Context context,String dexPath){
File dexOutputDir = mContext.getDir("dex", Context.MODE_PRIVATE);
dexOutputPath = dexOutputDir.getAbsolutePath();
DexClassLoader loader = new DexClassLoader(dexPath, dexOutputPath, null, context.getClassLoader());
return loader;
}
資源加載
參考PluginInstallUtils類
資源加載的方法是調用AssetManager中的addAssetPath方法,我們可以将一個apk中的資源加載到Resources中,由于addAssetPath是隐藏api我們無法直接調用,是以隻能通過反射,通過注釋我們可以看出,傳遞的路徑可以是zip檔案也可以是一個資源目錄,而apk就是一個zip,是以直接将apk的路徑傳給它,資源就加載到AssetManager中了,然後再通過AssetManager來建立一個新的Resources對象,這個對象就是我們可以使用的apk中的資源了,這樣我們的問題就解決了。
/**
* 建立AssetManager對象
*
* @param dexPath apk路徑
* @return
*/
private AssetManager createAssetManager(String dexPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
return assetManager;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 建立Resource對象
*
* @param assetManager 上邊方法建立的assetManager
* @return
*/
private Resources createResources(AssetManager assetManager) {
Resources superRes = mContext.getResources();
Resources resources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return resources;
}
如何讓插件環境載入APP上下文
參考PluginHostActivity類
類與資源已經載入到記憶體中了,那麼如何使用他們呢。
由于我們的界面使用的是fragment,然而fragment必須附加到一個Activity上,是以fragment使用的資源,ClassLoader等都是與附加的Activity相同的。Activity是繼承自Context,Context涉及到資源與類加載的有下列三個抽象方法,我們隻要實作下列方法就可以了,讓下列方法傳回上兩節中建立的插件的AssetManager,Resources,ClassLoader。
/** Return an AssetManager instance for your application's package. */
public abstract AssetManager getAssets();
/** Return a Resources instance for your application's package. */
public abstract Resources getResources();
/** Return a class loader you can use to retrieve classes in this package.*/
public abstract ClassLoader getClassLoader();
由于我們做的是界面相關的還要再實作一個Theme的方法
/** Return the Theme object associated with this Context.*/
public abstract Resources.Theme getTheme();
最後我們還是要提供一個Activity當做宿主,我們重寫這個Activity的上述四個方法(如果了解Context的架構可以修改ContextImpl也可以,我們直接修改Activity的滿足要求了,就沒必要去改ContextImpl了)。當然這個時候通過反射加載類并調用方法,有興趣可以往類中随便寫個方法打log測試下。
使用fragment建構頁面
參考PluginHostActivity類
由于大多數APP都是由一個個頁面組成的,使用Activity來建構頁面需要在mainifest檔案中注冊Activity,APP在安裝之初内部的Activity就固定了,不能動态任意添加删除Activity,同時Activity還需要管理生命周期,比較複雜(當然有好多插件架構實作了動态加載任意四大元件,例如DroidPlugin,DL等,有興趣可以自己去了解相關技術),這裡為了簡單我們使用fragment來建立界面,fragment沒有mainifest的限制,同時fragment由FragmentManager管理,可以任意的建立銷毀,是以我們可以做一個純fragment的應用。
/**
* 反射建立fragment,通過fragmentManager把建立的fragment附加到Activity
* @param fragClass
*/
protected void installPluginFragment(String fragClass) {
try {
if (isFinishing()) {
return;
}
ClassLoader classLoader = getClassLoader();
Fragment fg = (Fragment) classLoader.loadClass(fragClass).newInstance();
Bundle bundle = getIntent().getExtras();
fg.setArguments(bundle);
getSupportFragmentManager().beginTransaction()
.replace(android.R.id.primary, fg).commitAllowingStateLoss();
} catch (Exception e) {
e.printStackTrace();
}
}
上述代碼通過fragment類全名打開一個fragment,添加到PluginHostActivity中,詳情參考PluginHostActivity
fragment之間跳轉
參考BaseFragment,PluginHostActivity類
我們設定Activity啟動模式為standard模式,每次打開一個Activity執行個體附加一個fragment,通過Intent把fragment類名傳遞給Activity,讓Activity去反射建立fragment并且加載他,可以BaseFragment裡封裝一個startFragment方法用來打開頁面
加載多個插件
參考PluginInstallUtils,PluginEnv類
加載插件的過程還是很耗時的,是以我們可以通過一個Map把插件加載的資料緩存起來,下次遇到相同的插件就直接取出。
public final static HashMap<String, PluginEnv> mPackagesHolder = new HashMap<String, PluginEnv>();
這裡使用apkPath作為key,當然這個key隻要是唯一的就行,比如可以定義插件id之類的,總之就是為了不重複加載,下次可以根據這個标志能拿到緩存的資料即可。使用插件id也可以達到通過伺服器下發插件id來加載插件的目的。value裡存儲的就是一些插件相關環境,Resource,ClassLoader等
/**
* 插件的運作環境
*/
public class PluginEnv {
public ClassLoader pluginClassLoader;
public Resources pluginRes;
public AssetManager pluginAsset;
public Resources.Theme pluginTheme;
public String localPath;
public PluginEnv(String localPath, ClassLoader pluginClassLoader, Resources pluginRes, AssetManager pluginAsset, Resources.Theme pluginTheme) {
this.pluginClassLoader = pluginClassLoader;
this.pluginRes = pluginRes;
this.pluginAsset = pluginAsset;
this.pluginTheme = pluginTheme;
this.localPath = localPath;
}
}
類重複加載導緻的類沖突異常問題
參考PluginClassLoader,PluginInstallUtils類
加載多個插件的時候會遇到一個問題,就是當多個插件都引用一個lib,該lib内的類會被加載多次,這個時候使用這些類的時候,就會發生錯誤。
兩種解決方案:讓依賴包隻參與編譯,不打入最終的包内,這個好處是能讓插件包小一些;重寫類加載器,Android的類加載器是支援雙親委派的,可以保證宿主加載lib的類一份就行了。我們這裡使用了第二種重寫類加載器的方式。
public class PluginClassLoader extends DexClassLoader {
public PluginClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super(dexPath, optimizedDirectory, libraryPath, parent);
}
@Override
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
//如果vm已經加載了,傳回該類,否則傳回null
Class<?> clazz = findLoadedClass(className);
if (clazz == null) {
//如果vm沒有加載讓該類的父加載器加載
try {
clazz = this.getParent().loadClass(className);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
if (clazz == null) {
//目前加載器加載
clazz = findClass(className);
}
}
return clazz;
}
}
插件之間跳轉
參考PluginHostActivity
其實PluginHostActivity是在宿主中注冊的,插件不依賴宿主,是以不能直接顯示的startActivity,我們可以通過action方式來讓宿主的PluginHostActivity來加載其他插件的fragment。
LayoutInflate加載布局檔案自定義控件導緻類轉換錯誤問題
參考WidgetLayoutInflaterFactory,PluginHostActivity類
在使用插件更新的時候,使用新的插件替換了舊插件,當layout布局檔案中寫了自定義控件的時候,打開該頁面通常會抛出以下異常。
......
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.yuntao.host/com.yuntao.pluginlib.PluginHostActivity}: java.lang.ClassCastException: com.yuntao.plugin3.CustomTextView cannot be cast to com.yuntao.plugin3.CustomTextView
......
Caused by: java.lang.ClassCastException: com.yuntao.plugin3.CustomTextView cannot be cast to com.yuntao.plugin3.CustomTextView
......
在LayoutInflate内部有個靜态的Map儲存了曾經解析出的View的構造函數,key為控件的name,是以在更新插件之後,由于不同的插件使用了不同的類加載器,類加載器變了,在解析View的時候首先根據name去map中取,會傳回舊插件的View。然而目前插件與上個版本插件的ClassLoader不一樣,是以會出現類轉換錯誤。
研究下源碼,下邊代碼為緩存的資料結構
private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
new HashMap<String, Constructor<? extends View>>();
檢視建立view的過程,就是根據view的name,把constructor緩存了起來,存在就直接執行個體化了,不存在才建立
public final View createView(String name, String prefix, AttributeSet attrs{//略去throws
Constructor<? extends View> constructor = sConstructorMap.get(name);
Class<? extends View> clazz = null;
//略去try catch
if (constructor == null) {
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
//此處略去幾行
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
} else {
//此處略去n多行
}
Object[] args = mConstructorArgs;
args[] = attrs;
final View view = constructor.newInstance(args);
if (view instanceof ViewStub) {
// Use the same context when inflating ViewStub later.
final ViewStub viewStub = (ViewStub) view;
viewStub.setLayoutInflater(cloneInContext((Context) args[]));
}
return view;
}
檢視調用createView的代碼片段,調用之前會執行幾個factory的onCreateView方法,如果factory建立了view,則就傳回這個view,就不會走createView的邏輯了。
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[];
mConstructorArgs[] = context;
try {
if (- == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
是以我們可以給LayoutInflate設定一個factory,由于factory2在最前邊我們設定factory2,在PluginHostActivity的onCreate方法中設定下列代碼
getLayoutInflater().setFactory2(new WidgetLayoutInflaterFactory());
WidgetLayoutInflaterFactory實作Factory2接口,自己接管了自定義控件的建立過程。詳細可以見Demo的WidgetLayoutInflaterFactory類
class WidgetLayoutInflaterFactory implements LayoutInflater.Factory2 {
private final HashMap<String, Constructor<? extends View>> sConstructorMap = new
HashMap<String, Constructor<? extends View>>();
private final Class<?>[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};
private final Object[] mConstructorArgs = new Object[];
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals("view")) {
name = attrs.getAttributeValue(null, "class");
}
//如果沒有'.',說明是Android系統控件,直接傳回null,讓系統自己createView
if (- == name.indexOf('.')) {
return null;
}
Context lastContext = (Context) mConstructorArgs[];
mConstructorArgs[] = context;
Class<? extends View> clazz = null;
//先從本地緩存讀取
Constructor<? extends View> constructor = sConstructorMap.get(name);
try {
if (constructor == null) {
//沒有緩存,根據類名建立Constructor對象存入緩存
// Class not found in the cache, see if it's real, and try to add it
clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = clazz.getConstructor(mConstructorSignature);
sConstructorMap.put(name, constructor);
}
Object[] args = mConstructorArgs;
args[] = attrs;
constructor.setAccessible(true);
return constructor.newInstance(args);
} catch (NoSuchMethodException e) {
InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + name);
ie.initCause(e);
throw ie;
} catch (ClassCastException e) {
// If loaded class is not a View subclass
InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Class is not a View " + name);
ie.initCause(e);
throw ie;
} catch (ClassNotFoundException e) {
// If loaded class is not a View subclass
InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Class not found " + name);
ie.initCause(e);
throw ie;
} catch (Exception e) {
InflateException ie = new InflateException(attrs.getPositionDescription()
+ ": Error inflating class " + (clazz == null ? "<unknown>" : clazz.getName()));
ie.initCause(e);
throw ie;
} finally {
mConstructorArgs[] = lastContext;
mConstructorArgs[] = null;
}
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return onCreateView(null, name, context, attrs);
}
}
思考
- 插件包傳輸過程需要加密,然而虛拟機不能解密,隻能解密後加載入虛拟機,要及時删除解密後的插件。
- 為了減小插件大小,還是使用腳本讓依賴包隻參與編譯
- 如果修改了依賴包中的代碼與檔案,隻是釋出插件,由于依賴包沒有重新加載,是以需要重新發主程式包。
- 依賴包中的資源改變了,R檔案變化了,可能導緻新發出去的插件,在舊的主程式中找不到資源。
Android類動态加載技術http://www.blogjava.net/zh-weir/archive/2011/10/29/362294.html