外掛化之VirtualApk實戰一:專案配置

黃名堡發表於2018-12-12

(demo地址)

零、 介紹一下

VirtualApk是滴滴開源的一套外掛化方案,其支援四大元件,支援外掛宿主之間的互動,相容性強,在滴滴出行APP中有應用。下面是官方文件中與其他主流外掛化框架的對比(檢視原文):

特性 DynamicLoadApk DynamicAPK Small DroidPlugin VirtualAPK
支援四大元件 只支援Activity 只支援Activity 只支援Activity 全支援 全支援
元件無需在宿主manifest中預註冊 ×
外掛可以依賴宿主 ×
支援PendingIntent × × ×
Android特性支援 大部分 大部分 大部分 幾乎全部 幾乎全部
相容性適配 一般 一般 中等
外掛構建 部署aapt Gradle外掛 Gradle外掛

一、配置

1.1 接入主程式

  1. 新增gradle依賴 在根目錄build.gradle中新增外掛
    buildscript {
        dependencies {
            ...
            classpath 'com.didi.virtualapk:gradle:0.9.8.6'
            ...
        }
    }
複製程式碼
  1. 引入外掛 在app模組的build.gradle中新增 apply plugin: 'com.didi.virtualapk.host'

  2. 新增依賴 在app模組的build.gradle中的dependencies中加入 implementation 'com.didi.virtualapk:core:0.9.8'

  3. 初始化SDK 選擇一個合適的時機初始化SDK,一般是在專案的Application類的attachBaseContext方法中完成。

    override fun attachBaseContext(base: Context?) {
        super.attachBaseContext(base)
        PluginManager.getInstance(base).init()
    }
複製程式碼

1.2 接入外掛模組

  1. 新增gradle依賴 同上面接入主程式環節第一步配置,如果外掛模組和主程式在同一個專案中則可以忽略

  2. 引入外掛 在外掛模組的build.gradle中新增apply plugin: 'com.didi.virtualapk.plugin' 注意的是:外掛模組也是一個應用專案而非庫專案,即apply plugin: 'com.android.application'而不是apply plugin: 'com.android.library'

  3. 宣告外掛配置 在外掛模組的build.gradle底部宣告virtualApk配置

    virtualApk {
        packageId = 0x6f // 資源字首.
        targetHost = '../app' // 宿主模組的檔案路徑,生成外掛會檢查依賴項,分析和排除與宿主APP的共同依賴.
        applyHostMapping = true //optional, default value: true.
    }
    複製程式碼

    其中packageId是資源id的字首,用來區分外掛資源,所以外掛之間要使用不同的字首。 這個字首不一定要0x6f,正常我們的APP編譯出來的R檔案一般像下面這種,可以看出字首是0x7f,理論上這個packageId的取值範圍應為[0x00,0x7f),然而0x010x02等等已經被系統應用佔用,具體佔用多少不得而知,因此儘量選擇偏大且足夠分配給所有外掛使用的數字。

    public final class R {
        public static final class anim {
            public static final int abc_fade_in=0x7f010000;
            public static final int abc_fade_out=0x7f010001;
            public static final int abc_grow_fade_in_from_bottom=0x7f010002;
        }
    }
    複製程式碼

關於packageId的官方說明

到這裡就已經完成了VirtualApk的宿主以及外掛模組的配置,非常簡單,可以看出對我們現有的工程完全幾乎不需要修改,我們依然可以用我們習慣的模組化的開發方式。

截止發稿時的最新版本是0.9.8.6,建議大家儘量使用最新版本,畢竟安卓的碎片化這麼嚴重,而且hook方案多少會有些不完美的地方,相信滴滴以及gayhub的基友們會在新版本不停的完善它,而且老版本很可能不會維護。 一般從官方GitHub專案的releases可以找到當前最新版本。

