Jetpack Compose 主题与样式

本文是 Jetpack Compose 系列的第 12 篇。前 11 篇我们聊了声明式 UI 的思维方式、状态管理、副作用等话题。但如何让 App 看起来"专业"------不是靠单个控件的花哨,而是一套统一的、可扩展的视觉语言------这是今天要讨论的核心。

代码环境:

Kotlin 2.2.10 + AGP 9.1.1 + Android Studio Panda 4

一、前言

在传统的 Android View 开发中,样式管理 是一件让人头疼的事------styles.xmlcolors.xmlthemes.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.ktTheme.ktType.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 里。CardCardDefaults.shapeButtonButtonDefaults.shape,各自独立。你可以自己定义一套项目级的形状常量,但不再通过 MaterialTheme 注入。

kotlin 复制代码
// 带 Shapes 的 MaterialTheme
@Composable
fun MaterialTheme(
    colorScheme: ColorScheme,
    typography: Typography,
    shapes: Shapes, // ⭐
    content: @Composable () -> Unit
)

下面来认识下左膀右臂。

2.1 ColorTheme:语义化色彩系统

初学者常有这样的直觉:primary 就是主色,secondary 就是辅色。但这太表面了。M3 的 ColorScheme25+ 个颜色属性,真正重要的是理解它的语义分层

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 设计实践:

  1. 颜色和字体走主题,形状自己管。 MaterialThemecolorScheme + typography,形状用独立的 Kotlin 对象定义。
  2. **语义优先于数值。**用 primaryContainer 而不是 "#FFDBC9",用 titleMedium 而不是 "16sp Medium"。代码是给人读的。
  3. **深浅模式一起设计。**不要先写浅色再"反转"出深色。两个方案从同一个语义模型出发,独立推导。

这套方法论的收益不会立刻显现。但三个月后,当产品经理要求"整体调亮 10%"或者"深色模式对比度再降一点",你能在 5 分钟内改完而不影响任何组件的结构------这就是设计系统的价值。

八、参考资料

九、往期系列文章

相关推荐
Sirens.9 天前
从参考 iCost 到做自己的 OneLedger:一个 Android 本地记账 App 的开发记录
android·kotlin·room·jetpack compose·记账 app
换元不配限13 天前
Jetpack Compose 副作用
compose·jetpack compose·附带效应·副作用·launchedeffect·sideeffect·composable生命周期
换元不配限19 天前
Jetpack Compose 状态管理指南
android·状态管理·compose·状态·单向数据流·jetpack compose·状态容器
fundroid2 个月前
Google I/O 2026 | Android 全面进化:从操作系统到“智能中枢”
android·jetpack compose·google i/o 2026
千码君20164 个月前
kotlin:Jetpack Compose 给APP添加声音(点击音效/背景音乐)
android·开发语言·kotlin·音效·jetpack compose
特立独行的猫a6 个月前
腾讯Kuikly多端框架(KMP)实战:轮播图的完整实现
android·harmonyos·轮播图·jetpack compose·kuikly
川峰7 个月前
Jetpack Compose:实现图片的 Pinch Zoom 从未如此简单
图片缩放·jetpack compose·transformable
人间有清欢1 年前
Android开发补充内容
android·okhttp·rxjava·retrofit·hilt·jetpack compose
wangz761 年前
kotlin、jetpack compose、Android加速度传感器调用
android·kotlin·jetpack compose·加速度传感器