Android 构建指北 #11 BundleTool Gradle Plugin

1,052 阅读1分钟

*欢迎关注我的公众号 “Android高效开发”,注重 Android 工程效率与开发体验,涵盖基础架构、Kotlin、Gradle 构建优化等话题,同时也聊聊海外工作生活,推送“二分电台” Podcast 节目。

最近做了个 BundleTool 的 Gradle 插件,它提供几个简单的功能:

  1. 生成 Universal 的 .apk,方便内部测试或者上传到一些不支持 .apks/.aab 的平台进行测试。
  2. 按设备 spec.json 生成对应 .apks,方便跑多真机的测试(本地多设备、真机云平台等等)。
  3. 生成第二步每一个 .apks 的预测下载大小,按 .csv 格式导出,方便 CI 做数据统计和卡口。
  4. 插件可以按 Variant 决定是否开启对应功能。

我们会从头开始介绍这款插件是因为什么场景而做出来的,着急的同学可以拖到最后直接看怎么集成和源码地址。

App Bundle(.aab) 作为 Android 官方力推的新交付格式已经存在了一段时间,而今年 PlayStore 的政策强制“所有新应用必须使用 .aab”进行提交也成为了大家转换过去的一大动力。兼容和打包 .aab 文件格式其实并不复杂,简单添加对应的 DSL 配置并替换掉执行的打包命令为 bundle${VariantName} 即可。但是,打包后得到的 .aab 并不能直接在本地直接使用 adb 安装到调试设备,需要借助 bundletool 转换到 .apks 后再调用其安装命令进行安装。

常见场景

BundleTool 官方文档 列举了 CLI 命令的各类用途;放到更复杂的环境中,你可能碰到过 BundleTool 的这些场景:

  1. 本地测试时,需要使用 BundleTool 的 CLI 手动运行转换和安装命令;
  2. CI 环境下,需要配置 BundleTool CLI 工具,再编写一些 Shell 脚本进行打包后的转换流程控制;
  3. 真机实验室的测试、真机云平台的测试,需要集成 BundleTool 到相对应的测试中,方便生成对应机型的 apks 包。

这些场景常常伴随着下列的一些问题:

  1. 一些内部测试包发布渠道不支持 aab 和 apks,我们需要在这些非 GP 非真机实验平台做快速的功能测试,universal apk 才是这类场景的最好选择(但是从头再打一个 apk 显然是浪费资源);
  2. 在 aab 包上传至 GP 的 Console 后,可以清晰得看到所有支持机型的预计下载大小,但是这个数据指标不方便在 CI 上直接用 Shell 脚本来做收集和量化(硬上或者用 Python 大概也可以);
  3. AGP 其实集成了,BundleTool 包,每次又要单独配置 BundleTool 的 CLI 其实显得有些多余。

不禁思考,AGP 有 BundleTool 的依赖,没有这些 BundleTool 后续转换的 Task 吗?使用 Gradle 来做这些后续的操作其实更适合?

深入 AGP & BundleTool

如果我们打开 IDE 的 Gradle Task 列表,查询 “Bundle“ 关键词,很容易就会发现 makeApkFromBundleForXxxx 等任务,它们的实现是 com.android.build.gradle.internal.tasks.BundleToApkTask 这个类。

Task 列表和 BundleToApkTask 截图

在源码中查看该类的关联使用,你会发现除了注册没有任何地方有该类的使用痕迹。而这个任务自身的具体作用其实只是:

  • 依赖 PackageXxxBundle,等它打出一个未签名的 Bundle;
  • 输入上一步的 Bundle,执行 BundleTool 包里的 BuildApksCommand 命令打出一个 .apks 包。
  • 从它的输入参数可以看出,用户需要的配置它其实只暴露了一个 enableLocalTesting,其余的都使用 BundleTool build-apks 命令的默认值
  • 从它的输出结果(见下方)可以发现,它竟然只是一个“中间产物”(位于 /intermediates 文件夹中),而非最终产物。

这就有点食之无味、弃之可惜了:有现成的 Task 但使用范围十分局限。不如...咱借助 BundleTool 库自己封装一个 Gradle Plugin?

bundle-tool-gradle-plugin 怎么封装?

简单分析下 BundleTool 的几个命令,我们发现它们的依赖关系如下:

其中:

  • 从顺序来看 build-apks 是其他几个命令的必选前置任务(实线),get-device-sepc 是几个命令的可选前置任务(虚线)。
  • 从交互来看,build-apksget-size(斜体部分)和构建流程关系紧密,不需要测试设备参与;其余命令需要测试设备参与。
  • get-device-spec 导出 json 文件是一次性的任务,我们可以假设这部分已经完成;

如此,我们涉及的领域也清楚了:build-apksget-size 等和最终产物直接相关的命令。install-apksextract-apks 在本地测试可以根据当前设备使用 CLI 完成,在 CI 或者云真机测试平台等一般有专用的脚本去结合 BundleTool 处理。

