Android Gradle 使用 Groovy 实现快速多渠道打包

介绍

多渠道打包对于 Android 来说有很多种方式,网络上也有很多相应的文章可以参考,比如 stormzhang 的「Android Studio 系列教程六–Gradle 多渠道打包」,还有 美团技术分享 的「美团 Android 自动化之旅—生成渠道包」。

之前一直使用第一种方法,但是每个渠道都会重新构建一遍,一百多个渠道的打包需要花费一个小时,比较慢。美团的文章提供了一个很好的思路,在 META-INF 文件夹中添加空文件,使用文件名来标识渠道。美团的文章中主要是提供思路以及关键代码,GavinCT 的 「Android 批量打包提速 – 1 分钟 900 个市场不是梦」提供了一套完整的解决方案,非常具有参考价值。

美团以及 GavinCT 的文章中是使用 Python 进行打包处理的,我第一次参考实现的也一样。美中不足的是我的实现与 Gradle 无法有机结合。于是决定使用 Groovy 配合 Gradle 完成同样的操作,最后终于实现。

在折腾过程中参考了不少文章, 工匠若水 的两篇博客「Groovy 脚本基础全攻略」和「Gradle 脚本基础全攻略」非常有帮助,当然官方文档参考也是必不可少,主要有「Gradle 官方指导文档」和「Android Gradle 插件 DSL 文档」。

Gradle 中的 Task 简介

Gradle 构建系统中 Task 是非常重要的概念,最常用的生成 APK 包的命令 assembleRelease 就是一个 Task,而当执行 Task 时 Android Studio 的 Run 窗口会显示 Gradle 的输出,其中很多类似 :app:compileBaiduDebugAidl 的行就是已经执行了 Task 的输出。Task 之间可以互相依赖,可以用设置按照一定的顺序执行。

Zip 文件 JAVA 处理思路

在 JAVA 中的 Zip 压缩包内部的文件就是一个个 ENTRY,将文件添加到 Zip 文件中主要就是三步,简要 Groovy 代码如下:

// 在 ZipOututStream 中新建一个 Entry
zipOut.putNextEntry(new ZipEntry(entry.name))
// 写入内容
zipOut << originZipFile.getInputStream(entry).bytes
// 关闭 Entry
zipOut.closeEntry()

添加空文件即为新建 ENTRY 随即关闭。

Talk Is Cheap

需要注意一点,我在这里使用了两种渠道统计,所以添加了两个空文件。现学现卖的 Groovy,还请高手多多指教!

import java.util.zip.*


// 发布文件夹
def packageLocPath = "/your/apk/outputs/dir"
// 最终发布包存放的子目录
def childPath = "gen"
// 渠道文件名
def channelFileName = "id.txt"
// 打包日期
def releaseTime = new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("GMT+8"))
// 基础 flavor 名称
def baseFlavor = "your_flavor_name"
// 打包的 buildType 名称
def buildType = "your_build_type"
// 发布包前缀
def baseAppName = "your_apk_name_prefix"
// Zip 条目前缀
def entryPrefix = "META-INF/"
def UMENG_CHANNEL = entryPrefix + "UMENG_CHANNEL_"
def CHANNEL_VALUE = entryPrefix + "CHANNEL_VALUE_"

// 存储需要特殊处理的渠道
def specialChannel = android.productFlavors.findAll { baseFlavor != it.name }.collect { it.name }

// 将所有 AS 生成的包复制到发布文件夹
def prepareAllPackage = project.tasks.create("copyAllPackage")
prepareAllPackage.setGroup("MultiChannelPackage")
// 生成所有最终发布版的 APK 包
def publishAllPackage = project.tasks.create("publishAllPackage")
publishAllPackage.setGroup("MultiChannelPackage")

// 读取渠道值文件 "友盟渠道值:自定义渠道值" 一行一个
def readChannelFromFile = { String path ->
    def channelValue = [:]
    new File(path).eachLine {
        def channelName = it.split(":")[0].trim()
        def customValue = it.split(":")[-1].trim()
        channelValue[channelName] = customValue
    }
    return channelValue
}

// 对 APK 文件进行操作,添加代表渠道的空 entry
def processPackage = { String originFilePath, String processedFilePath, entriesPath ->
    def originZipFile = new ZipFile(originFilePath)
    def outFile = new File(processedFilePath)

    outFile.withOutputStream { os ->
        def zipOut = new ZipOutputStream(os)

        // 完全遍历拷贝原 APK 的 entry
        originZipFile.entries().each { entry ->
            zipOut.putNextEntry(new ZipEntry(entry.name))
            zipOut << originZipFile.getInputStream(entry).bytes
            zipOut.closeEntry()
        }

        // 创建传入的空 entry
        entriesPath.each {
            zipOut.putNextEntry(new ZipEntry(it))
            zipOut.closeEntry()
        }

        zipOut.close()
    }
    originZipFile.close()
}