這裡給大家安利一個maven構件搜尋網站mvnrepository.com/,在這裡可以搜尋主流maven倉庫中的構件,比如這裡的VirtualApk,可以很方便的檢視版本,以及生成maven、gradle等構建工具的引用語法。

二、應用

這裡以一個比較典型的場景:宿主APP啟動外掛中的Activity為例。

2.1 編寫外掛

外掛模組和平常的模組開發完全一樣,完全感知不到是在開發一個外掛,因此現有工程的模組也可以相對比較容易的轉換成外掛。

  1. 新建一個應用模組pluginA,按上面的提到的配置方法配好gradle,注意是apply plugin: 'com.android.application'

  2. 取一個唯一的applicationId,這裡以applicationId "com.huangmb.plugin.a"為例。

  3. 新建一個Activity,為簡單起見這裡直接選了Studio內建的滾動檢視模版com.huangmb.plugin.a.ScrollingActivity

    因為本身是一個應用模組,因此你也可以直接執行這個模組,會看到下面這個熟悉的介面。

    ScrollingActivity
    這種直接執行的方式非常方便我們開發除錯外掛,但這不是我們的最終目的,我們要把它變成一個外掛。

  4. 生成外掛 生成外掛非常簡單,執行命令./gradlew assemblePlugin或雙擊gradle皮膚的assemblePlugin即可。

    gradle命令
    在實踐中多次遇到過生成的外掛執行時閃退,主要出在id字首的問題上,這裡建議大家在assemble之前最好先clean一遍。

    執行後將會在build/outputs/plugin/release資料夾能找到生成的外掛包,檔名格式一般是"{applicationId}_yyyyMMddHHmmss.apk"。我沒找到配置輸出檔名的地方,我個人更傾向於一個固定的檔名,這種動態檔名會導致每編譯一次就增加一個檔案。

  5. 安裝外掛 安裝外掛本質上是把外掛apk放置到一個宿主外掛能訪問到檔案路徑下以便宿主載入。這裡演示為主,不去設計安裝外掛的邏輯了,直接把外掛重新命名為pluginA.apk,通過Android Studio的Device Explorer工具複製到宿主應用資料夾下,即Android/data/{app_applicationId}/cache。等下宿主APP會從這個目錄下讀取外掛。

2.2 宿主APP部分

宿主APP要做的事情很簡單,就是一個按鈕,在其點選事件中啟動pluginA.apk中的ScrollingActivity。

  1. 根據前面第一部分1.1節完成宿主上的外掛初始化。

  2. 載入外掛 一定要確保在啟動外掛程式碼之前的某個時機先載入外掛(不然哪有外掛的程式碼),比如在Application的onCreate中(適合已知外掛位置的情況,比如內建外掛或者已安裝外掛),或者在執行外掛程式碼前動態載入。 為了方便後面的程式碼,這裡定義了三個常量,分別是外掛檔名、外掛包名和外掛的Activity類名。

      private const val PLUGIN_NAME = "pluginA.apk"
      private const val PLUGIN_PKG = "com.huangmb.plugin.a"
      private const val PLUGIN_ACTIVITY = "com.huangmb.plugin.a.ScrollingActivity"
    複製程式碼

    載入外掛的方式為

    val apk = File(externalCacheDir, PLUGIN_NAME)
    PluginManager.getInstance(this).loadPlugin(apk)
    複製程式碼

    在VirtualApk中,外掛不允許重複載入,因此可以封裝一下外掛載入方法,在載入外掛前檢驗一下外掛載入情況

      //檢測是否已經安裝了外掛,未安裝則通過loadPlugin安裝
      private fun checkPlugin(): Boolean {
         PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) ?: return loadPlugin()
         return true
      }
      private fun loadPlugin(): Boolean {
         val apk = File(externalCacheDir, PLUGIN_NAME)
         if (apk.exists()) {
             //載入外掛
             val manager = PluginManager.getInstance(this)
             manager.loadPlugin(apk)
             PluginUtil.hookActivityResources(this, PLUGIN_PKG)
             return true
         }
         //外掛不存在
         return false
    
     }
    複製程式碼

    在呼叫外掛程式碼前可以先呼叫一下checkPlugin方法,正常載入了外掛時返回true,否則返回falsegetLoadedPlugin方法會返回一個LoadedPlugin物件,這是一個很有用的物件,宿主APP要獲取外掛中的AndroidManifest資訊就通過它,這個方法如果返回null則表明外掛未安裝。

  3. 跳轉外掛Activity 跳轉外掛Activity也是通過Intent跳轉,不過這裡通過外掛包名和Activity類名啟動,因為一般宿主專案不會依賴外掛,這裡沒法直接引用到ScrollingActivity.class。

   val i = Intent()
   i.setClassName(PLUGIN_PKG, PLUGIN_ACTIVITY)
   startActivity(i)
