個人Android重構之旅:插件化篇

隨着項目的不斷成長,即使項目採用了 MVP 或是 MVVM 這類優秀的架構,也很難跟得上迭代的腳步,當 APP 端功能愈來愈龐大、繁瑣,人員不斷加入後,牽一髮而動全局的事情時常發生,後續人員如同如履薄冰似的維護項目,爲此咱們必須考慮團隊壯大後的開發模式,提早對業務進行隔離,同時總結出插件化開發的流程,完善 Android 端基礎框架。android

本文是「個人Android重構之旅」的第三篇,也是讓我最爲頭疼的一篇,在本文中,我將會和你們聊一聊「插件化」的概念,以及咱們在「插件化」框架上的選擇與碰到的一些問題。git

魯迅如是說道

Plug-in Hello World

插件化是指將 APK 分爲宿主和插件的部分,在 APP 運行時,咱們能夠動態的載入或者替換插件部分。 宿主: 就是當前運行的APP。 插件: 相對於插件化技術來講,就是要加載運行的apk類文件。github

插件化分爲倆種形態,一種插件與宿主 APP 無交互例如微信與微信小程序,一種插件與宿主極度耦合例如滴滴出行,滴滴出行將用戶信息做爲獨立的模塊,須要與其餘模塊進行數據的交互,因爲使用場景不一致,本文只針對插件與宿主有頻繁數據交互的狀況。小程序

在咱們開發的過程當中,每每會碰到多人協做進行模塊化的開發,咱們指望可以獨立運行本身的模塊而又不受其餘人模塊的影響,還有一個更爲常見的需求,咱們在快速的產品迭代過程當中,咱們每每但願能無縫銜接新的功能至用戶手機上,過於頻繁的產品迭代或過長的開發週期,這會使得咱們在與竟品競爭時失去先機。後端

Git 提交記錄

上圖是一款人臉識別產品的迭代記錄,因爲上線的各個城市都有細微的邏輯差異,致使每次核心業務出現 BUG 同事要一個個 Push 至各各版本,而後通知各個城市的推廣商下載,這時候我就在想,能不能把咱們的應用作成插件的形式動態下發呢,這樣就避免了每次都須要的版本升級,在某次 Push 版本的深夜,我決定不能這樣下去了,我必定要用上插件化。微信小程序

插件化框架的選擇

下圖是主流的插件化、組件化框架bash

特性 DynamicLoadApk DynamicAPK Small DroidPlugin VirtualAPK
支持四大組件 只支持Activity 只支持Activity 只支持Activity 全支持 全支持
組件無需在宿主manifest中預註冊 ×
插件能夠依賴宿主 ×
支持PendingIntent × × ×
Android特性支持 大部分 大部分 大部分 幾乎所有 幾乎所有
兼容性適配 通常 通常 中等
插件構建 部署aapt Gradle插件 Gradle插件

最終反覆推敲決定使用滴滴出行的 VirtualAPK 做爲咱們的插件化框架,它有如下幾個優勢:微信

  • 可與宿主工程通訊
  • 兼容性強
  • 使用簡單
  • 編譯插件方便
  • 通過大規模使用

若是你要加載一個插件,而且這個插件無需和宿主有任何耦合,也無需和宿主進行通訊,而且你也不想對這個插件從新打包,那麼推薦選擇DroidPlugin。架構

Android 插件化技術的典型應用

插件化原理

VirtualAPK 對插件沒有額外的約束,原生的apk便可做爲插件。插件工程編譯生成 Apk 後,便可經過宿主 App 加載,每一個插件apk被加載後,都會在宿主中建立一個單獨的 LoadedPlugin 對象。以下圖所示,經過這些 LoadedPlugin 對象,VirtualAPK 就能夠管理插件並賦予插件新的意義,使其能夠像手機中安裝過的 App 同樣運行。app

咱們在引入一款框架的時候每每不能只單純的瞭解如何使用,應去深刻的瞭解它是如何工做的,特別是插件化這種熱門的技術,十分感謝開源項目給了咱們一把探尋 Android 世界的金鑰匙,下面將和你們簡易的分析下 VirtualAPK 的原理。

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, 轉載請註明原創

閱讀更多

個人Android重構之旅:框架篇

個人Android重構之旅:架構篇

Android進程保活招數概覽

NDK項目實戰—高仿360手機助手之卸載監聽

相關文章
相關標籤/搜索