// 遍历所有 Build Variants,添加动态 task
android.applicationVariants.all { variant ->
    // 只依赖特定 BuildType
    if (variant.buildType.name == buildType) {
        // 获取 productFlavor 名称
        def flavorName = variant.productFlavors[0].name
        // 新生成的文件名
        def newCopyFileName = "${baseAppName}_V${android.defaultConfig.versionName}_${releaseTime}_${flavorName}.apk"
        // 复制到文件夹
        def copyDir = "${packageLocPath}/"
        // 最终生成 APK 所在目录
        def genDir = "${packageLocPath}/${childPath}/"
        // 准备好文件夹
        file(genDir).mkdirs()


        // 创建复制类型的 task 参考文档 https://docs.gradle.org/current/userguide/working_with_files.html#sec:copying_files
        def copyAndRename = project.task("copy${variant.name.capitalize()}", type: Copy)
        copyAndRename.setGroup("MultiChannelPackage")
        copyAndRename.from(variant.outputs[0].outputFile)
        copyAndRename.into(copyDir)
        copyAndRename.rename { newCopyFileName }
        copyAndRename.doLast {
            println "Copy ${variant.name.capitalize()} APK File To ${packageLocPath} Done!"
        }
        // 处理依赖,动态子项依赖 assemble<ProductFlavorName><BuildType>
        copyAndRename.dependsOn project.getTasksByName("assemble${variant.name.capitalize()}", false)
        // 总 task 依赖所有动态子项
        prepareAllPackage.dependsOn copyAndRename

        // 定义处理 APK 文件的 task
        def channelMap = readChannelFromFile(channelFileName)
        def processApkTask
        // 因为除了需要特殊处理的渠道,其余的渠道包都是一个底包
        if (variant.name.contains(baseFlavor)) {
            channelMap.each { k, v ->
                if (!specialChannel.contains(k)) {
                    def newTaskName = "publish${variant.name.replace(baseFlavor, k).capitalize()}"
                    processApkTask = project.tasks.create(newTaskName)
                    processApkTask.setGroup("MultiChannelPackage")
                    processApkTask.doLast {
                        def newPkgFileName = newCopyFileName.replace(baseFlavor, k)
                        processPackage(copyDir + newCopyFileName, genDir + newPkgFileName,
                                [UMENG_CHANNEL + k, CHANNEL_VALUE + v])
                        println "${genDir + newPkgFileName} Generated"
                    }
                    processApkTask.dependsOn project.getTasksByName("copy${baseFlavor}Packages", false)
                    publishAllPackage.dependsOn processApkTask
                    processApkTask.outputs.file(genDir + newPkgFileName)
                }
            }
        } else { // 每个需要特殊处理的渠道包单独进行处理
            processApkTask = project.tasks.create("publish${variant.name.capitalize()}")
            processApkTask.setGroup("MultiChannelPackage")
            processApkTask.doLast {
                processPackage(copyDir + newCopyFileName, genDir + newCopyFileName,
                        [UMENG_CHANNEL + flavorName, CHANNEL_VALUE + channelMap.flavorName])
                println "${genDir + newCopyFileName} Generated"
            }
            processApkTask.dependsOn project.getTasks().findByName("copy${flavorName.capitalize()}Packages")
            processApkTask.outputs.file(genDir + newCopyFileName)
            publishAllPackage.dependsOn processApkTask
        }
    }
}

要点说明

  • android.applicationVariants.all
    Build Variant 是项目定义的 productFlavorbuildType 的排列组合,保存了所有 Build 的配置。在 Android Studio 左下角 Build Variants 标签,里面可以直观查看。

  • 关于 Task 之间的依赖
    以打包流程为例,默认的 assemble<ProductFlavorName><BuildTypeName> 这一类 Task 最终生成 APK 包,我们需要在这个包的基础上进行处理,先复制一份出来,再操作 Zip 文件。所以有这个写法 <复制的 task 对象>.dependsOn <assemble 的 task 对象><处理 APk 的 task 对象>.dependsOn <复制的 task 对象>。使用 project.getTasks().findByName() 获取已存在的 Task 对象。而且一个 Task 可以有多个依赖,所以创建一个 publishAllPackage 依赖所有动态添加的 "publish" Task,做到一个命令生成所有发布包。

  • 代码执行顺序
    Gradle 有两个执行阶段,首先是处理构建脚本的阶段,以上代码除了写在 doLast {} 中的代码,都是在初始化阶段执行。而 doLast {} 中的代码则是具体开始执行 Task 时才真正执行。这点在 「Gradle 脚本基础全攻略」中有更详细的说明。

  • Gradle 的增量构建机制
    Gradle 根据 Task 的输入和输出是否变更来判断是否需要重新执行,若删除之前代码中的 processApkTask.outputs.file(filePath) ,那么处理 APK 的 Task 每次都无脑执行,这明显是不科学的。关于增量构建的更详细内容可以参考 Gradle 官网教程 「Feature Spotlight: Incremental Builds」,英文但是并不难。

    原文作者:yellowjianshu
    原文地址: https://www.jianshu.com/p/2cbf4e6d09c8
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