Android 自定义Gradle插件(十):批量处理多渠道包

835 阅读5分钟

在日常开发中,应该不少人都遇到过用同一份代码出不同渠道包的需求,不同渠道包之间除了包名、图标、名称之外,可能也只有一些简单的配置有区别。最近工作中就碰上了这种需求,大概就是通过WebView加载链接的方式出一批包,每个包使用不同的链接。

接到需求的第一反应就是通过AS的多渠道配置来处理,在同个项目中就可出不同的包,也方便管理。着手实施时,配置完两三个包的信息之后,感觉这样一个一个配着实是有点繁琐,考虑到后续可能会出几十个包,决定通过自定义插件来批量处理多渠道包。

实现处理多渠道包插件

实现批量处理多渠道包大概分为下列几步:

  1. 在插件中获取渠道信息表,解析获得每个渠道的配置信息。
  2. 在插件中通过命令行生成每个渠道对应的签名文件,配置对应的signConfigs
  3. 在插件中根据渠道的配置信息生成每个渠道对应的资源文件,配置对应的productFlavors
  4. 实现打包Task,通过命令行批量生成apk或aab。

接下来详细说明一下各个步骤如何实现。

添加使用SDK

先在插件项目的build.gradle中添加使用到的SDK。

dependencies {
    implementation(gradleApi())
    // 网络请求
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    // 解析Excel
    implementation("net.sourceforge.jexcelapi:jxl:2.6.12")
    // 解析xml
    implementation("org.dom4j:dom4j:2.1.4")
}

获取与解析渠道配置信息

如果读者之前已经简单了解过自定义插件,那么应该知道可以通过Extension实现在主项目和插件之间传递参数。Extension需要在Project.afterEvaluate闭包中才能获取到参数,而signConfigsproductFlavors需要在Project.afterEvaluate执行前就配置好,因此通过Extension传参无法满足需求。

渠道配置信息来源于运营的同事,采用Excel来存放多渠道信息,协作上会更简单。插件中解析Excel采用的是jxl库。

获取并解析Excel示例代码如下:

class ProcessingFlavorPlugin implements Plugin<Project> {

    private def flavorParams = new ArrayList<FlavorParamsEntity>()

    @Override
    void apply(Project target) {
        // 从渠道参数Excel解析出需要的数据
        def flavorParamsExcel = new File(target.projectDir, "flavor_params.xls")
        parseFlavorParamsFromExcel(flavorParamsExcel)
    }

    private void parseFlavorParamsFromExcel(File targetExcel) {
        if (targetExcel.exists()) {
            flavorParams.clear()
            try {
                def flavorParamsSheet = Workbook.getWorkbook(targetExcel).getSheet(0)
                for (int rowIndex = 1; rowIndex < flavorParamsSheet.getRows(); rowIndex++) {
                    def flavorName = flavorParamsSheet.getCell(0, rowIndex).getContents()
                    def applicationId = flavorParamsSheet.getCell(1, rowIndex).getContents()
                    def versionCode = flavorParamsSheet.getCell(2, rowIndex).getContents()
                    def versionName = flavorParamsSheet.getCell(3, rowIndex).getContents()
                    def appName = flavorParamsSheet.getCell(4, rowIndex).getContents()
                    // 示例中使用网络图标
                    def appIconPath = flavorParamsSheet.getCell(5, rowIndex).getContents()
                    def openWebsite = flavorParamsSheet.getCell(6, rowIndex).getContents()
                    if (CommonUtils.isEmpty(flavorName) || CommonUtils.isEmpty(applicationId) || CommonUtils.isEmpty(versionCode) ||
                            CommonUtils.isEmpty(versionName) || CommonUtils.isEmpty(appName) || CommonUtils.isEmpty(appIconPath) || CommonUtils.isEmpty(openWebsite)) {
                        continue
                    }
                    flavorParams.add(new FlavorParamsEntity(flavorName, applicationId, Integer.parseInt(versionCode), versionName, appName, appIconPath, openWebsite))
                }
            } catch (Exception e) {
                e.printStackTrace()
                CommonUtils.log("parse flavor params from excel error due to " + e.getMessage())
            }
        }
    }
}

生成、配置签名

获取了渠道信息之后,接下来就是生成渠道对应的签名文件,并配置对应的signConfigs,示例代码如下:

class ProcessingFlavorPlugin implements Plugin<Project> {

