(demo地址)java
VirtualApk是滴滴开源的一套插件化方案,其支持四大组件,支持插件宿主之间的交互,兼容性强,在滴滴出行APP中有应用。下面是官方文档中与其余主流插件化框架的对比(查看原文):android
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支持四大组件 | 只支持Activity | 只支持Activity | 只支持Activity | 全支持 | 全支持 |
组件无需在宿主manifest中预注册 | √ | × | √ | √ | √ |
插件能够依赖宿主 | √ | √ | √ | × | √ |
支持PendingIntent | × | × | × | √ | √ |
Android特性支持 | 大部分 | 大部分 | 大部分 | 几乎所有 | 几乎所有 |
兼容性适配 | 通常 | 通常 | 中等 | 高 | 高 |
插件构建 | 无 | 部署aapt | Gradle插件 | 无 | Gradle插件 |
buildscript {
dependencies {
...
classpath 'com.didi.virtualapk:gradle:0.9.8.6'
...
}
}
复制代码
引入插件 在app模块的build.gradle中添加 apply plugin: 'com.didi.virtualapk.host'
git
添加依赖 在app模块的build.gradle中的dependencies
中加入 implementation 'com.didi.virtualapk:core:0.9.8'
github
初始化SDK 选择一个合适的时机初始化SDK,通常是在项目的Application类的attachBaseContext
方法中完成。bash
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
PluginManager.getInstance(base).init()
}
复制代码
添加gradle依赖 同上面接入主程序环节第一步配置,若是插件模块和主程序在同一个项目中则能够忽略app
引入插件 在插件模块的build.gradle中添加apply plugin: 'com.didi.virtualapk.plugin'
注意的是:插件模块也是一个应用项目而非库项目,即apply plugin: 'com.android.application'
而不是apply plugin: 'com.android.library'
框架
声明插件配置 在插件模块的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),然而0x01
、0x02
等等已经被系统应用占用,具体占用多少不得而知,所以尽可能选择偏大且足够分配给全部插件使用的数字。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;
}
}
复制代码
到这里就已经完成了VirtualApk的宿主以及插件模块的配置,很是简单,能够看出对咱们现有的工程彻底几乎不须要修改,咱们依然能够用咱们习惯的模块化的开发方式。
截止发稿时的最新版本是0.9.8.6
,建议你们尽可能使用最新版本,毕竟安卓的碎片化这么严重,并且hook方案多少会有些不完美的地方,相信滴滴以及gayhub的基友们会在新版本不停的完善它,并且老版本极可能不会维护。 通常从官方GitHub项目的releases能够找到当前最新版本。
这里给你们安利一个maven构件搜索网站mvnrepository.com/,在这里能够搜索主流maven仓库中的构件,好比这里的VirtualApk,能够很方便的查看版本,以及生成maven、gradle等构建工具的引用语法。
这里以一个比较典型的场景:宿主APP启动插件中的Activity为例。
插件模块和日常的模块开发彻底同样,彻底感知不到是在开发一个插件,所以现有工程的模块也能够相对比较容易的转换成插件。
新建一个应用模块pluginA,按上面的提到的配置方法配好gradle,注意是apply plugin: 'com.android.application'
取一个惟一的applicationId,这里以applicationId "com.huangmb.plugin.a"
为例。
新建一个Activity,为简单起见这里直接选了Studio内置的滚动视图模版com.huangmb.plugin.a.ScrollingActivity
由于自己是一个应用模块,所以你也能够直接运行这个模块,会看到下面这个熟悉的界面。
生成插件 生成插件很是简单,运行命令./gradlew assemblePlugin
或双击gradle面板的assemblePlugin
便可。
运行后将会在build/outputs/plugin/release
文件夹能找到生成的插件包,文件名格式通常是"{applicationId}_yyyyMMddHHmmss.apk"。我没找到配置输出文件名的地方,我我的更倾向于一个固定的文件名,这种动态文件名会致使每编译一次就增长一个文件。
安装插件 安装插件本质上是把插件apk放置到一个宿主插件能访问到文件路径下以便宿主加载。这里演示为主,不去设计安装插件的逻辑了,直接把插件重命名为pluginA.apk,经过Android Studio的Device Explorer工具复制到宿主应用文件夹下,即Android/data/{app_applicationId}/cache
。等下宿主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
不同)