从 VasDolly 中看到的 gradle 插件机制

基本结构

可执行文件的结构

Gradle 是一个开源的构建自动化工具。其所使用的语言为 groovy,是一个运行于 JVM 上的语言。也因此,一个 Gradle 插件的最终形式通常就是一个普通的 jar 文件。

源代码的组织结构

如果要实现一个 Gradle 插件,从源代码上看,通常需要有三个基本的部分组成:入口、任务/行为、配置/数据。

入口最简单,从 Plugin 接口派生一个类即可,该接口唯一的方法 apply 被调用时,会通过该方法的唯一参数传入类型 T 的一个实例对象。这是插件的初始化,传入的即是插件的环境上下文。常见的 T 类型就是 Project,对应了一个 Android Studio 打开的项目(project)中的模块(module)。

既然一个插件需要被实现,它显然应该要具备执行其特定行为的功能。向外暴露这些功能的方式,通常是由它注册一个或者多个任务(task)来完成,每个任务都是它的一个具体的行为能力。程序中,就是调用 Project 对象的 tasks 成员所具有的 register 方法。方法的参数提供了任务的名称,以及具体实现任务的类,如 project.tasks.register("simpleTask", SimpleTask.class),而 SimpleTask 需要是一个实现了 Task 接口的类(为了简化开发工作,从 Gradle 提供的 DefaultTask 类派生即可)。

为了更好、更灵活地控制插件的行为,插件还可以利用插件环境所提供的数据驱动的能力基础。插件向外暴露哪些数据可以被配置,需要创建一个或者多个扩展(extension)来完成,可以将一组关联性比较紧密的数据组合为一个扩展,如果有多组这样的数据,那就对应创建多个扩展。程序中,就是调用 Project 对象的 extensions 成员所具有的 create 方法。方法的参数提供了扩展的名称,以及具体实现扩展的类,如 project.extensions.create("simpleConfig", SimpleConfigExtension.class)create 方法尾部是可变参数列表,用以传递具体扩展类构造时要传入的参数。在扩展类中所定义的公用成员变量,即可以在 build.gradle 中以扩展的名字对应的块中进行赋值。

VasDolly 的局限以及应对方法

VasDolly 工作流程的基本介绍

VasDolly 是由腾讯开源的一款批量生成各渠道安装包的构建插件。渠道包的构建通常需要以一个基础包为母版(曾经有过短暂的每一个渠道包都是编译生成的原始阶段,但是那样太耗费资源和时间,不久就被淘汰)。

VasDolly 插件在初始化的时候,会为当前项目里的每一个应用类型的模块生成构建渠道包的任务。这样的任务分为两类,各自的母版安装包来源指定方式不一样。

一类是针对模块中的每个构建目标的变体(变体就是由 flavor 与构建类型也即 debug/release 组合出的形态),每个变体下都生成了一个与之关联的任务,这样的任务,会自动使用该变体的构建结果作为自己的基础包,因此无需手动指定。另一类是一个单一任务,要显式在 build.gradle 的配置中为其指定基础包。基础包作为一个属性数据,VasDolly 将其命名为 baseApk

在锁定基础包之后,VasDolly 采用手术式的文件级修改,衍生出最终的各个渠道包。

VasDolly 在实践中遇到的问题

前述 VasDolly 提供的两种生成渠道包的任务,在实践中都会遇到一些琐碎的障碍,使得使用体验大打折扣。

先说第一种,应用于构建变体的渠道包打包任务。对于最普通的情形,一个应用类型的项目只存在 debug 和 release 两种变体。对 debug 变体的安装包进行渠道包打包工作,实属罕见,个人认为除了能作为测试目的来验证一下 VasDolly 的能力以外,别无用途。而对于 release 变体进行渠道包打包的话,这就要涉及另外一个国情特色:在国区内上架的 Android 应用,除了要分发到若干不同的应用市场外,通常还需要做一些安装包的安全加固措施,以免上架后被轻易地逆向工程甚至破解。也就是说,release 变体的构建结果,往往并不是 VasDolly 可以直接使用的上游基本包。事实上,VasDolly 提供的第二种类型的打渠道包的任务,很大程度上也是为了应对这个问题的。

既然这种任务在实操环境里属于无足轻重的情形,所以它所面临的也就算不上是真正的问题,后文不再将之纳入考虑范畴。

接下来说第二种。第二种任务是用户可以在 build.gradle 中,在对应的任务配置块里为 baseApk 赋值,用这种方式来为渠道包指定母版安装包,然后再继续后续的流程。VasDolly 对于这种任务,在一个模块中仅生成一个,而不像第一种那样,为每个构建变体都生成一个。大概是因为其作者认为,通过更改 baseApk 即可指定母版基础包,应该就可以应对这个模块自身的需要了。很可惜,还不行。

对于稍微复杂一些的应用,往往还会存在产品角度的变体。就笔者的实际情况来说,应用中的一些特性,在华为手机上使用时,需要在清单文件中增加一些仅限于华为手机的内容,而如果含有这些内容的安装包安装到非华为手机上的话,又会带来干扰用户操作的副作用。在这种需求下,渠道包的母版其实就分裂了,成为:华为渠道的渠道包,其母版基础包需要构建一个独立变体,其余的渠道包,则是另一个适用于大多数情况的常规变体。

有实际正规开发经历的都知道,这两种变体很显然不会使用相同的输出文件名,其中必然包含着对应于变体的可供区别的部分,就像这样:prod-general-release-v1.0.0.apkprod-huawei-release-v1.0.0.apk。它们都需要在源码构建结束后,进行安全加固,然后再分别以它们为母版基础包,构建各自适用的渠道包,只不过在本例中,华为变体的安装包作为母版基础包,仅需打一个用于华为应用商店的渠道包,其余渠道的渠道包,则是以通用变体的安装包作为母版基础包。

