(demo地址)
零谊囚、 介紹一下
VirtualApk是滴滴開源的一套插件化方案,其支持四大組件提澎,支持插件宿主之間的交互,兼容性強(qiáng)念链,在滴滴出行APP中有應(yīng)用盼忌。下面是官方文檔中與其他主流插件化框架的對(duì)比(查看原文):
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支持四大組件 | 只支持Activity | 只支持Activity | 只支持Activity | 全支持 | 全支持 |
組件無需在宿主manifest中預(yù)注冊(cè) | √ | × | √ | √ | √ |
插件可以依賴宿主 | √ | √ | √ | × | √ |
支持PendingIntent | × | × | × | √ | √ |
Android特性支持 | 大部分 | 大部分 | 大部分 | 幾乎全部 | 幾乎全部 |
兼容性適配 | 一般 | 一般 | 中等 | 高 | 高 |
插件構(gòu)建 | 無 | 部署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
選擇一個(gè)合適的時(shí)機(jī)初始化SDK掂墓,一般是在項(xiàng)目的Application類的attachBaseContext
方法中完成谦纱。
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
PluginManager.getInstance(base).init()
}
1.2 接入插件模塊
添加gradle依賴
同上面接入主程序環(huán)節(jié)第一步配置,如果插件模塊和主程序在同一個(gè)項(xiàng)目中則可以忽略引入插件
在插件模塊的build.gradle中添加apply plugin: 'com.didi.virtualapk.plugin'
注意的是:插件模塊也是一個(gè)應(yīng)用項(xiàng)目而非庫項(xiàng)目君编,即apply plugin: 'com.android.application'
而不是apply plugin: 'com.android.library'
-
聲明插件配置
在插件模塊的build.gradle底部聲明virtualApk配置virtualApk { packageId = 0x6f // 資源前綴. targetHost = '../app' // 宿主模塊的文件路徑,生成插件會(huì)檢查依賴項(xiàng)跨嘉,分析和排除與宿主APP的共同依賴. applyHostMapping = true //optional, default value: true. }
其中
packageId
是資源id的前綴,用來區(qū)分插件資源吃嘿,所以插件之間要使用不同的前綴祠乃。
這個(gè)前綴不一定要0x6f
梦重,正常我們的APP編譯出來的R文件一般像下面這種,可以看出前綴是0x7f
亮瓷,理論上這個(gè)packageId
的取值范圍應(yīng)為[0x00,0x7f)琴拧,然而0x01
、0x02
等等已經(jīng)被系統(tǒng)應(yīng)用占用嘱支,具體占用多少不得而知艾蓝,因此盡量選擇偏大且足夠分配給所有插件使用的數(shù)字。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; } }
到這里就已經(jīng)完成了VirtualApk的宿主以及插件模塊的配置斗塘,非常簡(jiǎn)單赢织,可以看出對(duì)我們現(xiàn)有的工程完全幾乎不需要修改,我們依然可以用我們習(xí)慣的模塊化的開發(fā)方式馍盟。
截止發(fā)稿時(shí)的最新版本是0.9.8.6
于置,建議大家盡量使用最新版本,畢竟安卓的碎片化這么嚴(yán)重贞岭,而且hook方案多少會(huì)有些不完美的地方八毯,相信滴滴以及gayhub的基友們會(huì)在新版本不停的完善它,而且老版本很可能不會(huì)維護(hù)瞄桨。
一般從官方GitHub項(xiàng)目的releases可以找到當(dāng)前最新版本话速。
這里給大家安利一個(gè)maven構(gòu)件搜索網(wǎng)站https://mvnrepository.com/,在這里可以搜索主流maven倉庫中的構(gòu)件芯侥,比如這里的VirtualApk泊交,可以很方便的查看版本,以及生成maven柱查、gradle等構(gòu)建工具的引用語法廓俭。
二、應(yīng)用
這里以一個(gè)比較典型的場(chǎng)景:宿主APP啟動(dòng)插件中的Activity為例唉工。
2.1 編寫插件
插件模塊和平常的模塊開發(fā)完全一樣研乒,完全感知不到是在開發(fā)一個(gè)插件,因此現(xiàn)有工程的模塊也可以相對(duì)比較容易的轉(zhuǎn)換成插件淋硝。
新建一個(gè)應(yīng)用模塊pluginA雹熬,按上面的提到的配置方法配好gradle,注意是
apply plugin: 'com.android.application'
取一個(gè)唯一的applicationId谣膳,這里以
applicationId "com.huangmb.plugin.a"
為例竿报。-
新建一個(gè)Activity,為簡(jiǎn)單起見這里直接選了Studio內(nèi)置的滾動(dòng)視圖模版
com.huangmb.plugin.a.ScrollingActivity
因?yàn)楸旧硎且粋€(gè)應(yīng)用模塊参歹,因此你也可以直接運(yùn)行這個(gè)模塊仰楚,會(huì)看到下面這個(gè)熟悉的界面。
ScrollingActivity
這種直接運(yùn)行的方式非常方便我們開發(fā)調(diào)試插件,但這不是我們的最終目的僧界,我們要把它變成一個(gè)插件侨嘀。
-
生成插件
生成插件非常簡(jiǎn)單,運(yùn)行命令./gradlew assemblePlugin
或雙擊gradle面板的assemblePlugin
即可捂襟。
gradle命令
在實(shí)踐中多次遇到過生成的插件運(yùn)行時(shí)閃退咬腕,主要出在id前綴的問題上,這里建議大家在assemble之前最好先clean一遍葬荷。運(yùn)行后將會(huì)在
build/outputs/plugin/release
文件夾能找到生成的插件包涨共,文件名格式一般是"{applicationId}_yyyyMMddHHmmss.apk"。我沒找到配置輸出文件名的地方宠漩,我個(gè)人更傾向于一個(gè)固定的文件名举反,這種動(dòng)態(tài)文件名會(huì)導(dǎo)致每編譯一次就增加一個(gè)文件。 安裝插件
安裝插件本質(zhì)上是把插件apk放置到一個(gè)宿主插件能訪問到文件路徑下以便宿主加載扒吁。這里演示為主火鼻,不去設(shè)計(jì)安裝插件的邏輯了,直接把插件重命名為pluginA.apk雕崩,通過Android Studio的Device Explorer工具復(fù)制到宿主應(yīng)用文件夾下魁索,即Android/data/{app_applicationId}/cache
。等下宿主APP會(huì)從這個(gè)目錄下讀取插件盼铁。
2.2 宿主APP部分
宿主APP要做的事情很簡(jiǎn)單粗蔚,就是一個(gè)按鈕,在其點(diǎn)擊事件中啟動(dòng)pluginA.apk中的ScrollingActivity饶火。
根據(jù)前面第一部分1.1節(jié)完成宿主上的插件初始化鹏控。
-
加載插件
一定要確保在啟動(dòng)插件代碼之前的某個(gè)時(shí)機(jī)先加載插件(不然哪有插件的代碼),比如在Application的onCreate中(適合已知插件位置的情況趁窃,比如內(nèi)置插件或者已安裝插件)牧挣,或者在執(zhí)行插件代碼前動(dòng)態(tài)加載。
為了方便后面的代碼醒陆,這里定義了三個(gè)常量,分別是插件文件名裆针、插件包名和插件的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中,插件不允許重復(fù)加載世吨,因此可以封裝一下插件加載方法澡刹,在加載插件前檢驗(yàn)一下插件加載情況
//檢測(cè)是否已經(jīng)安裝了插件,未安裝則通過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 }
在調(diào)用插件代碼前可以先調(diào)用一下
checkPlugin
方法耘婚,正常加載了插件時(shí)返回true
罢浇,否則返回false
。getLoadedPlugin
方法會(huì)返回一個(gè)LoadedPlugin對(duì)象,這是一個(gè)很有用的對(duì)象嚷闭,宿主APP要獲取插件中的AndroidManifest信息就通過它攒岛,這個(gè)方法如果返回null則表明插件未安裝。 跳轉(zhuǎn)插件Activity
跳轉(zhuǎn)插件Activity也是通過Intent跳轉(zhuǎn)胞锰,不過這里通過插件包名和Activity類名啟動(dòng)灾锯,因?yàn)橐话闼拗黜?xiàng)目不會(huì)依賴插件,這里沒法直接引用到ScrollingActivity.class嗅榕。
val i = Intent()
i.setClassName(PLUGIN_PKG, PLUGIN_ACTIVITY)
startActivity(i)
這就完成了一次插件化實(shí)踐顺饮,來看一下運(yùn)行效果:
完美
三、原理
上面的的示例中凌那,我們并沒有在宿主的AndroidManifest中注冊(cè)ScrollingActivity兼雄,但是仍然可以通過startActivity來啟動(dòng)它。
這里簡(jiǎn)單介紹下Activity插件化的原理帽蝶,有時(shí)間再單獨(dú)開一篇介紹一下四大組件的插件原理君旦。
實(shí)際上,VirtualApk通過hook了一下系統(tǒng)API嘲碱,模擬了Activity的生命周期金砍。通過PluginManager源碼中我們可以看到這樣的代碼,通過反射替換了系統(tǒng)的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在自動(dòng)化測(cè)試中我們經(jīng)常見過它的身影恕稠,比如這段單元測(cè)試,通過Instrument啟動(dòng)了Activity扶欣,模擬了一個(gè)Activity運(yùn)行環(huán)境鹅巍。
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也是基于這個(gè)原理,通過一個(gè)自定義的VAInstrumentation料祠,重載了各個(gè)execStartActivity方法骆捧,將啟動(dòng)插件Activity的Intent做了一些識(shí)別和標(biāo)記,即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對(duì)象,替換的Resources對(duì)象來自于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的自動(dòng)化測(cè)試差不多。
四株茶、總結(jié)
引入VirtualApk總體還是比較容易的来涨,對(duì)項(xiàng)目的侵入性較小,尤其是插件工程和普通的應(yīng)用工程開發(fā)基本一樣启盛,現(xiàn)有的模塊做一下必要的調(diào)整和業(yè)務(wù)隔離蹦掐,可以比較容易的轉(zhuǎn)換成插件技羔,遷移成本較小。對(duì)插件開發(fā)者來說卧抗,一個(gè)插件就是一個(gè)獨(dú)立的單體應(yīng)用藤滥,這樣有利于進(jìn)行獨(dú)立的開發(fā)測(cè)試,較少開發(fā)環(huán)境的干擾颗味,最后和宿主進(jìn)行聯(lián)調(diào)一下就好了超陆。
當(dāng)然大部分業(yè)務(wù)場(chǎng)景下,插件都很難是完全獨(dú)立的浦马,并不能像上面的demo一樣时呀,一個(gè)按鈕,啟動(dòng)一個(gè)Activity就萬事大吉了晶默。很多時(shí)候谨娜,我們需要通過一定的擴(kuò)展接口邏輯來注入插件,而且插件與插件之間以及插件和宿主之間可能存在一些交互磺陡。這一點(diǎn)趴梢,VirtualApk還有一些高級(jí)玩法可以為這些場(chǎng)景做支撐,比如宿主插件依賴項(xiàng)去重功能币他,可以讓插件依賴一個(gè)由宿主提供的SDK坞靶,而不編譯到最終插件中,這樣插件能通過宿主提供的接口進(jìn)行交互蝴悉。有時(shí)間后面再進(jìn)一步解鎖更多玩法和大家分享一下彰阴。
五、問題
下面整理了下開發(fā)demo過程中遇到的一些問題以及解決方法拍冠。歡迎大家在留言中分享平時(shí)遇到的坑和解決方案尿这。也可以去官方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.
解決:出現(xiàn)這個(gè)問題是因?yàn)椴寮こ讨幸玫膁esign庫而宿主中沒有庆杜,需要將com.android.support:design:28.0.0
加入到宿主APP中并對(duì)宿主APP進(jìn)行assembleRelease
射众。這里有一些疑惑,VirtualApk不是支持在插件中單獨(dú)引入依賴的么晃财,難道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的構(gòu)建原理與gradle插件強(qiáng)依賴,建議使用官方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
...
}
}
- 重復(fù)加載插件
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)
解決:同一個(gè)插件只能加載一次郑临,可以在加載某個(gè)插件前校驗(yàn)一遍是否已加載過。
val hasLoaded = PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) != null
其中PLUGIN_PKG
是待校驗(yàn)的插件包名屑宠,也就是gradle中的applicationId
(可能和AndroidManifest中的package
不一樣)