安卓插件化VirtualAPK
本文思路:
1.VirtualAPK 介绍(如果只是想先简单接入,跳过这部分)
2.VirtualAPK 基本使用(实现基本插件化功能,超详细使用讲解)
3.基本使用爬坑详解
4.数据传递(4.5.6会在第二篇 深入中讲解:https://www.jianshu.com/p/a69c9897e729)
5.源码分析
6.使用进阶
前言
16年时候记得公司就用过插件化开发,也算是大公司吧,使用的是Small ,几个人的团队对这个框架更改了,但是那个时候,自己很不幸只是在一个插件项目中去开发,也没有查看源码的权限,只是使用过,并没有深入去了解,现在公司业务相对较少,刚好把这个任务分给别的同事了,但也无法掩盖我对插件化的情怀,好了,不瞎BB了。
VirtualAPK 介绍
VirtualAPK是滴滴出行自研的一款优秀的插件化框架,是该团队在17年6月3号开源的,到现在不到一年时间(18年4月),该框架通过将业务模块插件化,可随时更新插件来发布新功能,具备版本随时发布的能力 (这个功能和热修复要注意区别哦,后面会写一个和热修复的区别)
VirtualAPK的特性
支持几乎所有的Android特性;
四大组件均不需要在宿主manifest中预注册,每个组件都有完整的生命周期。
Activity:支持显示和隐式调用,支持Activity的theme和LaunchMode,支持透明主题;
Service:支持显示和隐式调用,支持Service的start、stop、bind和unbind,并支持跨进程bind插件中的Service;
Receiver:支持静态注册和动态注册的Receiver;
ContentProvider:支持provider的所有操作,包括CRUD和call方法等,支持跨进程访问插件中的Provider。
自定义View:支持自定义View,支持自定义属性和style,支持动画;
PendingIntent:支持PendingIntent以及和其相关的Alarm、Notification和AppWidget;
支持插件Application以及插件manifest中的meta-data;
支持插件中的so。
优秀的兼容性
兼容市面上几乎所有的Android手机,这一点已经在滴滴出行客户端中得到验证;
资源方面适配小米、Vivo、Nubia等,对未知机型采用自适应适配方案;
极少的Binder Hook,目前仅仅hook了两个Binder:AMS和IContentProvider,hook过程做了充分的兼容性适配;
插件运行逻辑和宿主隔离,确保框架的任何问题都不会影响宿主的正常运行。
入侵性极低
插件开发等同于原生开发,四大组件无需继承特定的基类;
精简的插件包,插件可以依赖宿主中的代码和资源,也可以不依赖;
插件的构建过程简单,通过Gradle插件来完成插件的构建,整个过程对开发者透明。
VirtualAPK和主流开源框架的对比
image.png为什么要是用它
-
大部分开源框架所支持的功能还不够全面 除了DroidPlugin,大部分都只支持Activity。
-
兼容性问题严重,大部分开源方案不够健壮 由于国内Rom尝试深度定制Android系统,这导致插件框架的兼容性问题特别多,而目前已有的开源方案中,除了DroidPlugin,其他方案对兼容性问题的适配程度是不足的。
-
已有的开源方案不适合滴滴的业务场景 虽然说DroidPlugin从功能的完整性和兼容性上来看,是一款非常完善的插件框架,然而它的使用场景和滴滴的业务不符。
DroidPlugin侧重于加载第三方独立插件,比如微信,并且插件不能访问宿主的代码和资源。而在滴滴打车中,其他业务模块均需要宿主提供的订单、定位、账号等数据,因此插件不可能和宿主没有交互。
其实在大部分产品中,一个业务模块实际上并不能轻而易举地独立出来,它们往往都会和宿主有交互,在这种情况下,DroidPlugin就有点力不从心了。
如果你是要加载微信、支付宝等第三方APP,那么推荐选择DroidPlugin;
如果你是要加载一个内部业务模块,并且这个业务模块很难从主工程中解耦,那么VirtualAPK是最好的选择如果你要加载一个插件,并且这个插件无需和宿主有任何耦合,也无需和宿主进行通信,并且你也不想对这个插件重新打包,那么推荐选择DroidPlugin;
除此之外,在同类的开源中,推荐大家选择VirtualAPK。
VirtualAPK的工作过程
VirtualAPK对插件没有额外的约束,原生的apk即可作为插件。插件工程编译生成apk后,即可通过宿主App加载,每个插件apk被加载后,都会在宿主中创建一个单独的LoadedPlugin对象。如下图所示,通过这些LoadedPlugin对象,VirtualAPK就可以管理插件并赋予插件新的意义,使其可以像手机中安装过的App一样运行。
借用官网的一张图:
image.png
.VirtualAPK 基本使用
必须要说的是官网的Demo 要研究很久才能跑起来,而且官网的Demo使用了AIDL数据交互,而且还有很多gradle 相关的坑建议先跟着下面步骤先跑起最基本的,
第一步:
创建两个项目,一个是宿主工程(DiDiBasePluginProject),也就是我们发布的主项目,再建一个插件APK,也就是我们可以控制的插件
第二步:
配置主项目:
1.在工程根目录下build.gradle中添加
dependencies {
classpath 'com.android.tools.build:gradle:2.3.3' //这个是默认创建项目就有的
classpath 'com.didi.virtualapk:gradle:0.9.0' // 这个是需要加的
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
2.在App的build.gradle中顶部添加
apply plugin: 'com.android.application'
apply plugin: 'com.didi.virtualapk.host' //这个是主项目中添加的
3.在App的build.gradle中 compile 添加
dependencies {
....
compile 'com.didi.virtualapk:core:0.9.0'
}
4.编写MyApp继承Application重写attachBaseContext方法中初始化插件引擎(别忘了在AndroidManifest.xml配置Application)
public class BaseApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
PluginManager.getInstance(base).init();
}
}
5.下面是官网建议的,主要是为了适配部分机型,但是这里会有个坑,后面在说:
推荐大家在Application启动的时候去加载插件,不然的话,请注意插件的加载时机。 考虑一种情况,如果在一个较晚的时机去加载插件并且去访问插件中的资源,请注意当前的Context。比如在宿主Activity(MainActivity)中去加载插件,接着在MainActivity去访问插件中的资源(比如Fragment),需要做一下显示的hook,否则部分4.x的手机会出现资源找不到的情况
这样在BaseApplication中的代码就是:
public class BaseApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
PluginManager.getInstance(base).init();
}
@Override
public void onCreate() {
super.onCreate();
PluginManager pluginManager = PluginManager.getInstance(this);
//此处是当查看插件apk是否存在,如果存在就去加载(比如修改线上的bug,把插件apk下载到sdcard的根目录下取名为Demo.apk)
File apk = new File(Environment.getExternalStorageDirectory(), "Demo.apk");
if (apk.exists()) {
try {
pluginManager.loadPlugin(apk);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
6.因为为了演示插件化,我们插件是通过下载 在手机上,需要添加权限,这里又有个坑,也是后面说
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
第三步:插件工程处理
1.在工程根目录下build.gradle中添加
dependencies {
classpath 'com.didi.virtualapk:gradle:0.9.0'
}
2.在App的build.gradle中顶部添加依赖以及插件配置信息,注意区别
apply plugin: 'com.didi.virtualapk.plugin'//注意这个是plugin结尾,宿主是以host结尾的
3.// 插件配置信息,放在文件最下面
virtualApk {
// 插件资源表中的packageId,需要确保不同插件有不同的packageId.
packageId = 0x6f
// 宿主工程application模块的路径,插件的构建需要依赖这个路径,我这个宿主工程和插件工程在同一级目录下,所以下面这样写
targetHost = '../DiDiBaseProject/app'
//默认为true,如果插件有引用宿主的类,那么这个选项可以使得插件和宿主保持混淆一致
applyHostMapping = true
上面的坑我就直接说了,后面不演示了,
1.pakageid 这个不要设置很多位数比如 0xfff 这个样会报错,就按照2位的用吧,我尝试换了2个三位的都报错了
还有一个坑我在后面说!!!
2.targetHost 地址,这个是目标路径,比如我的主项目和插件项目时在同一个层级,所以使用 ../就回到上一层在进入宿主项目 下的app (你的项目怎么放反正要指向app)
3.这个applyHostMapping 这个属性上面注释也说了一般设置为true吧主要就是混淆时候生成的映射表保持一致
第四步:
1.运行宿主项目到手机上,(必须先运行再执行第二步,否则会报错)
2在插件项目中打开android studio 命令终端:执行
gradlew clean assemblePlugin
或者:
gradle clean assemblePlugin
上面的命令是生成插件APK,注意,这个时候会跑build.gradle 里面的文件配置,基于宿主项目
gradle问题
不出意外不报错了:
image.png
这个就是滴滴这个框架要求的版本是在
gradle:distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
tools:classpath 'com.android.tools.build:gradle:2.1.3'
解决办法:
1.宿主工程中,修改根目录build.gradle 中gradle 的tools版本为2.1.3:
// classpath 'com.android.tools.build:gradle:2.3.3'
classpath 'com.android.tools.build:gradle:2.1.3'
2修改app同级目录gradle文件夹中gradle-wrapper.properties中的属性:
image.png
修改为:
distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
3.在 插件工程中也做同样的操作 并同步下 如果你的本地没有gradle 对应的版本可能需要下载哦。
再次在插件项目中去执行生成插件APK的命令
image.png
又报错了,这个时候 我重新同步了下宿主项目,然后在编译了一次到手机上,再次执行上述命令:
我擦。。。有报错了:
解决方法
这个报错就是说资源问题,
image.png
我直接在资源文件中随意加了一点,
有报错了。草
image.png
出现这个问题我在插件项目下终端执行命令:
gradlew clean
再次在插件中 执行之前的命令,如果不行就杀进程 去插件项目文件夹路径 去删除 app下的build文件夹,如果还是不行,就重启电脑(我的电脑是有时候可以,有时候必须重启,主要是因为会生成新的,必须要删除旧的 就是覆盖他,但是删除不了就报错了) 删除后在使用AS打开插件项目,再执行一次,终于build success
image.png
w
意味着我们的插件包生成好了,现在在插件工厂中打开查看如下:
image.png
这样就生成了
现在打开这个APK的文件目录 使用adb 命令把它放到我们的sdcard上
adb push /e/Project/VirtualAPK/DiDiPluginProject/app/build/outputs/apk/app-release-unsigned.apk /sdcard/Demo.apk
这个Demo.apk是在宿主项目中自己命名的哦。看下有没成功
现在打开这个APP,点击发现没反应啊????
theSame.gif
这个gif 图片看到了吧,我点击这个文字,然后跳了一下。没有跳转到我想要的那个页面啊,这个是因为我在宿主项目 和插件项目都使用了MainActivity 所以这个直接跳的是宿主的布局文件 ,而且 再次点击这个文案不会再响应:
现在 我直接把插件项目的首个Activity 更改为PlugiinMainActivity 也更改下布局文件避免出错
注意
如果宿主APK和插件APK 使用的布局名字一样,会用宿主的布局,
宿主APK可以是release也可以是debug 但是插件一定是release的
到现在我的应用还出现一个坑,这个坑可以说是自己的安卓基础不过关吧,做安卓也几年了 之前没有去关注这个东西,
现象:点击宿主页面的文案,这个时候回去执行插件APK的初始化,但是这个时候崩溃了,我百思不得其解啊,然后看报错:
image.png
大致意思就是这个findViewbyId 找出来的是个空对象,,,我仔细核对了,打断点了 还是不行,直到我怀疑插件中设置点击事件是不是有特殊的使用方法,又去看了官方文档,最后终于找到自己对基础不熟埋下的天坑
virtualApk {
// 插件资源表中的packageId,需要确保不同插件有不同的packageId.
packageId = 0xff
// 宿主工程application模块的路径,插件的构建需要依赖这个路径,我这个宿主工程和插件工程在同一级目录下,所以下面这样写
targetHost = '../DiDiBaseProject/app'
//默认为true,如果插件有引用宿主的类,那么这个选项可以使得插件和宿主保持混淆一致
applyHostMapping = true
}
注意我的packageid 取的是0xff ,之前我的理解是这个随便取就行了,只是去区分是不同的插件APK的,其实这个packageid 在我们打包的过程中 就是在aapt 执行的时候用到,他有他自己的规范
1.PackageId:是包的Id值,Android中如果是第三方应用的话,这个值默认就是0x7F,系统应用的话就是0x01,具体我们可以后面看aapt源码得知,他占用两个字节
2.TypeId:是资源的类型Id值,一般Android中有这几个类型:attr,drawable,layout,dimen,string,style等,而且这些类型的值是从1开始逐渐递增的,而且顺序不能改变,attr=0x01,drawable=0x02....他占用两个字节。
3.EntryId:是在具体的类型下资源实体的id值,从0开始,依次递增,他占用四个字节。
资源ID(packageId+typeId+ItemValue)
我们之前讲解了资源Id的组成结构,发现高两个字节是代表PackageId的值,而且第三方app的默认值是0x7F,那么我们能不能修改这个值呢?比如,插件1中的资源Id中的PackageId为0x30,插件2中的资源Id中的PackageId为0x31...这样每个插件的资源就被划分了一定的区域值,同时保证不要和主工程中的0x7F冲突即可,那么这些值就可以从0x02~0x7E了,这个区间值我们都是可以使用的,为什么0x01不能用呢?因为他是系统应用的呀,所以我们就有0x7E-0x02=124个区间,哈哈,听着好兴奋
这个天坑如果对aapt不深入的,一单进入,绝逼死路一条!!!!!!
下面还有几个坑
1,点击如果没反应的话,断点宿主项目抛出异常:
运行在安卓6.0 及以上版本就会出现点击弹出plugin not load 的 提示,这是因为我们加载插件的时候抛出了异常了 就是运行时权限的问题,
方案1:动态代码适配 建议使用这个
image.png
然后这里我们把获得插件实例的代码移动到Activity中,在 application中就做一个初始化
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},1);
return;
}else {
File apk = new File(Environment.getExternalStorageDirectory(), "Demo.apk");
PluginManager pluginManager = PluginManager.getInstance(this);
if (apk.exists()) {
try {
pluginManager.loadPlugin(apk);
} catch (Exception e) {
e.printStackTrace();
}
}
}
findViewById(R.id.text).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (PluginManager.getInstance(MainActivity.this).getLoadedPlugin("com.xiaoniu.finance.didipluginproject") == null) {
Toast.makeText(MainActivity.this, "plugin not loaded", Toast.LENGTH_SHORT).show();
} else {
Intent intent = new Intent();
intent.setClassName("com.xiaoniu.finance.didipluginproject", "com.xiaoniu.finance.didipluginproject.PluginMainActivity");
startActivity(intent);
}
}
});
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode){
case 1:
if(grantResults.length >0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
File apk = new File(Environment.getExternalStorageDirectory(), "Demo.apk");
PluginManager pluginManager = PluginManager.getInstance(this);
if (apk.exists()) {
try {
pluginManager.loadPlugin(apk);
} catch (Exception e) {
e.printStackTrace();
}
} else
{
Toast.makeText(MainActivity.this, "ssss", Toast.LENGTH_SHORT).show();
}
}
}
}
}
这个就是宿主apk 的第一个activity的代码
方案2:更改宿主项目的traget 版本为22
2.点击也是没反应进去断点抛出异常
image.png
再次断点 发现里面也报错了,就是说这个插件被加载过了。这时候需要在activity中加上如下代码:
//反射得到mPlugins域
Class cls = pluginManager.getClass();
try {
mPluginsField = cls.getDeclaredField("mPlugins");
mPluginsField.setAccessible(true);
ConcurrentHashMap mPlugin = (ConcurrentHashMap) mPluginsField.get(pluginManager);
mPlugin.remove("com.xiaoniu.finance.didipluginproject");
} catch (Exception e) {
}
3、配置不要错了
image.png第一个圈起来的是插件的包名,第二个也是,第三个也是插件包名.XXXActivity
项目地址
https://github.com/zh2016hz/DiDiVirtualAPKDemo.git
把2个工程放在一个仓库中,代码拉下来了可以使用AS分别打开查看
深入分析请移步到https://www.jianshu.com/p/a69c9897e729