Android動態加載進階 代理Activity模式
基本信息
-
作者: kaedea
技術背景
簡單模式中,使用ClassLoader加載外部的Dex或Apk文件,可以加載一些本地APP不存在的類,從而執行一些新的代碼邏輯。但是使用這種方法卻不能直接啟動插件里的Activity。
啟動沒有注冊的Activity的兩個主要問題
Activity等組件是需要在Manifest中注冊后才能以標準Intent的方式啟動的(如果有興趣強烈推薦你了解下Activity生命周期實現的機制及源碼),通過ClassLoader加載并實例化的Activity實例只是一個普通的Java對象,能調用對象的方法,但是它沒有生命周期,而且Activity等系統組件是需要Android的上下文環境的(Context等資源),沒有這些東西Activity根本無法工作。
使用插件APK里的Activity需要解決 兩個問題 :
-
如何使插件APK里的Activity具有生命周期;
-
如何使插件APK里的Activity具有上下文環境(使用R資源);
代理Activity模式為解決這兩個問題提供了一種思路。
代理Activity模式
這種模式也是我們項目中,繼“簡單動態加載模式”之后,第二種投入實際生產項目的開發方式。
其主要特點是:主項目APK注冊一個代理Activity(命名為ProxyActivity),ProxyActivity是一個普通的Activity,但只是一個空殼,自身并沒有什么業務邏輯。每次打開插件APK里的某一個Activity的時候,都是在主項目里使用標準的方式啟動ProxyActivity,再在ProxyActivity的生命周期里同步調用插件中的Activity實例的生命周期方法,從而執行插件APK的業務邏輯。
ProxyActivity + 沒注冊的Activity = 標準的Activity
下面談談代理模式是怎么處理上面提到的兩個問題的。
處理插件Activity的生命周期
目前還真的沒什么辦法能夠處理這個問題,一個Activity的啟動,如果不采用標準的Intent方式,沒有經歷過Android系統Framework層級的一系列初始化和注冊過程,它的生命周期方法是不會被系統調用的(除非你能夠修改Android系統的一些代碼,而這已經是另一個領域的話題了,這里不展開)。
那把插件APK里所有Activity都注冊到主項目的Manifest里,再以標準Intent方式啟動。但是事先主項目并不知道插件Activity里會新增哪些Activity,如果每次有新加的Activity都需要升級主項目的版本,那不是本末倒置了,不如把插件的邏輯直接寫到主項目里來得方便。
那就繞繞彎吧,生命周期不就是系統對Activity一些特定方法的調用嘛,那我們可以在主項目里創建一個ProxyActivity,再由它去代理調用插件Activity的生命周期方法(這也是代理模式叫法的由來)。用ProxyActivity(一個標準的Activity實例)的生命周期同步控制插件Activity(普通類的實例)的生命周期,同步的方式可以有下面兩種:
-
在ProxyActivity生命周期里用反射調用插件Activity相應生命周期的方法,簡單粗暴。
-
把插件Activity的生命周期抽象成接口,在ProxyActivity的生命周期里調用。另外,多了這一層接口,也方便主項目控制插件Activity。
這里補充說明下,Fragment自帶生命周期,用Fragment來代替Activity開發可以省去大部分生命周期的控制工作,但是會使得界面跳轉比較麻煩,而且Honeycomb以前沒有Fragment,無法在API11以前的系統使用。
在插件Activity里使用R資源
使用代理的方式同步調用生命周期的做法容易理解,也沒什么問題,但是要使用插件里面的res資源就有點麻煩了。簡單的說,res里的每一個資源都會在R.java里生成一個對應的Integer類型的id,APP啟動時會先把R.java注冊到當前的上下文環境,我們在代碼里以R文件的方式使用資源時正是通過使用這些id訪問res資源,然而插件的R.java并沒有注冊到當前的上下文環境,所以插件的res資源也就無法通過id使用了。
這個問題困擾了我們很久,一開始的項目急于投入生產,所以我們索性拋開res資源,插件里需要用到的新資源都通過純Java代碼的方式創建(包括XML布局、動畫、點九圖等),蛋疼但有效。知道網上出現了解決這一個問題的有效方法(一開始貌似是在手機QQ項目中出現的,但是沒有開源所以不清楚,在這里真的佩服這些對技術這么有追求的開發者)。
記得我們平時怎么使用res資源的嗎,就是“getResources().getXXX(resid)”,看看“getResources()”
@Override public Resources getResources() { if (mResources != null) { return mResources; } if (mOverrideConfiguration == null) { mResources = super.getResources(); return mResources; } else { Context resc = createConfigurationContext(mOverrideConfiguration); mResources = resc.getResources(); return mResources; } }
看起來像是通過mResources實例獲取res資源的,在找找mResources實例是怎么初始化的,看看上面的代碼發現是使用了super類ContextThemeWrapper里的“getResources()”方法,看進去
Context mBase; public ContextWrapper(Context base) { mBase = base; } @Override public Resources getResources() { return mBase.getResources(); }
看樣子又調用了Context的“getResources()”方法,看到這里,我們知道Context只是個抽象類,其實際工作都是在ContextImpl完成的,趕緊去ContextImpl里看看“getResources()”方法吧
@Override public Resources getResources() { return mResources; }
…………
……
你TM在逗我么,還是沒有mResources的創建過程啊!啊,不對,mResources是ContextImpl的成員變量,可能是在構造方法中創建的,趕緊去看看構造方法(這里只給出關鍵代碼)。
resources = mResourcesManager.getTopLevelResources(packageInfo.getResDir(), packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(), packageInfo.getApplicationInfo().sharedLibraryFiles, displayId, overrideConfiguration, compatInfo); mResources = resources;
看樣子是在ResourcesManager的“getTopLevelResources”方法中創建的,看進去
Resources getTopLevelResources(String resDir, String[] splitResDirs, String[] overlayDirs, String[] libDirs, int displayId, Configuration overrideConfiguration, CompatibilityInfo compatInfo) { Resources r; AssetManager assets = new AssetManager(); if (libDirs != null) { for (String libDir : libDirs) { if (libDir.endsWith(".apk")) { if (assets.addAssetPath(libDir) == 0) { Log.w(TAG, "Asset path '" + libDir + "' does not exist or contains no resources."); } } } } DisplayMetrics dm = getDisplayMetricsLocked(displayId); Configuration config ……; r = new Resources(assets, dm, config, compatInfo); return r; }
看來這里是關鍵了,看樣子就是通過這些代碼從一個APK文件加載res資源并創建Resources實例,經過這些邏輯后就可以使用R文件訪問資源了。具體過程是,獲取一個AssetManager實例,使用其“addAssetPath”方法加載APK(里的資源),再使用DisplayMetrics、Configuration、CompatibilityInfo實例一起創建我們想要的Resources實例。
最終訪問插件APK里res資源的關鍵代碼如下
try { AssetManager assetManager = AssetManager.class.newInstance(); Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class); addAssetPath.invoke(assetManager, mDexPath); mAssetManager = assetManager; } catch (Exception e) { e.printStackTrace(); } Resources superRes = super.getResources(); mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
注意,有的人擔心從插件APK加載進來的res資源的ID可能與主項目里現有的資源ID沖突,其實這種方式加載進來的res資源并不是融入到主項目里面來,主項目里的res資源是保存在ContextImpl里面的Resources實例,整個項目共有,而新加進來的res資源是保存在新創建的Resources實例的,也就是說ProxyActivity其實有兩套res資源,并不是把新的res資源和原有的res資源合并了(所以不怕R.id重復),對兩個res資源的訪問都需要用對應的Resources實例,這也是開發時要處理的問題。(其實應該有3套,Android系統會加載一套framework-res.apk資源,里面存放系統默認Theme等資源)
額外補充下,這里你可能注意到了我們采用了反射的方法調用AssetManager的“addAssetPath”方法,而在上面ResourcesManager中調用AssetManager的“addAssetPath”方法是直接調用的,不用反射啊,而且看看SDK里AssetManager的“addAssetPath”方法的源碼(這里也能看到具體APK資源的提取過程是在Native里完成的),發現它也是public類型的,外部可以直接調用,為什么還要用反射呢?
/** * 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) { synchronized (this) { int res = addAssetPathNative(path); makeStringBlocks(mStringBlocks); return res; } }
這里有個誤區,SDK的源碼只是給我們參考用的,APP實際上運行的代碼邏輯在android.jar里面(位于android-sdk\platforms\android-XX),反編譯android.jar并找到ResourcesManager類就可以發現這些接口都是對應用層隱藏的。
public final class AssetManager{ AssetManager(){throw new RuntimeException("Stub!"); } public void close() { throw new RuntimeException("Stub!"); } public final InputStream open(String fileName) throws IOException { throw new RuntimeException("Stub!"); } public final InputStream open(String fileName, int accessMode) throws IOException { throw new RuntimeException("Stub!"); } public final AssetFileDescriptor openFd(String fileName) throws IOException { throw new RuntimeException("Stub!"); } public final native String[] list(String paramString) throws IOException; public final AssetFileDescriptor openNonAssetFd(String fileName) throws IOException { throw new RuntimeException("Stub!"); } public final AssetFileDescriptor openNonAssetFd(int cookie, String fileName) throws IOException { throw new RuntimeException("Stub!"); } public final XmlResourceParser openXmlResourceParser(String fileName) throws IOException { throw new RuntimeException("Stub!"); } public final XmlResourceParser openXmlResourceParser(int cookie, String fileName) throws IOException { throw new RuntimeException("Stub!"); } protected void finalize() throws Throwable { throw new RuntimeException("Stub!"); } public final native String[] getLocales(); }
到此,啟動插件里的Activity的兩大問題都有解決的方案了。
代理模式的具體項目 dynamic-load-apk
上面只是分析了代理模式的關鍵技術點,如果運用到具體項目中去的話,除了兩個關鍵的問題外,還有許多繁瑣的細節需要處理,我們需要設計一個框架,規范插件APK項目的開發,也方便以后功能的擴展。這里, dynamic-load-apk 向我們展示了許多優秀的處理方法,比如:
-
把Activity關鍵的生命周期方法抽象成DLPlugin接口,ProxyActivity通過DLPlugin代理調用插件Activity的生命周期;
-
設計一個基礎的BasePluginActivity類,插件項目里使用這些基類進行開發,可以以接近常規Android開發的方式開發插件項目;
-
以類似的方式處理Service的問題;
-
處理了大量常見的兼容性問題(比如使用Theme資源時出現的問題);
-
處理了插件項目里的so庫的加載問題;
-
使用PluginPackage管理插件APK,從而可以方便地管理多個插件項目;
具體的代碼請參考原項目的文檔、源碼以及Sample里面的示例代碼,在這里感謝 singwhatiwanna 的開源精神。
實際應用中可能要處理的問題
插件APK的管理后臺
使用動態加載的目的,就是希望可以繞過APK的安裝過程升級應用的功能,如果插件APK是打包在主項目內部的那動態加載純粹是多次一舉。更多的時候我們希望可以在線下載插件APK,并且在插件APK有新版本的時候,主項目要從服務器下載最新的插件替換本地已經存在的舊插件。為此,我們應該有一個管理后臺,它大概有以下功能:
-
上傳不同版本的插件APK,并向APP主項目提供插件APK信息查詢功能和下載功能;
-
管理在線的插件APK,并能向不同版本號的APP主項目提供最合適的插件APK;
-
萬一最新的插件APK出現緊急BUG,要提供舊版本回滾功能;
-
出于安全考慮應該對APP項目的請求信息做一些安全性校驗;
插件APK合法性校驗
加載外部的可執行代碼,一個逃不開的問題就是要確保外部代碼的安全性,我們可不希望加載一些來歷不明的插件APK,因為這些插件有的時候能訪問主項目的關鍵數據。
最簡單可靠的做法就是校驗插件APK的MD5值,如果插件APK的MD5與我們服務器預置的數值不同,就認為插件被改動過,棄用。
是熱部署,還是插件化?
這一部分作為補充說明,如果不太熟悉動態加載的使用姿勢,可能不是那么容易理解。
談到動態加載的時候我們經常說到“熱部署”和“插件化”這些名詞,它們雖然都和動態加載有關,但是還是有一點區別,這個問題涉及到主項目與插件項目的 交互方式 。前面我們說到,動態加載方式,可以在“項目層級”做到代碼分離,按道理我們希望是主項目和插件項目不要有任何交互行為,實際上也應該如此!這樣做不僅能確保項目的安全性,也能簡化開發工作,所以一般的做法是
只有在用戶使用到的時候才加載插件
主項目還是像常規Android項目那樣開發,只有用戶使用插件APK的功能時才動態加載插件并運行,插件一旦運行后,與主項目沒有任何交互邏輯,只有在主項目啟動插件的時候才觸發一次調用插件的行為。比如,我們的主項目里有幾款推廣的游戲,平時在用戶使用主項目的功能時,可以先靜默把游戲(其實就是一個插件APK)下載好,當用戶點擊游戲入口時,以動態加載的方式啟動游戲,游戲只運行插件APK里的代碼邏輯,結束后返回主項目界面。
一啟動主項目就加載插件
另外一種完全相反的情形是,主項目只提供一個啟動的入口,以及從服務器下載最新插件的更新邏輯,這兩部分的代碼都是長期保持不變的,應用一啟動就動態加載插件,所有業務邏輯的代碼都在插件里實現。比如現在一些游戲市場都要求開發者接入其SDK項目,如果SDK項目采用這種開發方式,先提供一個空殼的SDK給開發者,空殼SDK能從服務器下載最新的插件再運行插件里的邏輯,就能保證開發者開發的游戲每次啟動的時候都能運行最新的代碼邏輯,而不用讓開發者在SDK有新版本的時候重新更換SDK并構建新的游戲APK。
讓插件使用主項目的功能
明明,說了不要交互的,偏偏,Android開發者就是這么執著于技術。
有些時候,比如,主項目里有一個成熟的圖片加載框架ImageLoader,而插件里也有一個ImageLoader。如果一個應用同時運行兩套ImageLoader,那會有許多額外的性能開銷,如果能讓插件也用主項目的ImageLoader就好了。另外,如果在插件里需要用到用戶登錄功能,我們總不希望用戶使用主項目時進行一次登錄,進入插件時由來一次登錄,如果能在插件里使用主項目的登錄狀態就好了。
因此,有些時候我們希望插件項目能調用主項目的功能。怎么處理好呢,由于插件項目與主項目是分開的,我們在開發插件的時候,怎么調用主項目的代碼啊?這里需要稍微了解一下Android項目間的依賴方式。
想想一個普通的APK是怎么構建和運行的,Android SDK提供了許多系統類(如Activity、Fragment等,一般我們也喜歡在這里查看源碼),我們的Android項目依賴Android SDK項目并使用這些類進行開發,那構建APK的時候會把這些類打包進來嗎?不會,要是每個APK都打包一份,那得有多少冗余啊。所以Android項目至少有兩種依賴的方式,一種構建時會把被依賴的項目(Library)的類打包進來,一種不會。
在Android Studio打開項目的Project Structure,找到具體Module的Dependencies選項卡

可以看到Library項目有個Scope屬性,這里的Compile模式就是會把Library的類打包進來,而Provided模式就不會。
注意,使用Provided模式的Library只能是jar文件,而不能是一個Android Library項目,因為后者可能自帶了一些res資源,這些資源無法一并塞進標準的jar文件里面。到這里我們明白,Android SDK的代碼其實是打包進系統ROM(俗稱Framework層級)里面的,我們開發Android項目的時候,只是以Provided模式引用android.jar,從這個角度也佐證了上面談到的“為什么APP實際運行時AssetManager類的邏輯會與Android SDK里的源碼不一樣”。
現在好辦了,如果要在插件里使用主項目的ImageLoader,我們可以把ImageLoader的相關代碼抽離成一個Android Libary項目,主項目以Compile模式引用這個Libary,而插件項目以Provided模式引用這個Library(編譯出來的jar),這樣能實現兩者之間的交互了,當然代價也是明顯的。
-
我們應該只給插件開放一些必要的接口,不然會有安全性問題;
-
作為通用模塊的Library應該保持不變(起碼接口不變),不然主項目與插件項目的版本同步會復雜許多;
-
因為插件項目已經嚴重依賴主項目了,所以插件項目不能獨立運行,因為缺少必要的 環境 ;
最后我們再說說“熱部署”和“插件化”的區別,一般我們把獨立運行的插件APK叫熱部署,而需要依賴主項目的環境運行的插件APK叫做插件化。