本文是 Jetpack Compose 系列的第 12 篇。前 11 篇我们聊了声明式 UI 的思维方式、状态管理、副作用等话题。但如何让 App 看起来"专业"------不是靠单个控件的花哨,而是一套统一的、可扩展的视觉语言------这是今天要讨论的核心。
代码环境:
Kotlin 2.2.10 + AGP 9.1.1 + Android Studio Panda 4
一、前言
在传统的 Android View 开发中,样式管理 是一件让人头疼的事------styles.xml、colors.xml、themes.xml,文件散落在 res/values 目录下,主题和样式的边界模糊不清。你想改一个全局颜色,得在好几个文件里翻找。
Jetpack Compose 把这一切彻底改变了------主题不再是 XML 文件里的配置,而是 Kotlin 代码中的一等公民(即函数)。
kotlin
// View 体系的思维:theme 是一个外部引用
// <TextView android:textColor="?attr/colorOnSurface" />
// Compose 的思维:theme 是一个函数参数,你在代码里直接使用它
@Composable
fun Greeting(name: String) {
Text(
text = "Hello $name",
color = MaterialTheme.colorScheme.onSurface // 编译期可追踪
)
}
这个转变的意义在于:主题变成了 Kotlin 对象,而非字符串引用。IDE 可以自动补全,编译器可以校验类型,重构不会漏改。这是"能用"到"可控"的第一步。
二、主题在哪里
创建一个 Compose 项目后打开 theme 目录,可以看到三个自动生成的文件:Color.kt 、Theme.kt 、Type.kt。

三分文件内容对比图:

Theme.kt→ 主入口 ,定义了@Composable fun YourAppTheme(...)函数,组合了颜色、排版和形状。Color.kt→ 定义了所有颜色值(如Purple80,Gray90等)。Type.kt→ 定义了字体排版样式(Typography)。
二、MaterialTheme的左膀右臂
现在打开项目中 Android Studio自动生成的 Theme.kt (详见 Jetpack Compose 入门指南),你会看到 MaterialTheme 的 M3 签名非常简单(下面是简化后的代码示例):
kotlin
// M3 MaterialTheme 核心签名------只有两个可配置项
@Composable
fun MaterialTheme(
colorScheme: ColorScheme,
typography: Typography,
content: @Composable () -> Unit
)
等等,Shapes 去哪了? 如果你之前看过 M2 的教程,一定会发现这里少了 shapes 参数。这不是疏漏------这是 M3 有意为之的架构变更。M2 时代,全局 Shapes 定义了 small / medium / large 三级圆角,所有组件共享。但 Google 在实践中发现,形状是组件级别的关注点,Button 和 Card 对圆角的需求天然不同,强行全局统一反而别扭。
所以 M3 做了一次"减法":把形状收敛到每个组件自己的 Defaults 里。Card 有 CardDefaults.shape,Button 有 ButtonDefaults.shape,各自独立。你可以自己定义一套项目级的形状常量,但不再通过 MaterialTheme 注入。
kotlin
// 带 Shapes 的 MaterialTheme
@Composable
fun MaterialTheme(
colorScheme: ColorScheme,
typography: Typography,
shapes: Shapes, // ⭐
content: @Composable () -> Unit
)
下面来认识下左膀右臂。
2.1 ColorTheme:语义化色彩系统
初学者常有这样的直觉:primary 就是主色,secondary 就是辅色。但这太表面了。M3 的 ColorScheme 有 25+ 个颜色属性,真正重要的是理解它的语义分层:
Material 3 的配色体系比 Material 2 复杂得多,核心变化是引入了角色化语义。
| 层级 | 分类 | 包含变量 | 作用说明 |
|---|---|---|---|
| 第一层 | 品牌色(Brand) | primary, secondary, tertiary | 决定 App "看起来是谁",定义核心品牌识别度 |
| 第二层 | 表面色(Surface) | surface, surfaceVariant | 决定卡片、对话框、菜单等组件的底色 |
| 第三层 | 内容色(On-*) | onPrimary, onSurface, onSurfaceVariant | 决定文字和图标在对应背景上的可读性与对比度 |
| 第四层 | 容器色(Container) | primaryContainer, secondaryContainer | M3 新增的"浅色块"概念,替代了 M2 中泛滥的 alpha 叠加做法 |
为什么要分这么多层?因为深色模式不是"把白色变黑色"。一个专业的主题,浅色和深色模式下每个语义色都应该独立决策:
shell
// 同样是"强调色背景",深浅模式下差异巨大
// 浅色模式:primaryContainer = 浅蓝底 + 深蓝字
// 深色模式:primaryContainer = 深蓝底 + 浅蓝字
// 如果只用 alpha 叠加,做不到这种"色调反转"效果
2.2 Typography:字体即信息架构
很多团队对字体的态度是"默认就好",但这浪费了 Compose 提供的灵活度。Typography 不是 14 个字号定义,而是14 个语义角色:
kotlin
val AppTypography = Typography(
displayLarge, displayMedium, displaySmall, // 展示型标题(极少用)
headlineLarge, headlineMedium, headlineSmall, // 页面标题
titleLarge, titleMedium, titleSmall, // 组件标题
bodyLarge, bodyMedium, bodySmall, // 正文
labelLarge, labelMedium, labelSmall // 按钮文字、标签
)
// 使用时------语义驱动,自适应缩放
Text("标题", style = MaterialTheme.typography.headlineMedium)
这背后是 Material Design 的字体缩放梯度:从小到大不只是字号变化,还包括字重(weight)、行高(lineHeight)、字间距(letterSpacing)的联动调整。你不需要手工调------这是设计系统的价值。
三、Shapes 去哪了:M3 的形状管理哲学
理解了 M3 移除了全局 Shapes,下一个问题是:那我怎么保证全 App 圆角的一致性?
答案是用 Kotlin 对象自己管理。不通过 MaterialTheme,但依然可以定义一套项目级的形状令牌:
kotlin
// 项目级形状令牌:一次定义,全局引用
object AppShapes {
val extraSmall = RoundedCornerShape(4.dp) // 小标签、Chip
val small = RoundedCornerShape(8.dp) // 输入框、Snackbar
val medium = RoundedCornerShape(12.dp) // 卡片、对话框
val large = RoundedCornerShape(16.dp) // 底部弹窗
val extraLarge = RoundedCornerShape(24.dp) // 大面板
}
// 使用时直接引用
Card(shape = AppShapes.medium) { /* ... */ }
Button(shape = AppShapes.small) { /* ... */ }
这比 M2 的全局 Shapes 更灵活:你可以为不同组件选不同尺寸,而不是被迫统一。M3 的设计哲学是------主题负责色彩和字体(贯穿全局),形状由组件自行决定(按需取用)。
四、实战:从零搭建一套品牌主题
理论讲了这么多,动手写一个完整的主题。假设你要为一个叫"青橙 "的阅读 App 设计主题,品牌色是暖橙色系。
kotlin
// ========== 1. 定义色彩 ==========
private val QingChengLightColors = lightColorScheme(
primary = Color(0xFFE8751A),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDBC9),
onPrimaryContainer = Color(0xFF361100),
secondary = Color(0xFF755A4A),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFDBC9),
onSecondaryContainer = Color(0xFF2B170B),
tertiary = Color(0xFF006B5A),
onTertiary = Color(0xFFFFFFFF),
surface = Color(0xFFFFF8F6),
onSurface = Color(0xFF221914),
surfaceVariant = Color(0xFFF5DDD4),
onSurfaceVariant = Color(0xFF53433C),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF221914),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
outline = Color(0xFF85736C),
outlineVariant = Color(0xFFD7C1B9),
inverseSurface = Color(0xFF382E29),
inverseOnSurface = Color(0xFFFFEDE6),
inversePrimary = Color(0xFFFFB793),
)
// 深色模式:不是反转,而是"自底向上重新推导"
private val QingChengDarkColors = darkColorScheme(
primary = Color(0xFFFFB793),
onPrimary = Color(0xFF5A1E00),
primaryContainer = Color(0xFF7A2E00),
onPrimaryContainer = Color(0xFFFFDBC9),
secondary = Color(0xFFE6BEAA),
onSecondary = Color(0xFF432C1E),
secondaryContainer = Color(0xFF5C4233),
onSecondaryContainer = Color(0xFFFFDBC9),
tertiary = Color(0xFF5EDBC7),
onTertiary = Color(0xFF00382E),
surface = Color(0xFF1A1110),
onSurface = Color(0xFFF1DFD8),
surfaceVariant = Color(0xFF53433C),
onSurfaceVariant = Color(0xFFD7C1B9),
background = Color(0xFF1A1110),
onBackground = Color(0xFFF1DFD8),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
outline = Color(0xFFA08C84),
outlineVariant = Color(0xFF53433C),
inverseSurface = Color(0xFFF1DFD8),
inverseOnSurface = Color(0xFF382E29),
inversePrimary = Color(0xFFE8751A),
)
// ========== 2. 定义字体 ==========
private val QingChengTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
// ========== 3. 定义形状令牌(独立于 MaterialTheme) ==========
object QingChengShapes {
val extraSmall = RoundedCornerShape(4.dp)
val small = RoundedCornerShape(8.dp)
val medium = RoundedCornerShape(16.dp) // 阅读 App:圆角略大,更柔和
val large = RoundedCornerShape(24.dp)
val extraLarge = RoundedCornerShape(32.dp)
}
// ========== 4. 组装主题(M3 签名:只有 colorScheme + typography) ==========
@Composable
fun QingChengTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> QingChengDarkColors
else -> QingChengLightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = QingChengTypography,
content = content
)
}
// ========== 5. 使用示例 ==========
@Composable
fun BookCard(title: String, author: String, description: String) {
Card(
shape = QingChengShapes.medium, // 从自己的形状令牌取
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
关键点:
- 颜色通过
MaterialTheme.colorScheme.xxx引用 - 字体通过
MaterialTheme.typography.xxx引用 - 形状通过自己的
QingChengShapes对象引用
三者都在定义处集中管理,使用时只引用语义名、不硬编码数值------这是设计令牌(Design Token)思想的核心。
代码运行效果对比:

五、Material You:用户定义 App 外观
上一节的 QingChengTheme 已经内置了动态颜色的支持。当 dynamicColor = true 且系统在 Android 12+ 时,会调用 dynamicLightColorScheme(context) / dynamicDarkColorScheme(context),从用户的壁纸中提取种子色,自动生成整套 ColorScheme。
kotlin
// 这就是启用动态颜色的全部代码(已经在 QingChengTheme 里了)
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> QingChengDarkColors
else -> QingChengLightColors
}
代码很简单,但设计决策不简单。我认为有价值的做法是:dynamicColor 默认为 true,让大多数用户享受系统级一致性;同时保留品牌的 ColorScheme 作为 fallback------Android 11 及以下设备、或者用户关闭了动态颜色的场景下,App 依然有自己的视觉识别。品牌型 App 尤其不应该把所有视觉交给系统壁纸决定。
六、多主题切换的正确姿势
除了深色/浅色,你可能还需要"品牌主题 A / 品牌主题 B"的切换。常见错误做法是为每个主题单独写一套 Composable。正确做法是:
kotlin
// 1. 将主题配置抽象为数据
data class AppThemeConfig(
val colorScheme: ColorScheme,
val typography: Typography
)
// 2. 全局持有当前主题状态
object AppThemeManager {
private val _currentConfig = MutableStateFlow(
AppThemeConfig(
colorScheme = QingChengLightColors,
typography = QingChengTypography
)
)
val currentConfig: StateFlow<AppThemeConfig> = _currentConfig.asStateFlow()
fun switchToDark(dark: Boolean) {
_currentConfig.value = _currentConfig.value.copy(
colorScheme = if (dark) QingChengDarkColors else QingChengLightColors
)
}
}
// 3. Composable 中响应式消费
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val config by AppThemeManager.currentConfig.collectAsState()
MaterialTheme(
colorScheme = config.colorScheme,
typography = config.typography,
content = content
)
}
七、总结
回头看,这篇文章从头到尾其实在讲一件事:从"散落各地的颜色常量"升级到"有语义层级的设计令牌体系"。
三句话总结我的 M3 设计实践:
- 颜色和字体走主题,形状自己管。
MaterialTheme管colorScheme+typography,形状用独立的 Kotlin 对象定义。 - **语义优先于数值。**用
primaryContainer而不是"#FFDBC9",用titleMedium而不是"16sp Medium"。代码是给人读的。 - **深浅模式一起设计。**不要先写浅色再"反转"出深色。两个方案从同一个语义模型出发,独立推导。
这套方法论的收益不会立刻显现。但三个月后,当产品经理要求"整体调亮 10%"或者"深色模式对比度再降一点",你能在 5 分钟内改完而不影响任何组件的结构------这就是设计系统的价值。
八、参考资料
-
https://developer.android.google.cn/develop/ui/compose/designsystems/material3?hl=zh-cn
-
https://source.android.google.cn/docs/core/display/dynamic-color?hl=zh-cn
-
《Jetpack Compose 从入门到实战》
-
《Jetpack Compose Android 全新UI编程》