卷 VI · 交付·
CH 21·
token 三层
深度 21/28
把这些数写进 Compose
前面二十章定下了一堆值。这一章把它们装进代码——装的方式决定了三个月后它们还在不在。
为什么不能直接写值
你已经知道主色是 #6750A4。为什么不能直接写进按钮里?
三层的意义,说到底是改动的传播范围:
改参考层换品牌 影响全部
改系统层换主题 影响一整类角色
改组件层调一个控件 影响一个
直接写死值,这三种改动全部退化成「全局搜索」
而全局搜索永远会漏——漏掉那个写成 0xFF6750A4 的、漏掉那个在 XML 里的、漏掉那个被拼进字符串的。
更糟的是误伤:你搜 #6750A4 全部替换成新品牌色,但其中有三处根本不是「主色」的意思,它们只是碰巧同一个值。
三层的作用就是让「碰巧同值」和「本来就该同值」在代码里长得不一样。
一份可以直接抄的
把全书的结论装进四个文件。这是一份能直接粘进项目的骨架:
① Tokens.kt——参考层。全是事实,没有含义。
// 这一层的每个值都带出处注释。改这里 = 换品牌。
internal object Ref {
// —— 色:种子 #6750A4,其余由 HCT 生成(第 15 章)——
val primary40 = Color(0xFF6750A4)
val primary80 = Color(0xFFCFBCFF)
val primary90 = Color(0xFFE9DDFF)
val primary10 = Color(0xFF22005D)
val neutral10 = Color(0xFF1C1B1E)
val neutral99 = Color(0xFFFFFBFF)
// …其余 60 多个,由 Material Theme Builder 一次生成
// —— 间距:8 的倍数(第 6 章)——
val space0 = 0.dp; val space1 = 2.dp; val space2 = 4.dp
val space3 = 8.dp; val space4 = 12.dp; val space5 = 16.dp
val space6 = 24.dp; val space7 = 32.dp; val space8 = 48.dp
// —— 字号:17 × 1.25ⁿ 圆整(第 9 章)——
val size0 = 11.sp; val size1 = 14.sp; val size2 = 17.sp
val size3 = 21.sp; val size4 = 27.sp; val size5 = 33.sp
// —— 时长:M3 token(第 18 章)——
const val short2 = 100; const val short4 = 200
const val medium2 = 300; const val long2 = 500
}
② Theme.kt——系统层。角色,不是值。
// 换主题 = 改这里的指向(tone 40 → tone 80),组件代码一行不动
private val LightColors = lightColorScheme(
primary = Ref.primary40,
onPrimary = Color.White,
primaryContainer = Ref.primary90,
onPrimaryContainer = Ref.primary10,
surface = Ref.neutral99,
onSurface = Ref.neutral10,
// …
)
private val DarkColors = darkColorScheme(
primary = Ref.primary80, // ← 同一个角色,另一个 tone
onPrimary = Ref.primary20,
primaryContainer = Ref.primary30,
onPrimaryContainer = Ref.primary90,
surface = Ref.neutral10,
onSurface = Ref.neutral90,
// …
)
// 间距和 Material 没有对应物 —— 自己挂一个 CompositionLocal
@Immutable
data class Spacing(
val tiny: Dp = Ref.space2, // 4 图标和它的文字
val tight: Dp = Ref.space3, // 8 组内
val base: Dp = Ref.space5, // 16 屏幕边距
val group: Dp = Ref.space6, // 24 组之间
val block: Dp = Ref.space7 // 32 大区块之间
)
val LocalSpacing = staticCompositionLocalOf { Spacing() }
val MaterialTheme.spacing: Spacing
@Composable @ReadOnlyComposable get() = LocalSpacing.current
@Composable
fun AppTheme(dark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
CompositionLocalProvider(LocalSpacing provides Spacing()) {
MaterialTheme(
colorScheme = if (dark) DarkColors else LightColors,
typography = AppTypography,
content = content
)
}
}
③ Type.kt——把第 9、10、12 章的结论落地。
private val Body = TextStyle(
// 第 10 章:关掉那截 padding,并把行盒切齐
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(
trim = LineHeightStyle.Trim.Both,
alignment = LineHeightStyle.Alignment.Center
)
)
val AppTypography = Typography().let { d ->
Typography(
// 行高按第 10 章的公式算,中文取 1.6–1.8
headlineMedium = d.headlineMedium.merge(Body).copy(
fontSize = Ref.size5, lineHeight = 40.sp, letterSpacing = (-0.015).em
),
titleLarge = d.titleLarge.merge(Body).copy(
fontSize = Ref.size3, lineHeight = 31.sp, letterSpacing = (-0.008).em
),
bodyLarge = d.bodyLarge.merge(Body).copy(
fontSize = Ref.size2, lineHeight = 30.sp
),
bodyMedium = d.bodyMedium.merge(Body).copy(
fontSize = Ref.size1, lineHeight = 24.sp
),
labelSmall = d.labelSmall.merge(Body).copy(
fontSize = Ref.size0, lineHeight = 16.sp, letterSpacing = 0.06.em
)
)
}
④ 用的时候——组件层。只出现角色名。
@Composable
fun OrderCard(order: Order, modifier: Modifier = Modifier, onClick: () -> Unit) {
Card(onClick = onClick, modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.base),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.tight)
) {
Text(order.title, style = MaterialTheme.typography.titleLarge)
Text(
order.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant // ← 不是灰,是角色
)
}
}
}
看这最后一段:里面没有一个数字。这就是这一章的目标。
让它守得住
光有 token 不够——你还是会顺手写字面量。三道防线,从软到硬:
| 手段 | 拦得住什么 | 成本 |
|---|---|---|
| 约定(写在 README 里) | 几乎什么都拦不住 | 零 |
| code review | 拦得住大部分,漏掉赶工期的那些 | 持续的人力 |
| lint 规则 | 全拦住 | 写一次,半天 |
| 类型系统 | 全拦住,而且不用配置 | API 设计时的一点克制 |
// lint 规则要拦的三样东西:
// ① 裸的 .dp / .sp 字面量(0.dp 除外)
// ② Color(0x…) 字面量(Tokens.kt 除外)
// ③ tween/spring 里的裸时长数字
// 类型系统的做法更狠 —— 让组件只接受 token 类型
@JvmInline value class SpacingToken internal constructor(val dp: Dp)
@Composable
fun Section(
gap: SpacingToken = MaterialTheme.spacing.groupToken, // 只能传 token
content: @Composable ColumnScope.() -> Unit
) = Column(verticalArrangement = Arrangement.spacedBy(gap.dp), content = content)
// Section(gap = 13.dp) ← 编译不过。这就是第 1 章说的主场优势。
不用一上来就上 value class——那是有成本的。先把 lint 规则写了,它的性价比高得离谱:半天的工作,换来这套东西在整个项目生命周期里不被侵蚀。
一个常见的过度设计
不要给每个组件都建一层「组件 token」(OrderCardTokens.paddingTop 这种)。
那会把第 1 章那 110 个随手定的数换个写法重新造出来——只是现在它们有名字了,看起来更正当。
组件层 token 只在一种情况下值得建:这个组件有多个变体,而它们必须共享某些值(比如三种按钮的高度必须一致)。其余情况直接用系统层就好。
本章关掉的自由度本章 3 条 · 累计 61 / 78
- 「值写在哪」——参考层写事实、系统层写角色、组件层只引用。UI 代码里不出现任何字面量。
- 「怎么保证不被侵蚀」——lint 规则拦
.dp/.sp/Color(0x…)/ 裸时长。约定和 review 都不够。 - 「要不要给每个组件建 token」——不要。只在「多个变体必须共享值」时才建组件层。