前言
- Android 项目日常开发过程中,经常需要打包给到非开发人员验收或调试,例如测试阶段,就要经常基于测试服务器地址,打包安装包,给到组内测试人员进行测试,并且 BUG 修复完成之后也需要再次打包给到测试人员回测。
- 为了减免机械性的重复步骤,为项目配置不同的 渠道(Product Flavors),不同渠道对应不同的服务器地址,并且为每一个渠道创建一个 Gradle Task 执行打包并上传至蒲公英的操作,同时在蒲公英中配置 Webhook ,最终可实现:执行对应 **渠道(Product Flavors)**的 Gradle Task,即可自动打包并上传至蒲公英,并将包更新信息同步至企业微信、钉钉、飞书等工作群组,使得包更新流程可视化,并简化了开发和测试联调流程。
实现步骤
1.创建 pgyer-upload.gradle 文件
每个渠道的 Task 执行内容一致:打包并记录更新信息后上传至蒲公英 ,所以抽取公共内容(方法)创建如下 pgyer-upload.gradle 文件
import groovy.json.JsonSlurperimport java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.text.SimpleDateFormatext.uploadApk = this.&uploadApk/*** Sept 1 创建执行任务*//*** 蒲公英 ApiKey* https://www.pgyer.com/account/api* TODO 替换成自己的蒲公英 APIkey*/
static String getApiKey() {return "myApiKey"
}/*** 上传 apk 到蒲公英* @apk 安装包文件* @flavorName 渠道名*/
def uploadApk(File apk, String flavorName) {if (apk == null) {throw new RuntimeException("apk file not exists!")}def apkName = apk.nameprintln "*************** Upload Init ***************"//渠道信息String buildFlavorName = flavorNameif (flavorName == "DemoRelease") {buildFlavorName = "Test"} else if (flavorName == "DevRelease") {buildFlavorName = "Dev"} else if (flavorName == "ProduceRelease") {buildFlavorName = "Produce"} else if (flavorName == "BetaRelease") {buildFlavorName = "Beta"}println "flavorName = ${buildFlavorName} apkName = ${apkName} apkSize = ${apk.size()}"// Git 提交信息String commitLogStr = getGitCommitLogByCount(5)
// String commitLogStr = getGitCommitLogByToDay()def appModule = project.rootProject.project(':app')def appVersionName = appModule.android.defaultConfig.versionNamedef appVersionCode = appModule.android.defaultConfig.versionCode//更新信息String updateDescription = "\n[${getBranchName()}]:${buildFlavorName}-${appVersionName}-${appVersionCode}" +"\n${commitLogStr}"println "updateDescription : ${updateDescription}"println "*************** Upload Get Token ***************"//组装cosToken需要的的参数,见https://www.pgyer.com/doc/view/api#fastUploadAppList<KeyValue> cosTokenParams = new ArrayList<>()//API KEYcosTokenParams.add(new KeyValue("_api_key", apiKey))//属于android平台cosTokenParams.add(new KeyValue("buildType", "android"))//更新描述cosTokenParams.add(new KeyValue("buildUpdateDescription", updateDescription))// 获取上传的 token ,见 https://www.pgyer.com/doc/view/api#fastUploadAppHttpResponse<String> response = postFormData("https://www.pgyer.com/apiv2/app/getCOSToken", cosTokenParams)def resp = new JsonSlurper().parseText(response.body())println ">>>> Get Token Response :\n${response.body()}"println "*************** Uploading Apk File ***************"// 上传文件到第上一步获取的 URL,参数从上一步获取,这里需要解析参数String paramsString = String.valueOf(resp.data.params)String[] params = paramsString.substring(1, paramsString.length() - 1).split(',')List<KeyValue> list = new ArrayList<>()if (params != null) {for (i in 0..<params.length) {String rawParam = params[i].trim()String parsedKey = rawParam.substring(0, rawParam.indexOf("="))String parsedValue = rawParam.substring(rawParam.indexOf("=") + 1, rawParam.length())//添加参数list.add(new KeyValue(parsedKey, parsedValue))}}// 添加apk文件list.add(new KeyValue("file", apk.getPath(), true))HttpResponse<String> uploadResponse = postFormData(resp.data.endpoint, list)if (uploadResponse.statusCode() == 204) {println(">>>> Upload Success ")} else {println(">>>> Upload Fail :" + uploadResponse.body())}println "*************** Upload Completed ***************"
}static HttpResponse<String> postFormData(String url, List<KeyValue> list) {long requestStartTime = System.nanoTime()String boundary = "*********"// Result request bodyList<byte[]> byteArrays = new ArrayList<>()// Separator with boundarybyte[] separator = ("--" + boundary + "\r\nContent-Disposition: form-data; name=").getBytes(StandardCharsets.UTF_8)// Iterating over data partsfor (i in 0..<list.size()) {// Opening boundarybyteArrays.add(separator)def entry = list[i]// If value is type of Path (file) append content type with file name and file binaries, otherwise simply append key=valueif (entry.isFile) {java.nio.file.Path path = new File(entry.getValue()).toPath()String mimeType = Files.probeContentType(path)byteArrays.add(("\"" + entry.getKey() + "\"; filename=\"" + path.getFileName()+ "\"\r\nContent-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8))byteArrays.add(Files.readAllBytes(path))byteArrays.add("\r\n".getBytes(StandardCharsets.UTF_8))} else {byteArrays.add(("\"" + entry.getKey() + "\"\r\n\r\n" + entry.getValue() + "\r\n").getBytes(StandardCharsets.UTF_8))}}byteArrays.add(("--" + boundary + "--").getBytes(StandardCharsets.UTF_8))def publisher = HttpRequest.BodyPublishers.ofByteArrays(byteArrays)HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(url)).version(HttpClient.Version.HTTP_1_1).header("Content-Type", "multipart/form-data;boundary=" + boundary).POST(publisher).build()return HttpClient.newHttpClient().send(httpRequest, HttpResponse.BodyHandlers.ofString())
}/*** 获取当天提交日志* @return*/
static String getGitCommitLogByToDay() {//获取 git 提交日志Calendar calendar = Calendar.getInstance()String endTime = new SimpleDateFormat("yyyy-MM-dd").format(calendar.getTime())calendar.add(Calendar.DATE, -1)String startTime = new SimpleDateFormat("yyyy-MM-dd").format(calendar.getTime())//git 命令String gitCommand = "git log --pretty=\"%s\" --since=\"${startTime}\" --before=\"${endTime}\""//println "getUpdateDescription() --> gitCommand = ${gitCommand}"String description = gitCommand.execute().text.trim()return description
}/*** 获取最近 n 条提交日志*/
static String getGitCommitLogByCount(int count) {//git 命令String gitCommand = "git log -${count} --pretty=format:\"%s\""//println "getUpdateDescription() --> gitCommand = ${gitCommand}"String description = gitCommand.execute().text.trim()return description
}/*** 获取分支名*/
static String getBranchName() {String gitCommand = "git rev-parse --abbrev-ref HEAD"return gitCommand.execute().text.trim()
}class KeyValue {String keyString valueboolean isFileKeyValue(String key, String value) {this(key, value, false)}KeyValue(String key, String value, boolean isFile) {this.key = keythis.value = valuethis.isFile = isFile}@OverrideString toString() {return "{key:" + key + ", value:" + value + ", isFile:$isFile}"}
}
该代码主要执行 APK 上传至蒲公英的操作,并上传指定更新内容:
- [ Git 分支名]:渠道名-APP版本名-APP版本号
- 5 条 Git Commit 信息
2.引用自定义 Gradle 文件
在项目的 build.gradle 文件最外层执行
apply from: "pgyer-upload.gradle"
3.配置渠道(非必须)
在 app module 的 build.gradle 文件 android 层内执行
/*** Sept 3 配置渠道 (非必须)*/flavorDimensions "channel"productFlavors {// 生产环境produce {buildConfigField "String", "HOST", "\"${HOST}\""}// 预生产环境beta {buildConfigField "String", "HOST", "\"${HOST_BETA}\""}// 开发环境dev {buildConfigField "String", "HOST", "\"${HOST_DEV}\""}// 测试demo {buildConfigField "String", "HOST", "\"${HOST_TEST}\""}productFlavors.all {// 遍历 productFlavors 多渠道,设置渠道号flavor -> flavor.manifestPlaceholders.put("CHANNEL", name)}}
此处代码中的 produce、beta 、dev、demo 均为自定义的渠道名,可根据自身业务需求进行增删修改,其中 HOST 为自定义的变量名,不同渠道引用值不一样(示例中的值来源配置在项目的 gradle.properties 文件中,如下所示),项目编译后,在代码中即可通过 BuildConfig.FLAVOR 和 BuildConfig.HOST 获取当前编译环境的 渠道名 和 自定义变量 HOST 的值
# gradle.properties 文件内
# Host
HOST=https://hao123.com
HOST_BETA=https://hao123.com:1008
HOST_TEST=https://hao123.com:1010
HOST_DEV=https://hao123.com:1024
Android Studio 中,可通过左下方 Build Variants 手动切换编译渠道
4.创建打包 Task
同样在 app module 的 build.gradle 文件 android 层内执行
/*** Sept 4 创建打包 Task* 遍历所有可执行的 variants 创建对应的打包 Task* 生成后的路径及名称:Tasks/build/pushApk[productFlavorsName][Release/Debug]* eg:Tasks/build/pushApkDevRelease*/android.applicationVariants.all { variant ->String taskSuffix = variant.name.capitalize()if (taskSuffix.contains("Release") || taskSuffix.contains("Debug")) {task("pushApk${taskSuffix}") {dependsOn ":app:assemble${taskSuffix}"group 'build'description 'Custom task for gradle'doLast {variant.outputs.all { output ->// 执行脚本任务uploadApk(output.outputFile, taskSuffix)}}}}}
5.执行 Gradle Task
按上述步骤操作之后,先执行 Sync Project with Gradle Files 生成不同渠道对应的打包 Task
然后可通过以下两种方式进行执行打包并上传的脚本任务
-
直接执行 Task : Tasks/build/pushApkDevRelease
-
通过 Gradle 命令执行 Task : gradle pgyerUploadDevRelease
5.1执行效果
6.补充说明
6.1 关于变种(variants)
在 Android 应用程序构建过程中,变种(variants)是指基于不同构建配置或渠道进行构建的应用程序版本。
Android Gradle 插件使用变种来生成不同版本的应用程序,以满足不同的需求,如不同的构建类型、不同的渠道或不同的产品变体等。
每个变种具有自己的构建配置和特定的属性设置,例如包名、应用图标、应用名称等。通过创建不同的变种,可以实现以下目标:
1.构建类型(Build Types):构建类型定义了不同的构建环境和配置,例如调试版(Debug)和发布版(Release)。每个构建类型可以具有自己的代码、资源、签名证书、编译标志等。
2.渠道(Product Flavors):渠道是为了满足不同目标市场或用户群体的需求而定义的版本变体。通过渠道,可以为不同的渠道定制应用程序的内容,如应用程序图标、名称、启动画面、配置文件等。
3.变体(Build Variants):变体是构建类型和渠道的组合,表示一个具体的应用程序版本。每个变体都有其自己的构建输出,如 APK 文件或可安装的应用程序包。
通过定义和配置不同的变种,开发人员可以轻松地构建适用于不同需求的不同版本的应用程序,以便进行测试、发布和分发。
6.2 上传 APK 文件失败
该问题产生的原因是当前项目 JDK 版本 > 11 ,而当前 Gradle Task 内执行的文件 POST 操作是自行封装的请求参数,该封装在 JDK 版本 > 11 的情况下,无法被服务器正常识别,猜测原因是自定义包装请求参数过程中出现了偏差导致。
最后的解决方案是:
1.降低项目 JDK 版本为 11 或以下即可解决该问题(大部分情况下需要同步修改项目的 Gradle 和 Gradle Plugin 版本,改动较大,不建议)
2.改用自定义 Gradle 插件形式创建 Task ,即可在 Task 中引用第三方的网络请求库如 OKHttp ,使用第三方的网络请求库封装 form-data,解决该问题(无需改动项目 JDK、Gradle 及 Gradle Plugin 版本,推荐使用该方案)
方案 2 的具体实施,将在下一篇文章中进行演示
PS:如果你们可以在自定义 Gradle 文件中引用到第三方的网络请求库或者是正确编写携带文件的 form-data 网络请求,则可以自行更改部分代码后修复该问题
6.3 Gradle 控制台中文显示异常
在 studio64.exe.vmoptions 文件中输入
# 解决 gradle 控制台中文乱码问题
-Dfile.encoding=utf-8
6.4 Android Studio 右侧 Gradle 栏内无法看到 Tasks 列表
在 Android studio 的 Setting 中找到最底部 Experimental ,取消如下图中的勾选并应用
6.5 蒲公英 Webhook 配置
参考 https://seed.pgyer.com/WGNQkEpP