複製程式碼

這就完成了一次外掛化實踐,來看一下執行效果:

執行效果
完美

三、原理

上面的的示例中,我們並沒有在宿主的AndroidManifest中註冊ScrollingActivity,但是仍然可以通過startActivity來啟動它。

這裡簡單介紹下Activity外掛化的原理,有時間再單獨開一篇介紹一下四大元件的外掛原理。

實際上,VirtualApk通過hook了一下系統API,模擬了Activity的生命週期。通過PluginManager原始碼中我們可以看到這樣的程式碼,通過反射替換了系統的Instrument。

   protected void hookInstrumentationAndHandler() {
        try {
            ActivityThread activityThread = ActivityThread.currentActivityThread();
            Instrumentation baseInstrumentation = activityThread.getInstrumentation();
    
            final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
            Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
            Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
            Reflector.with(mainHandler).field("mCallback").set(instrumentation);
            this.mInstrumentation = instrumentation;
            Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
        } catch (Exception e) {
            Log.w(TAG, e);
        }
    }
複製程式碼

Instrument在自動化測試中我們經常見過它的身影,比如這段單元測試,通過Instrument啟動了Activity,模擬了一個Activity執行環境。

   Intent intent = new Intent();
        intent.setClassName("com.sample", Sample.class.getName());
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        sample = (Sample) getInstrumentation().startActivitySync(intent);
        text = (TextView) sample.findViewById(R.id.text1);
        button = (Button) sample.findViewById(R.id.button1);
複製程式碼

VirtualApk也是基於這個原理,通過一個自定義的VAInstrumentation,過載了各個execStartActivity方法,將啟動外掛Activity的Intent做了一些識別和標記,即injectIntent方法,

  public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, String target, Intent intent, int requestCode, Bundle options) {
        injectIntent(intent);
        return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode, options);
    }
    
    protected void injectIntent(Intent intent) {
        mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
        // null component is an implicitly intent
        if (intent.getComponent() != null) {
            Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(), intent.getComponent().getClassName()));
            // resolve intent with Stub Activity if needed
            this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
        }
    }
複製程式碼

並在newActivity方法中做了從外掛中載入Activity的邏輯,在injectActivity方法中通過反射替換了外掛Activity中的resources物件,替換的Resources物件來自於LoadedPlugin的createResources方法,將外掛安裝包資料夾加入到AssetManager路徑中:

  protected Resources createResources(Context context, String packageName, File apk) throws Exception {
        if (Constants.COMBINE_RESOURCES) {
            return ResourcesManager.createResources(context, packageName, apk);
        } else {
            Resources hostResources = context.getResources();
            AssetManager assetManager = createAssetManager(context, apk);
            return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
        }
    }
複製程式碼

這樣外掛Activity中的getResources.getXXX方法就能從外掛中讀取資源了。 整體思路和Activity的自動化測試差不多。

四、總結

