隨著專案的不斷成長,即便專案採用了 MVP 或是 MVVM 這類優秀的架構,也很難跟得上迭代的腳步,當 APP 端功能越來越龐大、繁瑣,人員不斷加入後,牽一髮而動全域性的事情時常發生,後續人員如同如履薄冰似的維護專案,為此我們必須考慮團隊壯大後的開發模式,提前對業務進行隔離,同時總結出外掛化開發的流程,完善 Android 端基礎框架。
本文是“我的Android重構之旅”的第三篇,也是讓我最為頭疼的一篇,在本文中,我將會和大家聊一聊“外掛化”的概念,以及我們在“外掛化”框架上的選擇與碰到的一些問題。
Plug-in Hello World
外掛化是指將 APK 分為宿主和外掛的部分,在 APP 執行時,我們可以動態的載入或者替換外掛部分。 宿主: 就是當前執行的APP。 外掛: 相對於外掛化技術來說,就是要載入執行的apk類檔案。
外掛化分為倆種形態,一種外掛與宿主 APP 無互動例如微信與微信小程式,一種外掛與宿主極度耦合例如滴滴出行,滴滴出行將使用者資訊作為獨立的模組,需要與其他模組進行資料的互動,由於使用場景不一致,本文只針對外掛與宿主有頻繁資料互動的情況。
在我們開發的過程中,往往會碰到多人協作進行模組化的開發,我們期望能夠獨立執行自己的模組而又不受其他人模組的影響,還有一個更為常見的需求,我們在快速的產品迭代過程中,我們往往希望能無縫銜接新的功能至使用者手機上,過於頻繁的產品迭代或過長的開發週期,這會使得我們在與竟品競爭時失去先機。
上圖是一款人臉識別產品的迭代記錄,由於上線的各個城市都有細微的邏輯差別,導致每次核心業務出現 BUG 同事要一個個 Push 至各各版本,然後通知各個城市的推廣商下載,這時候我就在想,能不能把我們的應用做成外掛的形式動態下發呢,這樣就避免了每次都需要的版本升級,在某次 Push 版本的深夜,我決定不能這樣下去了,我一定要用上外掛化。
外掛化框架的選擇
下圖是主流的外掛化、元件化框架
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支援四大元件 | 只支援Activity | 只支援Activity | 只支援Activity | 全支援 | 全支援 |
元件無需在宿主manifest中預註冊 | √ | × | √ | √ | √ |
外掛可以依賴宿主 | √ | √ | √ | × | √ |
支援PendingIntent | × | × | × | √ | √ |
Android特性支援 | 大部分 | 大部分 | 大部分 | 幾乎全部 | 幾乎全部 |
相容性適配 | 一般 | 一般 | 中等 | 高 | 高 |
外掛構建 | 無 | 部署aapt | Gradle外掛 | 無 | Gradle外掛 |
最終反覆推敲決定使用滴滴出行的 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 ,希望本文的設計思路能帶給你一些幫助。
連結:https://www.jianshu.com/p/c6f2a516b182, 轉載請註明原創
閱讀更多