    /**
     * 生成签名文件时设置的密码
     * 如果要使用不一样的密码,可以考虑放到FlavorParamsEntity中
     */
    private String keystorePassword = "test123456"

    /**
     * 渠道多的时候,签名文件也会对应的增多
     * 放到一个单独的文件中,项目结构更清晰
     */
    private String keystoreSaveFolder = "signKey"

    @Override
    void apply(Project target) {
        def android = target.extensions.findByName("android")
        if (!android || !android.hasProperty("applicationVariants")) {
            throw IllegalArgumentException("must apply this plugin after 'com.android.application'")
        }

        ......

        if (!flavorParams.isEmpty()) {
            for (FlavorParamsEntity flavorParam : flavorParams) {
                def flavorName = flavorParam.flavorName

                // 配置签名
                def keystoreFile = generateKeystoreIfNeed(target, flavorParam)
                android.signingConfigs.register(flavorName, {
                    keyAlias keystoreFile.getName()
                    keyPassword keystorePassword
                    storeFile keystoreFile
                    storePassword keystorePassword
                })
            }
        }
    }

    private File generateKeystoreIfNeed(Project project, FlavorParamsEntity flavorParam) {
        def finalKeystoreSaveFolder = new File(project.projectDir, keystoreSaveFolder)
        if (finalKeystoreSaveFolder.mkdirs()) {
            CommonUtils.log("create keystore save folder succeed")
        }

        // 示例中直接使用渠道名作为签名文件的名字和别名
        // 需要特别定制可以考虑放到FlavorParamsEntity中
        def keystoreFileName = flavorParam.flavorName
        def keystoreFile = new File(finalKeystoreSaveFolder, keystoreFileName)
        if (!keystoreFile.exists()) {
            // 对应用AS生成签名时需要填写的信息
            def dName = "CN=$keystoreFileName, OU=$keystoreFileName, O=$keystoreFileName, L=$keystoreFileName, ST=$keystoreFileName, C=$keystoreFileName"
            // 通过命令行生成签名文件
            project.exec {
                commandLine(
                        "keytool", "-genkey",
                        "-alias", keystoreFileName,
                        "-keypass", keystorePassword,
                        "-keyalg", "RSA",
                        "-validity", "10950",
                        "-keystore", keystoreFile.getAbsolutePath(),
                        "-storepass", keystorePassword,
                        "-dname", dName
                )
            }
        }
        return keystoreFile
    }
}

生成、配置Flavor

根据渠道信息,创建每个渠道的资源文件夹,配置对应的productFlavors,示例代码如下:

class ProcessingFlavorPlugin implements Plugin<Project> {

    private def okHttpHelper = new OkHttpHelper()

    private String dimensionValue = "apptag"

    private def flavorParams = new ArrayList<FlavorParamsEntity>()

    @Override
    void apply(Project target) {
        def android = target.extensions.findByName("android")
        if (!android || !android.hasProperty("applicationVariants")) {
            throw IllegalArgumentException("must apply this plugin after 'com.android.application'")
        }

        okHttpHelper.init()
        
        ......

        if (!flavorParams.isEmpty()) {
            android.flavorDimensionList.add(dimensionValue)

            for (FlavorParamsEntity flavorParam : flavorParams) {
                def flavorName = flavorParam.flavorName

                // 配置flavor
                generateFlavorFolderAndResIfNeed(target, flavorParam)
                android.productFlavors.register(flavorName, {
                    applicationId flavorParam.applicationId
                    versionCode flavorParam.versionCode
                    versionName flavorParam.versionName

                    manifestPlaceholders = [app_icon      : "@drawable/${flavorName}",
                                            app_round_icon: "@drawable/${flavorName}"]

                    signingConfig android.signingConfigs.getByName(flavorName)

                    buildConfigField("String", "open_website", "\"${flavorParam.openWebsite}\"")

                    dimension dimensionValue
                })
            }
        }
    }

