卷 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」——不要。只在「多个变体必须共享值」时才建组件层。