mcpField
E
enforcing-stability-in-ci

enforcing-stability-in-ci

github.com/skydoves测试与质量520122 次安装19 周前更新
代码仓库

关于 enforcing-stability-in-ci

这项技能介绍如何使用 skydoves/compose-stability-analyzer Gradle 插件在 CI 中检查 Compose 稳定性回归。Android 团队可提交基线并在拉取请求中检测变化,也介绍了多平台项目可用的 ComposeGuard。

主要功能

  1. 01生成各模块的稳定性基线
  2. 02对比当前结果与已提交基线
  3. 03在 CI 中阻止稳定性回归
  4. 04配置包和类的排除项
  5. 05在多平台项目中使用 ComposeGuard

适用场景

  1. 01阻止降低可跳过性的拉取请求
  2. 02跟踪单体仓库中各 UI 模块的稳定性
  3. 03审查并说明有意更新基线的原因

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/compose-performance-skills --skill enforcing-stability-in-ci

安装后在 Claude Code 中输入/enforcing-stability-in-ci确认已加载。

详细介绍

在 CI 中强制检查稳定性——让稳定性回退导致构建失败

稳定性是一种会悄然回退的属性。共享数据类中新增一个 var,就可能导致数十个屏幕无法跳过重组,而构建输出不会发出任何提示。应像对待二进制兼容性一样对待稳定性:提交基线,在每个 PR 上对比,并在差异朝错误方向发展时让构建失败。本技能使用 skydoves/compose-stability-analyzer Gradle 插件设置这一门禁,并为非 Android 多平台项目提供 j-roskopf/ComposeGuard 参考。

何时使用本技能

  • 团队希望在 CI 中强制执行稳定性服务级别目标,而不是依靠人工检查。
  • 某个 PR 引入了不稳定参数,直到收到现场掉帧反馈后才有人发现。
  • 正在将大型应用迁移到强跳过模式,并需要在重构期间设置回归防护。
  • 需要在包含许多功能模块的单体仓库中统一稳定性实践。
  • 用户提到 stabilityCheck、stabilityDump、基线漂移、ComposeGuard 或“稳定性回退时让构建失败”。

何时不使用本技能

  • 这是单人开发的一次性原型。维护基线的成本不值得。
  • 尚未诊断稳定性问题。请先运行 ../diagnosing-compose-stability/SKILL.md,确保当前状态可接受;为有问题的状态建立基线,只会把问题锁定下来。
  • 问题是概念性的:“为什么编译器会将 X 分类为 Y?”请使用 ../understanding-stability-inference/SKILL.md。

前置条件

  • 使用 Kotlin 2.0+ 和 Compose 编译器 Gradle 插件(org.jetbrains.kotlin.plugin.compose)。强跳过模式默认开启。
  • Gradle 8+。
  • 已有可正常运行的 CI 流水线,会运行 ./gradlew assemble 或等效命令。
  • 已理解 Compose 编译器报告(请先阅读 ../diagnosing-compose-stability/SKILL.md)。
  • 能够将生成文件提交到仓库(必须签入 .stability 基线)。

工作流

1. 应用插件

将插件添加到版本目录,并应用到每个包含 UI 可组合项的模块。

# gradle/libs.versions.toml
[versions]
compose-stability-analyzer = "0.7.3"

[plugins]
compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "compose-stability-analyzer" }
// app/build.gradle.kts (and every UI feature module)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.compose.stability.analyzer)
}

必须在 Compose 编译器插件之后应用该插件,这样它才能读取相同的编译器输出。

2. 配置 DSL 代码块
// app/build.gradle.kts
composeStabilityAnalyzer {
    enabled.set(true)

    stabilityValidation {
        enabled.set(true)
        outputDir.set(layout.projectDirectory.dir("stability"))
        failOnStabilityChange.set(true)
        ignoredPackages.set(
            listOf(
                "com.example.preview",     // @Preview-only composables
                "com.example.benchmark",   // microbenchmark fixtures
            )
        )
        ignoredClasses.set(
            listOf(
                "com.example.testing.FakeRepository",
            )
        )
        stabilityConfigurationFiles.add(
            rootProject.layout.projectDirectory.file("stability_config.conf")
        )
    }
}

关键设置:

  • outputDir——写入 .stability 基线文件的位置。请选择位于模块内的路径,使其自然地与源代码放在一起。不要写入 build/;该目录已被 gitignore 忽略,而且必须提交基线。
  • failOnStabilityChange——在 CI 中设为 true。开发者本地可以将其切换为 false 以加快迭代,但提交的值必须为 true。
  • ignoredPackages / ignoredClasses——排除 @Preview 可组合项、基准测试夹具,以及故意违反稳定性规则的测试替身。
  • stabilityConfigurationFiles——指向 Compose 编译器使用的同一个 stability_config.conf,确保插件的判断与编译器一致。
