插件化之VirtualApk实战一:项目配置

(demo地址)

零、 介绍一下

VirtualApk是滴滴开源的一套插件化方案,其支持四大组件,支持插件宿主之间的交互,兼容性强,在滴滴出行APP中有应用。下面是官方文档中与其他主流插件化框架的对比(查看原文):

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

一、配置

1.1 接入主程序

  1. 添加gradle依赖
    在根目录build.gradle中添加插件
 buildscript {
     dependencies {
         ...
         classpath 'com.didi.virtualapk:gradle:0.9.8.6'
         ...
     }
 }
  1. 引入插件
    在app模块的build.gradle中添加
    apply plugin: 'com.didi.virtualapk.host'

  2. 添加依赖
    在app模块的build.gradle中的dependencies中加入
    implementation 'com.didi.virtualapk:core:0.9.8'

  3. 初始化SDK
    选择一个合适的时机初始化SDK,一般是在项目的Application类的attachBaseContext方法中完成。

   override fun attachBaseContext(base: Context?) {
       super.attachBaseContext(base)
       PluginManager.getInstance(base).init()
   }

1.2 接入插件模块

  1. 添加gradle依赖
    同上面接入主程序环节第一步配置,如果插件模块和主程序在同一个项目中则可以忽略

  2. 引入插件
    在插件模块的build.gradle中添加apply plugin: 'com.didi.virtualapk.plugin'
    注意的是:插件模块也是一个应用项目而非库项目,即apply plugin: 'com.android.application'而不是apply plugin: 'com.android.library'

  3. 声明插件配置
    在插件模块的build.gradle底部声明virtualApk配置

    virtualApk {
        packageId = 0x6f // 资源前缀.
        targetHost = '../app' // 宿主模块的文件路径,生成插件会检查依赖项,分析和排除与宿主APP的共同依赖.
        applyHostMapping = true //optional, default value: true.
    }
    

    其中packageId是资源id的前缀,用来区分插件资源,所以插件之间要使用不同的前缀。
    这个前缀不一定要0x6f,正常我们的APP编译出来的R文件一般像下面这种,可以看出前缀是0x7f,理论上这个packageId的取值范围应为[0x00,0x7f),然而0x010x02等等已经被系统应用占用,具体占用多少不得而知,因此尽量选择偏大且足够分配给所有插件使用的数字。

    public final class R {
        public static final class anim {
            public static final int abc_fade_in=0x7f010000;
            public static final int abc_fade_out=0x7f010001;
            public static final int abc_grow_fade_in_from_bottom=0x7f010002;
        }
    }
    

关于packageId的官方说明

到这里就已经完成了VirtualApk的宿主以及插件模块的配置,非常简单,可以看出对我们现有的工程完全几乎不需要修改,我们依然可以用我们习惯的模块化的开发方式。

截止发稿时的最新版本是0.9.8.6,建议大家尽量使用最新版本,毕竟安卓的碎片化这么严重,而且hook方案多少会有些不完美的地方,相信滴滴以及gayhub的基友们会在新版本不停的完善它,而且老版本很可能不会维护。
一般从官方GitHub项目的releases可以找到当前最新版本。

这里给大家安利一个maven构件搜索网站https://mvnrepository.com/,在这里可以搜索主流maven仓库中的构件,比如这里的VirtualApk,可以很方便的查看版本,以及生成maven、gradle等构建工具的引用语法。

二、应用

这里以一个比较典型的场景:宿主APP启动插件中的Activity为例。

2.1 编写插件

插件模块和平常的模块开发完全一样,完全感知不到是在开发一个插件,因此现有工程的模块也可以相对比较容易的转换成插件。

  1. 新建一个应用模块pluginA,按上面的提到的配置方法配好gradle,注意是apply plugin: 'com.android.application'

  2. 取一个唯一的applicationId,这里以applicationId "com.huangmb.plugin.a"为例。

  3. 新建一个Activity,为简单起见这里直接选了Studio内置的滚动视图模版com.huangmb.plugin.a.ScrollingActivity

    因为本身是一个应用模块,因此你也可以直接运行这个模块,会看到下面这个熟悉的界面。


    ScrollingActivity

    这种直接运行的方式非常方便我们开发调试插件,但这不是我们的最终目的,我们要把它变成一个插件。

  4. 生成插件
    生成插件非常简单,运行命令./gradlew assemblePlugin或双击gradle面板的assemblePlugin即可。

    gradle命令

    在实践中多次遇到过生成的插件运行时闪退,主要出在id前缀的问题上,这里建议大家在assemble之前最好先clean一遍。

    运行后将会在build/outputs/plugin/release文件夹能找到生成的插件包,文件名格式一般是"{applicationId}_yyyyMMddHHmmss.apk"。我没找到配置输出文件名的地方,我个人更倾向于一个固定的文件名,这种动态文件名会导致每编译一次就增加一个文件。

  5. 安装插件
    安装插件本质上是把插件apk放置到一个宿主插件能访问到文件路径下以便宿主加载。这里演示为主,不去设计安装插件的逻辑了,直接把插件重命名为pluginA.apk,通过Android Studio的Device Explorer工具复制到宿主应用文件夹下,即Android/data/{app_applicationId}/cache。等下宿主APP会从这个目录下读取插件。

