外掛化之VirtualApk實戰一:專案配置
零、 介紹一下
VirtualApk是滴滴開源的一套外掛化方案,其支援四大元件,支援外掛宿主之間的互動,相容性強,在滴滴出行APP中有應用。下面是官方文件中與其他主流外掛化框架的對比(檢視原文):
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支援四大元件 | 只支援Activity | 只支援Activity | 只支援Activity | 全支援 | 全支援 |
元件無需在宿主manifest中預註冊 | √ | × | √ | √ | √ |
外掛可以依賴宿主 | √ | √ | √ | × | √ |
支援PendingIntent | × | × | × | √ | √ |
Android特性支援 | 大部分 | 大部分 | 大部分 | 幾乎全部 | 幾乎全部 |
相容性適配 | 一般 | 一般 | 中等 | 高 | 高 |
外掛構建 | 無 | 部署aapt | Gradle外掛 | 無 | Gradle外掛 |
一、配置
1.1 接入主程式
- 新增gradle依賴 在根目錄build.gradle中新增外掛
buildscript {
dependencies {
...
classpath 'com.didi.virtualapk:gradle:0.9.8.6'
...
}
}
複製程式碼
-
引入外掛 在app模組的build.gradle中新增
apply plugin: 'com.didi.virtualapk.host'
-
新增依賴 在app模組的build.gradle中的
dependencies
中加入implementation 'com.didi.virtualapk:core:0.9.8'
-
初始化SDK 選擇一個合適的時機初始化SDK,一般是在專案的Application類的
attachBaseContext
方法中完成。
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
PluginManager.getInstance(base).init()
}
複製程式碼
1.2 接入外掛模組
-
新增gradle依賴 同上面接入主程式環節第一步配置,如果外掛模組和主程式在同一個專案中則可以忽略
-
引入外掛 在外掛模組的build.gradle中新增
apply plugin: 'com.didi.virtualapk.plugin'
注意的是:外掛模組也是一個應用專案而非庫專案,即apply plugin: 'com.android.application'
而不是apply plugin: 'com.android.library'
-
宣告外掛配置 在外掛模組的build.gradle底部宣告virtualApk配置
virtualApk { packageId = 0x6f // 資源字首. targetHost = '../app' // 宿主模組的檔案路徑,生成外掛會檢查依賴項,分析和排除與宿主APP的共同依賴. applyHostMapping = true //optional, default value: true. } 複製程式碼
其中
packageId
是資源id的字首,用來區分外掛資源,所以外掛之間要使用不同的字首。 這個字首不一定要0x6f
,正常我們的APP編譯出來的R檔案一般像下面這種,可以看出字首是0x7f
,理論上這個packageId
的取值範圍應為[0x00,0x7f),然而0x01
、0x02
等等已經被系統應用佔用,具體佔用多少不得而知,因此儘量選擇偏大且足夠分配給所有外掛使用的數字。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; } } 複製程式碼
到這裡就已經完成了VirtualApk的宿主以及外掛模組的配置,非常簡單,可以看出對我們現有的工程完全幾乎不需要修改,我們依然可以用我們習慣的模組化的開發方式。
截止發稿時的最新版本是0.9.8.6
,建議大家儘量使用最新版本,畢竟安卓的碎片化這麼嚴重,而且hook方案多少會有些不完美的地方,相信滴滴以及gayhub的基友們會在新版本不停的完善它,而且老版本很可能不會維護。 一般從官方GitHub專案的releases可以找到當前最新版本。
這裡給大家安利一個maven構件搜尋網站mvnrepository.com/,在這裡可以搜尋主流maven倉庫中的構件,比如這裡的VirtualApk,可以很方便的檢視版本,以及生成maven、gradle等構建工具的引用語法。
二、應用
這裡以一個比較典型的場景:宿主APP啟動外掛中的Activity為例。
2.1 編寫外掛
外掛模組和平常的模組開發完全一樣,完全感知不到是在開發一個外掛,因此現有工程的模組也可以相對比較容易的轉換成外掛。
-
新建一個應用模組pluginA,按上面的提到的配置方法配好gradle,注意是
apply plugin: 'com.android.application'
-
取一個唯一的applicationId,這裡以
applicationId "com.huangmb.plugin.a"
為例。 -
新建一個Activity,為簡單起見這裡直接選了Studio內建的滾動檢視模版
com.huangmb.plugin.a.ScrollingActivity
因為本身是一個應用模組,因此你也可以直接執行這個模組,會看到下面這個熟悉的介面。
這種直接執行的方式非常方便我們開發除錯外掛,但這不是我們的最終目的,我們要把它變成一個外掛。 -
生成外掛 生成外掛非常簡單,執行命令
在實踐中多次遇到過生成的外掛執行時閃退,主要出在id字首的問題上,這裡建議大家在assemble之前最好先clean一遍。./gradlew assemblePlugin
或雙擊gradle面板的assemblePlugin
即可。執行後將會在
build/outputs/plugin/release
資料夾能找到生成的外掛包,檔名格式一般是"{applicationId}_yyyyMMddHHmmss.apk"。我沒找到配置輸出檔名的地方,我個人更傾向於一個固定的檔名,這種動態檔名會導致每編譯一次就增加一個檔案。 -
安裝外掛 安裝外掛本質上是把外掛apk放置到一個宿主外掛能訪問到檔案路徑下以便宿主載入。這裡演示為主,不去設計安裝外掛的邏輯了,直接把外掛重新命名為pluginA.apk,通過Android Studio的Device Explorer工具複製到宿主應用資料夾下,即
Android/data/{app_applicationId}/cache
。等下宿主APP會從這個目錄下讀取外掛。
2.2 宿主APP部分
宿主APP要做的事情很簡單,就是一個按鈕,在其點選事件中啟動pluginA.apk中的ScrollingActivity。
-
根據前面第一部分1.1節完成宿主上的外掛初始化。
-
載入外掛 一定要確保在啟動外掛程式碼之前的某個時機先載入外掛(不然哪有外掛的程式碼),比如在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
,否則返回false
。getLoadedPlugin
方法會返回一個LoadedPlugin物件,這是一個很有用的物件,宿主APP要獲取外掛中的AndroidManifest資訊就通過它,這個方法如果返回null則表明外掛未安裝。 -
跳轉外掛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
不一樣)