由此给 VasDolly 带来的影响是,如果多次在不同的变体间切换构建,则每一次都要去修改 build.gradlebaseApk 的值,然后执行 Gradle 的 sync 步骤,然后再执行打渠道包的任务。很烦人,很无聊,很不计算机,很不自动化。这是问题一

问题一的解决

笔者一开始的思路是,对 VasDolly 进行改造增强,毕竟它是一款开源产品,恰好笔者也会写几行代码。采取的方法非常直接,把 VasDolly 项目的源代码拉取到本地,找到相应的负责打渠道包的实现类 RebuildApkChannelPackageTask 及其对应的配置类 RebuildChannelConfigExtension。在后者中,就放着 baseApk 本尊,其类型为一个 File 对象。显然,如果要支持多个 baseApk,我们就应该把 baseApk 改名为 baseApks,再把类型从 File? 改为一个 List?。这个思路过于直接,如果是在一款产品的创建期,并无不可,很可惜的是,VasDolly 插件已经被大范围使用,这样的改动其实打破了与用户之间的协议。更成熟的方法是,继续保留 baseApk,然后新增一个 baseApks。这样的设计当然还会有一些微妙的需要考虑的细节,例如,假使用户两者都进行了设置,如何处理?是选择只处理其一,还是两者都处理?当然是后者,而且还要考虑到,如果 baseApk 指定的安装包同时也存在于 baseApks 当中的话,应该将之去重排除出去(baseApks 本身也要去重),以减轻工作负担。

根据上述思路,对源代码进行了修改之后,如何测试对笔者成了个问题。一是在本地搭建 Maven 仓库,这个不在行,当即放弃;二是去篡改 Gradle 已经下载回来的插件缓存,不过考虑到签名等安全机制,又怕偷鸡不成蚀把米,也放弃;三是给官方提 PR,这个更不可行,咱本地都还没测试验证过呢。于是一搁置就是半年。(前种思路的实现方式还有个特点,那就是打渠道包这个任务一执行,默认就会是 baseApk + baseApks 全量的)

最近又捡起来这个线头,有了另一个不一样的思路。既然通过改动插件本身来实现需求比较困难(虽然比较直接),那可不可以在使用侧想点招儿?简单来说就是,在执行打渠道包的任务时,自动找到最后构建出的那个变体的安装包,将其赋予 baseApk,这样就实现了自动化。既不用像原始 VasDolly 需要的那样,一切换构建变体就得手动修改 baseApk,也不像上面构思的增强 VasDolly 那样,每次都会打出所有变体的渠道包,听上去是个不错的主意。至于如何找到最后构建的包,到输出路径下去根据文件名和创建时间就可以筛选得到。

不过这里有个实现时的小难点。baseApk 的值是配置块中写死的,怎么才能做到在任务执行前动态更新掉它?答案是使用配置钩子。限于篇幅,此处不张贴完整代码,关键语句就是:

不过这样做还有一个问题,那就是,不同的构建变体其实很可能对应的渠道列表是不同的。而 VasDolly 的机制下,渠道列表无论是用在 properties 文件中的属性值直接指定,还是在用属性值所指定的渠道列表文件中间接指定,都只有一份。如何使每一个 baseApk 都能正确对应到不同的渠道列表上,这是问题二

问题二的解决

如果你仔细看 VasDolly 的文档,就会发现有一个额外的 channelFile 属性可以配置。因为有了上面动态更新 baseApk 的经验加持,很容易在这时大喜过望,想当然地认为如法炮制即可。然而现实比想象残酷一些。

首先是,channelFile 这个属性,在有些情况下不生效。VasDolley 的作者们为 channelFile 这一属性是否生效设计了一个控制机制,具体方式是,如果项目的 properties 文件中是直接指定了渠道列表,则 channelFile 中指定的属性列表文件就不会生效。当然,这个问题可以变通解决,properties 文件中不直接指定渠道列表,而是指定一个包含渠道列表的文件。然而,这还不算完。

对于 channelFile 中指定的文件中所指定的渠道列表,VasDolly 会将之与初始化时从 properties 文件中直接或者间接地获取到的渠道列表进行合并操作,而不是完全取代。这就需要另一个变通操作,properties 文件中指定的渠道列表文件的内容留空。如此来达到后续可以用 channelFile 属性中指定的渠道列表文件来表示当前构建变体所应当应用的全部渠道列表。

截至目前,在所有这些前提下,在配置钩子中加入对 channelFile 属性的动态更新逻辑,对于我们着重讨论的第二种打渠道包的任务来说,就可以完美工作了。不过从整体视角来审视,解决方案并非已经没有改进空间。

遗留问题

此处所说的遗留问题,其实是 VasDolly 自身实现时考虑不周的结果,如果要改进,就真的只能修改源码发布新版才能补救了。如果直接执行一个 Gradle 任务,事实上它会依次执行项目中所有模块下名字与要执行的任务名字匹配的任务。假如有一个场景是,在一个项目里有不止一个应用类型的模块(尽管不多见),统一执行打渠道包这一任务时,一旦 VasDolly 检测有错误发生,它都会以异常的方式退出。抛出的异常不但中止了当前模块的打渠道包任务,同时也中止了后续所有模块的相同任务。笔者认为,使用输出错误信息,返回成功与否的标志或者错误码是更合适的方法。

另外,VasDolly 插件在初始化的时候,如果 properties 文件中是指定了一个渠道列表文件,直接当即就将文件内容读入并缓存下来也在一定程度上对灵活性进行了束缚。如果是在执行打渠道包任务时才读取该文件中的内容,则为在初始化与执行任务的时间间隔内对渠道列表文件进行变更提供了时机。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注