本文译自「Your Design System Is a Contract. Enforce It with Konsist and Android Lint」,原文链接proandroiddev.com/konsist-des…,由Ravi Rawal发布于2026年10月21日。
本文将介绍如何在 Jetpack Compose 中强制执行颜色、间距、排版、形状和组件规范,并演示如何配合自定义 Android Lint 规则实现这一目标。
设计系统的可靠性取决于防止团队绕过它的规则的可靠性。
大多数安卓开发团队都有设计系统。
它们定义了字体、颜色、间距、形状、尺寸和可重用的 Compose 组件。
它们编写了使用指南,发布了组件库,并要求开发者遵循既定的规范。
然而,经过几个月的开发,同样的问题又开始出现了。
一个屏幕使用 16dp 的水平内边距;另一个屏幕使用 18dp。一位开发者创建了一个自定义文本样式,因为现有的字体看起来不太合适。
一项功能引入了一个硬编码的十六进制颜色值。一个按钮的圆角半径与应用程序的其他部分不同。有人创建了另一个卡片组件,因为现有的卡片组件似乎不方便重用。
这些决定单独来看似乎都不算特别危险。但它们结合起来,却破坏了设计系统原本应该提供的连贯性。
代码审查可以发现一些违规行为,但它并非可靠的强制执行机制。审查人员时间有限,规范容易被忽略,而且相同的反馈会在不同的拉取请求中重复出现。
更好的方法是将设计系统视为工程合同,并通过自动化检查来执行该合同。
对于使用 Jetpack Compose 的 Android 团队来说,Konsist 和自定义 Android Lint 规则可以互补地解决此问题。 Konsist 可以强制执行架构和结构规范。Android Lint 可以检查源代码中是否存在禁止的模式。Compose UI 测试可以验证最终界面在运行时的行为。
它们共同将设计系统规范从文档转化为可执行的规则。
首先要明确合同的实际含义
在编写测试或代码检查器之前,请确定你的团队希望每个功能遵循的规则。
并非所有设计系统规范都适用于同一种强制执行机制。有些规则涉及模块边界,有些涉及源代码表达式,还有一些涉及渲染后的界面。
例如:
这种区别很重要,因为没有任何一种工具能够同样有效地执行每一种规则。
Konsist 适用于结构约束。Lint 更适合检测禁止的源代码模式。运行时测试验证仅通过检查源代码无法确定的行为。
目标不是增加更多检查,而是将每条规则都放到执行层,以便能够可靠地进行检查。
基于已批准的 Token 构建设计系统
设计系统在实施之前需要一个清晰的真理来源。
考虑一个间距不一致的 Compose 应用程序:
@Composable
fun ProductDetailsScreen() {
Column(
modifier = Modifier.padding(17.dp),
) {
Text(
text = "Product details",
fontSize = 22.sp,
)
Spacer(modifier = Modifier.height(13.dp))
Button(
onClick = {},
modifier = Modifier.height(47.dp),
) {
Text("Continue")
}
}
}
问题不在于 17.dp、22.sp 或 47.dp 本身是否错误,而在于功能代码没有遵循系统已建立的值,而是自行做出独立的设计决策。
首先定义 Token。
间距和尺寸
package com.acme.designsystem.tokens
import androidx.compose.ui.unit.dp
object AppSpacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
}
object AppDimensions {
val buttonHeight = 48.dp
val iconSmall = 16.dp
val iconMedium = 24.dp
val iconLarge = 32.dp
}
功能代码现在可以传达意图:
Column(
modifier = Modifier.padding(AppSpacing.md),
) {
// Screen content
}
Token 名称体现了设计意图,其实际值仍由设计系统统一管理。
如果设计系统将其中心间距从 16.dp 更改为另一个值,则可以在一个地方进行更改,而不是分散在各个功能模块中。
排版
Material 3 的排版为文本角色提供了有用的基础。
package com.acme.designsystem.tokens
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
)
这些数值仅供参考。生产设计系统应根据其实际设计规范定义排版角色,包括字体系列、字重、行高、字间距和支持的文本变体。
重要的原则是,功能代码应该使用语义角色,而不是自行创造排版格式。
Text(
text = "Product details",
style = MaterialTheme.typography.titleLarge,
)
颜色和形状
颜色和形状应遵循相同的原则。在设计系统中,应尽可能使用 Material 3 的颜色角色:
Text(
text = "Available",
color = MaterialTheme.colorScheme.primary,
)
对于自定义品牌颜色,在设计系统拥有的包中定义已批准的值,并向消费者公开语义名称。
同样,应该集中定义已批准的形状,而不是允许每个特征选择自己的拐角半径。
Token 系统构建了契约的词汇表。自动化检查能有效防止绕过这些约定。
使用 Konsist 保护架构边界
Konsist 是一个 Kotlin 架构测试库。它允许团队针对包结构、依赖关系、命名约定和其他源代码级架构规则编写测试。
当设计系统被拆分为公共 API 和内部实现包时,这种方法尤其有用。
想象一下这样的软件包结构:
com.acme
├── feature
│ ├── home
│ └── product
└── designsystem
├── components
├── tokens
└── internal
└── palette
功能模块应该使用公共的组件和 Token API,而不应直接依赖 designsystem.internal 下的实现细节。
Konsist架构测试可以表达这种边界。
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.architecture.Layer
import org.junit.Test
class DesignSystemArchitectureTest {
@Test
fun `features must not depend on design system internals`() {
Konsist.scopeFromProject()
.assertArchitecture {
val features = Layer(
"Features",
"com.acme.feature..",
)
val designSystemInternal = Layer(
"Design System Internal",
"com.acme.designsystem.internal..",
)
features.doesNotDependOn(designSystemInternal)
}
}
}
包模式必须与你仓库中的实际包布局相匹配。测试应在预期的项目范围内运行,并且 Konsist 版本应已锁定,并已根据你项目使用的 API 进行验证。
这样做的好处是,架构决策可以执行。
如果某个功能开始导入内部调色板或实现类,则在更改合并之前,架构测试可能会失败。
Konsist还能强制执行哪些其他措施?
同样的方法也适用于命名和包规范。
例如,一个团队可能需要:
- 公共设计系统组件位于
designsystem.components下。 - Token 定义应放在 tokens 包内。
- 特定于功能的 UI 组件位于其所属功能包内。
- 内部实现类型遵循约定的命名规则。
- 架构层保持其预期的依赖关系方向。
这些检查有助于防止结构漂移。
然而,存在一个重要的限制:**包架构测试不会自动强制执行可组合组件内部的每种使用约定。**一个功能可以仅依赖于已批准的包,并且仍然可以直接构造 Color(0xFF1769E0)。
这时,Android Lint 就派上用场了。
使用自定义 Android Lint 规则来捕获设计系统违规
Android Lint 会分析 Android 源代码并报告潜在的错误和问题。它还提供用于编写自定义检测器的 API。
对于设计系统而言,自定义 Lint 规则可以检查 Kotlin 源代码并标记违反团队约定的模式。
例如:
- 在功能代码中使用数值字面量构建颜色。
- 使用原始 sp 值而非已批准的排版角色。
- 在需要标记的地方使用原始 dp 值设置间距。
- 调用已弃用的设计系统 API。
- 使用未经批准的组件实现。
- 引用功能包中的内部设计系统符号。
每条规则都应该有明确的适用范围和清晰的失败信息。过于宽泛的规则如果会标记出所有合理的异常情况,就会造成不必要的干扰,最终导致开发者禁用这些检查。
让我们实现一个具体的例子:检测 Compose Color 构造函数调用中的数值颜色字面量。
禁止在特征代码中使用原始数值颜色构造函数
请看以下代码:
val accent = Color(0xFF1769E0L)
颜色可能与当前的品牌色调相符,但该功能现在拥有了本应属于设计系统的设计决策权。
我们可以创建一个自定义检测器,用于标记未经批准的软件包中的这种模式。
以下示例针对直接传递给 androidx.compose.ui.graphics.Color 构造函数的数值字面量。这是一个重点突出的入门规则,并非对所有可能的硬编码颜色进行全面检测。
package com.acme.designsystem.lint
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.intellij.psi.PsiMethod
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.ULiteralExpression
class NoHardcodedColorDetector : Detector(), SourceCodeScanner {
override fun getApplicableConstructorTypes(): List<String> =
listOf(COLOR_CLASS)
override fun visitConstructor(
context: JavaContext,
node: UCallExpression,
constructor: PsiMethod,
) {
val packageName = context.uastFile?.packageName.orEmpty()
if (isAllowedPackage(packageName)) return
val hasNumericLiteral = node.valueArguments.any { argument ->
argument is ULiteralExpression &&
argument.value is Number
}
if (!hasNumericLiteral) return
context.report(
ISSUE,
node,
context.getLocation(node),
"Use an approved design-system color or " +
"MaterialTheme.colorScheme instead of " +
"constructing a Color from a numeric literal.",
)
}
private fun isAllowedPackage(packageName: String): Boolean =
ALLOWED_PACKAGES.any { allowed ->
packageName == allowed ||
packageName.startsWith("$allowed.")
}
companion object {
private const val COLOR_CLASS =
"androidx.compose.ui.graphics.Color"
private val ALLOWED_PACKAGES = setOf(
"com.acme.designsystem.tokens",
"com.acme.designsystem.internal.palette",
)
@JvmField
val ISSUE: Issue = Issue.create(
id = "HardcodedDesignColor",
briefDescription = "Hardcoded design-system color",
explanation = """
Feature code must use approved design-system
colors or semantic MaterialTheme color roles.
Raw numeric color definitions belong in
approved design-system packages.
""".trimIndent(),
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.ERROR,
implementation = Implementation(
NoHardcodedColorDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
}
该探测器由三个重要部分组成。
首先,getApplicableConstructorTypes() 将检测器需要检查的调用限制为 Compose Color 类型的构造函数。
其次,visitConstructor() 检查参数中是否存在数值字面量,并跳过明确允许定义颜色的包。
第三,所报告的问题严重级别为 ERROR。这意味着 Lint 将此违规行为报告为错误。它是否会阻塞特定的 CI 作业取决于该作业的运行方式以及 Lint 的配置。
此允许列表是有意为之。设计系统必须能够定义自己的调色板。该规则可防止任意的功能级定义,同时又不妨碍中心令牌实现正常工作。
应根据项目中选择的 Android Lint 版本验证具体的检测器 API。自定义 Lint 对版本敏感,因此请确保 lint-api 和 lint-tests 与兼容的 Android Gradle 插件工具链保持一致。
登记问题
Lint 需要一个问题注册表来发现自定义问题。
package com.acme.designsystem.lint
import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
class DesignSystemIssueRegistry : IssueRegistry() {
override val issues = listOf(
NoHardcodedColorDetector.ISSUE,
)
override val api: Int = CURRENT_API
override val minApi: Int = 8
override val vendor = Vendor(
vendorName = "Acme Engineering",
feedbackUrl = "https://example.com/engineering/issues",
contact = "mobile-platform@example.com",
)
}
请将示例中的供应商信息替换为贵公司的详细信息。
自定义 Lint 模块还必须通过服务加载器注册问题注册表。创建以下文件:
design-system-lint/
└── src/main/resources/
└── META-INF/services/
└── com.android.tools.lint.client.api.IssueRegistry
文件内容应为完整的注册表类名:
com.acme.designsystem.lint.DesignSystemIssueRegistry
这样,Lint 就可以在加载自定义规则工件时发现自定义问题。
配置自定义 Lint 模块
一个典型的基于 JVM 的自定义 Lint 模块需要 Lint API 进行编译,以及 Lint 测试基础架构进行单元测试。
plugins {
kotlin("jvm")
}
dependencies {
compileOnly(
"com.android.tools.lint:lint-api:$lintVersion",
)
testImplementation(
"com.android.tools.lint:lint-tests:$lintVersion",
)
testImplementation("junit:junit:4.13.2")
}
此处,lintVersion 代表与你的 Android Gradle 插件和 Lint 环境兼容的版本。请勿脱离 Android 构建工具链单独选择此版本。
在 Android 应用模块中,通过 lintChecks 配置来使用这些规则:
dependencies {
lintChecks(project(":design-system-lint"))
}
一旦将模块包含在构建中,Lint 就可以在分析应用程序时加载已注册的检测器。
在强制执行规则之前先进行测试
没有测试的自定义检测器只不过是另一段可能悄无声息停止工作的代码。
Lint 提供了一个测试基础架构,允许你提供源代码片段并断言所报告的问题。
以下测试类展示了检测器测试的预期结构。请确保 Lint 测试依赖项与项目使用的版本保持一致,并在将其应用于持续集成 (CI) 之前,使用该版本验证代码片段。
package com.acme.designsystem.lint
import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.LintDetectorTest
import org.junit.Test
class NoHardcodedColorDetectorTest : LintDetectorTest() {
override fun getDetector(): Detector =
NoHardcodedColorDetector()
override fun getIssues(): List<Issue> =
listOf(NoHardcodedColorDetector.ISSUE)
private val colorStub = kotlin(
"src/main/java/androidx/compose/ui/graphics/Color.kt",
"""
package androidx.compose.ui.graphics
class Color(val value: Long) {
constructor(value: Int) : this(value.toLong())
}
""".trimIndent(),
)
@Test
fun `flags numeric colors in feature code`() {
lint()
.files(
colorStub,
kotlin(
"src/main/java/com/acme/feature/home/Colors.kt",
"""
package com.acme.feature.home
import androidx.compose.ui.graphics.Color
val accent = Color(0xFF1769E0L)
""".trimIndent(),
),
)
.run()
.expectErrorCount(1)
.expectContains("[HardcodedDesignColor]")
}
@Test
fun `allows numeric colors in token package`() {
lint()
.files(
colorStub,
kotlin(
"src/main/java/com/acme/designsystem/tokens/AppColors.kt",
"""
package com.acme.designsystem.tokens
import androidx.compose.ui.graphics.Color
val primary = Color(0xFF1769E0L)
""".trimIndent(),
),
)
.run()
.expectClean()
}
}
第一个测试验证了特征包中的数值颜色构造函数会产生错误。第二个测试验证了相同的模式在已批准的标记包中是否允许。
测试桩为测试环境提供了一个最基本的 Compose Color 类型。实际的检测器仍需针对项目使用的 Lint 版本和源代码配置进行测试。
这些测试也证明了一个关键原则:**既要测试违规行为,也要测试合法的例外情况。**否则,一条规则可能看起来有效,但实际上却破坏了它本应保护的代码。
了解探测器的局限性
这个启动检测器并不能检测到颜色绕过设计系统的所有方式。
例如,它并未全面处理以下问题:
Color.Red
或者存储在常量中的颜色、从辅助函数返回的颜色、从资源加载的颜色,或者通过别名传递的颜色。
这也不能证明命名标记在语义上适合使用它的组件。
生产环境检测器可能需要检查已解析的符号、附加构造函数形式、属性访问或其他表达式。它应该包含针对组织希望禁止的具体模式的测试。
正确的做法是根据实际违规情况和设计系统契约逐步扩展检测器,而不是声称单个语法检查可以保证完全的颜色控制。
将同样的方法应用于字体排印、间距和尺寸。
一旦第一个自定义规则生效,其他规则就可以强制执行其他设计系统约定。
不要试图把所有规则都塞进一个庞大的检测器里。要给每条规则都赋予明确的目的、范围和测试套件。
排版
当项目需要语义化的排版角色时,排版规则可能会标记功能代码中直接赋值的 fontSize。
例如:
Text(
text = "Product title",
fontSize = 19.sp,
)
首选方案是使用已定义的角色:
Text(
text = "Product title",
style = MaterialTheme.typography.titleLarge,
)
该实现需要明确规定禁止哪些操作。如果一概禁止所有 TextStyle 或 fontSize 表达式,则可能会误判一些合法情况,例如自定义绘图、特殊编辑内容或内部设计系统实现。
明确合同是禁止所有直接文本尺寸调整,还是仅禁止指定功能包中的某些特定模式。
间距和尺寸
类似的规则可以检测原始维度文字:
Modifier.padding(17.dp)
并引导开发者使用 Token:
Modifier.padding(AppSpacing.md)
然而,并非所有的 dp 值都代表间距。尺寸还可以表示组件的最小触摸目标、边框宽度、图标大小或特定的布局约束。
检测器应该能够区分设计系统实际控制的 API 和上下文。如果团队想要强制执行间距标记,那么围绕已知间距 API 制定一个范围较窄的规则,比制定一个标记整个代码库中所有维度字面量的规则更有用。
形状
假设设计系统定义了卡片和按钮的认可形状。一条规则可以检测特征代码中直接构造的形状对象,并要求改用设计系统中指定的形状。
具体检测器取决于应用程序如何对形状进行建模以及允许使用哪些 API。它还应该能够区分合法的自定义形状和意外重复的形状。
命名规则
命名规则通常可以通过 Konsist 来强制执行,尤其是在要求涉及包和类结构时。
例如,团队可以要求设计系统可组合体使用一致的命名约定,或者将实现类限制在内部包中。
规则应该反映团队实际采用的惯例,而不是仅仅因为命名限制容易自动化就强加这些限制。
强制组件重用,而不是仅仅强制使用 Token
集中管理的 Token 可以防止数值漂移,但无法保证组件行为的一致性。
考虑两个功能,它们都使用相同的间距和颜色标记,但各自实现了不同的按钮。一个功能能正确处理加载状态;另一个功能允许重复点击。一个功能具有预期的最小高度;另一个功能则没有。一个功能提供了适当的辅助功能语义;另一个功能则没有。
这两个功能虽然都能通过令牌检查,但仍然会呈现不同的用户体验。
因此,组件约定应该成为合同的一部分。
例如,一个设计系统可能提供:
@Composable
fun AppPrimaryButton(
text: String,
onClick: () -> Unit,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.height(AppDimensions.buttonHeight),
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
)
}
}
这个例子特意做得比较简单。一个真正的组件可能还会定义加载行为、图标、内容内边距、语义和分析钩子。
关键在于,功能会使用公共组件,而不是独立地重新创建相同的行为。
Lint 规则可以标记出直接使用底层组件的情况,而这些组件必须使用特定的包装器。Konsist 可以帮助保护这些组件周围的包和模块边界。
不过,全局禁令要谨慎使用。有时,某个功能确实需要专门的控制。可以通过经过审核的 API 或范围较小的抑制机制来允许显式的例外情况,并保持这些例外情况的可见性。
使用 Compose UI 测试确保运行时正确性
静态分析可以识别源代码模式,但无法确定渲染界面的所有属性。
例如,Lint 规则可能会确认某个功能使用了 MaterialTheme.typography.bodyLarge,但它本身无法证明最终屏幕在大字体比例下仍然可读,也无法证明文本不会被其父布局裁剪。
Compose UI 测试应该涵盖这些运行时要求。
有用的测试目标包括:
- 辅助功能语义和有意义的标签。
- 支持的字体大小下的布局行为。
- 启用、禁用、加载和错误状态。
- 最小触摸目标要求。
- 内容溢出和用户生成的长文本。
- 交互行为和状态转换。
这将创建一个更完善的执法模式。
Konsist 检查架构边界。Lint 检测禁止的源代码模式。Compose UI 测试验证渲染行为。每一层都保护着契约的不同部分。
在 CI 中暴露违规行为
规则只有始终如一地执行才能有效。
将架构测试和自定义 Lint 检查作为常规验证流程的一部分运行。具体的 Gradle 任务路径取决于模块的名称和配置,但典型的流程可能包括:
./gradlew :design-system-lint:test
./gradlew :app:lint
作为项目单元测试或验证任务的一部分,运行相关的 Konsist 测试任务。
期望的结果很明确:引入禁止的设计系统模式的拉取请求会未能通过相应的检查,并解释如何修复它。
一条有用的错误信息应该回答三个问题:
- 违反了哪条规则?
- 这条规则存在的意义是什么?
- 开发人员应该使用什么替代方法?
例如:
检测到硬编码的设计系统颜色。请使用已批准的设计系统颜色或语义化的 MaterialTheme 颜色角色。
这比“颜色使用无效”之类的通用消息更有用。
平稳落地规则,不影响交付节奏
如果一个代码库已经包含了多年不一致的 UI 代码,那么在第一天就将每条规则都变成阻塞性错误可能会导致大量的故障。
首先对现有代码库进行检查,了解违规之处。优先修复高价值案例,然后分阶段引入规则。
实际推广流程如下:
- 建立令牌和组件 API。
- 添加架构边界测试。
- 实现并测试一条自定义 Lint 规则。
- 对现有功能模块运行该规则,并评估误报情况。
- 修复违规或记录合理的例外情况。
- 将该规则设置为阻止新变更。
- 随着设计系统的完善,添加更多规则。
避免为了让持续集成(CI)通过而随意压制违规行为。例外情况应具体、合理且可审查。
此外,还应追踪违规次数、规则被抑制的频率以及哪些规则会产生误报。这些信息有助于区分有效的系统设计规范和一堆无意义的检查结果。
设计系统成为开发工作流程的一部分
这种方法的真正价值不在于团队编写了多少自定义规则。
这是责任的转移。
如果没有自动化强制执行机制,保持视觉一致性很大程度上依赖于开发人员对规范的记忆以及审核人员对偏差的发现。随着团队、模块和屏幕数量的增加,这变得越来越困难。
通过可执行规则,存储库本身可以传达哪些决策属于设计系统,哪些是合法的功能级选择。
开发人员仍然可以构建新屏幕,但屏幕必须遵守相同的架构边界,使用已批准的令牌,并遵循组织已建立的组件约定。
这在大型 Android 代码库中尤其有用,因为设计系统偏差通常会在各个独立功能团队之间逐渐显现。修复单个不一致的颜色或间距值的成本很小,但维护数百个略有不同的实现的成本却不低。
Konsist 和 Android Lint 并不能取代设计评审、用户体验评估或无障碍测试。它们只是从这些流程中移除了重复性的机械检查,以便评审人员能够将更多时间用于需要人为判断的决策。
这一原则不仅适用于 Compose 系统,也适用于更广泛的场景:如果一项约定重要到需要记录下来,并且足够精确需要描述,那么它可能也重要到需要自动强制执行。
设计系统不应依赖于每个人都记住约定。它应该让正确的路径易于遵循,并在意外违规行为进入生产环境之前将其暴露出来。
参考
- Konsist - Kotlin 架构测试
- Android 开发者 - 使用 lint 检查改进代码
- Android 开发者 - 自定义 lint 检查
- Material 3 - Jetpack Compose 中的主题化
实现说明:Kotlin 示例仅供参考。在将这些规则应用到生产 CI 流水线之前,请先根据项目中指定的版本验证 Konsist 和 Android Lint API,并运行检测器测试和 Gradle 检查。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!