安卓插件化VirtualAPK

作者: gogoingmonkey | 来源:发表于2018-04-28 16:47 被阅读16次

    本文思路:

    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

    相关文章

      网友评论

        本文标题:安卓插件化VirtualAPK

        本文链接:https://www.haomeiwen.com/subject/hglblftx.html