我的Android重構(gòu)之旅:架構(gòu)篇
我的Android重構(gòu)之旅:框架篇
我的Android重構(gòu)之旅:插件化篇
隨著項(xiàng)目的不斷成長渣玲,即便項(xiàng)目采用了 MVP 或是 MVVM 這類優(yōu)秀的架構(gòu)忘衍,也很難跟得上迭代的腳步铅搓,當(dāng) APP 端功能越來越龐大星掰、繁瑣蹋偏,人員不斷加入后威始,牽一發(fā)而動全局的事情時(shí)常發(fā)生黎棠,后續(xù)人員如同如履薄冰似的維護(hù)項(xiàng)目脓斩,為此我們必須考慮團(tuán)隊(duì)壯大后的開發(fā)模式随静,提前對業(yè)務(wù)進(jìn)行隔離燎猛,同時(shí)總結(jié)出插件化開發(fā)的流程沸停,完善 Android 端基礎(chǔ)框架愤钾。
本文是“我的Android重構(gòu)之旅”的第三篇能颁,也是讓我最為頭疼的一篇劲装,在本文中占业,我將會和大家聊一聊“插件化”的概念谦疾,以及我們在“插件化”框架上的選擇與碰到的一些問題念恍。
Plug-in Hello World
插件化是指將 APK 分為宿主和插件的部分,在 APP 運(yùn)行時(shí)瞳氓,我們可以動態(tài)的載入或者替換插件部分匣摘。
宿主: 就是當(dāng)前運(yùn)行的APP音榜。
插件: 相對于插件化技術(shù)來說赠叼,就是要加載運(yùn)行的apk類文件嘴办。
插件化分為倆種形態(tài),一種插件與宿主 APP 無交互例如微信與微信小程序癞谒,一種插件與宿主極度耦合例如滴滴出行双仍,滴滴出行將用戶信息作為獨(dú)立的模塊朱沃,需要與其他模塊進(jìn)行數(shù)據(jù)的交互逗物,由于使用場景不一致翎卓,本文只針對插件與宿主有頻繁數(shù)據(jù)交互的情況失暴。
在我們開發(fā)的過程中逗扒,往往會碰到多人協(xié)作進(jìn)行模塊化的開發(fā)矩肩,我們期望能夠獨(dú)立運(yùn)行自己的模塊而又不受其他人模塊的影響述暂,還有一個(gè)更為常見的需求畦韭,我們在快速的產(chǎn)品迭代過程中艺配,我們往往希望能無縫銜接新的功能至用戶手機(jī)上转唉,過于頻繁的產(chǎn)品迭代或過長的開發(fā)周期赠法,這會使得我們在與竟品競爭時(shí)失去先機(jī)砖织。
上圖是一款人臉識別產(chǎn)品的迭代記錄侧纯,由于上線的各個(gè)城市都有細(xì)微的邏輯差別,導(dǎo)致每次核心業(yè)務(wù)出現(xiàn) BUG 同事要一個(gè)個(gè) Push 至各各版本块请,然后通知各個(gè)城市的推廣商下載墩新,這時(shí)候我就在想抖棘,能不能把我們的應(yīng)用做成插件的形式動態(tài)下發(fā)呢切省,這樣就避免了每次都需要的版本升級朝捆,在某次 Push 版本的深夜驯用,我決定不能這樣下去了,我一定要用上插件化记餐。
插件化框架的選擇
下圖是主流的插件化片酝、組件化框架
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支持四大組件 | 只支持Activity | 只支持Activity | 只支持Activity | 全支持 | 全支持 |
組件無需在宿主manifest中預(yù)注冊 | √ | × | √ | √ | √ |
插件可以依賴宿主 | √ | √ | √ | × | √ |
支持PendingIntent | × | × | × | √ | √ |
Android特性支持 | 大部分 | 大部分 | 大部分 | 幾乎全部 | 幾乎全部 |
兼容性適配 | 一般 | 一般 | 中等 | 高 | 高 |
插件構(gòu)建 | 無 | 部署aapt | Gradle插件 | 無 | Gradle插件 |
最終反復(fù)推敲決定使用滴滴出行的 VirtualAPK 作為我們的插件化框架,它有以下幾個(gè)優(yōu)點(diǎn):
- 可與宿主工程通信
- 兼容性強(qiáng)
- 使用簡單
- 編譯插件方便
- 經(jīng)過大規(guī)模使用
如果你要加載一個(gè)插件审轮,并且這個(gè)插件無需和宿主有任何耦合,也無需和宿主進(jìn)行通信榆苞,并且你也不想對這個(gè)插件重新打包坐漏,那么推薦選擇DroidPlugin赊琳。
插件化原理
VirtualAPK 對插件沒有額外的約束,原生的apk即可作為插件趁尼。插件工程編譯生成 Apk 后酥泞,即可通過宿主 App 加載芝囤,每個(gè)插件apk被加載后悯姊,都會在宿主中創(chuàng)建一個(gè)單獨(dú)的 LoadedPlugin 對象悯许。如下圖所示岸晦,通過這些 LoadedPlugin 對象启上,VirtualAPK 就可以管理插件并賦予插件新的意義,使其可以像手機(jī)中安裝過的 App 一樣運(yùn)行倒慧。
我們在引入一款框架的時(shí)候往往不能只單純的了解如何使用纫谅,應(yīng)去深入的了解它是如何工作的,特別是插件化這種熱門的技術(shù)询吴,十分感謝開源項(xiàng)目給了我們一把探尋 Android 世界的金鑰匙亮元,下面將和大家簡易的分析下 VirtualAPK 的原理奉瘤。
四大組件對于安卓人員都是再熟悉不過了盗温,我們都清楚四大組建都是需要在 AndroidManifest 中注冊的肌访,而對于 VirtualAPK 來說是不可能預(yù)先知曉名字惩激,提前注冊在宿主 Apk 中的风钻,所以現(xiàn)在基本都采用 hack 方案解決骡技,VirtualAPK 大致方案如下:
- Activity:在宿主 Apk 中提前占坑,然后通過 Hook Activity 的啟動過程昼窗,“欺上瞞下”啟動插件 Apk 中的 Activity唆途,因?yàn)?Activity 存在不同的 LaunchMode 以及一些特殊的熟悉肛搬,所以需要多個(gè)占坑的“李鬼” Activity温赔。
- Service:通過代理 Service 的方式去分發(fā)陶贼;主進(jìn)程和其他進(jìn)程骇窍,VirtualAPK 使用了兩個(gè)代理Service。
- BroadcastReceiver:靜態(tài)轉(zhuǎn)動態(tài)驱犹。
- ContentProvider:通過一個(gè)代理Provider進(jìn)行分發(fā)雄驹。
在本文医舆,我們主要分析 Activity 的占坑過程,如果需要更深入的了解 VirtualAPK 請點(diǎn)我
Activity 流程
我們?nèi)绻獑⒂?VirtualAPK 的話惫东,需要先調(diào)用pluginManager.loadPlugin(apk)
廉沮,進(jìn)行加載插件,然后我們繼續(xù)向下調(diào)用
// 調(diào)用 LoadedPlugin 加載插件 Activity 信息
LoadedPlugin plugin = LoadedPlugin.create(this, this.mContext, apk);
// 加載插件的 Application
plugin.invokeApplication();
我們可以發(fā)現(xiàn)插件 Activity 的解析是交由LoadedPlugin.create 去完成的徐矩,完成之后保存至 mPlugins
這個(gè) Map 當(dāng)中方便下次調(diào)用與解綁插件滞时,我們繼續(xù)往下探索
// 拷貝Resources
this.mResources = createResources(context, apk);
// 使用DexClassLoader加載插件并與現(xiàn)在的Dex進(jìn)行合并
this.mClassLoader = createClassLoader(context, apk, this.mNativeLibDir, context.getClassLoader());
// 如果已經(jīng)初始化不解析
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 參數(shù)
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 中將我們插件中的資源合并進(jìn)了宿主 App 中,至此插件 App 的加載過程就已經(jīng)完成了丧蘸,這里大家肯定會有疑惑漂洋,該Activity必然沒有在Manifest中注冊,這么啟動不會報(bào)錯(cuò)嗎力喷?
這就要涉及到 Activity 的啟動流程了刽漂,我們在startActivity
之后系統(tǒng)最終會調(diào)用 Instrumentation 的 execStartActivity 方法弟孟,然后再通過 ActivityManagerProxy 與 AMS 進(jìn)行交互。
Activity 是否注冊在 Manifest 的校驗(yàn)是由 AMS 進(jìn)行的,所以我們在于 AMS 交互前,提前將 ActivityManagerProxy 提交給 AMS 的 ComponentName
替換為我們占坑的名字即可。
通常我們可以選擇 Hook Instrumentation 或者 Hook ActivityManagerProxy 都可以達(dá)到目標(biāo),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();
}
}
上面我們已經(jīng)成功的 Hook 了 Instrumentation ,接下來就是需要我們的李鬼上場了
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode, Bundle options) {
mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
// 只有是插件中的Activity 才進(jìn)行替換
if (intent.getComponent() != null) {
Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(),
intent.getComponent().getClassName()));
// 使用"李鬼"進(jìn)行替換
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();
// 保存我們原有數(shù)據(jù)
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 中颈墅,然后調(diào)用了 getStubActivity(targetClassName, launchMode, themeObj);
進(jìn)行了替換
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 根據(jù)同的 launchMode
啟動相應(yīng)的“李鬼” Activity 至此腿箩,我們已經(jīng)成功的 欺騙了 AMS ,啟動了我們占坑的 Activity 但是只成功了一半滑潘,為什么這么說呢追逮?因?yàn)槠垓_過了 AMS,AMS 執(zhí)行完成后漾唉,最終要啟動的并非是占坑的 Activity 般此,所以我們還要能正確的啟動目標(biāo)Activity。
我們在 Hook Instrumentation 的同時(shí)一并 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就可以正常啟動了芥挣。
小結(jié)
VritualApk 整理思路很清晰,在這里我們只介紹了 Activity 的啟動方式都弹,感興趣的同學(xué)可以去網(wǎng)上了解下其余三大組建的代理方式。不論如何如果想使用插件化框架,一定要了解其中的實(shí)現(xiàn)原理油狂,文檔上描述的并不是所有的細(xì)節(jié)溪烤,很多一些屬性什么的掂铐,以及由于其實(shí)現(xiàn)的方式造成一些特性的不支持炮叶。
引入插件化之痛
由于項(xiàng)目的宿主與插件需要進(jìn)行較為緊密的交互,在插件化的同時(shí)需要對項(xiàng)目進(jìn)行模塊化矩距,但是模塊化并不能一蹴而就,在模塊化的過程中經(jīng)常出現(xiàn)艇潭,牽一發(fā)而動全身的問題,在經(jīng)歷過無數(shù)個(gè)通宵的夜晚后,我總結(jié)出了模塊化的幾項(xiàng)準(zhǔn)則盗扇。
VirtualAPK 本身的使用并不困難颠锉,困難的是需要逐步整理項(xiàng)目的模塊戈毒,在這期間問題百出,因?yàn)樽陨頉]有相關(guān)經(jīng)驗(yàn)在網(wǎng)上看了很多關(guān)于模塊化的文章迹蛤,最終我找到有贊模塊化的文章,對他們總結(jié)出來的經(jīng)驗(yàn)深刻認(rèn)同落包。
在項(xiàng)目模塊化時(shí)應(yīng)該遵循以下幾個(gè)準(zhǔn)則
- 確定業(yè)務(wù)邏輯邊界
- 模塊的更改上保持克制
- 公共資源及時(shí)抽取
確定業(yè)務(wù)邏輯邊界
在模塊化之前旭寿,我們先要詳細(xì)的分析業(yè)務(wù)邏輯疾层,App 作為業(yè)務(wù)鏈的末端琉历,由于角色所限,開發(fā)人員對業(yè)務(wù)的理解比后端要淺授舟,所謂欲速則不達(dá)桩盲,重構(gòu)不能急伤靠,理清楚業(yè)務(wù)邏輯之后再動手该窗。
在模塊化進(jìn)行時(shí)贪绘,我們需要將業(yè)務(wù)模塊進(jìn)行隔離狸窘,業(yè)務(wù)模塊之間不能互相依賴能存在數(shù)據(jù)傳輸,只能單向依賴宿主項(xiàng)目奴曙,為了達(dá)到這個(gè)效果 我們需要借用市面上的路由方案 ARouter 洽糟,由于篇幅原因沉御,我在這里不做過多介紹伐谈,感興趣的同學(xué)可以自行搜索距贷。
項(xiàng)目改造后宿主只留下最簡單的公共基礎(chǔ)邏輯配阵,其他部分都由插件的形式裝載拂铡,這樣使得我們在版本更新的過程中自由度很高壹无,從項(xiàng)目結(jié)構(gòu)上我們看起來很像所有插件都依賴了宿主 App 的代碼,但實(shí)際上在打包的過程中 VirtualAPK 會幫助我們剔除重復(fù)資源感帅。
模塊的更改上保持克制
在模塊化進(jìn)行時(shí)斗锭,不要過分的追求完美的目標(biāo),簡單粗暴一點(diǎn)失球,后續(xù)再逐漸改善岖是,很多業(yè)務(wù)邏輯經(jīng)常會和其他業(yè)務(wù)邏輯產(chǎn)生牽連,它們倆會處于一個(gè)相對曖昧的關(guān)系实苞,這種時(shí)候我們不要去強(qiáng)行的分割它們的業(yè)務(wù)邊界豺撑,過分的分割往往會因?yàn)榫幋a人員對于模塊的不清晰導(dǎo)致項(xiàng)目改造的全盤崩潰。
公共資源及時(shí)抽取
VirtualAPK 會幫助我們剔除重復(fù)資源黔牵,對于一些曖昧不清的資源我們可以索性將它放入宿主項(xiàng)目中聪轿,如果將過多的資源存于插件項(xiàng)目中,這樣會導(dǎo)致我們的插件失去應(yīng)有的靈活性和資源的復(fù)用性猾浦。
總結(jié)
最初在公司內(nèi)部推廣插件化的時(shí)候陆错,同事們嘩然一片大多數(shù)都是對插件化的質(zhì)疑,在這里我要感謝我原來的領(lǐng)導(dǎo)金赦,在關(guān)鍵時(shí)刻給我的支持幫我頂住了大家質(zhì)疑的聲音音瓷,在十多個(gè)日日夜夜的修改重構(gòu)后,插件化后的第一個(gè)上線的版本素邪,插件化靈活的優(yōu)勢體現(xiàn)的淋漓盡致外莲,每個(gè)插件只有60 KB 的大小猪半,對服務(wù)端的帶寬幾乎沒有絲毫的壓力兔朦,幫助我們快速的進(jìn)行了產(chǎn)品的迭代 、Bug的修復(fù)磨确。
本文中沽甥,只是我自己在項(xiàng)目插件化的一些經(jīng)驗(yàn)與想法,并沒有深入的介紹如何使用 VirtualAPK 感興趣的同學(xué)可以讀一下 VirtualAPK 的 WiKi 乏奥,希望本文的設(shè)計(jì)思路能帶給你一些幫助摆舟。