插件化之VirtualApk實戰一:項目配置

(demo地址)java

零、 介紹一下

VirtualApk是滴滴開源的一套插件化方案,其支持四大組件,支持插件宿主之間的交互,兼容性強,在滴滴出行APP中有應用。下面是官方文檔中與其餘主流插件化框架的對比(查看原文):android

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

1、配置

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'git

  2. 添加依賴 在app模塊的build.gradle中的dependencies中加入 implementation 'com.didi.virtualapk:core:0.9.8'github

  3. 初始化SDK 選擇一個合適的時機初始化SDK,通常是在項目的Application類的attachBaseContext方法中完成。bash

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

1.2 接入插件模塊

  1. 添加gradle依賴 同上面接入主程序環節第一步配置,若是插件模塊和主程序在同一個項目中則能夠忽略app

  2. 引入插件 在插件模塊的build.gradle中添加apply plugin: 'com.didi.virtualapk.plugin' 注意的是:插件模塊也是一個應用項目而非庫項目,即apply plugin: 'com.android.application'而不是apply plugin: 'com.android.library'框架

  3. 聲明插件配置 在插件模塊的build.gradle底部聲明virtualApk配置maven

    virtualApk {
        packageId = 0x6f // 資源前綴.
        targetHost = '../app' // 宿主模塊的文件路徑,生成插件會檢查依賴項,分析和排除與宿主APP的共同依賴.
        applyHostMapping = true //optional, default value: true.
    }
    複製代碼

    其中packageId是資源id的前綴,用來區分插件資源,因此插件之間要使用不一樣的前綴。 這個前綴不必定要0x6f,正常咱們的APP編譯出來的R文件通常像下面這種,能夠看出前綴是0x7f,理論上這個packageId的取值範圍應爲[0x00,0x7f),然而0x010x02等等已經被系統應用佔用,具體佔用多少不得而知,所以儘可能選擇偏大且足夠分配給全部插件使用的數字。ide

    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等構建工具的引用語法。

2、應用

這裏以一個比較典型的場景:宿主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)
複製代碼

這就完成了一次插件化實踐,來看一下運行效果:

運行效果
完美

3、原理

上面的的示例中,咱們並無在宿主的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的自動化測試差很少。

4、總結

引入VirtualApk整體仍是比較容易的,對項目的侵入性較小,尤爲是插件工程和普通的應用工程開發基本同樣,現有的模塊作一下必要的調整和業務隔離,能夠比較容易的轉換成插件,遷移成本較小。對插件開發者來講,一個插件就是一個獨立的單體應用,這樣有利於進行獨立的開發測試,較少開發環境的干擾,最後和宿主進行聯調一下就行了。

固然大部分業務場景下,插件都很難是徹底獨立的,並不能像上面的demo同樣,一個按鈕,啓動一個Activity就萬事大吉了。不少時候,咱們須要經過必定的擴展接口邏輯來注入插件,並且插件與插件之間以及插件和宿主之間可能存在一些交互。這一點,VirtualApk還有一些高級玩法能夠爲這些場景作支撐,好比宿主插件依賴項去重功能,可讓插件依賴一個由宿主提供的SDK,而不編譯到最終插件中,這樣插件能經過宿主提供的接口進行交互。有時間後面再進一步解鎖更多玩法和你們分享一下。

5、問題

下面整理了下開發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不同)

相關文章
相關標籤/搜索