    private void generateFlavorFolderAndResIfNeed(Project project, FlavorParamsEntity flavorParam) {
        def srcFolder = new File(project.projectDir, "src")
        // 生成渠道文件夹
        def flavorFolder = new File(srcFolder, flavorParam.flavorName)
        if (flavorFolder.mkdirs()) {
            CommonUtils.log("${flavorParam.flavorName} flavor folder create succeed")
        }
        // 生成渠道的res文件夹
        def flavorResFolder = new File(flavorFolder, "res")
        if (flavorResFolder.mkdirs()) {
            CommonUtils.log("${flavorParam.flavorName} flavor res folder create succeed")
        }
        // 生成渠道的drawable文件夹(放app icon)
        def flavorDrawableFolder = new File(flavorResFolder, "drawable")
        if (flavorDrawableFolder.mkdirs()) {
            CommonUtils.log("${flavorParam.flavorName} flavor res drawable folder create succeed")
        }
        // 生成渠道的values文件夹(放strings.xml,并配置app name)
        def flavorValuesFolder = new File(flavorResFolder, "values")
        if (flavorValuesFolder.mkdirs()) {
            CommonUtils.log("${flavorParam.flavorName} flavor res values folder create succeed")
        }
        downloadAppIconIfNeed(flavorDrawableFolder, flavorParam.appIconPath, flavorParam.flavorName)
        generateStringsXmlIfNeed(flavorValuesFolder, flavorParam.flavorName)
    }

    private void downloadAppIconIfNeed(File flavorDrawableFolder, String downloadUrl, String appIconName) {
        def appIconFileName = "$appIconName.${CommonUtils.getImageType(downloadUrl)}"
        def appIconFile = new File(flavorDrawableFolder, appIconFileName)
        if (!appIconFile.exists()) {
            CommonUtils.log("start download appIconName")
            okHttpHelper.downloadFileFromPath(downloadUrl, new RequestCallback() {
                @Override
                void onResponse(boolean success, ResponseBody responseBody) {
                    if (success && responseBody != null) {
                        // 下载图标到渠道对应的drawable文件夹中,若已经存在,不重新创建
                        CommonUtils.saveFileToTargetFolder(flavorDrawableFolder, appIconFileName, responseBody.bytes(), false)
                    }
                }

                @Override
                void onFailure(String errorMessage) {
                    CommonUtils.log("download app icon failed errorMessage:$errorMessage")
                }
            })
        }
    }

    private void generateStringsXmlIfNeed(File flavorValuesFolder, String appName) {
        def stringsXmlFile = new File(flavorValuesFolder, "strings.xml")
        if (stringsXmlFile.exists()) {
            // 存在则判断当前appName是否跟配置一样,不是则修改
            changeAppNameIfNeed(stringsXmlFile, appName)
        } else {
            // 不存在则重新创建
            def stringsXmlContent = "<resources>\n    <string name=\"app_name\">$appName</string>\n</resources>"
            CommonUtils.writeDataToFile(stringsXmlFile, stringsXmlContent)
        }
    }

    private void changeAppNameIfNeed(File stringsXmlFile, String appName) {
        try {
            def saxReader = new SAXReader()
            def needSaveDocument = false
            def resourceDocument = saxReader.read(stringsXmlFile)
            def resourceElement = resourceDocument.rootElement
            def stringElements = resourceElement.elements("string")
            for (Element element : stringElements) {
                if ("app_name" == element.attributeValue("name")) {
                    if (element.textTrim != appName) {
                        CommonUtils.log("element name:${element.name}, oldValue:${element.textTrim}, newValue:${appName}")
                        element.setText(appName)
                        needSaveDocument = true
                    }
                    break
                }
            }
            if (needSaveDocument) {
                OutputFormat format = OutputFormat.createPrettyPrint()
                format.setIndentSize(4)
                XMLWriter logWriter = new XMLWriter(System.out, format)
                logWriter.write(resourceDocument)
                logWriter.close()
                XMLWriter writer = new XMLWriter(stringsXmlFile, format)
                writer.write(resourceDocument)
                writer.close()
            }
        } catch (Exception e) {
            e.printStackTrace()
            CommonUtils.log("read strings xml file failed due to ${e.message}")
        }
    }
}

实现打包Task

渠道配置信息处理完后,创建一个根据打包参数批量生成产物的Task。

  • ProcessingFlavorExtension(在主项目和插件间传递打包参数)。
class ProcessingFlavorExtension {

    /**
     * 生成apk指令
     */
    final String COMMAND_ASSEMBLE = "assemble"

    /**
     * 生成aab指令
     */
    final String COMMAND_BUNDLE = "bundle"

    /**
     * 生成的apk或aab为debug包
     */
    final String BUILD_TYPE_DEBUG = "Debug"

    /**
     * 生成的apk或aab为release包
     */
    final String BUILD_TYPE_RELEASE = "release"