2.2 宿主APP部分

宿主APP要做的事情很简单,就是一个按钮,在其点击事件中启动pluginA.apk中的ScrollingActivity。

  1. 根据前面第一部分1.1节完成宿主上的插件初始化。

  2. 加载插件
    一定要确保在启动插件代码之前的某个时机先加载插件(不然哪有插件的代码),比如在Application的onCreate中(适合已知插件位置的情况,比如内置插件或者已安装插件),或者在执行插件代码前动态加载。
    为了方便后面的代码,这里定义了三个常量,分别是插件文件名、插件包名和插件的Activity类名。

      private const val PLUGIN_NAME = "pluginA.apk"
      private const val PLUGIN_PKG = "com.huangmb.plugin.a"
      private const val PLUGIN_ACTIVITY = "com.huangmb.plugin.a.ScrollingActivity"
    

    加载插件的方式为

    val apk = File(externalCacheDir, PLUGIN_NAME)
    PluginManager.getInstance(this).loadPlugin(apk)
    

    在VirtualApk中,插件不允许重复加载,因此可以封装一下插件加载方法,在加载插件前检验一下插件加载情况

      //检测是否已经安装了插件,未安装则通过loadPlugin安装
      private fun checkPlugin(): Boolean {
         PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) ?: return loadPlugin()
         return true
      }
      private fun loadPlugin(): Boolean {
         val apk = File(externalCacheDir, PLUGIN_NAME)
         if (apk.exists()) {
             //加载插件
             val manager = PluginManager.getInstance(this)
             manager.loadPlugin(apk)
             PluginUtil.hookActivityResources(this, PLUGIN_PKG)
             return true
         }
         //插件不存在
         return false
    
     }
    

    在调用插件代码前可以先调用一下checkPlugin方法,正常加载了插件时返回true,否则返回falsegetLoadedPlugin方法会返回一个LoadedPlugin对象,这是一个很有用的对象,宿主APP要获取插件中的AndroidManifest信息就通过它,这个方法如果返回null则表明插件未安装。

  3. 跳转插件Activity
    跳转插件Activity也是通过Intent跳转,不过这里通过插件包名和Activity类名启动,因为一般宿主项目不会依赖插件,这里没法直接引用到ScrollingActivity.class。

   val i = Intent()
   i.setClassName(PLUGIN_PKG, PLUGIN_ACTIVITY)
   startActivity(i)

这就完成了一次插件化实践,来看一下运行效果:


运行效果

完美

三、原理

上面的的示例中,我们并没有在宿主的AndroidManifest中注册ScrollingActivity,但是仍然可以通过startActivity来启动它。

这里简单介绍下Activity插件化的原理,有时间再单独开一篇介绍一下四大组件的插件原理。

实际上,VirtualApk通过hook了一下系统API,模拟了Activity的生命周期。通过PluginManager源码中我们可以看到这样的代码,通过反射替换了系统的Instrument。

   protected void hookInstrumentationAndHandler() {
        try {
            ActivityThread activityThread = ActivityThread.currentActivityThread();
            Instrumentation baseInstrumentation = activityThread.getInstrumentation();
    
            final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
            Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
            Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
            Reflector.with(mainHandler).field("mCallback").set(instrumentation);
            this.mInstrumentation = instrumentation;
            Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
        } catch (Exception e) {
            Log.w(TAG, e);
        }
    }

Instrument在自动化测试中我们经常见过它的身影,比如这段单元测试,通过Instrument启动了Activity,模拟了一个Activity运行环境。

   Intent intent = new Intent();
        intent.setClassName("com.sample", Sample.class.getName());
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        sample = (Sample) getInstrumentation().startActivitySync(intent);
        text = (TextView) sample.findViewById(R.id.text1);
        button = (Button) sample.findViewById(R.id.button1);

