概述 

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

以下步骤可以构建一套 签名文件安全保存的开发环境。

  1. 在本机添加系统环境变量,设置签名所需4个参数:签名文件路径ANDROID\_KEYSTORE\_PATH,ANDROID\_STORE\_PASSWORD 密码,ANDROID\_KEYALIAS 别名,ANDROID\_KEYALIAS\_PWD,别名密码。
  2. 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
      }
  }
  1. 签名文件仅团队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等,都可以做区分。

除了以上的字符串类型之外,还可以定义如下类型:

  1. 整数常量:
buildConfigField "int", "VERSION_CODE", "10"

这会在BuildConfig中添加一个名为VERSION\_CODE的整数常量,其值为10。

  1. 布尔值常量:
buildConfigField "boolean", "ENABLE_FEATURE", "true"

这会在BuildConfig中添加一个名为ENABLE\_FEATURE的布尔值常量,其值为true。

  1. 浮点数常量:
buildConfigField "float", "PI_VALUE", "3.14f"

这会在BuildConfig中添加一个名为PI\_VALUE的浮点数常量,其值为3.14。

  1. 长整数常量:
buildConfigField "long", "MAX_SIZE", "100000000L"

这会在BuildConfig中添加一个名为MAX\_SIZE的长整数常量,其值为100000000。

  1. 字符常量:
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抽离为多个外部文件,按照特征进行分批次保存,进一步提高代码的可读性。