Android Gradle 使用 Groovy 實(shí)現(xiàn)快速多渠道打包

介紹

多渠道打包對(duì)于 Android 來(lái)說(shuō)有很多種方式,網(wǎng)絡(luò)上也有很多相應(yīng)的文章可以參考,比如 stormzhang 的「Android Studio 系列教程六--Gradle 多渠道打包」,還有 美團(tuán)技術(shù)分享 的「美團(tuán) Android 自動(dòng)化之旅—生成渠道包」。

之前一直使用第一種方法,但是每個(gè)渠道都會(huì)重新構(gòu)建一遍,一百多個(gè)渠道的打包需要花費(fèi)一個(gè)小時(shí),比較慢。美團(tuán)的文章提供了一個(gè)很好的思路,在 META-INF 文件夾中添加空文件,使用文件名來(lái)標(biāo)識(shí)渠道。美團(tuán)的文章中主要是提供思路以及關(guān)鍵代碼,GavinCT 的 「Android 批量打包提速 - 1 分鐘 900 個(gè)市場(chǎng)不是夢(mèng)」提供了一套完整的解決方案,非常具有參考價(jià)值。

美團(tuán)以及 GavinCT 的文章中是使用 Python 進(jìn)行打包處理的,我第一次參考實(shí)現(xiàn)的也一樣。美中不足的是我的實(shí)現(xiàn)與 Gradle 無(wú)法有機(jī)結(jié)合。于是決定使用 Groovy 配合 Gradle 完成同樣的操作,最后終于實(shí)現(xiàn)。

在折騰過(guò)程中參考了不少文章, 工匠若水 的兩篇博客「Groovy 腳本基礎(chǔ)全攻略」和「Gradle 腳本基礎(chǔ)全攻略」非常有幫助,當(dāng)然官方文檔參考也是必不可少,主要有「Gradle 官方指導(dǎo)文檔」和「Android Gradle 插件 DSL 文檔」。


Gradle 中的 Task 簡(jiǎn)介

Gradle 構(gòu)建系統(tǒng)中 Task 是非常重要的概念,最常用的生成 APK 包的命令 assembleRelease 就是一個(gè) Task,而當(dāng)執(zhí)行 Task 時(shí) Android Studio 的 Run 窗口會(huì)顯示 Gradle 的輸出,其中很多類似 :app:compileBaiduDebugAidl 的行就是已經(jīng)執(zhí)行了 Task 的輸出。Task 之間可以互相依賴,可以用設(shè)置按照一定的順序執(zhí)行。

Zip 文件 JAVA 處理思路

在 JAVA 中的 Zip 壓縮包內(nèi)部的文件就是一個(gè)個(gè) ENTRY,將文件添加到 Zip 文件中主要就是三步,簡(jiǎn)要 Groovy 代碼如下:

// 在 ZipOututStream 中新建一個(gè) Entry
zipOut.putNextEntry(new ZipEntry(entry.name))
// 寫(xiě)入內(nèi)容
zipOut << originZipFile.getInputStream(entry).bytes
// 關(guān)閉 Entry
zipOut.closeEntry()

添加空文件即為新建 ENTRY 隨即關(guān)閉。


Talk Is Cheap

需要注意一點(diǎn),我在這里使用了兩種渠道統(tǒng)計(jì),所以添加了兩個(gè)空文件?,F(xiàn)學(xué)現(xiàn)賣的 Groovy,還請(qǐng)高手多多指教!

import java.util.zip.*


// 發(fā)布文件夾
def packageLocPath = "/your/apk/outputs/dir"
// 最終發(fā)布包存放的子目錄
def childPath = "gen"
// 渠道文件名
def channelFileName = "id.txt"
// 打包日期
def releaseTime = new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("GMT+8"))
// 基礎(chǔ) flavor 名稱
def baseFlavor = "your_flavor_name"
// 打包的 buildType 名稱
def buildType = "your_build_type"
// 發(fā)布包前綴
def baseAppName = "your_apk_name_prefix"
// Zip 條目前綴
def entryPrefix = "META-INF/"
def UMENG_CHANNEL = entryPrefix + "UMENG_CHANNEL_"
def CHANNEL_VALUE = entryPrefix + "CHANNEL_VALUE_"

// 存儲(chǔ)需要特殊處理的渠道
def specialChannel = android.productFlavors.findAll { baseFlavor != it.name }.collect { it.name }

