End

插件化 VirtualAPK 简介 体验

本文地址


目录

插件化 VirtualAPK 简介 体验

简介

个人使用体验:功能强大,遍地是坑!

VirtualAPK是滴滴出行自研的一款优秀的插件化框架

VirtualAPK is a powerful yet lightweight plugin framework for Android. It can dynamically load and run an APK file (we call it LoadedPlugin) seamlessly as an installed application. Developers can use any Class, Resources, Activity, Service, Receiver and Provider in LoadedPlugin as if they are registered in app's manifest file.

Supported Features

Feature Detail
Supported components Activity, Service, Receiver and Provider
Manually register components in AndroidManifest.xml No need
Access host app classes and resources Supported
PendingIntent Supported
Supported Android features Almost all features
Compatibility Almost all devices
Building system Gradle plugin
Supported Android versions API Level 15+

基本原理

  • Activity:在宿主apk中提前占几个坑,然后通过“欺上瞒下”的方式启动插件apk的Activity;因为要支持不同的launchMode以及一些特殊的属性,所以需要占多个坑。
  • BroadcastReceiver:将静态注册的广播改为动态注册。
  • Service:通过代理Service的方式去分发;主进程和其他进程,VirtualAPK使用了两个代理Service。
  • ContentProvider:通过一个代理Provider进行分发。

基本使用

VirtualAPK 对插件没有额外的约束,原生的apk即可作为插件。插件工程编译生成apk后,即可通过宿主App加载,每个插件apk被加载后,都会在宿主中创建一个单独的LoadedPlugin对象。通过这些LoadedPlugin对象,VirtualAPK就可以管理插件赋予插件新的意义,使其可以像手机中安装过的App一样运行。

宿主项目的配置

1、在 project 的build.gradle中添加依赖:

classpath 'com.android.tools.build:gradle:3.1.4' //这个版本不能修改,否则同步时就会失败
classpath 'com.didi.virtualapk:gradle:0.9.8.6' //2019-1-14最新版本

2、在 app 模块的build.gradle中使用插件:

apply plugin: 'com.didi.virtualapk.host'

3、在 app 模块的build.gradle中添加依赖:

implementation 'com.didi.virtualapk:core:0.9.8'
//注意,宿主项目中需要包含所有插件项目中的support依赖,否则插件编译不通过(会提示要在宿主中添加依赖)
//但对于其他依赖则没有此要求,例如可以在插件中依赖gson,而无需在宿主中依赖gson

4、在 Application 中初始化插件引擎:

@Override
protected void attachBaseContext(Context context) {
    super.attachBaseContext(context);
    PluginManager.getInstance(context).init();
}

5、在合适的时机加载插件(APP退出后下次使用前仍需要加载):

PluginManager.getInstance(context).loadPlugin(apkFile);
//当插件入口被调用后,插件的后续逻辑均不需要宿主干预,均走原生的Android流程。

6、判断是否已加载插件

LoadedPlugin loadedPlugin = PluginManager.getInstance(this).getLoadedPlugin(PKG); //包名
if (loadedPlugin == null) Toast.makeText(this, "尚未加载 " + PKG, Toast.LENGTH_SHORT).show();
else Toast.makeText(this, "已加载 " + loadedPlugin.getPackageName(), Toast.LENGTH_SHORT).show();

7、跳转到插件的Activity中

Intent intent = new Intent();
intent.setClassName(this, "com.didi.virtualapk.demo.aidl.BookManagerActivity");
intent.putExtra("name","包青天");
startActivity(intent);

注意,如果遇到如下提示,可以不必关心,因为并没有什么影响:

Configuration on demand is not supported by the current version of the Android Gradle plugin since you are using Gradle version 4.6 or above.
Suggestion: disable configuration on demand by setting org.gradle.configureondemand=false in your gradle.properties file or use a Gradle version less than 4.6.

插件项目的配置

在VirtualAPK中,插件开发等同于原生Android开发,因此开发插件就和开发APP一样。

如果有使用nativeActivity需要的用户请更新使用fix_native_activity分支并修改依赖为CoreLibrary,未来会合入主线。