3. 在本地生成基线
./gradlew :app:stabilityDump
# or, for a specific variant:
./gradlew :app:debugStabilityDump

插件会在 outputDir 下为每个模块和每个变体写入一个 .stability 文件。请提交这些文件。

git add app/stability/
git commit -m "chore(stability): seed baseline"

单条 .stability 记录如下:

@Composable
public fun com.example.CounterDisplay(count: com.example.MainViewModel): kotlin.Unit
  skippable: false
  restartable: true
  params:
    - count: RUNTIME (requires runtime check)

每条记录都包含可组合项签名、skippable / restartable 标志,以及各参数的稳定性分类。差异格式在不同运行之间保持稳定,因此 PR 审阅者可以看到有意义的变更集。

4. 将 stabilityCheck 接入 CI

在编译任务所在的同一个任务中添加 Gradle 任务调用。越早发现问题越好,无需单独设置一个阶段。

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4
      - name: Assemble and check stability
        run: ./gradlew assemble :app:stabilityCheck
      - name: Upload stability diff on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: stability-diff
          path: app/build/reports/stability/

stabilityCheck 会读取已提交的基线,在 build/ 中重新生成当前快照,对两者进行差异比较,并在任何可组合项的可跳过性降低或任何类的稳定性降低时使任务失败。PR 检查会变红,开发者也能准确看到哪些可组合项发生了回退及其原因。

5. 处理回归

当 stabilityCheck 失败时,任务输出会列出出现回归的可组合项,以及新的不稳定参数或类。开发者有两种有效的处理方式:

  • 修复回归。 稳定导致问题的类型(var → val、List → ImmutableList、上游的 Flow 参数 → collectAsStateWithLifecycle)。在本地重新运行 :stabilityDump,确认基线未变,然后推送。
  • 提供理由并更新基线。 有些回归可以接受(例如,必须接受 Java POJO 的互操作边界)。运行 :stabilityDump 更新基线并提交,然后在 PR 描述中说明为何有意引入该回归。代码审查会核查理由——差异是纯文本,可供审查。

对待基线更新应与团队对待二进制兼容性基线更新的方式相同:必须在代码审查中公开可见,并附上说明。

6. 保守地设置忽略项

ignoredPackages 和 ignoredClasses 用于合理豁免:预览、基准测试、测试替身、生成的代码。不要用它们来掩盖生产代码中的回归。每个忽略项都需要附上说明原因的注释;对于缺少理由的忽略项,审查者应予以拒绝。

// RIGHT
ignoredPackages.set(
    listOf(
        "com.example.preview",  // @Preview composables intentionally use mutable state for tooling
        "com.example.benchmark" // microbenchmark fixtures need fresh allocations per iteration
    )
)
// WRONG
ignoredPackages.set(listOf("com.example.feature.cart"))
// WRONG because: silencing an entire production package hides real regressions in cart.
// The team will rediscover the lost skipping as field jank weeks later.
7. 备选方案:针对多平台/非 Android 项目使用 ComposeGuard

如果项目使用 Compose Multiplatform(通过 KMP 支持桌面、iOS),或者属于 Android Gradle 插件无法覆盖的范围,请使用 j-roskopf/ComposeGuard。它实现了相同的思路(提交基线、在 CI 中比较差异、回归时失败),并提供 <variant>ComposeCompilerGenerate 和 <variant>ComposeCompilerCheck 任务。按上述 stabilityDump / stabilityCheck 的方式配置。对于仅面向 Android 的项目,首选 skydoves 插件,因为它能与 @TraceRecomposition 集成,实现运行时关联。

模式

模式:提交基线,在 PR 中比较差异
# RIGHT
app/stability/
  app-debug.stability   <-- committed
  app-release.stability <-- committed

# CI runs:
./gradlew :app:stabilityCheck   # diffs current vs committed baseline; fails on regression
# WRONG
app/build/stability/    <-- in build/, gitignored, never committed
# WRONG because: every CI run starts from a fresh build/, the baseline does not exist,
# stabilityCheck either no-ops or always passes. The gate is theater.
模式:在 CI 中启用 failOnStabilityChange,本地可选
// RIGHT — committed default
composeStabilityAnalyzer {
    stabilityValidation {
        failOnStabilityChange.set(true)
    }
}
// WRONG — committed default
composeStabilityAnalyzer {
    stabilityValidation {
        failOnStabilityChange.set(false)
    }
}
// WRONG because: turns the gate into an informational warning. Developers learn to ignore
// the warning. Stability regresses anyway. Set true and let CI block the merge.

