我的Android重構(gòu)之旅:插件化篇
隨著項(xiàng)目的不斷成長(zhǎng),即便項(xiàng)目采用了 MVP 或是 MVVM 這類(lèi)優(yōu)秀的架構(gòu),也很難跟得上迭代的腳步,當(dāng) APP 端功能越來(lái)越龐大、繁瑣,人員不斷加入后,牽一發(fā)而動(dòng)全局的事情時(shí)常發(fā)生,后續(xù)人員如同如履薄冰似的維護(hù)項(xiàng)目,為此我們必須考慮團(tuán)隊(duì)壯大后的開(kāi)發(fā)模式,提前對(duì)業(yè)務(wù)進(jìn)行隔離,同時(shí)總結(jié)出插件化開(kāi)發(fā)的流程,完善 Android 端基礎(chǔ)框架。
本文是“我的Android重構(gòu)之旅”的第三篇,也是讓我最為頭疼的一篇,在本文中,我將會(huì)和大家聊一聊“插件化”的概念,以及我們?cè)?ldquo;插件化”框架上的選擇與碰到的一些問(wèn)題。
Plug-in Hello World
- 插件化是指將 APK 分為宿主和插件的部分,在 APP 運(yùn)行時(shí),我們可以動(dòng)態(tài)的載入或者替換插件部分。宿主: 就是當(dāng)前運(yùn)行的APP。插件: 相對(duì)于插件化技術(shù)來(lái)說(shuō),就是要加載運(yùn)行的apk類(lèi)文件。
插件化分為倆種形態(tài),一種插件與宿主 APP 無(wú)交互例如微信與微信小程序,一種插件與宿主極度耦合例如滴滴出行,滴滴出行將用戶(hù)信息作為獨(dú)立的模塊,需要與其他模塊進(jìn)行數(shù)據(jù)的交互,由于使用場(chǎng)景不一致,本文只針對(duì)插件與宿主有頻繁數(shù)據(jù)交互的情況。
在我們開(kāi)發(fā)的過(guò)程中,往往會(huì)碰到多人協(xié)作進(jìn)行模塊化的開(kāi)發(fā),我們期望能夠獨(dú)立運(yùn)行自己的模塊而又不受其他人模塊的影響,還有一個(gè)更為常見(jiàn)的需求,我們?cè)诳焖俚漠a(chǎn)品迭代過(guò)程中,我們往往希望能無(wú)縫銜接新的功能至用戶(hù)手機(jī)上,過(guò)于頻繁的產(chǎn)品迭代或過(guò)長(zhǎng)的開(kāi)發(fā)周期,這會(huì)使得我們?cè)谂c竟品競(jìng)爭(zhēng)時(shí)失去先機(jī)。
上圖是一款人臉識(shí)別產(chǎn)品的迭代記錄,由于上線的各個(gè)城市都有細(xì)微的邏輯差別,導(dǎo)致每次核心業(yè)務(wù)出現(xiàn) BUG 同事要一個(gè)個(gè) Push 至各各版本,然后通知各個(gè)城市的推廣商下載,這時(shí)候我就在想,能不能把我們的應(yīng)用做成插件的形式動(dòng)態(tài)下發(fā)呢,這樣就避免了每次都需要的版本升級(jí),在某次 Push 版本的深夜,我決定不能這樣下去了,我一定要用上插件化。
插件化框架的選擇
下圖是主流的插件化、組件化框架
最終反復(fù)推敲決定使用滴滴出行的 VirtualAPK 作為我們的插件化框架,它有以下幾個(gè)優(yōu)點(diǎn):
- 可與宿主工程通信
- 兼容性強(qiáng)
- 使用簡(jiǎn)單
- 編譯插件方便
- 經(jīng)過(guò)大規(guī)模使用
如果你要加載一個(gè)插件,并且這個(gè)插件無(wú)需和宿主有任何耦合,也無(wú)需和宿主進(jìn)行通信,并且你也不想對(duì)這個(gè)插件重新打包,那么推薦選擇DroidPlugin。
插件化原理
- VirtualAPK 對(duì)插件沒(méi)有額外的約束,原生的apk即可作為插件。插件工程編譯生成 Apk 后,即可通過(guò)宿主 App 加載,每個(gè)插件apk被加載后,都會(huì)在宿主中創(chuàng)建一個(gè)單獨(dú)的 LoadedPlugin 對(duì)象。如下圖所示,通過(guò)這些 LoadedPlugin 對(duì)象,VirtualAPK 就可以管理插件并賦予插件新的意義,使其可以像手機(jī)中安裝過(guò)的 App 一樣運(yùn)行。
我們?cè)谝胍豢羁蚣艿臅r(shí)候往往不能只單純的了解如何使用,應(yīng)去深入的了解它是如何工作的,特別是插件化這種熱門(mén)的技術(shù),十分感謝開(kāi)源項(xiàng)目給了我們一把探尋 Android 世界的金鑰匙,下面將和大家簡(jiǎn)易的分析下 VirtualAPK 的原理。
四大組件對(duì)于安卓人員都是再熟悉不過(guò)了,我們都清楚四大組建都是需要在 AndroidManifest 中注冊(cè)的,而對(duì)于 VirtualAPK 來(lái)說(shuō)是不可能預(yù)先知曉名字,提前注冊(cè)在宿主 Apk 中的,所以現(xiàn)在基本都采用 hack 方案解決,VirtualAPK 大致方案如下:
Activity:在宿主 Apk 中提前占坑,然后通過(guò) Hook Activity 的啟動(dòng)過(guò)程,“欺上瞞下”啟動(dòng)插件 Apk 中的 Activity,因?yàn)? Activity 存在不同的 LaunchMode 以及一些特殊的熟悉,所以需要多個(gè)占坑的“李鬼” Activity。
- Service:通過(guò)代理 Service 的方式去分發(fā);主進(jìn)程和其他進(jìn)程,VirtualAPK 使用了兩個(gè)代理Service。
- BroadcastReceiver:靜態(tài)轉(zhuǎn)動(dòng)態(tài)。
- ContentProvider:通過(guò)一個(gè)代理Provider進(jìn)行分發(fā)。
在本文,我們主要分析 Activity 的占坑過(guò)程,如果需要更深入的了解 VirtualAPK 請(qǐng)點(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 的加載過(guò)程就已經(jīng)完成了,這里大家肯定會(huì)有疑惑,該Activity必然沒(méi)有在Manifest中注冊(cè),這么啟動(dòng)不會(huì)報(bào)錯(cuò)嗎?
這就要涉及到 Activity 的啟動(dòng)流程了,我們?cè)趕tartActivity之后系統(tǒng)最終會(huì)調(diào)用 Instrumentation 的 execStartActivity 方法,然后再通過(guò) ActivityManagerProxy 與 AMS 進(jìn)行交互。
Activity 是否注冊(cè)在 Manifest 的校驗(yàn)是由 AMS 進(jìn)行的,所以我們?cè)谟?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 的名稱(chēng)
- final VAInstrumentation instrumentation = new VAInstrumentation(this, baseInstrumentation);
- Object activityThread = ReflectUtil.getActivityThread(this.mContext);
- // Hook Instrumentation 替換 Activity 名稱(chēng)
- ReflectUtil.setInstrumentation(activityThread, instrumentation);
- // Hook handleLaunchActivity
- ReflectUtil.setHandlerCallback(this.mContext, instrumentation);
- this.mInstrumentation = instrumentation;
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
上面我們已經(jīng)成功的 Hook 了 Instrumentation ,接下來(lái)就是需要我們的李鬼上場(chǎng)了
- 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;
- }
我們來(lái)看一看 markIntentIfNeeded(intent); 到底做了什么
- public void markIntentIfNeeded(Intent intent) {
- if (intent.getComponent() == null) {
- return;
- }
- String targetPackageName = intent.getComponent().getPackageName();
- String targetClassName = intent.getComponent().getClassName();
- // 保存我們?cè)袛?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);
- }
可以看到上面將我們?cè)镜男畔⒈4嬷?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 啟動(dòng)相應(yīng)的“李鬼” Activity 至此,我們已經(jīng)成功的 欺騙了 AMS ,啟動(dòng)了我們占坑的 Activity 但是只成功了一半,為什么這么說(shuō)呢?因?yàn)槠垓_過(guò)了 AMS,AMS 執(zhí)行完成后,最終要啟動(dòng)的并非是占坑的 Activity ,所以我們還要能正確的啟動(dòng)目標(biāo)Activity。
我們?cè)?Hook Instrumentation 的同時(shí)一并 Hook 了 handleLaunchActivity,所以我們之間到 Instrumentation 的 newActivity 方法查看啟動(dòng) 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 啟動(dòng)的插件 Activity
- if (targetClassName != null) {
- Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
- // 啟動(dòng)插件 Activity
- activity.setIntent(intent);
- try {
- // for 4.1+
- ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", plugin.getResources());
- } catch (Exception ignored) {
- // ignored.
- }
- return activity;
- }
- }
- // 宿主的 Activity 直接啟動(dòng)
- return mBase.newActivity(cl, className, intent);
- }
好了,到此Activity就可以正常啟動(dòng)了。
小結(jié)
VritualApk 整理思路很清晰,在這里我們只介紹了 Activity 的啟動(dòng)方式,感興趣的同學(xué)可以去網(wǎng)上了解下其余三大組建的代理方式。不論如何如果想使用插件化框架,一定要了解其中的實(shí)現(xiàn)原理,文檔上描述的并不是所有的細(xì)節(jié),很多一些屬性什么的,以及由于其實(shí)現(xiàn)的方式造成一些特性的不支持。
引入插件化之痛
由于項(xiàng)目的宿主與插件需要進(jìn)行較為緊密的交互,在插件化的同時(shí)需要對(duì)項(xiàng)目進(jìn)行模塊化,但是模塊化并不能一蹴而就,在模塊化的過(guò)程中經(jīng)常出現(xiàn),牽一發(fā)而動(dòng)全身的問(wèn)題,在經(jīng)歷過(guò)無(wú)數(shù)個(gè)通宵的夜晚后,我總結(jié)出了模塊化的幾項(xiàng)準(zhǔn)則。
VirtualAPK 本身的使用并不困難,困難的是需要逐步整理項(xiàng)目的模塊,在這期間問(wèn)題百出,因?yàn)樽陨頉](méi)有相關(guān)經(jīng)驗(yàn)在網(wǎng)上看了很多關(guān)于模塊化的文章,最終我找到有贊模塊化的文章,對(duì)他們總結(jié)出來(lái)的經(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ù)鏈的末端,由于角色所限,開(kāi)發(fā)人員對(duì)業(yè)務(wù)的理解比后端要淺,所謂欲速則不達(dá),重構(gòu)不能急,理清楚業(yè)務(wù)邏輯之后再動(dòng)手。
在模塊化進(jìn)行時(shí),我們需要將業(yè)務(wù)模塊進(jìn)行隔離,業(yè)務(wù)模塊之間不能互相依賴(lài)能存在數(shù)據(jù)傳輸,只能單向依賴(lài)宿主項(xiàng)目,為了達(dá)到這個(gè)效果 我們需要借用市面上的路由方案 ARouter ,由于篇幅原因,我在這里不做過(guò)多介紹,感興趣的同學(xué)可以自行搜索。
項(xiàng)目改造后宿主只留下最簡(jiǎn)單的公共基礎(chǔ)邏輯,其他部分都由插件的形式裝載,這樣使得我們?cè)诎姹靖碌倪^(guò)程中自由度很高,從項(xiàng)目結(jié)構(gòu)上我們看起來(lái)很像所有插件都依賴(lài)了宿主 App 的代碼,但實(shí)際上在打包的過(guò)程中 VirtualAPK 會(huì)幫助我們剔除重復(fù)資源。
模塊的更改上保持克制 在模塊化進(jìn)行時(shí),不要過(guò)分的追求***的目標(biāo),簡(jiǎn)單粗暴一點(diǎn),后續(xù)再逐漸改善,很多業(yè)務(wù)邏輯經(jīng)常會(huì)和其他業(yè)務(wù)邏輯產(chǎn)生牽連,它們倆會(huì)處于一個(gè)相對(duì)曖昧的關(guān)系,這種時(shí)候我們不要去強(qiáng)行的分割它們的業(yè)務(wù)邊界,過(guò)分的分割往往會(huì)因?yàn)榫幋a人員對(duì)于模塊的不清晰導(dǎo)致項(xiàng)目改造的全盤(pán)崩潰。
公共資源及時(shí)抽取 VirtualAPK 會(huì)幫助我們剔除重復(fù)資源,對(duì)于一些曖昧不清的資源我們可以索性將它放入宿主項(xiàng)目中,如果將過(guò)多的資源存于插件項(xiàng)目中,這樣會(huì)導(dǎo)致我們的插件失去應(yīng)有的靈活性和資源的復(fù)用性。
總結(jié)
最初在公司內(nèi)部推廣插件化的時(shí)候,同事們嘩然一片大多數(shù)都是對(duì)插件化的質(zhì)疑,在這里我要感謝我原來(lái)的領(lǐng)導(dǎo),在關(guān)鍵時(shí)刻給我的支持幫我頂住了大家質(zhì)疑的聲音,在十多個(gè)日日夜夜的修改重構(gòu)后,插件化后的***個(gè)上線的版本,插件化靈活的優(yōu)勢(shì)體現(xiàn)的***,每個(gè)插件只有60 KB 的大小,對(duì)服務(wù)端的帶寬幾乎沒(méi)有絲毫的壓力,幫助我們快速的進(jìn)行了產(chǎn)品的迭代 、Bug的修復(fù)。本文中,只是我自己在項(xiàng)目插件化的一些經(jīng)驗(yàn)與想法,并沒(méi)有深入的介紹如何使用 VirtualAPK 感興趣的同學(xué)可以讀一下 VirtualAPK 的 WiKi ,希望本文的設(shè)計(jì)思路能帶給你一些幫助。