App Bundle(.aab) 作为 Android 官方力推的新交付格式已经存在了一段时间,而今年 PlayStore 的政策强制“所有新应用必须使用 .aab”进行提交也成为了大家转换过去的一大动力。兼容和打包 .aab 文件格式其实并不复杂,简单添加对应的 DSL 配置并替换掉执行的打包命令为 bundle${VariantName} 即可。但是,打包后得到的 .aab 并不能直接在本地直接使用 adb 安装到调试设备,需要借助 bundletool 转换到 .apks 后再调用其安装命令进行安装。
BundleTool 官方文档 列举了 CLI 命令的各类用途;放到更复杂的环境中,你可能碰到过 BundleTool 的这些场景:
这些场景常常伴随着下列的一些问题:
不禁思考,AGP 有 BundleTool 的依赖,没有这些 BundleTool 后续转换的 Task 吗?使用 Gradle 来做这些后续的操作其实更适合?
如果我们打开 IDE 的 Gradle Task 列表,查询 “Bundle“ 关键词,很容易就会发现 makeApkFromBundleForXxxx 等任务,它们的实现是 com.android.build.gradle.internal.tasks.BundleToApkTask 这个类。

在源码中查看该类的关联使用,你会发现除了注册没有任何地方有该类的使用痕迹。而这个任务自身的具体作用其实只是:
PackageXxxBundle,等它打出一个未签名的 Bundle;BuildApksCommand 命令打出一个 .apks 包。enableLocalTesting,其余的都使用 BundleTool build-apks 命令的默认值;/intermediates 文件夹中),而非最终产物。
这就有点食之无味、弃之可惜了:有现成的 Task 但使用范围十分局限。不如...咱借助 BundleTool 库自己封装一个 Gradle Plugin?
简单分析下 BundleTool 的几个命令,我们发现它们的依赖关系如下:

其中:
build-apks 是其他几个命令的必选前置任务(实线),get-device-sepc 是几个命令的可选前置任务(虚线)。build-apks 和 get-size(斜体部分)和构建流程关系紧密,不需要测试设备参与;其余命令需要测试设备参与。get-device-spec 导出 json 文件是一次性的任务,我们可以假设这部分已经完成;如此,我们涉及的领域也清楚了:build-apks 和 get-size 等和最终产物直接相关的命令。install-apks 和 extract-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。
欢迎关注我的 Github / 公众号 / 播客 / 微博 / Twitter。
