背景
近几年Android插件化技术是比较热门领域,6月30日滴滴也开源了自研的插件化框架VirtualAPK。一周多的时间已经有3300+star,从官方wiki可以看到,该框架功能完备,支持Android四大组件,良好的兼容性,且入侵性较低,作为加载耦合插件方案是较好选择。笔者会写一系列VirtualAPK学习的文章,第一篇先介绍一下插件化和VirtualAPK如何接入,请先Clone https://github.com/piglet696/VirtualAPKDemo.git 项目,从一个全新的项目接入VirtualAPK。
什么是插件化?
插件(Plugin)在计算机领域是一个通用的概念,维基百科上这样解释:
In computing, a plugin is a software component that adds a specific feature to an existing computer program.
意思是对现有的程序添加额外的功能组件。这样的好处是可以对原有程序进行扩展,以Android Studio为例,我们可以安装FindBugs插件,这样IDE就具备了更强大的静态代码检查功能。插件可以是第三方的开发者开发,只需要遵循一定的协议即可,插件可以随时更新、卸载,可以看到插件化使我们的程序更加灵活。
为什么要插件化?
插件化听起来很美好,但是谷歌并没有赋予Android这种能力,因为这样存在安全风险,试想一下表面上是一个计算器App,谷歌审核通过后,加载插件后变成一个窃取隐私的App怎么办。那为什么在国内插件化技术非常热门,各个大厂都在使用呢?总结了下有以下几个原因:
- 国内App的版本碎片较为严重,想减少升级成本。国内没有统一的应用分发市场,静默升级需要ROM的支持,否则第三方应用市场需要Root的方式实现;
- 解决App方法数超65536问题。在谷歌官方的Multidex方案没有出现时,可以采用插件方式解决,而现在该问题不应该是你选择插件化的原因;
- 减少App包大小。宿主App包含了主要功能,其余放到插件中实现,动态下发;
- 快速修改线上Bug或者发布新功能。作为程序员这个场景肯定遇到过,刚发布一个新版本存在Bug,再重新发版成本又特别高。而只发布插件成本就会很低,又能快速解决线上问题。
- 模块解耦,协同开发。一个超级App,可以拆分成不同的插件模块,基于一定的规则业务线之间协同开发,最终每个业务模块生成一个插件,由宿主加载组合即可。再比如你需要接入第三方App的功能,只需要第三方基于插件协议开发即可,不需要在宿主代码中进行开发,很好的做到业务隔离,合作终止时直接移除插件即可。
当你想引入插件化技术时问一下自己接入的原因和要解决什么问题,不要为了接入而接入,毕竟接入和维护需要一定的成本,比如解决一些奇怪的问题、一套插件管理&下发的后台等。
VirtualAPK框架接入
简介
VirtualAPK的开源地址:https://github.com/didi/VirtualAPK ,请读者详细阅读一下Readme和Wiki,对VirtualAPK框架有个整体的认识。引用Wiki中VirtualAPK和其他开源框架的对比:
特性 | DynamicLoadApk | DynamicAPK | Small | DroidPlugin | VirtualAPK |
---|---|---|---|---|---|
支持四大组件 | 只支持Activity | 只支持Activity | 只支持Activity | 全支持 | 全支持 |
组件无需在宿主manifest中预注册 | √ | × | √ | √ | √ |
插件可以依赖宿主 | √ | √ | √ | × | √ |
支持PendingIntent | × | × | × | √ | √ |
Android特性支持 | 大部分 | 大部分 | 大部分 | 几乎全部 | 几乎全部 |
兼容性适配 | 一般 | 一般 | 中等 | 高 | 高 |
插件构建 | 无 | 部署aapt | Gradle插件 | 无 | Gradle插件 |
VirtualAPK工程中包括了插件的源代码和DEMO,为了更加清晰的展示如何接入SDK,笔者新建了一个VirtualAPKDemo工程,建议Clone代码后结合源码阅读文章,更容易理解。该工程包括宿主工程Host和插件工程ImageBrowser,可以把宿主工程想象成一个所有功能的展示入口,而ImageBrowser插件工程用于实现具体的图片浏览业务。为了展示VirtualAPK对耦合型插件的支持,这里宿主和插件都会依赖Picasso库加载图片,但VirtualAPK框架构建插件APK时会把插件中的Picasso库移除,插件直接使用宿主的Picasso库,下文会具体说明。
环境准备
- Gradle版本需要为2.14.1,可以使用
gradle -v
查看环境中配置的Gradle版本号。也可以使用工程中gradlew
来编译,可以在gradle/wrapper/gradle-wrapper.properties
中更改版本号
distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
- com.android.tools.build的版本号为2.1.3
宿主工程接入
Host宿主工程接入需要以下6步
- 在宿主工程根目录的build.gradle添加依赖
dependencies {
classpath 'com.didi.virtualapk:gradle:0.9.0'
}
- 在App的工程模块的build.gradle添加使用gradle插件
apply plugin: 'com.didi.virtualapk.host'
- 添加VirtualAPK SDK compile依赖
dependencies {
compile 'com.didi.virtualapk:core:0.9.0'
}
- 在App的工程模块proguard-rules.pro文件添加混淆规则(Ps:Picasso库的混淆规则没有列出来)
-keep class com.didi.virtualapk.internal.VAInstrumentation { *; }
-keep class com.didi.virtualapk.internal.PluginContentResolver { *; }
-dontwarn com.didi.virtualapk.**
-dontwarn android.content.pm.**
-keep class android.** { *; }
- MyApplication类是继承了Application,覆写attachBaseContext函数,进行插件SDK初始化工作
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
PluginManager.getInstance(base).init();
}
- 在使用插件之前加载插件,可以根据具体业务场景选择合适时机加载,我是在MainActivity的onCreate时机加载
protected void onCreate(Bundle savedInstanceState) {
// 加载plugin.apk插件包
PluginManager pluginManager = PluginManager.getInstance(this);
File apk = new File(getExternalStorageDirectory(), "plugin.apk");
if (apk.exists()) {
try {
pluginManager.loadPlugin(apk);
} catch (Exception e) {
e.printStackTrace();
}
}
}
经过上述6步后,VirtualAPK插件功能就集成到宿主中了,宿主打包和运行方式没有任何改变。接下来看下插件工程如何集成和构建的。
插件工程接入
ImageBrowser插件工程接入分为3步:
- ImageBrowser工程根目录的build.gradle添加依赖
dependencies {
classpath 'com.didi.virtualapk:gradle:0.9.0'
}
- 在App的工程模块的build.gradle添加使用gradle插件和插件配置信息,信息需要放在文件最下面
apply plugin: 'com.didi.virtualapk.plugin'
...
...
// 插件配置信息,放在文件最下面
virtualApk {
packageId = 0x6f // 插件资源id,避免资源id冲突
targetHost='../host/app' // 宿主工程的路径
applyHostMapping = true // 插件编译时是否启用应用宿主的apply mapping
}
解释一下上面3个参数的作用
- packageId用于定义每个插件的资源id,多个插件间的资源Id前缀要不同,避免资源合并时产生冲突
- targetHost指明宿主工程的应用模块,插件编译时需要获取宿主的一些信息,比如mapping文件、依赖的SDK版本信息、R资源文件,一定不能填错,否则在编译插件时会提示找不到宿主工程。
- applyHostMapping表示插件是否开启apply mapping功能。当宿主开启混淆时,一般情况下插件就要开启applyHostMapping功能。因为宿主混淆后函数名可能有fun()变为a(),插件使用宿主混淆后的mapping映射来编译插件包,这样插件调用fun()时实际调用的是a(),才能找到正确的函数调用。
- 最后一步生成插件,需要使用Gradle命令
gradle clean assemblePlugin
或者
./gradlew clean assemblePlugin
强调一下如果构建时确保Gradle版本需要为2.14.1,否则构建可能发生错误。构建成功后在build/outputs/apk 或者plugin目录中查看插件,plugin目录和apk目录中插件的区别在于plugin将插件以packageName_timestamp格式重命名,DEMO中的插件构建成功后才3KB。
官方WIKI中还说明了:
- 插件包均是Release包,不支持debug模式的插件包
- 如果存在多个productFlavors,那么将会构建出多个插件包
前面说到VirtualAPK是对耦合型业务有很好的支持,对于我们的DEMO来说,宿主和插件都用到了Picasso库,我们反编译插件包后看一下里面包含的内容如下图所示。
可以看到,插件包中没有Picasso库的相关源码,构建时VirtualAPK已经帮我们移除了。需要注意,宿主和插件包中依赖的SDK版本需要完全一致时才会被移除。
运行插件
因为宿主中代码写的是从SD卡根目录加载plugin.apk插件,所以我们需要将生成的插件重命名后放到指定位置。
adb push 插件路径 /sdcard/plugin.apk
然后启动宿主程序后点击"查看更多美图",此时加载的Activity来自于插件中,启动代码如下所示
@Override
public void onClick(View v) {
if (PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG_NAME) == null) {
Toast.makeText(getApplicationContext(),
"插件未加载,请尝试重启APP", Toast.LENGTH_SHORT).show();
return;
}
Intent intent = new Intent();
intent.setClassName("com.virtualapk.imageplugin", "com.virtualapk.imageplugin.ImageBrowserActivity");
startActivity(intent);
}
OK,插件就么运行起来了,可以看到在开发宿主和插件时,几乎没有侵入,在生成插件时需要一些命令操作,可以通过脚本实现编译插件+Push到SD卡中,提高开发效率。
DEMO比较简单只包含了Activity,后面会扩展到其他组件的使用,大家可以仔细看下官方WIKI-插件开发指南,里面介绍了插件中四大组件的使用、so库的加载、已知的约束和插件加载时机的选取问题。
其他注意点
- 要想清楚宿主和插件的业务边界很重要,才能找到插件的入口点。Demo中是以ImageBrowserActivity为边界,从这个Activity之后的功能来来自于插件中。其实我们可以把插件看成一个类提供者,可以使用Class.forName()这种方式使用插件中的类,所以不是只能从Activity/Fragment作为入口点。
- 编译宿主工程时会生成一些信息(在build/VAHost文件夹下),插件构建时会读取这些信息,所以要确保运行的宿主和插件基于相同信息构建的,宿主变化时请重新构建插件。
常见错误解法
以下问题是基于VirtuAPK 0.9.0版本,官方Wiki也列举了常见问题解决方式。
- 编译时报错 Failed to notify project evaluation listener,具体信息如下
What went wrong:
A problem occurred configuring project ':app'.
> Failed to notify project evaluation listener.
> com/android/builder/dependency/ManifestDependency
解决方式:修改Gradle和build tools的版本,参考前文环境准备。
- 编译插件时报错 Caused by: java.lang.NullPointerException: Cannot invoke method getAt() on null object
解决方式:插件中至少包含一个资源文件。 - 编译插件时报错The directory of host application doesn't exist!
解决方式:检查插件工程中build.gradle文件targetHost参数填写的路径是否正确。 - 编译插件时报错 java.lang.ArrayIndexOutOfBoundsException: 2,具体信息如下
Caused by: java.lang.ArrayIndexOutOfBoundsException: 2
at com.didi.virtualapk.VAPlugin$_pickSplitEntries_closure8.doCall(VAPlug
in.groovy:188)
at com.didi.virtualapk.VAPlugin.pickSplitEntries(VAPlugin.groovy:186)
at com.didi.virtualapk.VAPlugin$_apply_closure1$_closure16.doCall(VAPlug
in.groovy:66)
at com.didi.virtualapk.VAPlugin$_apply_closure1.doCall(VAPlugin.groovy:5
2)
at org.gradle.listener.ClosureBackedMethodInvocationDispatch.dispatch(Cl
osureBackedMethodInvocationDispatch.java:40)
at org.gradle.listener.ClosureBackedMethodInvocationDispatch.dispatch(Cl
osureBackedMethodInvocationDispatch.java:25)
解决方式:请检查dependencies中aar的依赖方式,请参下面的方式
dependencies {
√ compile 'com.didi.virtualapk:core:0.9.0'
√ compile project (":CoreLibrary")
√ compile(group:'test', name: 'CoreLibrary-release', version:'0.1', ext: 'aar') // group和version字段必须有
× releaseCompile 'com.didi.virtualapk:core:0.9.0'
× compile(name: 'CoreLibrary-release', ext: 'aar')
}
- 启动插件时显示的界面与宿主相同,或者引用资源错误。
解决方式:注意插件资源名称不要和宿主相同,否则会认为使用宿主的资源,导致插件的资源编译时被移除。有网友定义创建demo时,宿主和插件布局文件都叫R.layout.activity_main,就会导致该问题。 - 运行时Crash报错java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
解决方式:检查下宿主里引用的所有SDK版本号和插件中的版本号是否一致,不一致的话会插件编译时不会把共有SDK移除,导致两个相同类打包进来,加载时会出错。最常见的就是插件和宿主的V4包版本不一致导致问题。
结束语
VirtualAPK插件框架在使用过程中还是比较简单的,没有过多的侵入性,所以大家可以尝试玩起来。要实现一个插件化技术需要对Android系统原理、编译构建都有较好理解,所以接下会学习、拆解该框架的技术原理,请大家继续关注后续文章吧,会第一时间发布在微信公众号:Mob行者。VirtualAPK官方QQ群号656602897,作者玉刚大神也在啊!