在日常开发中,应该不少人都遇到过用同一份代码出不同渠道包的需求,不同渠道包之间除了包名、图标、名称之外,可能也只有一些简单的配置有区别。最近工作中就碰上了这种需求,大概就是通过WebView加载链接的方式出一批包,每个包使用不同的链接。
接到需求的第一反应就是通过AS的多渠道配置来处理,在同个项目中就可出不同的包,也方便管理。着手实施时,配置完两三个包的信息之后,感觉这样一个一个配着实是有点繁琐,考虑到后续可能会出几十个包,决定通过自定义插件来批量处理多渠道包。
实现处理多渠道包插件
实现批量处理多渠道包大概分为下列几步:
- 在插件中获取渠道信息表,解析获得每个渠道的配置信息。
- 在插件中通过命令行生成每个渠道对应的签名文件,配置对应的
signConfigs。 - 在插件中根据渠道的配置信息生成每个渠道对应的资源文件,配置对应的
productFlavors。 - 实现打包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闭包中才能获取到参数,而signConfigs和productFlavors需要在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
测试插件
集成插件
- 在项目的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内容:
存放位置:
测试效果
集成插件并同步项目后,就会根据渠道信息生成对应的渠道资源文件夹和签名文件夹,如图:
执行ExecutePackagingCommandTask后,生成对应的产物,如图:
完整示例代码
所有演示代码已在示例Demo中添加,插件代码在dev_plugin分支,测试插件项目在processing_flavor_test分支。