构建环境建议直接使用Demo中的配置,插件构建强依赖构建环境,请不要轻易尝试修改。

1、在 project 的build.gradle中添加依赖:

classpath 'com.android.tools.build:gradle:3.1.4' //这个版本不能修改,否则同步时就会失败
classpath 'com.didi.virtualapk:gradle:0.9.8.6'  //2019-1-14最新版本,和宿主中用的是同一个依赖

2、在 app 模块的gradle.properties中(如没有请创建)添加如下配置:

android.useDexArchive=false

3、在 app 模块的build.gradle中使用插件:

apply plugin: 'com.didi.virtualapk.plugin'
virtualApk {
    packageId = 0x6f // 插件资源表中的packageId,需要确保不同插件有不同的packageId.
    targetHost = 'D:/code/PluginDemo/app' // 宿主工程application模块的路径,插件的构建需要依赖这个路径
    applyHostMapping = true //默认为true,如果插件有引用宿主的类,那么这个选项可以使得插件和宿主保持混淆一致
}

4、构建插件

请通过gradle assemblePlugin来构建插件,assemblePlugin依赖于assembleRelease,这意味着:

  • 插件包均是release包,不支持debug模式的插件包
  • 如果存在多个productFlavors,那么将会构建出多个插件包
  • 插件包位于build目录下

其实主要区别在于:插件包是不包含宿主中已经存在aar依赖库res资源的内容的,因为这些内容最终是用的宿主包中的。

一定要给插件设置一个资源别名resourcePrefix,以防止插件中误用到了宿主中已经存在的资源名,导致解析出错。
最典型的是默认的activity_main.xml,如果插件和宿主中都有这个布局文件,那么打包后会删除插件中定义的activity_main.xml,所以在运行时使用的是宿主中的activity_main.xml,那么就很可能会导致调用findViewBuId时崩溃!
宿主如果更改后最好先build一次,因为生成插件包时需要用到宿主构建时生成的文件。

构建插件时可能出现的问题

我通过AS创建了一个最最纯净的项目(默认包含kotlin),结果运行时发现一堆问题。

1、提示设置在app模块中的gradle.properties中添加android.useDexArchive=false

A problem occurred configuring project ':app'.
> Failed to notify project evaluation listener.
   > Can't using incremental dexing mode, please add 'android.useDexArchive=false' in gradle.properties of :app.
   > Cannot invoke method onProjectAfterEvaluate() on null object

我们按照上述提示修改即可。

2、修改后再运行出现如下提示:

Failed to notify task execution listener.
> The dependencies 
[
   com.android.support.constraint:constraint-layout:1.1.3,
   com.android.support:support-fragment: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.

意思是说,在插件项目中包含的库也必须在宿主项目存在。可以发现全部是 support 库,我们只需统一宿主和插件的support库版本就可以了,比如都用如下最新的设置:

implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'

3、沃日,配置为宿主的依赖后便开始出现各种问题,clean不行、build不行、手动删除build目录也不行,重启AS也不行

AAPT2 error: check logs for details

查看报错详细信息,说什么资源文件找不到什么问题,完全是莫名其妙嘛,为什么会有这个错呢?

网上搜了一通,找不到解决方案,只找到一种委曲求全的扯淡方案,那就是在project中的gradle.properties中添加android.enableAapt2=false

4、添加完之后clean了一下,结果那个问题没有了,又出另一个莫名其妙的错误:

Process 'command 'D:\software\android_sdk\build-tools\26.0.2\aapt.exe'' finished with non-zero exit value 1

报错的原因可能和我们上面的操作有关,因为看到有这么两行信息:

Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
See https://docs.gradle.org/4.6/userguide/command_line_interface.html#sec:command_line_warnings

5、把所有设置都还原吧,完全没法搞嘛!

我猜测可能与插件中采用了kotlin而宿主没有采用有关,于是在宿主中添加了kotlin相关的依赖,结果这货同步时又报一个错:

A problem occurred evaluating project ':CoreLibrary'.
> Failed to apply plugin [id 'com.android.library']
   > Configuration on demand is not supported by the current version of the Android Gradle plugin since you are using Gradle version 4.6 or above. Suggestion: disable configuration on demand by setting org.gradle.configureondemand=false in your gradle.properties file or use a Gradle version less than 4.6.

意思是说当前版本的Gradle插件不支持按需配置,日了狗了,什么鬼呀,搜索了一下,说可以这样禁用按需配置:

  • 在你的Project和报错的CoreLibrary模块中的gradle.properties 文件中设置 org.gradle.configureondemand=false
  • 在AS的设置中禁用按需配置

配置完成后同步一下发现成功了。

6、继续构建插件

> Failed to notify project evaluation listener.
   > Can't find C:\Users\baiqi\Desktop\VirtualAPK-master\app\build\VAHost\versions.txt, please check up your host application
       need apply com.didi.virtualapk.host in build.gradle of host application
   > Cannot invoke method onProjectAfterEvaluate() on null object

这个错误提示就比较好处理了因为提示找不到versions.txt,而这个文件是构建后由 VirtualAPK 产生的,我们要先构建一次宿主app,才可以构建plugin(因为插件构建需要宿主的mapping以及其他信息),可以尝试使用build -> build apk(s)直接构建宿主apk。

7、然后处理之后继续构建插件又遇到了最初遇到的问题,也就是提示我添加一堆 support 库,干脆我把插件中所有用到的 support 库全部去掉得了,看你还报不报错!

果不其然,又一个错误出来了:

Cannot get property 'id' on null object

这又是什么鬼?

网上搜了半天,有人说,这个问题是因为插件中布局文件没有id,在插件主activity的布局文件中增加一个view,声明一个id就可以了。

然而我按照上述方式设置之后并没有任何卵用!

我通过以下指令

gradle assemblePlugin --stacktrace

拿到了如下错误信息:

* Exception is:
java.lang.NullPointerException: Cannot get property 'id' on null object
        at com.didi.virtualapk.aapt.ArscEditor.slice(ArscEditor.groovy:66)
        ...

然后又去查看了ArscEditor.groovy中相应的源码:

这意思大致是说,要确保有一个'attr',否则就会报异常(垃圾代码都不判空的吗?)

但是这是什么垃圾东西呢?我搞了老半天,这个问题始终解决不了!

8、坑实在是太多了,填不完了,重新开始集成吧。

这次我决定将demo中的配置全部迁移过来,然后再一点一点的更新到新版本,或添加新功能,看看到哪一步时会失败!
然而理想很丰满现实很骨感,仍旧是遍地错误!

9、重新开始集成!

这次我在网上仔细搜了一遍,发现很多人反映,Gradle的 build tools 版本问题会导致失败,即使使用 demo 中的配置也不行,所以这一次我使用网上说的版本吧。

classpath 'com.android.tools.build:gradle:2.1.3'

gradle-wrapper

distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip

然而发现这TMD全是扯淡,可能旧版本需要这么配置,然而新版本并不需要这么配置。

10、重新开始集成!

这一次决定在原demo基础上修改,这次终于成功了。具体配置就是上面所述的。

相关知识

VirtualAPK 的特性

1、功能完备

  • 支持几乎所有的Android特性;
  • 四大组件均不需要在宿主manifest中预注册,每个组件都有完整的生命周期。
    • Activity:支持显示和隐式调用,支持Activity的themeLaunchMode,支持透明主题;
    • Service:支持显示和隐式调用,支持Service的startstopbindunbind,并支持跨进程bind插件中的Service;
    • Receiver:支持静态注册和动态注册的Receiver;
    • ContentProvider:支持provider的所有操作,包括CRUDcall方法等,支持跨进程访问插件中的Provider。
  • 支持自定义View,支持自定义属性和style,支持动画;
  • 支持PendingIntent以及和其相关的AlarmNotificationAppWidget
  • 支持插件Application以及插件manifest中的meta-data
  • 支持插件中的so

2、优秀的兼容性

  • 兼容市面上几乎所有的Android手机(兼容性问题没法保证,从它文档中遍地的"升级提示"便可以看出来);
  • 资源方面适配小米、Vivo、Nubia等,对未知机型采用自适应适配方案(意思就是说,只象征性的进行了一些适配);
  • 极少的Binder Hook,目前仅仅hook了两个Binder:AMSIContentProvider,hook过程做了充分的兼容性适配;
  • 插件运行逻辑和宿主隔离,确保框架的任何问题都不会影响宿主的正常运行(扯淡,一上手就遇到几个框架导致的崩溃)。

3、入侵性极低

  • 插件开发等同于原生开发,四大组件无需继承特定的基类;
  • 精简的插件包,插件可以依赖宿主中的代码和资源,也可以不依赖;
  • 插件的构建过程简单,通过Gradle插件来完成插件的构建,整个过程对开发者透明。

主流插件化框架的对比

如下是VirtualAPK和主流的插件化框架之间的对比。

特性 DynamicLoadApk DynamicAPK Small DroidPlugin VirtualAPK
支持四大组件 只支持Activity 只支持Activity 只支持Activity 全支持 全支持
组件无需在宿主中预注册 ×
插件可以依赖宿主 ×
支持PendingIntent × × ×
Android特性支持 大部分 大部分 大部分 几乎全部 几乎全部
兼容性适配 一般 一般 中等
插件构建 部署aapt Gradle插件 Gradle插件

为什么选择 VirtualAPK

已经有那么多优秀的开源的插件化框架,滴滴为什么要重新造一个轮子呢?

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

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

  • 已有的开源方案不适合滴滴的业务场景
    虽然说DroidPlugin从功能的完整性和兼容性上来看,是一款非常完善的插件框架,然而它的使用场景和滴滴的业务不符。
    DroidPlugin侧重于加载第三方独立插件,并且插件不能访问宿主的代码和资源。而在滴滴打车中,其他业务模块均需要宿主提供的订单、定位、账号等数据,因此插件不可能和宿主没有交互。
    其实在大部分产品中,一个业务模块实际上并不能轻而易举地独立出来,它们往往都会和宿主有交互,在这种情况下,DroidPlugin就有点力不从心了。

基于上述几点,我们只能重新造一个轮子,它不但功能全面、兼容性好,还必须能够适用于有耦合的业务插件,这就是VirtualAPK存在的意义。

如何选择适合的插件化框架

在加载耦合插件方面,VirtualAPK是开源方案的首选,推荐大家使用。

抽象地说

  • 如果你要加载一个插件,并且这个插件无需和宿主有任何耦合,也无需和宿主进行通信,并且你也不想对这个插件重新打包,那么推荐选择DroidPlugin;
  • 除此之外,在同类的开源中,推荐大家选择VirtualAPK。

通俗易懂地说

  • 如果你是要加载微信、支付宝等第三方APP,那么推荐选择DroidPlugin;
  • 如果你是要加载一个内部业务模块,并且这个业务模块很难从主工程中解耦,那么VirtualAPK是最好的选择。

VirtualAPK 原理简介

基本原理

  • 合并宿主和插件的ClassLoader。需要注意的是,插件中的类不可以和宿主重复
  • 合并插件和宿主的资源。重设插件资源的packageId,将插件资源和宿主资源合并
  • 去除插件包对宿主的引用。构建时通过Gradle插件去除插件对宿主的代码以及资源的引用

四大组件的实现原理

  • Activity
    采用宿主manifest中占坑的方式来绕过系统校验,然后再加载真正的activity;
  • Service
    动态代理AMS,拦截service相关的请求,将其中转给Service Runtime去处理,Service Runtime会接管系统的所有操作;
  • Receiver
    将插件中静态注册的receiver重新注册一遍;
  • ContentProvider
    动态代理IContentProvider,拦截provider相关的请求,将其中转给Provider Runtime去处理,Provider Runtime会接管系统的所有操作。

如下是VirtualAPK的整体架构图,更详细的内容请大家阅读源码。

VirtualAPK

插件如何和宿主交互

通过compile相同aar的方式来交互。

比如,宿主工程中compile了如下aar:

compile 'com.didi.foundation:sdk:1.2.0'
compile 'com.didi.virtualapk:core:[newest version]'
compile 'com.android.support:appcompat-v7:22.2.0'

但是插件工程需要访问宿主sdk中的类和资源,那么可以在插件工程中同样compile sdk的aar,如下:

compile 'com.didi.foundation:sdk:1.2.0'

这样一来,插件工程就可以正常地引用sdk了。并且,插件构建的时候会自动将这个aar从apk中剔除

上述就是VirtualAPK中插件和宿主通信的基本方式。

然而,VirtualAPK仍然有一些小小的约束,如下注意事项,请务必仔细阅读。

已知约束

目前暂不支持的特性

  • 暂不支持Activity的一些不常用特性,比如processconfigChanges等属性,但是支持themelaunchModescreenOrientation属性
  • overridePendingTransition(int enterAnim, int exitAnim)这种形式的转场动画,动画资源不能使用插件的(可以使用宿主或系统的)
  • 插件中弹通知,需要统一处理,走宿主的逻辑,通知中的资源文件不能使用插件的(可以使用宿主或系统的)
  • 插件的Activity中不支持动态申请权限

Activity

支持LaunchMode和theme

  • 透明Activity不能有启动模式,并且主题中必须含有android:windowIsTranslucent属性;
<style name="AppTheme.Transparent">
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:windowIsTranslucent">true</item>
</style>
  • 插件中调用宿主的四大组件,请注意Intent中的包名

VirtualAPK对Intent的处理遵循Android规范,插件之间乃至插件和宿主之间,包名是区分它们的唯一标识。

为了兼容宿主与插件之间的activity互调的场景,我们弱化了插件的包名,在插件中通过context.getPackageName()取到的仍然是宿主的包名。因此在下面的例子中,假如宿主的包名是com.didi.virtualapk,然后在插件中启动一个宿主Activity,仍然可正确的调用:

// 兼容方式
Intent intent = new Intent(this, HostActivity.class);
startActivity(intent);

// 显式指定包名的方式
Intent intent = new Intent();
intent.setClassName("com.didi.virtualapk", "com.didi.virtualapk.HostActivity");
startActivity(intent);

如果想在插件中去访问插件的四大组件,那么就没有任何要求了,下面的代码会在插件的一个Activity中尝试启动插件中的另一个Activity:

// 正确的用法,因为此时intent中的包名是插件的包名
Intent intent = new Intent(this, PluginActivity.class);
startActivity(intent);

Service

支持跨进程bind service

无约束

BroadcastReceiver

  • 静态Receiver将被动态注册,当宿主停止运行时,外部广播将无法唤醒宿主;
  • 由于动态注册的缘故,插件中的Receiver必须通过隐式调用来唤起。

ContentProvider

支持跨进程访问ContentProvider

  • 分情况,插件调用自己的ContentProvider,如果需要用到call方法,那么需要将provideruri放到bundle中,否则调用不生效;
Uri bookUri = Uri.parse("content://com.didi.virtualapk.demo.book.provider/book");
Bundle bundle = PluginContentResolver.getBundleForCall(bookUri);
getContentResolver().call(bookUri, "testCall", null, bundle);
  • 插件调用宿主和外部的ContentProvider,无约束;

  • 宿主调用插件的ContentProvider,需要将provideruri包装一下,通过PluginContentResolver.wrapperUri方法,如果涉及到call方法,参考上面所描述的;

String pkg = "com.didi.virtualapk.demo";
LoadedPlugin plugin = PluginManager.getInstance(this).getLoadedPlugin(pkg);
Uri bookUri = Uri.parse("content://com.didi.virtualapk.demo.book.provider/book");
bookUri = PluginContentResolver.wrapperUri(plugin, bookUri);
Cursor bookCursor = getContentResolver().query(bookUri, new String[]{"_id", "name"}, null, null, null);

Fragment

推荐大家在Application启动的时候去加载插件,不然的话,请注意插件的加载时机。

考虑一种情况,如果在一个较晚的时机去加载插件并且去访问插件中的资源,请注意当前的Context。比如在宿主Activity(MainActivity)中去加载插件,接着在MainActivity去访问插件中的资源(比如Fragment),需要做一下显示的hook,否则部分4.x的手机会出现资源找不到的情况。

String pkg = "com.didi.virtualapk.demo";
PluginUtil.hookActivityResources(MainActivity.this, pkg);

so文件的加载

为了提升性能,VirtualAPK 在加载一个插件时并不会主动去释放插件中的so,除非你在插件apk的manifest中显式地指定VA_IS_HAVE_LIB为true,如下所示:

<meta-data
    android:name="VA_IS_HAVE_LIB"
    android:value="true" />

为了通用性,在armeabi路径下放置对应的so文件即可满足需求。如果考虑性能请做好各种so文件的适配。

FQA

The directory of host application doesn't exist!

错误分析:宿主工程的application模块的路径不存在,一般是指路径配错了
解决方式:检测targetHost这个路径是否正确,相对路径或者绝对路径都行

java.lang.ArrayIndexOutOfBoundsException: 2

错误分析:请检查dependencies中aar的依赖方式

解决方式:按如下建议修改

dependencies {
    √ compile 'com.didi.virtualapk:core:0.9.0'
    √ compile project (":CoreLibrary")

    // group和version字段必须有
    √ compile(group:'test', name:'CoreLibrary-release', version:'0.1', ext:'aar')

    × releaseCompile 'com.didi.virtualapk:core:0.9.0'
    × compile(name:'CoreLibrary-release', ext:'aar')
}

编译插件时空指针:Cannot invoke method getAt() on null object

解决方式:请确保插件中至少有一个自己的资源

插件的activity能正常打开,但是插件中的资源读取失败

解决方式:依次检查:

  • 先检查 packageId 的取值范围(在下面)是否正确
  • 再检查插件依赖的所有com.android.support包在宿主都有显式依赖,并且版本和宿主保持一致
  • 读取失败的资源id是否和宿主的资源重名,重名资源会在构建插件包时被自动剔除

Failed to notify project evaluation listener

解决方式:修改Gradlebuild tools的版本

构建环境建议:

Gradle 2.14.1
com.android.tools.build 2.1.3

java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity

解决方式:构建插件请使用gradle assemblePlugin,而不能直接通过AndroidStudio run出来一个插件apk。

关于Android M及以上版本动态申请权限问题

从Android 6.0开始,系统采用了新的权限机制,为了保证插件的加载,请保证APP具有SD卡的访问权限。如果你的app没有在android 6.0上做足够的测试,请不要设置targetSdk为23。

注意:目前暂时不支持在插件中动态申请权限

插件的gradle文件中对于packageID设置有什么范围吗?

  • 采用正常的android资源命名方式,PPTTNNNN:PackageId + TypeId + EntryId。
  • 运行时获取资源需要通过packageId来映射apk中的资源文件,不同apk的packageId值不能相同,所以插件的packageId范围是介于系统应用(0x01,0x02,...具体占用多少值视系统而定)和宿主(0x7F)之间。
  • 多个插件的packageId和packageName一样,在宿主中需要确保是唯一的。

生成的插件apk中会发现有些png图片是黑色的,大小为0,这是怎么回事?

为了减小包的大小对于那些没有引用的资源进行压缩了,在gradle中配置shrinkResources true即可,位置和minifyEnabled true一起。

关于Activity的configchanges

因为configChanges的选项组合太多,坑位比较多,这个暂时不准备支持,因为在日常使用的时候就横竖常用。

iR是什么意思?

install Release,gradle中的一种小驼峰命名的缩写方式。如果发现冲突,可以通过assembleRelease来实现构建宿主工程。

0.9.1版本的VirtualAPK构建插件在构建插件的时候assets目录下的文件会被删除

这是0.9.1版本的bug,更高版本已经修复,请更新版本。

宿主和插件同时依赖公共的本地jar文件或library module,支持在构建插件时自动剔除吗?

不支持。

构建插件的依赖自动剔除功能仅支持内容稳定不变,路径稳定的资源,而本地的jar或其它资源的路径和内容都是可变更的,因此无法直接自动剔除,如果需要剔除,请将资源打包导出部署到maven或其它依赖管理服务器。如果资源不可公开发布,可在内网部署私有maven服务

2019-1-13

posted @ 2019-01-14 01:30  白乾涛  阅读(2051)  评论(1编辑  收藏  举报