18、Compose主题:MaterialTheme、颜色系统、字体系统、形状系统、暗黑模式、实战:主题定制

各位同学,今天我们来聊聊 Compose 里的主题系统。说实话,主题这个东西,在传统 View 体系里我一直觉得挺麻烦的——写一堆 style.xml,还要处理各种主题继承,稍不留神就乱了。到了 Compose 时代,一切都变得清晰了。

MaterialTheme 是 Compose 主题的核心。它把颜色、字体、形状这三个维度统一管理起来。我个人习惯是,拿到一个新项目,第一件事就是搭好主题框架。为什么?因为后面所有 UI 组件都会基于这个主题来渲染,你改一个地方,全局都跟着变。

颜色系统

颜色系统是主题里最直观的部分。Material Design 3 把颜色分成了几个角色:Primary、Secondary、Tertiary,以及对应的 Surface、Background、Error 等。每个角色又有自己的变体,比如 onPrimary 表示在 Primary 背景上显示的文字颜色。

我在项目中遇到过一个问题:设计师给了一套品牌色,但直接套到 MaterialTheme 里发现按钮文字看不清。后来排查发现,是 onPrimary 颜色没配好。说白了,颜色系统不是孤立的,你得保证前景色和背景色有足够的对比度。

// 定义颜色
private val LightColorScheme = lightColorScheme(
    primary = Color(0xFF1976D2),
    onPrimary = Color.White,
    secondary = Color(0xFF03DAC6),
    onSecondary = Color.Black,
    background = Color(0xFFF5F5F5),
    surface = Color.White,
    error = Color(0xFFB00020)
)

private val DarkColorScheme = darkColorScheme(
    primary = Color(0xFF90CAF9),
    onPrimary = Color(0xFF0D47A1),
    secondary = Color(0xFF03DAC6),
    onSecondary = Color.Black,
    background = Color(0xFF121212),
    surface = Color(0xFF1E1E1E),
    error = Color(0xFFCF6679)
)

你想想看,如果只定义了 primary 没定义 onPrimary,Compose 会怎么处理?它会用默认的黑色或白色。但默认值不一定适合你的品牌色。所以我的建议是:把每个颜色角色都显式定义一遍,别偷懒。

字体系统

字体系统在 Compose 里通过 Typography 类来管理。它定义了从 displayLarge 到 labelSmall 共 15 个文本样式。每个样式都可以单独设置字体、字号、行高、字重等属性。

嗯,这里要注意:不要每个文本组件都手动设置字体。你应该在主题层面统一配置,然后 Text 组件会自动匹配正确的样式。

val AppTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp
    ),
    headlineMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp
    ),
    bodyLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp
    )
)

我曾经踩过一个坑:项目里用了自定义字体,但只在几个关键页面设置了 fontFamily,结果其他页面的字体不一致。后来我把自定义字体统一放到了 Typography 的默认配置里,问题就解决了。

形状系统

形状系统控制的是组件的圆角、裁剪方式。MaterialTheme 提供了 Shapes 类,包含 small、medium、large 三个层级。Card、Button、Dialog 等组件会分别使用不同层级的形状。

层级 典型组件 默认圆角
small Button、TextField 4.dp
medium Card、AlertDialog 8.dp
large ModalDrawer、BottomSheet 16.dp
val AppShapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(8.dp),
    large = RoundedCornerShape(16.dp)
)

我个人习惯把形状也纳入设计系统的统一管理。设计师给了一个圆角规范,我就直接映射到这三个层级上。这样所有组件都遵循同一套规则,视觉上非常统一。

暗黑模式

暗黑模式在 Compose 里实现起来非常优雅。你只需要准备两套 ColorScheme:一套亮色、一套暗色。然后通过 isSystemInDarkTheme() 来判断当前系统主题,动态切换即可。

核心思路:亮色和暗色不是简单的颜色反转。暗色模式下,背景应该用深色,文字用浅色,但饱和度要降低,避免刺眼。

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

为什么暗色模式要单独定义一套颜色?直接反转不行吗?我试过,效果很差。比如亮色下的 primary 是蓝色,反转后变成黄色,完全不符合设计规范。所以老老实实手写两套吧。

实战:主题定制

好了,理论讲完了,我们来动手做一个完整的主题定制。假设我们要做一个阅读类 App,主题要求:暖色调、圆角适中、字体偏大。

第一步,定义颜色。我选了一个暖橙色作为 primary,搭配米白色背景。

private val ReadingLightColors = lightColorScheme(
    primary = Color(0xFFE65100),
    onPrimary = Color.White,
    secondary = Color(0xFFFFB74D),
    onSecondary = Color(0xFF3E2723),
    background = Color(0xFFFFF8E1),
    surface = Color(0xFFFFF3E0),
    onBackground = Color(0xFF3E2723),
    onSurface = Color(0xFF3E2723)
)

第二步,定义字体。阅读类 App 需要大一点的字号,我把 bodyLarge 从 16sp 调到了 18sp。

val ReadingTypography = Typography(
    bodyLarge = TextStyle(fontSize = 18.sp, lineHeight = 28.sp),
    bodyMedium = TextStyle(fontSize = 16.sp, lineHeight = 24.sp),
    headlineMedium = TextStyle(fontSize = 24.sp, lineHeight = 32.sp)
)

第三步,定义形状。阅读类 App 不需要太尖锐的边角,我把 medium 的圆角设成了 12.dp。

val ReadingShapes = Shapes(
    small = RoundedCornerShape(6.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(20.dp)
)

最后,组合起来使用:

@Composable
fun ReadingTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) ReadingDarkColors else ReadingLightColors
    MaterialTheme(
        colorScheme = colors,
        typography = ReadingTypography,
        shapes = ReadingShapes,
        content = content
    )
}

小技巧:如果你不确定暗色模式下的颜色是否合适,可以用 Material Theme Builder 这个在线工具。它可以根据你的亮色主题自动生成暗色主题,省去很多调试时间。

到这里,一个完整的主题定制就完成了。你可以在 App 的入口处包裹 ReadingTheme,所有子组件都会自动应用这套主题。以后要改设计风格,只需要改这一个地方就行。

注意:主题定制不是一次性的工作。随着项目迭代,设计师可能会调整颜色或字体。我建议把主题配置单独放在一个文件中,并加上清晰的注释,方便后续维护。

最后,我用一张图来总结本章的知识体系:

MaterialTheme 颜色系统 字体系统 形状系统 Primary Secondary Background Surface Display Headline Body Label Small Medium Large 暗黑模式(亮色/暗色切换)

这张图展示了 MaterialTheme 的三大支柱:颜色、字体、形状,以及暗黑模式如何贯穿其中。你只需要定义好这三套配置,整个 App 的视觉风格就统一了。

公众号:蓝海资料掘金营,微信deep3321