引入VirtualApk總體還是比較容易的,對專案的侵入性較小,尤其是外掛工程和普通的應用工程開發基本一樣,現有的模組做一下必要的調整和業務隔離,可以比較容易的轉換成外掛,遷移成本較小。對外掛開發者來說,一個外掛就是一個獨立的單體應用,這樣有利於進行獨立的開發測試,較少開發環境的干擾,最後和宿主進行聯調一下就好了。

當然大部分業務場景下,外掛都很難是完全獨立的,並不能像上面的demo一樣,一個按鈕,啟動一個Activity就萬事大吉了。很多時候,我們需要通過一定的擴充套件介面邏輯來注入外掛,而且外掛與外掛之間以及外掛和宿主之間可能存在一些互動。這一點,VirtualApk還有一些高階玩法可以為這些場景做支撐,比如宿主外掛依賴項去重功能,可以讓外掛依賴一個由宿主提供的SDK,而不編譯到最終外掛中,這樣外掛能通過宿主提供的介面進行互動。有時間後面再進一步解鎖更多玩法和大家分享一下。

五、問題

下面整理了下開發demo過程中遇到的一些問題以及解決方法。歡迎大家在留言中分享平時遇到的坑和解決方案。也可以去官方issues提問和解答。

  • 編譯失敗
[INFO][VAPlugin] Evaluating VirtualApk's configurations...

FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring project ':plugina'.
> Failed to notify project evaluation listener.
   > Can't using incremental dexing mode, please add 'android.useDexArchive=false' in gradle.properties of :plugina.
   > Cannot invoke method onProjectAfterEvaluate() on null object

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.
複製程式碼

解決:新建gradle.properties檔案並加入配置android.useDexArchive=false

  • 編譯失敗
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> The dependencies [com.android.support:design:28.0.0, com.android.support:recyclerview-v7:28.0.0, com.android.support:transition:28.0.0, com.android.support:cardview-v7:28.0.0] that will be used in the current plugin must be included in the host app first. Please add it in the host app as well.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.
複製程式碼

解決:出現這個問題是因為外掛工程中引用的design庫而宿主中沒有,需要將com.android.support:design:28.0.0加入到宿主APP中並對宿主APP進行assembleRelease。這裡有一些疑惑,VirtualApk不是支援在外掛中單獨引入依賴的麼,難道support包比較特殊?

  • 編譯失敗
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> com/android/build/gradle/internal/scope/TaskOutputHolder$TaskOutputType

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.
複製程式碼

解決: 可能gradle外掛版本過高,VirtualApk的構建原理與gradle外掛強依賴,建議使用官方demo工程使用的gradle外掛版本,這裡降至3.0.0 就ok了。classpath 'com.android.tools.build:gradle:3.0.0'

  • 外掛未簽名
Caused by: android.content.pm.PackageParser$PackageParserException: Package /storage/emulated/0/Android/data/com.huangmb.virtualapkdemo/cache/pluginA.apk has no certificates at entry AndroidManifest.xml
複製程式碼

解決:外掛必須有正式簽名。

signingConfigs {
    release {
        storeFile file("...")
        storePassword "..."
        keyAlias "..."
        keyPassword "..."
    }
}
buildTypes {
    release {
        ...
        signingConfig signingConfigs.release
        ...
    }
}
複製程式碼
  • 重複載入外掛
java.lang.RuntimeException: plugin has already been loaded : xxx
        at com.didi.virtualapk.internal.LoadedPlugin.<init>(LoadedPlugin.java:172)
        at com.didi.virtualapk.PluginManager.createLoadedPlugin(PluginManager.java:177)
        at com.didi.virtualapk.PluginManager.loadPlugin(PluginManager.java:318)
複製程式碼

解決:同一個外掛只能載入一次,可以在載入某個外掛前校驗一遍是否已載入過。

val hasLoaded = PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) != null
複製程式碼

其中PLUGIN_PKG是待校驗的外掛包名,也就是gradle中的applicationId(可能和AndroidManifest中的package不一樣)

相關文章