Skip to content

可重现构建实战

源: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 build

AGP 版本

锁定版本

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-locks

CI 验证

yaml
# 每次 PR 验证可重现性
- name: Verify Reproducibility
  run: ./scripts/verify-reproducibility.sh

使用 Provider API

kotlin
val version = providers.exec { ... }.standardOutput.asText