可重现构建实战
源:Reproducible Builds | Gradle 官方文档
确保相同源码在不同环境生成完全一致的构建产物,提升安全性和可调试性。
可重现构建概念
什么是可重现构建
可重现构建:相同源码在不同时间、不同机器、不同环境下构建,生成字节级完全相同的产物。
验证方式:
bash
# 构建1
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build1.apk
# 构建2
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build2.apk
# 对比
diff build1.apk build2.apk
# 如果相同,则无输出为什么重要
安全性:
- 验证构建产物未被篡改
- 防止供应链攻击
- 确保 CI 构建的可信度
可调试性:
- 本地构建 = CI 构建
- 避免"为什么 CI 上会崩溃"
- 可重现的 Bug
缓存效率:
- 相同输入 → 相同输出
- 最大化缓存命中率
- 跨机器共享缓存
破坏可重现性的因素
1. 时间戳
问题:
kotlin
// ❌ 错误:每次构建时间不同
android {
defaultConfig {
buildConfigField("String", "BUILD_TIME", "\"${Date()}\"")
}
}结果:
Build 1: BUILD_TIME = "Mon Jan 26 10:00:00 CST 2026"
Build 2: BUILD_TIME = "Mon Jan 26 10:01:00 CST 2026"
// 不同!解决方案:
kotlin
// ✅ 正确:使用固定时间或环境变量
android {
defaultConfig {
val buildTime = providers.environmentVariable("BUILD_TIME")
.getOrElse("0")
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
}
}或从 Git 获取:
kotlin
val buildTime = providers.exec {
commandLine("git", "log", "-1", "--format=%ct")
}.standardOutput.asText.map { it.trim() }
android {
defaultConfig {
buildConfigField("String", "BUILD_TIME", "\"${buildTime.get()}\"")
}
}2. 绝对路径
问题:
kotlin
// ❌ 错误:包含用户路径
@get:InputFile
val inputFile = file("/Users/virogu/project/input.txt")结果:
Machine 1: /Users/virogu/project/input.txt
Machine 2: /home/user/project/input.txt
// 不同!解决方案:
kotlin
// ✅ 正确:使用相对路径
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputFile: RegularFileProperty
// 配置
inputFile.set(file("input.txt"))3. 文件排序
问题:
kotlin
// ❌ 错误:文件顺序不确定
val files = file("src").listFiles()
files?.forEach { processFile(it) }结果:
Windows: [A.kt, B.kt, C.kt]
Linux: [C.kt, A.kt, B.kt]
// 可能不同!解决方案:
kotlin
// ✅ 正确:显式排序
val files = file("src").listFiles()?.sortedBy { it.name }
files?.forEach { processFile(it) }4. HashMap 顺序
问题:
kotlin
// ❌ 错误:HashMap 顺序不确定
val config = hashMapOf(
"key1" to "value1",
"key2" to "value2"
)
config.forEach { (k, v) -> ... }解决方案:
kotlin
// ✅ 正确:使用 LinkedHashMap 或 sortedMap
val config = linkedMapOf(
"key1" to "value1",
"key2" to "value2"
)
// 或
val config = mapOf(
"key1" to "value1",
"key2" to "value2"
).toSortedMap()5. 随机数
问题:
kotlin
// ❌ 错误:使用随机数
val randomId = Random.nextInt()解决方案:
kotlin
// ✅ 正确:使用固定种子或确定性算法
val seed = "fixed-seed".hashCode().toLong()
val random = Random(seed)
val id = random.nextInt()Path Sensitivity
PathSensitivity 选项
kotlin
enum class PathSensitivity {
ABSOLUTE, // 绝对路径敏感
RELATIVE, // 相对路径敏感(推荐)
NAME_ONLY, // 仅文件名敏感
NONE // 忽略路径
}使用场景
源代码文件:
kotlin
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sources: ConfigurableFileCollection配置文件:
kotlin
@get:InputFile
@get:PathSensitive(PathSensitivity.NAME_ONLY)
abstract val configFile: RegularFileProperty资源文件:
kotlin
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
abstract val resources: ConfigurableFileCollection环境一致性
JDK 版本
问题:不同 JDK 版本生成不同字节码。
解决方案:使用 Toolchains。
kotlin
// build.gradle.kts
kotlin {
jvmToolchain(17)
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}Gradle 版本
使用 Gradle Wrapper:
properties
# gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip锁定版本:
bash
# 使用 wrapper 而非全局 gradle
./gradlew buildAGP 版本
锁定版本:
kotlin
// build.gradle.kts
plugins {
id("com.android.application") version "8.2.0" // 明确版本
}依赖版本
使用版本锁定:
bash
# 生成锁文件
./gradlew dependencies --write-locks
# 提交锁文件
git add gradle/dependency-locks/验证可重现性
本地验证
bash
#!/bin/bash
# 第一次构建
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build1.apk
# 第二次构建
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build2.apk
# 对比
if diff build1.apk build2.apk > /dev/null; then
echo "✅ 构建可重现"
else
echo "❌ 构建不可重现"
exit 1
fi使用 diffoscope
bash
# 安装 diffoscope
pip install diffoscope
# 对比 APK
diffoscope build1.apk build2.apk
# 如果有差异,会显示详细对比CI 验证
GitHub Actions:
yaml
name: Reproducibility Check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: First Build
run: |
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build1.apk
- name: Second Build
run: |
./gradlew clean assembleRelease
cp app/build/outputs/apk/release/app-release.apk build2.apk
- name: Compare
run: |
if diff build1.apk build2.apk; then
echo "✅ Build is reproducible"
else
echo "❌ Build is not reproducible"
exit 1
fi实战案例
案例1:完全可重现的构建配置
build.gradle.kts:
kotlin
android {
defaultConfig {
// ✅ 从环境变量或 Git 读取
val versionCode = providers.environmentVariable("VERSION_CODE")
.map { it.toInt() }
.getOrElse(1)
val versionName = providers.exec {
commandLine("git", "describe", "--tags", "--always")
}.standardOutput.asText.map { it.trim() }.getOrElse("1.0.0")
this.versionCode = versionCode
this.versionName = versionName
// ✅ 使用 Git 提交时间
val buildTime = providers.exec {
commandLine("git", "log", "-1", "--format=%ct")
}.standardOutput.asText.map { it.trim() }
buildConfigField("String", "BUILD_TIME", "\"${buildTime.get()}\"")
buildConfigField("String", "GIT_HASH", "\"${versionName}\"")
}
}
// ✅ 锁定 JDK 版本
kotlin {
jvmToolchain(17)
}案例2:自定义任务的可重现性
kotlin
@CacheableTask
abstract class ProcessFilesTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE) // ✅ 相对路径
abstract val inputFiles: ConfigurableFileCollection
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun process() {
// ✅ 显式排序
val sortedFiles = inputFiles.files.sortedBy { it.name }
sortedFiles.forEach { file ->
// 处理逻辑
}
}
}最佳实践
避免时间戳:
kotlin
// ❌ 避免
buildConfigField("Long", "BUILD_TIME", "${System.currentTimeMillis()}L")
// ✅ 使用 Git 提交时间
val commitTime = providers.exec {
commandLine("git", "log", "-1", "--format=%ct")
}.standardOutput.asText使用相对路径:
kotlin
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sources: ConfigurableFileCollection显式排序:
kotlin
val files = directory.listFiles()?.sortedBy { it.name }锁定环境:
kotlin
kotlin {
jvmToolchain(17)
}依赖锁定:
bash
./gradlew dependencies --write-locksCI 验证:
yaml
# 每次 PR 验证可重现性
- name: Verify Reproducibility
run: ./scripts/verify-reproducibility.sh使用 Provider API:
kotlin
val version = providers.exec { ... }.standardOutput.asText