    /**
     * 执行的打包命令(打apk或打aab)
     */
    String executeCommand = COMMAND_ASSEMBLE

    /**
     * 打包类型(debug包或release包)
     */

    String buildType = BUILD_TYPE_DEBUG

    /**
     * 待打包的渠道名(为空时打包所有渠道)
     */
    ArrayList<String> packagingFlavor = []
}
  • ExecutePackagingCommandTask
class ExecutePackagingCommandTask extends DefaultTask {

    // 执行的打包命令(打apk或打aab)
    private String command

    // 打包类型(debug包或release包)
    private String buildType

    // 待打包的渠道名(为空时打包所有渠道)
    private def packingFlavors = new ArrayList<String>()

    // 所有渠道名
    private def allFlavors = new ArrayList<String>()

    void setParams(@NonNull String command, @NonNull String buildType, @NonNull List<String> packingFlavors, @NonNull List<String> allFlavors) {
        CommonUtils.log("command:$command, buildType:$buildType, packingFlavors:$packingFlavors, allFlavors:$allFlavors")
        if (!CommonUtils.isEmpty(command)) {
            this.command = command
        }
        if (!CommonUtils.isEmpty(buildType)) {
            this.buildType = buildType
        }
        if (!packingFlavors.isEmpty()) {
            this.packingFlavors.clear()
            this.packingFlavors.addAll(packingFlavors)
        }
        if (!allFlavors.isEmpty()) {
            this.allFlavors.clear()
            this.allFlavors.addAll(allFlavors)
        }
    }

    @TaskAction
    void doTaskAction() {
        if (packingFlavors.isEmpty() && allFlavors.isEmpty()) {
            CommonUtils.log("flavor info is empty!")
            return
        }

        executeCleanCommand(project)
        if (!packingFlavors.isEmpty()) {
            for (String flavorName in packingFlavors) {
                executePackagingCommand(project, "$command${flavorName.capitalize()}$buildType")
            }
        } else if (!allFlavors.isEmpty()) {
            for (String flavorName in allFlavors) {
                executePackagingCommand(project, "$command${flavorName.capitalize()}$buildType")
            }
        }
    }

    private static void executeCleanCommand(Project project) {
        project.exec {
            commandLine("gradle", "clean")
        }
    }

    private static void executePackagingCommand(Project project, String command) {
        CommonUtils.log("excute packaging command:$command")
        project.exec {
            commandLine("gradle", command)
        }
    }
}
  • 注册Task
class ProcessingFlavorPlugin implements Plugin<Project> {

    @Override
    void apply(Project target) {
        def processingFlavorExtension = target.extensions.create("ProcessingFlavorExtension", ProcessingFlavorExtension)

        target.afterEvaluate {
            def executePackagingCommandTask = target.getTasks().register("ExecutePackagingCommandTask", ExecutePackagingCommandTask).get()
            executePackagingCommandTask.setParams(processingFlavorExtension.executeCommand, processingFlavorExtension.buildType, processingFlavorExtension.packagingFlavor, flavorParams.collect { it.flavorName })
            executePackagingCommandTask.group = "Processing Flavor"
        }
    }
}

发布到本地Maven

在插件项目的build.gradle中配置好推送信息后执行PublishToMavenLocal

image.png

测试插件

集成插件

  • 在项目的build.gradle添加如下代码:
buildscript {

    repositories {
        mavenLocal()
    }

    dependencies {
        classpath("com.chenyihong.plugin:processing-flavor:1.0.1")
    }
}
  • 在项目module中的build.gradle添加如下代码:
plugins {
    // 需要添加在com.android.application之后
    id 'processing-flavor-plugin'
}

ProcessingFlavorExtension {

    executeCommand = COMMAND_ASSEMBLE

    buildType = BUILD_TYPE_DEBUG

    packagingFlavor = ["plugintesta", "plugintestb"]
}

添加渠道信息Excel

渠道信息Excel内容:

image.png

存放位置:

image.png

测试效果

集成插件并同步项目后,就会根据渠道信息生成对应的渠道资源文件夹和签名文件夹,如图:

image.png

执行ExecutePackagingCommandTask后,生成对应的产物,如图:

image.png

完整示例代码

所有演示代码已在示例Demo中添加,插件代码在dev_plugin分支,测试插件项目在processing_flavor_test分支

ExampleDemo github

ExampleDemo gitee