然后我们考虑获取如何获取最终输出的 Bundle 并修改。新版 Variant API 其实已经提供了方便修改和获取最终 Bundle 的方法,整个流程可以参考如下的运行截图。可以看到对比之前的中间产物模式,Variant API 的产物都已经输出到 /outputs 文件夹了。由于我们不需要中间 aab,所以我们只要简单调用 variants.get(SingleArtifact.BUNDLE),把获取的 .aab 文件传入自定义 Task,之后再借鉴 AGP 的代码包装下各类命令即可:

// 一个验证想法的简单 Task,完整插件的实现比这个复杂一些,请直接参考文末的仓库链接
abstract class ConsumeBundleFileTask : DefaultTask() {

    @get:InputFiles
    abstract val finalBundleProperty: RegularFileProperty

    @get:Internal
    abstract val buildToolInfo: Property<BuildToolInfo>

    @get:Nested
    lateinit var signingConfigData: SigningConfigDataProvider

    @get:OutputFile
    abstract val apksFileProperty: RegularFileProperty

    @TaskAction
    fun taskAction() {
        val aapt2Path = buildToolInfo.get().getPath(BuildToolInfo.PathId.AAPT2)
        println(".get(SingleArtifact.BUNDLE)")
        println("[ConsumeBundleFileTask][input]:" + finalBundleProperty.asFile.get().absolutePath)
        println("[ConsumeBundleFileTask][output]:" + apksFileProperty.asFile.get().absolutePath)
        val signingConfigData = signingConfigData.resolve()!!
        val command = BuildApksCommand.builder()
            .setBundlePath(finalBundleProperty.asFile.get().toPath())
            .setOutputFile(apksFileProperty.asFile.get().toPath())
            .setAapt2Command(
                Aapt2Command.createFromExecutablePath(
                    File(aapt2Path).toPath()
                )
            )
            .setSigningConfiguration2(
                keystoreFile = signingConfigData.storeFile,
                keystorePassword = signingConfigData.storePassword,
                keyAlias = signingConfigData.keyAlias,
                keyPassword = signingConfigData.keyPassword
            ).setLocalTestingMode(false)
        command.build().execute()
    }
    ...
}

不了解新版 Variant API 的朋友可以参考我这个月在 GDG 社区的分享《扩展 Android 构建流程 - 基于新版 Variant/Artifact APIs》(回放地址)。

最后我们简单看下 BundleTool 的 BuildApksCommand.Builder,这个 Builder 的 setXxx 相关的 API 过去一年也就两个小改动,其中还有一个是新增方法,不太影响原有的兼容性,相比 AGP 来说整体相对稳定了。

至此,整个插件的理论构建成本和维护成本都在可接受范围内。

使用插件

插件的开发和我之前写过的几个并无差别,我们直接来看插件的使用。

0x01. Add the plugin to classpath:

buildscript {
    repositories {
        ...
        mavenCentral()
    }
    dependencies {
        classpath("com.android.tools.build:gradle:7.0.4")
        classpath("me.2bab:bundle-tool-plugin:1.1.0")
    }
}

0x02. Apply Plugin:

// For your application module
plugins {
    id("me.2bab.bundletool")
}

0x03. Advanced Configurations

import me.xx2bab.bundletool.*

bundleTool {
    // 这里是一个很有趣的配置项,它可以按不同 variant 渠道去
    // 开启插件的几个功能特性,例如这里我们把 debug + Get_SIZE 功能的组合禁掉了。
    // 你可以根据项目实际的 buildtype 和 flavor 去调整和开启需要的功能。
    enableByVariant { variant, feature ->
        !(variant.name.contains("debug", true) && feature == BundleToolFeature.GET_SIZE)
    }
    
    // 每个配置项会对应到一个 `build-apks` 命令的执行
    buildApks {
        create("universal") {
            buildMode.set(ApkBuildMode.UNIVERSAL.name)
        }
        create("pixel4a") {
            deviceSpec.set(file("./pixel4a.json"))
        }
    }

    // 每个配置项都会依次计算上面 `buildApks` 所有输出的 apks 的大小,
    // 按当前的配置会输出 2 * 1 = 2 份 csv 文件
    getSize {
        create("all") {
            dimensions.addAll(
                GetSizeDimension.SDK.name,
                GetSizeDimension.ABI.name,
                GetSizeDimension.SCREEN_DENSITY.name,
                GetSizeDimension.LANGUAGE.name)
        }
    }
}

0x04. Build your App and Enjoy!

# 确保执行命令里的 Variant 是 `enableByVariant` 中允许的
./gradlew TransformApksFromBundleForProductionRelease

最后可以在 /app/outputs/bundle/${variantName}/bundletool 中找到输出的结果。

总结

希望这个小工具可以帮助大家在集成新的 Android Bundle 时提供一些帮助,插件已经开源到我 Github:bundle-tool-gradle-plugin。关于按 Variant 开关插件功能的思路,请参考下一篇构建指北#12。

喜欢这篇文章的话不妨点个赞~ 也欢迎关注公众号专栏 “Android高效开发”