VirtualApk也是基于这个原理,通过一个自定义的VAInstrumentation,重载了各个execStartActivity方法,将启动插件Activity的Intent做了一些识别和标记,即injectIntent方法,

  public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, String target, Intent intent, int requestCode, Bundle options) {
        injectIntent(intent);
        return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode, options);
    }
    
    protected void injectIntent(Intent intent) {
        mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
        // null component is an implicitly intent
        if (intent.getComponent() != null) {
            Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(), intent.getComponent().getClassName()));
            // resolve intent with Stub Activity if needed
            this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
        }
    }

并在newActivity方法中做了从插件中加载Activity的逻辑,在injectActivity方法中通过反射替换了插件Activity中的resources对象,替换的Resources对象来自于LoadedPlugin的createResources方法,将插件安装包文件夹加入到AssetManager路径中:

  protected Resources createResources(Context context, String packageName, File apk) throws Exception {
        if (Constants.COMBINE_RESOURCES) {
            return ResourcesManager.createResources(context, packageName, apk);
        } else {
            Resources hostResources = context.getResources();
            AssetManager assetManager = createAssetManager(context, apk);
            return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
        }
    }

这样插件Activity中的getResources.getXXX方法就能从插件中读取资源了。
整体思路和Activity的自动化测试差不多。

四、总结

引入VirtualApk总体还是比较容易的,对项目的侵入性较小,尤其是插件工程和普通的应用工程开发基本一样,现有的模块做一下必要的调整和业务隔离,可以比较容易的转换成插件,迁移成本较小。对插件开发者来说,一个插件就是一个独立的单体应用,这样有利于进行独立的开发测试,较少开发环境的干扰,最后和宿主进行联调一下就好了。

当然大部分业务场景下,插件都很难是完全独立的,并不能像上面的demo一样,一个按钮,启动一个Activity就万事大吉了。很多时候,我们需要通过一定的扩展接口逻辑来注入插件,而且插件与插件之间以及插件和宿主之间可能存在一些交互。这一点,VirtualApk还有一些高级玩法可以为这些场景做支撑,比如宿主插件依赖项去重功能,可以让插件依赖一个由宿主提供的SDK,而不编译到最终插件中,这样插件能通过宿主提供的接口进行交互。有时间后面再进一步解锁更多玩法和大家分享一下。

五、问题

下面整理了下开发demo过程中遇到的一些问题以及解决方法。欢迎大家在留言中分享平时遇到的坑和解决方案。也可以去官方issues提问和解答。

  • 编译失败
[INFO][VAPlugin] Evaluating VirtualApk's configurations...

FAILURE: Build failed with an exception.

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

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解决:新建gradle.properties文件并加入配置android.useDexArchive=false

  • 编译失败
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> The dependencies [com.android.support:design:28.0.0, com.android.support:recyclerview-v7:28.0.0, com.android.support:transition:28.0.0, com.android.support:cardview-v7: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.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解决:出现这个问题是因为插件工程中引用的design库而宿主中没有,需要将com.android.support:design:28.0.0加入到宿主APP中并对宿主APP进行assembleRelease。这里有一些疑惑,VirtualApk不是支持在插件中单独引入依赖的么,难道support包比较特殊?

  • 编译失败
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> com/android/build/gradle/internal/scope/TaskOutputHolder$TaskOutputType

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解决: 可能gradle插件版本过高,VirtualApk的构建原理与gradle插件强依赖,建议使用官方demo工程使用的gradle插件版本,这里降至3.0.0 就ok了。classpath 'com.android.tools.build:gradle:3.0.0'

  • 插件未签名
Caused by: android.content.pm.PackageParser$PackageParserException: Package /storage/emulated/0/Android/data/com.huangmb.virtualapkdemo/cache/pluginA.apk has no certificates at entry AndroidManifest.xml

解决:插件必须有正式签名。

signingConfigs {
    release {
        storeFile file("...")
        storePassword "..."
        keyAlias "..."
        keyPassword "..."
    }
}
buildTypes {
    release {
        ...
        signingConfig signingConfigs.release
        ...
    }
}
  • 重复加载插件
java.lang.RuntimeException: plugin has already been loaded : xxx
        at com.didi.virtualapk.internal.LoadedPlugin.<init>(LoadedPlugin.java:172)
        at com.didi.virtualapk.PluginManager.createLoadedPlugin(PluginManager.java:177)
        at com.didi.virtualapk.PluginManager.loadPlugin(PluginManager.java:318)

解决:同一个插件只能加载一次,可以在加载某个插件前校验一遍是否已加载过。

val hasLoaded = PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) != null

其中PLUGIN_PKG是待校验的插件包名,也就是gradle中的applicationId(可能和AndroidManifest中的package不一样)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容