概述
Andorid Gradle支持一些实用的功能,比如:隐藏签名证书文件,降低证书暴露的风险。批量修改apk的名称,让名称一眼就能看出渠道,版本号,生成日期等关键信息。
1. 批量修改生成的apk名称
apk文件作为 AndroidGradle打包的最终产物,修改它的名称,其实就是修改输出产物的流程。而,Android Gradle插件中,有一个android对象,也就是下面的:
android {
compileSdkVersion rootProject.ext.compileSdkVerion
...
}
它为我们提供了 3个有用的属性,application Variants (应用变体),libraryVariant(库变体), testVariants(测试变体)
这3个变体返回的都是一个对象集合,集合的类型是:DomainObjectSet , 就apk生成的流程来说,它受到 buildTypes (构件类型) 和 Product Flavors(多渠道设置) 的影响。
通常我们通过迭代访问这些集合的元素,就能达成修改最终产物名称的目的,当然这里说的是修改apk文件名,那么针对性的就是在 访问 application Variants 。
以下是示例代码,这段代码以多flavor渠道,多buildType配置的情况下,会产生很多个variant,我们获取到每一个variant之后,能修改它的output文件,此时甚至可以修改它的文件路径。
每个gradle版本的写法都有可能不同,某些属性可能被移除,以下是 gradle-5.6.4 的写法:
import java.text.SimpleDateFormat
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 31
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "com.example.myapplication"
minSdkVersion 16
targetSdkVersion 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
flavorDimensions "channel"
productFlavors {
flavor1 {
dimension "channel"
// 配置其他自定义属性
}
flavor2 {
dimension "channel"
// 配置其他自定义属性
}
// 添加更多的渠道配置
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
def variantName = variant.name.capitalize()
def versionName = variant.versionName
def versionCode = variant.versionCode
def apkDirectory = output.outputFile.parentFile
// 获取当前日期时间
def timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date())
// 是否签名
def isSigned = variant.signingConfig != null
def signedText = isSigned ? "signed" : "unsigned"
def apkName = "myProjectName_${timeStamp}_v${versionName}_(${versionCode})_${variantName}_${signedText}.apk"
// 修改输出路径和文件名
output.outputFileName = new File(apkDirectory, apkName).name
}
}
}
当然,我们apk的输出文件名形如: myProjectName_20230722_165242_v1.0_(1)_Flavor1Release_unsigned.apk
,
从头到尾,包含了 以下主要元素:
- 项目名
myProjectName
- 日期时间
20230722_165242
- 版本名称和版本号
v1.0_(1)
- 变体名
Flavor1Release
- 是否签名
unsigned
2. 动态生成版本信息
应用的版本号 通常由3个部分组成,major.minor.path
,例如:1.0.0.当然也有短号 major.minor
,例如:1.0. 通常以前者较为主流。
开发中经常遇到的一个情况,打测试包,要经常修改bug后重新打包,为了防止应用无法安装,我们通过gradle配置,将生产包和测试包进行分开处理。比如,我们先定义2个buildType(release和uat),uat 包我们需要在每次修改bug后更新版本号,而生产包则必须读取 全局的版本号配置。uat的包还需要从git提交记录中提取最后一次修改的 提交者名字和批次名,用于确定本次打包时用的是何时的代码,减少与测试同事交流过程中的无效沟通。
生产和测试包分开两套versionCode/versionName
针对上述要求,我们设计了如下Gradle配置, 新增一个叫做uat的buildType
,并且复制自 debug buildType
。所有用uat打出的包,使用固定的versionCode,以及动态的versionName(时间日期组成)。而用release打出的包,都使用正式的版本号和版本名。
gradle具体配置如下:
import java.text.SimpleDateFormat
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
ext {
releaseVersionName = "2.1.3"
appVersionCode = 213
debugAppVersionCode = 99999
}
android {
compileSdkVersion 31
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "com.example.myapplication"
minSdkVersion 16
targetSdkVersion 31
versionCode getVersionCode(true)
versionName getVersionName(true)
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
uat {
// 复制 debug 配置
initWith debug
debuggable false
}
}
// 市场渠道
flavorDimensions "channel"
productFlavors {
google {
dimension "channel"
// 配置其他自定义属性
}
huawei {
dimension "channel"
// 配置其他自定义属性
}
// 添加更多的渠道配置
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
def variantName = variant.name.capitalize()
def versionName
def versionCode
if (variantName.contains("Release")) {
versionName = getVersionName(true)
versionCode = getVersionCode(true)
output.versionCodeOverride = versionCode
output.versionNameOverride = versionName
} else if (variantName.contains("Uat")) {
versionName = getVersionName(false)
versionCode = getVersionCode(false)
output.versionCodeOverride = versionCode
output.versionNameOverride = versionName
}
println("variantName -> ${variantName} -> ${versionName} -> ${versionCode}")
def apkDirectory = output.outputFile.parentFile
// 获取当前日期时间
def timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date())
// 是否签名
def isSigned = variant.signingConfig != null
def signedText = isSigned ? "signed" : "unsigned"
def apkName = "myProjectName_${timeStamp}_v${versionName}(${versionCode})_${variantName}_${signedText}.apk"
// 修改输出路径和文件名
output.outputFileName = new File(apkDirectory, apkName).name
}
}
}
def getVersionName(boolean isRelease) {
// 正式环境
if (isRelease) {
releaseVersionName
}
// debug环境
else {
String today = new Date().format("MMdd")
String time = new Date().format("HHmm")
"${releaseVersionName}.$today.$time"
}
}
def getVersionCode(boolean isRelease) {
if (isRelease) {// 正式环境
appVersionCode
} else {// debug环境
debugAppVersionCode
}
}
dependencies {
...
}
通过 assembleGoogleUat打出的包,文件名为:myProjectName_20230722_190601_v2.1.3.0722.1906(99999)_GoogleUat_signed.apk
, 安装apk之后检查版本号,发现与预期的版本号也对的上。
而通过 assembleGoogleRelease打出包,文件名为:myProjectName_20230722_191223_v2.1.3(213)_GoogleRelease_unsigned.apk
以git提交记录为数据源提取versionName和versionCode
git中存在这么一个指令:git describe --abbrev=0 --tags
它用于获取git仓库上的最近一个tag名称。还有一个指令,git tag --list
, 它用于获取当前git仓库的所有tag标签。
如果我们能通过gradle脚本执行这两个指令,通过前者获取的tag名称,定义为动态的versionName,通过后者获取tag的list,再取它的size,定义为versionCode。那么我们在发新版本完成之后,就只需要打一个新的tag,打出的生产保就是使用的最新的tag名称,build.gradle中的versionCode和VersionName则不用再随着发版本而变动。
实操
很多groovy脚本与上一小节生产和测试包分开两套versionCode/versionName
是重复的,所以本节只列出关键代码:
def getAppVersionName(){
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git','describe','--abbrev=0','--tags'
standardOutput = stdout
}
return stdout.toString()
}
def getAppVersionCode(){
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git','tag','--list'
standardOutput = stdout
}
return stdout.toString().split("\n").size
}
解释
exec 是 gradle的project对象 提供的一个方法,可以当做全局函数来使用。通常用一个闭包作为它的参数,这个闭包是通过 ExecSpec来配置的,ExecSpec内部有一个 commandLine 属性用来配置 命令的各个部分,standardOutput则表示将命令的执行结果输出给哪个输出流。
3. 隐藏签名文件信息
签名文件通常被存储在项目的根目录下的keystore
文件中,并在build.gradle
文件中配置签名信息。然而,由于签名文件包含敏感的证书信息,不应该被公开或无意泄露。 一个开发组中通常由某个leader持有真实的签名文件来打出最终的生产包,而其他人仅有开发权限以及打出测试包的权限,测试包不具有真是签名,所以可以保证签名文件不泄露。 这也是保证安全生产的场景之一。
方案1
以下步骤可以构建一套 签名文件安全保存的开发环境。
-
在本机添加系统环境变量,设置签名所需4个参数:签名文件路径ANDROID_KEYSTORE_PATH,ANDROID_STORE_PASSWORD 密码,ANDROID_KEYALIAS 别名,ANDROID_KEYALIAS_PWD,别名密码。
-
Gradle中读取环境变量来配置签名
def env = System.getenv() def keystorePath = env['ANDROID_KEYSTORE_PATH'] def keystorePwd = env['ANDROID_STORE_PASSWORD'] def keyAliasName = env['ANDROID_KEYALIAS'] def keyAliasPwd = env['ANDROID_KEYALIAS_PWD'] signingConfigs { release { storeFile file(keystorePath) storePassword keystorePwd keyAlias keyAliasName keyPassword keyAliasPwd } }
-
签名文件仅团队leader持有,其他人如果尝试打release包,则提示,gradle编译异常,出现签名文件找不到的错误。
方案2
签名文件不放在leader本机环境变量,而是加密后放在项目中随git提交。同时为了保证签名文件的安全,leader保存唯一一份解密密钥。
详细步骤如下:
-
关键要素加密
由于是文件级别的加密,所以,优先使用AES进行加密(保证加解密的效率),然后将 加密后的签名文件进行保存,同时将对称加密的密钥,通过RSA加密(保证密钥的安全)。
此时,有几个关键要素要明确:
名称 说明 是否随git提交 签名文件原件 不随git提交 否 AES的密钥 用于给签名文件原件AES加密 否 RSA的公钥和私钥 用于给“AES密钥” 进行加密 否 签名文件的密件 通过 对称加密处理过的签名文件 是 RSA加密后的AES密钥 此密钥,必须通过RSA解密后才能用于 处理签名文件密件 是 项目中,可随git提交的,都是加密处理后的文件或字符串,要进行release打包,则只有leader所独有的 RSA私钥,才能使得打包流程走通。
-
gitignore设置
按照上述表格,设置好 gitignore。
-
自定义解密Task,在检测到release打包时,使用本地的RSA私钥来解密
AES密钥的密文
, 然后使用AES密钥来对 签名文件密件进行解密,并将解密后的原件存在项目的固定位置。 -
signingConfigs 配置
release配置中的 storeFile 对应上一步骤中的签名文件原件。其他参数可随git提交,不影响安全(?)
signingConfigs { release { storeFile file(keystorePath) storePassword keystorePwd keyAlias keyAliasName keyPassword keyAliasPwd } }
-
当执行release打包任务结束之后,删除签名文件的原件。
同样,在其他组员尝试打出release包时,则会提示,签名文件找不到。
4. 自定义BuildConfig
基本方法
Gradle生成最终产物apk的过程中,其中一个步骤就是 生成buildConfig文件。而这个文件的内容来源,一部分就是来自Gradle配置。
android {
...
defaultConfig {
...
// 自定义常量
buildConfigField "String", "API_KEY", ""your_api_key""
}
}
上面的代码会向BuildConfig类添加一个名为API_KEY的常量,它的值为”your_api_key”。我们可以在代码中通过BuildConfig.API_KEY访问这个常量。
通常我们会选择将一部分参数用于区分 当前app的打包环境(release生产,或者uat测试),不同环境下的 app包名,版本号,版本名,API_KEY等,都可以做区分。
除了以上的字符串类型之外,还可以定义如下类型:
-
整数常量:
buildConfigField "int", "VERSION_CODE", "10"
这会在BuildConfig中添加一个名为VERSION_CODE的整数常量,其值为10。
-
布尔值常量:
buildConfigField "boolean", "ENABLE_FEATURE", "true"
这会在BuildConfig中添加一个名为ENABLE_FEATURE的布尔值常量,其值为true。
-
浮点数常量:
buildConfigField "float", "PI_VALUE", "3.14f"
这会在BuildConfig中添加一个名为PI_VALUE的浮点数常量,其值为3.14。
-
长整数常量:
buildConfigField "long", "MAX_SIZE", "100000000L"
这会在BuildConfig中添加一个名为MAX_SIZE的长整数常量,其值为100000000。
-
字符常量:
buildConfigField "char", "LOG_LEVEL", "'D'"
这会在BuildConfig中添加一个名为LOG_LEVEL的字符常量,其值为’D’。
优化方案
如果一个项目中存在很多个 buildConfigField,特别是字符串特别多的时候,导致gradle文件看起来很乱。我们可以通过对字符串进行抽离并进行外部存储,gradle中进行读取的方式进行优化。
1. 使用自定义gradle脚本
将一些常用的buildConfigField移动到单独的gradle脚本文件中。例如,你可以创建一个名为 build_config.gradle
的文件,然后在主build.gradle文件中引入它:
apply from: "build_config.gradle"
在 build_config.gradle
文件中,可以定义所有的buildConfigField:
ext {
appVersion = "1.0"
apiKey = "your_api_key"
maxItemCount = 100
// 其他buildConfigFields...
}
android {
defaultConfig {
// 使用自定义常量
buildConfigField "String", "APP_VERSION", ""${appVersion}""
buildConfigField "String", "API_KEY", ""${apiKey}""
buildConfigField "int", "MAX_ITEM_COUNT", "${maxItemCount}"
// 其他buildConfigFields...
}
}
这样,你可以将所有的buildConfigField集中在一个单独的脚本中,使主build.gradle文件更加清晰,易于维护。
2. 使用额外的构建变量文件
除了使用自定义gradle脚本外,你也可以使用额外的构建变量文件,例如 build_vars.properties
。在这个文件中,你可以定义所有的buildConfigField:
APP_VERSION=1.0
API_KEY=your_api_key
MAX_ITEM_COUNT=100
然后,在build.gradle文件中读取这些变量并使用它们:
def buildVars = new Properties()
buildVars.load(new FileInputStream(file('build_vars.properties')))
android {
defaultConfig {
// 使用自定义常量
buildConfigField "String", "APP_VERSION", ""${buildVars['APP_VERSION']}""
buildConfigField "String", "API_KEY", ""${buildVars['API_KEY']}""
buildConfigField "int", "MAX_ITEM_COUNT", "${buildVars['MAX_ITEM_COUNT']}"
// 其他buildConfigFields...
}
}
这样,你可以将所有的buildConfigField集中在一个单独的文件中,并且可以通过编辑这个文件来调整常量的值,而不需要修改build.gradle文件。
这些方法可以帮助你将代码整理得更加优雅和易于维护,减少build.gradle文件的复杂度,并避免在文件中出现大量的buildConfigField声明。选择适合你项目需求的方法,以提高代码的可读性和可维护性。同时,如果buildConfigField过分复杂,还可以利用上面两种方式,将buildConfigField抽离为多个外部文件,按照特征进行分批次保存,进一步提高代码的可读性。