开发者可以通过传入 Gradle 属性在本地覆盖设置:

failOnStabilityChange.set(
    providers.gradleProperty("composeStabilityStrict").orNull?.toBoolean() ?: true
)
# Local fast iteration — opt out explicitly per invocation
./gradlew :app:stabilityCheck -PcomposeStabilityStrict=false
模式:在 assemble 作业中运行 stabilityCheck,而非单独设置作业
# RIGHT — earlier signal, single workflow run
- run: ./gradlew assemble :app:stabilityCheck
# WRONG — separate jobs split signal
jobs:
  assemble:
    steps: [./gradlew assemble]
  stability:
    needs: assemble
    steps: [./gradlew :app:stabilityCheck]
# WRONG because: the stability job re-resolves dependencies and re-runs Compose compilation
# from scratch, doubling CI time and delaying the failure signal. Same job, single Gradle daemon.
模式:将 CI 门禁与运行时的 @TraceRecomposition 配合使用

该插件可以证明一个类被归类为稳定类,但无法证明运行中的应用实际减少了重组。将 CI 门禁与运行时跟踪配合使用(参见 ../../measurement/tracing-recompositions-at-runtime/SKILL.md),让团队同时掌握两类信号:编译时稳定性分类和运行时重组次数。如果 CI 门禁通过,但重组次数不断上升,就说明存在仅在运行时触发的失效来源(例如,mutableStateOf 读取位置不当),这是稳定性分类无法检测到的。

强制规则

  • 必须将 .stability 基线文件提交到版本控制。存放在 build/ 中的基线不算基线。
  • 必须在已提交的 Gradle 配置中设置 failOnStabilityChange.set(true),以便 CI 阻止回归代码合并。允许通过 Gradle 属性在本地选择退出;但不得提交 false。
  • 不得在没有一行注释说明原因的情况下,将程序包或类添加到 ignoredPackages / ignoredClasses。无正当理由的忽略会让稳定性在不知不觉中再次退化。
  • 不得将已知有问题的初始状态设为基线,然后宣称工作已完成。先运行 ../diagnosing-compose-stability/SKILL.md 和 ../stabilizing-compose-types/SKILL.md;只为团队愿意负责维护的内容设定基线。
  • 必须将插件应用于每个 UI 模块,而不只是 :app。在某个功能模块接入主依赖图之前,:app 单独检查无法发现该模块中的回归。
  • **建议:**在与编译相同的 CI 任务中运行 :stabilityCheck,并置于 assemble 之后。这样能更早发现问题,只需启动一个 Gradle 守护进程,耗时也能减半。
  • **建议:**如果 ComposeGuard 更适合(KMP/桌面/iOS),就使用它,但工作流必须完全匹配:提交基线、因差异而导致构建失败,并在代码审查中说明忽略项的理由。
  • **建议:**将 stabilityConfigurationFiles 指向 Compose 编译器读取的同一个 stability_config.conf,这样插件和编译器使用的分类才一致。

稳定性是一份契约,不是魔法咒语;CI 门禁负责执行这份契约。可跳过性是一项诊断指标,而不是 KPI:目标是“相较团队认可的基线不发生回归”,而不是“100% 可跳过”。

验证

  • 每个 UI 模块的 build.gradle.kts 中都包含 composeStabilityAnalyzer 插件块。
  • app/stability/(或配置的 outputDir)已提交到代码仓库,并且每个发布变体至少包含一个 .stability 文件。
  • 已提交的默认配置为 failOnStabilityChange.set(true)。
  • CI 工作流会在每个拉取请求上运行 ./gradlew :app:stabilityCheck(或对应的模块路径)。
  • 测试门禁:在临时分支中,暂时将共享数据类中的一个 val 改为 var,推送后确认 PR 检查变红,且失败输出中指出发生回归的可组合项。然后还原这项测试改动。
  • ignoredPackages 和 ignoredClasses 中的每一项都有注释说明豁免原因。
  • 开发者在本地运行 ./gradlew :app:stabilityDump 时,唯一的更改(如有)都在 .stability 文件中,且可以作为纯文本进行审查。

参考资料

如需关联 CI 中通过的稳定性检查与实际重组次数,请参阅 ../../measurement/tracing-recompositions-at-runtime/SKILL.md。如需了解 stabilityCheck 所检查内容的概念分类模型,请参阅 ../understanding-stability-inference/SKILL.md。如需查看用于修复检查门禁发现的回归问题的上游类型修复,请参阅 ../stabilizing-compose-types/SKILL.md。

评论

支持 Markdown