我的Android重構之旅:插件化篇
隨著項目的不斷成長,即便項目采用了 MVP 或是 MVVM 這類優秀的架構,也很難跟得上迭代的腳步,當 APP 端功能越來越龐大、繁瑣,人員不斷加入后,牽一發而動全局的事情時常發生,后續人員如同如履薄冰似的維護項目,為此我們必須考慮團隊壯大后的開發模式,提前對業務進行隔離,同時總結出插件化開發的流程,完善 Android 端基礎框架。
本文是“我的Android重構之旅”的第三篇,也是讓我最為頭疼的一篇,在本文中,我將會和大家聊一聊“插件化”的概念,以及我們在“插件化”框架上的選擇與碰到的一些問題。
Plug-in Hello World
- 插件化是指將 APK 分為宿主和插件的部分,在 APP 運行時,我們可以動態的載入或者替換插件部分。宿主: 就是當前運行的APP。插件: 相對于插件化技術來說,就是要加載運行的apk類文件。
插件化分為倆種形態,一種插件與宿主 APP 無交互例如微信與微信小程序,一種插件與宿主極度耦合例如滴滴出行,滴滴出行將用戶信息作為獨立的模塊,需要與其他模塊進行數據的交互,由于使用場景不一致,本文只針對插件與宿主有頻繁數據交互的情況。
在我們開發的過程中,往往會碰到多人協作進行模塊化的開發,我們期望能夠獨立運行自己的模塊而又不受其他人模塊的影響,還有一個更為常見的需求,我們在快速的產品迭代過程中,我們往往希望能無縫銜接新的功能至用戶手機上,過于頻繁的產品迭代或過長的開發周期,這會使得我們在與竟品競爭時失去先機。
上圖是一款人臉識別產品的迭代記錄,由于上線的各個城市都有細微的邏輯差別,導致每次核心業務出現 BUG 同事要一個個 Push 至各各版本,然后通知各個城市的推廣商下載,這時候我就在想,能不能把我們的應用做成插件的形式動態下發呢,這樣就避免了每次都需要的版本升級,在某次 Push 版本的深夜,我決定不能這樣下去了,我一定要用上插件化。
插件化框架的選擇
下圖是主流的插件化、組件化框架
最終反復推敲決定使用滴滴出行的 VirtualAPK 作為我們的插件化框架,它有以下幾個優點:
- 可與宿主工程通信
- 兼容性強
- 使用簡單
- 編譯插件方便
- 經過大規模使用
如果你要加載一個插件,并且這個插件無需和宿主有任何耦合,也無需和宿主進行通信,并且你也不想對這個插件重新打包,那么推薦選擇DroidPlugin。
插件化原理
- VirtualAPK 對插件沒有額外的約束,原生的apk即可作為插件。插件工程編譯生成 Apk 后,即可通過宿主 App 加載,每個插件apk被加載后,都會在宿主中創建一個單獨的 LoadedPlugin 對象。如下圖所示,通過這些 LoadedPlugin 對象,VirtualAPK 就可以管理插件并賦予插件新的意義,使其可以像手機中安裝過的 App 一樣運行。
我們在引入一款框架的時候往往不能只單純的了解如何使用,應去深入的了解它是如何工作的,特別是插件化這種熱門的技術,十分感謝開源項目給了我們一把探尋 Android 世界的金鑰匙,下面將和大家簡易的分析下 VirtualAPK 的原理。
四大組件對于安卓人員都是再熟悉不過了,我們都清楚四大組建都是需要在 AndroidManifest 中注冊的,而對于 VirtualAPK 來說是不可能預先知曉名字,提前注冊在宿主 Apk 中的,所以現在基本都采用 hack 方案解決,VirtualAPK 大致方案如下:
Activity:在宿主 Apk 中提前占坑,然后通過 Hook Activity 的啟動過程,“欺上瞞下”啟動插件 Apk 中的 Activity,因為 Activity 存在不同的 LaunchMode 以及一些特殊的熟悉,所以需要多個占坑的“李鬼” Activity。
- Service:通過代理 Service 的方式去分發;主進程和其他進程,VirtualAPK 使用了兩個代理Service。
- BroadcastReceiver:靜態轉動態。
- ContentProvider:通過一個代理Provider進行分發。
在本文,我們主要分析 Activity 的占坑過程,如果需要更深入的了解 VirtualAPK 請點我
Activity 流程
我們如果要啟用 VirtualAPK 的話,需要先調用pluginManager.loadPlugin(apk),進行加載插件,然后我們繼續向下調用
- // 調用 LoadedPlugin 加載插件 Activity 信息
- LoadedPlugin plugin = LoadedPlugin.create(this, this.mContext, apk);
- // 加載插件的 Application
- plugin.invokeApplication();
我們可以發現插件 Activity 的解析是交由LoadedPlugin.create 去完成的,完成之后保存至 mPlugins 這個 Map 當中方便下次調用與解綁插件,我們繼續往下探索
- // 拷貝Resources
- this.mResources = createResources(context, apk);
- // 使用DexClassLoader加載插件并與現在的Dex進行合并
- this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());
- // 如果已經初始化不解析
- if (pluginManager.getLoadedPlugin(mPackageInfo.packageName) != null) {
- throw new RuntimeException("plugin has already been loaded : " + mPackageInfo.packageName);
- }
- // 解析APK
- this.mPackage = PackageParserCompat.parsePackage(context, apk, PackageParser.PARSE_MUST_BE_APK);
- // 拷貝插件中的So
- tryToCopyNativeLib(apk);
- // 保存插件中的 Activity 參數
- Map<ComponentName, ActivityInfo> activityInfos = new HashMap<ComponentName, ActivityInfo>();
- for (PackageParser.Activity activity : this.mPackage.activities) {
- activityInfos.put(activity.getComponentName(), activity.info);
- }
- this.mActivityInfos = Collections.unmodifiableMap(activityInfos);
- this.mPackageInfo.activities = activityInfos.values().toArray(new ActivityInfo[activityInfos.size()]);
LoadedPlugin 中將我們插件中的資源合并進了宿主 App 中,至此插件 App 的加載過程就已經完成了,這里大家肯定會有疑惑,該Activity必然沒有在Manifest中注冊,這么啟動不會報錯嗎?
這就要涉及到 Activity 的啟動流程了,我們在startActivity之后系統最終會調用 Instrumentation 的 execStartActivity 方法,然后再通過 ActivityManagerProxy 與 AMS 進行交互。
Activity 是否注冊在 Manifest 的校驗是由 AMS 進行的,所以我們在于 AMS 交互前,提前將 ActivityManagerProxy 提交給 AMS 的 ComponentName替換為我們占坑的名字即可。通常我們可以選擇 Hook Instrumentation 或者 Hook ActivityManagerProxy 都可以達到目標,VirtualAPK 選擇了 Hook Instrumentation 。
- private void hookInstrumentationAndHandler() {
- try {
- Instrumentation baseInstrumentation = ReflectUtil.getInstrumentation(this.mContext);
- if (baseInstrumentation.getClass().getName().contains("lbe")) {
- // reject executing in paralell space, for example, lbe.
- System.exit(0);
- }
- // 用于處理替換 Activity 的名稱
- final VAInstrumentation instrumentation = new VAInstrumentation(this, baseInstrumentation);
- Object activityThread = ReflectUtil.getActivityThread(this.mContext);
- // Hook Instrumentation 替換 Activity 名稱
- ReflectUtil.setInstrumentation(activityThread, instrumentation);
- // Hook handleLaunchActivity
- ReflectUtil.setHandlerCallback(this.mContext, instrumentation);
- this.mInstrumentation = instrumentation;
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
上面我們已經成功的 Hook 了 Instrumentation ,接下來就是需要我們的李鬼上場了
- public ActivityResult execStartActivity(
- Context who, IBinder contextThread, IBinder token, Activity target,
- Intent intent, int requestCode, Bundle options) {
- mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
- // 只有是插件中的Activity 才進行替換
- if (intent.getComponent() != null) {
- Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(),
- intent.getComponent().getClassName()));
- // 使用"李鬼"進行替換
- this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
- }
- ActivityResult result = realExecStartActivity(who, contextThread, token, target,
- intent, requestCode, options);
- return result;
- }
我們來看一看 markIntentIfNeeded(intent); 到底做了什么
- public void markIntentIfNeeded(Intent intent) {
- if (intent.getComponent() == null) {
- return;
- }
- String targetPackageName = intent.getComponent().getPackageName();
- String targetClassName = intent.getComponent().getClassName();
- // 保存我們原有數據
- if (!targetPackageName.equals(mContext.getPackageName()) && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
- intent.putExtra(Constants.KEY_IS_PLUGIN, true);
- intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
- intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
- dispatchStubActivity(intent);
- }
- }
- private void dispatchStubActivity(Intent intent) {
- ComponentName component = intent.getComponent();
- String targetClassName = intent.getComponent().getClassName();
- LoadedPlugin loadedPlugin = mPluginManager.getLoadedPlugin(intent);
- ActivityInfo info = loadedPlugin.getActivityInfo(component);
- // 判斷是否是插件中的Activity
- if (info == null) {
- throw new RuntimeException("can not find " + component);
- }
- int launchMode = info.launchMode;
- // 并入主題
- Resources.Theme themeObj = loadedPlugin.getResources().newTheme();
- themeObj.applyStyle(info.theme, true);
- // 將插件中的 Activity 替換為占坑的 Activity
- String stubActivity = mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj);
- Log.i(TAG, String.format("dispatchStubActivity,[%s -> %s]", targetClassName, stubActivity));
- intent.setClassName(mContext, stubActivity);
- }
可以看到上面將我們原本的信息保存至 Intent 中,然后調用了 getStubActivity(targetClassName, launchMode, themeObj); 進行了替換
- public static final String STUB_ACTIVITY_STANDARD = "%s.A$%d";
- public static final String STUB_ACTIVITY_SINGLETOP = "%s.B$%d";
- public static final String STUB_ACTIVITY_SINGLETASK = "%s.C$%d";
- public static final String STUB_ACTIVITY_SINGLEINSTANCE = "%s.D$%d";
- public String getStubActivity(String className, int launchMode, Theme theme) {
- String stubActivity= mCachedStubActivity.get(className);
- if (stubActivity != null) {
- return stubActivity;
- }
- TypedArray array = theme.obtainStyledAttributes(new int[]{
- android.R.attr.windowIsTranslucent,
- android.R.attr.windowBackground
- });
- boolean windowIsTranslucent = array.getBoolean(0, false);
- array.recycle();
- if (Constants.DEBUG) {
- Log.d("StubActivityInfo", "getStubActivity, is transparent theme ? " + windowIsTranslucent);
- }
- stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
- switch (launchMode) {
- case ActivityInfo.LAUNCH_MULTIPLE: {
- stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
- if (windowIsTranslucent) {
- stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, 2);
- }
- break;
- }
- case ActivityInfo.LAUNCH_SINGLE_TOP: {
- usedSingleTopStubActivity = usedSingleTopStubActivity % MAX_COUNT_SINGLETOP + 1;
- stubActivity = String.format(STUB_ACTIVITY_SINGLETOP, corePackage, usedSingleTopStubActivity);
- break;
- }
- case ActivityInfo.LAUNCH_SINGLE_TASK: {
- usedSingleTaskStubActivity = usedSingleTaskStubActivity % MAX_COUNT_SINGLETASK + 1;
- stubActivity = String.format(STUB_ACTIVITY_SINGLETASK, corePackage, usedSingleTaskStubActivity);
- break;
- }
- case ActivityInfo.LAUNCH_SINGLE_INSTANCE: {
- usedSingleInstanceStubActivity = usedSingleInstanceStubActivity % MAX_COUNT_SINGLEINSTANCE + 1;
- stubActivity = String.format(STUB_ACTIVITY_SINGLEINSTANCE, corePackage, usedSingleInstanceStubActivity);
- break;
- }
- default:break;
- }
- mCachedStubActivity.put(className, stubActivity);
- return stubActivity;
- }
- <!-- Stub Activities -->
- <activity android:name=".B$1" android:launchMode="singleTop"/>
- <activity android:name=".C$1" android:launchMode="singleTask"/>
- <activity android:name=".D$1" android:launchMode="singleInstance"/>
- 其余略····
StubActivityInfo 根據同的 launchMode 啟動相應的“李鬼” Activity 至此,我們已經成功的 欺騙了 AMS ,啟動了我們占坑的 Activity 但是只成功了一半,為什么這么說呢?因為欺騙過了 AMS,AMS 執行完成后,最終要啟動的并非是占坑的 Activity ,所以我們還要能正確的啟動目標Activity。
我們在 Hook Instrumentation 的同時一并 Hook 了 handleLaunchActivity,所以我們之間到 Instrumentation 的 newActivity 方法查看啟動 Activity 的流程。
- @Override
- public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
- try {
- // 是否能直接加載,如果能就是宿主中的 Activity
- cl.loadClass(className);
- } catch (ClassNotFoundException e) {
- // 取得正確的 Activity
- LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
- String targetClassName = PluginUtil.getTargetActivity(intent);
- Log.i(TAG, String.format("newActivity[%s : %s]", className, targetClassName));
- // 判斷是否是 VirtualApk 啟動的插件 Activity
- if (targetClassName != null) {
- Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
- // 啟動插件 Activity
- activity.setIntent(intent);
- try {
- // for 4.1+
- ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", plugin.getResources());
- } catch (Exception ignored) {
- // ignored.
- }
- return activity;
- }
- }
- // 宿主的 Activity 直接啟動
- return mBase.newActivity(cl, className, intent);
- }
好了,到此Activity就可以正常啟動了。
小結
VritualApk 整理思路很清晰,在這里我們只介紹了 Activity 的啟動方式,感興趣的同學可以去網上了解下其余三大組建的代理方式。不論如何如果想使用插件化框架,一定要了解其中的實現原理,文檔上描述的并不是所有的細節,很多一些屬性什么的,以及由于其實現的方式造成一些特性的不支持。
引入插件化之痛
由于項目的宿主與插件需要進行較為緊密的交互,在插件化的同時需要對項目進行模塊化,但是模塊化并不能一蹴而就,在模塊化的過程中經常出現,牽一發而動全身的問題,在經歷過無數個通宵的夜晚后,我總結出了模塊化的幾項準則。
VirtualAPK 本身的使用并不困難,困難的是需要逐步整理項目的模塊,在這期間問題百出,因為自身沒有相關經驗在網上看了很多關于模塊化的文章,最終我找到有贊模塊化的文章,對他們總結出來的經驗深刻認同。
在項目模塊化時應該遵循以下幾個準則
- 確定業務邏輯邊界
- 模塊的更改上保持克制
- 公共資源及時抽取
確定業務邏輯邊界 在模塊化之前,我們先要詳細的分析業務邏輯,App 作為業務鏈的末端,由于角色所限,開發人員對業務的理解比后端要淺,所謂欲速則不達,重構不能急,理清楚業務邏輯之后再動手。
在模塊化進行時,我們需要將業務模塊進行隔離,業務模塊之間不能互相依賴能存在數據傳輸,只能單向依賴宿主項目,為了達到這個效果 我們需要借用市面上的路由方案 ARouter ,由于篇幅原因,我在這里不做過多介紹,感興趣的同學可以自行搜索。
項目改造后宿主只留下最簡單的公共基礎邏輯,其他部分都由插件的形式裝載,這樣使得我們在版本更新的過程中自由度很高,從項目結構上我們看起來很像所有插件都依賴了宿主 App 的代碼,但實際上在打包的過程中 VirtualAPK 會幫助我們剔除重復資源。
模塊的更改上保持克制 在模塊化進行時,不要過分的追求***的目標,簡單粗暴一點,后續再逐漸改善,很多業務邏輯經常會和其他業務邏輯產生牽連,它們倆會處于一個相對曖昧的關系,這種時候我們不要去強行的分割它們的業務邊界,過分的分割往往會因為編碼人員對于模塊的不清晰導致項目改造的全盤崩潰。
公共資源及時抽取 VirtualAPK 會幫助我們剔除重復資源,對于一些曖昧不清的資源我們可以索性將它放入宿主項目中,如果將過多的資源存于插件項目中,這樣會導致我們的插件失去應有的靈活性和資源的復用性。
總結
最初在公司內部推廣插件化的時候,同事們嘩然一片大多數都是對插件化的質疑,在這里我要感謝我原來的領導,在關鍵時刻給我的支持幫我頂住了大家質疑的聲音,在十多個日日夜夜的修改重構后,插件化后的***個上線的版本,插件化靈活的優勢體現的***,每個插件只有60 KB 的大小,對服務端的帶寬幾乎沒有絲毫的壓力,幫助我們快速的進行了產品的迭代 、Bug的修復。本文中,只是我自己在項目插件化的一些經驗與想法,并沒有深入的介紹如何使用 VirtualAPK 感興趣的同學可以讀一下 VirtualAPK 的 WiKi ,希望本文的設計思路能帶給你一些幫助。