start_time: 2024-05-02 00:52:08 +0800

安卓插件化VirtualAPK

96
gogoingmonkey
IP属地: 辽宁
0.7 2018.04.28 16:47 字数 3266

本文思路:

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

为什么要是用它

  1. 大部分开源框架所支持的功能还不够全面 除了DroidPlugin,大部分都只支持Activity。

  2. 兼容性问题严重,大部分开源方案不够健壮 由于国内Rom尝试深度定制Android系统,这导致插件框架的兼容性问题特别多,而目前已有的开源方案中,除了DroidPlugin,其他方案对兼容性问题的适配程度是不足的。

  3. 已有的开源方案不适合滴滴的业务场景 虽然说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

我直接在资源文件中随意加了一点,
有报错了。草


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 也更改下布局文件避免出错

success.gif

注意

如果宿主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

最后编辑于 :2018-04-28 16:57:33
©著作权归作者所有,转载或内容合作请联系作者
序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 160165 评论 4 赞 364
序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 67720 评论 1 赞 298
文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
8 开封第一讲书人 阅读 109849 评论 0 赞 244
文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
8 开封第一讲书人 阅读 44245 评论 0 赞 213
正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
388e473c fe2f 40e0 9301 e357ae8f1b41 茶点故事 阅读 52596 评论 3 赞 288
文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
8 开封第一讲书人 阅读 40747 评论 1 赞 222
那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 31977 评论 2 赞 315
文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
8 开封第一讲书人 阅读 30708 评论 0 赞 204
序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 34448 评论 1 赞 246
正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
388e473c fe2f 40e0 9301 e357ae8f1b41 茶点故事 阅读 30657 评论 2 赞 249
正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
388e473c fe2f 40e0 9301 e357ae8f1b41 茶点故事 阅读 32141 评论 1 赞 261
序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 28493 评论 3 赞 258
正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
388e473c fe2f 40e0 9301 e357ae8f1b41 茶点故事 阅读 33153 评论 3 赞 238
文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
8 开封第一讲书人 阅读 26108 评论 0 赞 8
文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
8 开封第一讲书人 阅读 26890 评论 0 赞 198
我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
783c64db 45e5 48d7 82e4 95736f50533e 沈念sama 阅读 35799 评论 2 赞 277
正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
388e473c fe2f 40e0 9301 e357ae8f1b41 茶点故事 阅读 35685 评论 2 赞 272
end_time: 2024-05-02 00:52:08 +0800 Completed in 135.08135299999998ms