// 將所有 AS 生成的包復(fù)制到發(fā)布文件夾
def prepareAllPackage = project.tasks.create("copyAllPackage")
prepareAllPackage.setGroup("MultiChannelPackage")
// 生成所有最終發(fā)布版的 APK 包
def publishAllPackage = project.tasks.create("publishAllPackage")
publishAllPackage.setGroup("MultiChannelPackage")

// 讀取渠道值文件 "友盟渠道值:自定義渠道值" 一行一個(gè)
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
}

// 對(duì) APK 文件進(jìn)行操作,添加代表渠道的空 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()
        }

        // 創(chuàng)建傳入的空 entry
        entriesPath.each {
            zipOut.putNextEntry(new ZipEntry(it))
            zipOut.closeEntry()
        }

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

// 遍歷所有 Build Variants,添加動(dòng)態(tài) 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"
        // 復(fù)制到文件夾
        def copyDir = "${packageLocPath}/"
        // 最終生成 APK 所在目錄
        def genDir = "${packageLocPath}/${childPath}/"
        // 準(zhǔn)備好文件夾
        file(genDir).mkdirs()


        // 創(chuàng)建復(fù)制類型的 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!"
        }
        // 處理依賴,動(dòng)態(tài)子項(xiàng)依賴 assemble<ProductFlavorName><BuildType>
        copyAndRename.dependsOn project.getTasksByName("assemble${variant.name.capitalize()}", false)
        // 總 task 依賴所有動(dòng)態(tài)子項(xiàng)
        prepareAllPackage.dependsOn copyAndRename

        // 定義處理 APK 文件的 task
        def channelMap = readChannelFromFile(channelFileName)
        def processApkTask
        // 因?yàn)槌诵枰厥馓幚淼那?,其余的渠道包都是一個(gè)底包
        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 { // 每個(gè)需要特殊處理的渠道包單獨(dú)進(jìn)行處理
            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
        }
    }
}

要點(diǎn)說(shuō)明

  • android.applicationVariants.all
    Build Variant 是項(xiàng)目定義的 productFlavorbuildType 的排列組合,保存了所有 Build 的配置。在 Android Studio 左下角 Build Variants 標(biāo)簽,里面可以直觀查看。

  • 關(guān)于 Task 之間的依賴
    以打包流程為例,默認(rèn)的 assemble<ProductFlavorName><BuildTypeName> 這一類 Task 最終生成 APK 包,我們需要在這個(gè)包的基礎(chǔ)上進(jìn)行處理,先復(fù)制一份出來(lái),再操作 Zip 文件。所以有這個(gè)寫(xiě)法 <復(fù)制的 task 對(duì)象>.dependsOn <assemble 的 task 對(duì)象>,<處理 APk 的 task 對(duì)象>.dependsOn <復(fù)制的 task 對(duì)象>。使用 project.getTasks().findByName() 獲取已存在的 Task 對(duì)象。而且一個(gè) Task 可以有多個(gè)依賴,所以創(chuàng)建一個(gè) publishAllPackage 依賴所有動(dòng)態(tài)添加的 "publish" Task,做到一個(gè)命令生成所有發(fā)布包。

  • 代碼執(zhí)行順序
    Gradle 有兩個(gè)執(zhí)行階段,首先是處理構(gòu)建腳本的階段,以上代碼除了寫(xiě)在 doLast {} 中的代碼,都是在初始化階段執(zhí)行。而 doLast {} 中的代碼則是具體開(kāi)始執(zhí)行 Task 時(shí)才真正執(zhí)行。這點(diǎn)在 「Gradle 腳本基礎(chǔ)全攻略」中有更詳細(xì)的說(shuō)明。

  • Gradle 的增量構(gòu)建機(jī)制
    Gradle 根據(jù) Task 的輸入和輸出是否變更來(lái)判斷是否需要重新執(zhí)行,若刪除之前代碼中的 processApkTask.outputs.file(filePath) ,那么處理 APK 的 Task 每次都無(wú)腦執(zhí)行,這明顯是不科學(xué)的。關(guān)于增量構(gòu)建的更詳細(xì)內(nèi)容可以參考 Gradle 官網(wǎng)教程 「Feature Spotlight: Incremental Builds」,英文但是并不難。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容