A
第 52 章KOTLIN40 分钟

Compose 主题与 Material 3

Material 3 主题体系在 Compose 中的落地:MaterialTheme 的颜色/字体/形状三大支柱、lightColorScheme/darkColorScheme、Android 12+ 动态颜色、自定义字体 Typography、Shapes 形状、自定义主题与组件级主题覆盖。

学习目标

  • 理解 MaterialTheme 的颜色/字体/形状三大支柱
  • 掌握 lightColorScheme/darkColorScheme 与暗色模式切换
  • 启用 Android 12+ 动态颜色并适配老版本回退
  • 自定义 Typography 接入自定义字体
  • 自定义 Shapes 与组件级主题覆盖

学习目标

  • 理解 MaterialTheme 的颜色/字体/形状三大支柱;
  • 掌握 lightColorScheme/darkColorScheme 与暗色模式切换;
  • 启用 Android 12+ 动态颜色并适配老版本回退;
  • 自定义 Typography 接入自定义字体;
  • 自定义 Shapes 与组件级主题覆盖。

Compose 主题与 Material 3

Compose 用 MaterialTheme 统一管理“颜色 + 字体 + 形状”三套设计系统参数。所有 Material 3 组件(Button/Card/TopAppBar…)都从 MaterialTheme 读取这些参数,因此改主题就能全局生效。

依赖配置

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
    implementation(composeBom)
    implementation("androidx.compose.material3:material3")
    // 字体加载用 Accompanist 或 Compose 1.6+ 原生
    implementation("androidx.compose.ui:ui-text-googlefonts")
}

MaterialTheme 的三大支柱

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.Typography
import androidx.compose.material3.Shapes

@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = ...,   // ColorScheme
        typography = ...,    // Typography
        shapes = ...,        // Shapes
        content = content
    )
}

子树中任意位置可通过 MaterialTheme.colorScheme.primary、MaterialTheme.typography.bodyLarge、MaterialTheme.shapes.medium 读取当前主题。

ColorScheme:浅色与深色

ColorScheme 定义了一组 Material 3 角色色板:primary/onPrimary/primaryContainer、secondary、tertiary、background/onBackground、surface/onSurface 等。Compose 提供 lightColorScheme() 与 darkColorScheme() 两个构造器:

private val LightColors = lightColorScheme(
    primary = Color(0xFF6750A4),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFFEADDFF),
    onPrimaryContainer = Color(0xFF21005D),
    secondary = Color(0xFF625B71),
    background = Color(0xFFFFFBFE),
    surface = Color(0xFFFFFBFE),
    onSurface = Color(0xFF1C1B1F),
    error = Color(0xFFBA1A1A),
    onError = Color(0xFFFFFFFF)
)

private val DarkColors = darkColorScheme(
    primary = Color(0xFFD0BCFF),
    onPrimary = Color(0xFF381E72),
    primaryContainer = Color(0xFF4F378B),
    onPrimaryContainer = Color(0xFFEADDFF),
    secondary = Color(0xFFCCC2DC),
    background = Color(0xFF1C1B1F),
    surface = Color(0xFF1C1B1F),
    onSurface = Color(0xFFE6E1E5),
    error = Color(0xFFF2B8B5),
    onError = Color(0xFF601410)
)

Material 3 设计原则:on* 是“放在对应颜色上的前景色”,对比度必须达到 WCAG AA。改色时务必同时调整 on*,否则文字看不清。

跟随系统暗色模式

import androidx.compose.foundation.isSystemInDarkTheme

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) DarkColors else LightColors
    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

应用入口:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                AppNavHost()
            }
        }
    }
}

Material 3 默认组件已包含“暗色模式自动适配”——只要 ColorScheme 提供完整,Button、Card、TopAppBar 都会自动用对应颜色。

动态颜色(Dynamic Color,Android 12+)

Android 12+ 引入“动态颜色”:系统从壁纸提取主色,自动生成主题色板。Compose 通过 dynamicLightColorScheme(context) / dynamicDarkColorScheme(context) 获取:

import android.os.Build
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.dynamicDarkColorScheme

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    context: Context = LocalContext.current,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}

要点:

  • 仅 Android 12+(API 31+)支持,低版本回退到自定义 ColorScheme;
  • 建议让用户在设置里关闭动态颜色(部分品牌 OEM 也有自己的动态颜色实现);
  • 动态颜色优先于“品牌色”,如果你的 App 有强品牌识别需求,可以 dynamicColor = false。

Typography:自定义字体

Typography 由 Material 3 字阶(display/headline/title/body/label,每档 Large/Medium/Small)组成。默认用 Roboto,可整体替换:

资源方式

把 .ttf 放到 res/font/:

import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.text.googlefonts.GoogleFont.Provider
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.Font

val provider = GoogleFont.Provider(
    providerAuthority = "com.google.android.gms.fonts",
    providerPackage = "com.google.android.gms",
    queries = listOf("Poppins")
)

val AppFontFamily = FontFamily(
    Font(R.font.poppins_regular),
    Font(R.font.poppins_medium, FontWeight.Medium),
    Font(R.font.poppins_bold, FontWeight.Bold)
)

val AppTypography = Typography(
    displayLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 57.sp),
    headlineMedium = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 28.sp),
    bodyLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp),
    bodyMedium = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp),
    labelLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp)
)

只覆盖必要的几档,其余用默认值。

@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
    MaterialTheme(typography = AppTypography, content = content)
}

自定义字体通常需要 CJK 子集 才能完整覆盖中文。常用做法:西文用 Poppins,中文回退系统字体。可在 TextStyle 里写 fontFamily = FontFamily(AppFontFamily, FontFamily.Default)。

Shapes:形状定义

Shapes 定义小/中/大三种圆角,组件按尺寸自动选用:

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes

val AppShapes = Shapes(
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(16.dp)
)
MaterialTheme(shapes = AppShapes, content = content)

Card 默认用 medium,Button 用 small,BottomSheet 用 large。统一形状能让设计语言一致。

自定义主题:完整示例

把以上要素整合成一个完整主题:

private val LightColors = lightColorScheme(
    primary = Color(0xFF00696D),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFF6CF6FE),
    onPrimaryContainer = Color(0xFF002022),
    secondary = Color(0xFF4A6363),
    onSecondary = Color(0xFFFFFFFF),
    background = Color(0xFFF4FBFA),
    onBackground = Color(0xFF161D1D),
    surface = Color(0xFFF4FBFA),
    onSurface = Color(0xFF161D1D),
    error = Color(0xFFBA1A1A),
    onError = Color(0xFFFFFFFF)
)
private val DarkColors = darkColorScheme(
    primary = Color(0xFF4AD9E2),
    onPrimary = Color(0xFF00373A),
    primaryContainer = Color(0xFF004F53),
    onPrimaryContainer = Color(0xFF6CF6FE),
    secondary = Color(0xFFB1CCCB),
    background = Color(0xFF0E1514),
    surface = Color(0xFF0E1514),
    onSurface = Color(0xFFB1CCCB),
    error = Color(0xFFFFB4AB)
)

val AppFontFamily = FontFamily(
    Font(R.font.poppins_regular),
    Font(R.font.poppins_medium, FontWeight.Medium),
    Font(R.font.poppins_bold, FontWeight.Bold)
)

val AppTypography = Typography(
    bodyLarge = TextStyle(fontFamily = AppFontFamily, fontSize = 16.sp, lineHeight = 24.sp),
    headlineLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp)
)

val AppShapes = Shapes(
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(16.dp),
    large = RoundedCornerShape(28.dp)
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val ctx = LocalContext.current
    val scheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(
        colorScheme = scheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

组件级主题覆盖

Material 3 允许在 MaterialTheme 之外,单独覆盖某类组件的默认样式:

import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults

@Composable
fun BrandedCard(content: @Composable ColumnScope.() -> Unit) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primaryContainer,
            contentColor = MaterialTheme.colorScheme.onPrimaryContainer
        ),
        shape = MaterialTheme.shapes.large,
        modifier = Modifier.fillMaxWidth()
    ) {
        content()
    }
}

更系统化的做法是给整个 App 提供 ButtonColors/CardColors 等的统一对象:

object AppComponents {
    @Composable
    fun primaryButtonColors() = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.primary,
        contentColor = MaterialTheme.colorScheme.onPrimary
    )
}

Compose Material 3 暂未提供像 View 体系那样的 MaterialComponents 统一覆盖 attribute,更推荐的做法是写一层薄包装(如上)让所有页面调用同一套预设。

状态栏与系统栏适配

主题切换通常还要同步状态栏图标颜色:

import androidx.core.view.WindowCompat
import androidx.compose.ui.graphics.luminance

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContent {
            val darkTheme = isSystemInDarkTheme()
            MyAppTheme(darkTheme) {
                val barColor = Color.Transparent
                SideEffect {
                    val controller = WindowCompat.getInsetsController(window, window.decorView)
                    controller.isAppearanceLightStatusBars = !darkTheme
                    controller.isAppearanceLightNavigationBars = !darkTheme
                }
                AppNavHost()
            }
        }
    }
}

SideEffect 在每次重组完成、提交到屏幕之前执行一次,适合同步 Compose 状态到 View 层(如 Window)。

实战:主题切换 + 持久化

把暗色模式选项存到 DataStore/SharedPreferences,启动时恢复:

class ThemeViewModel(private val prefs: SharedPreferences) : ViewModel() {
    private val _dark = MutableStateFlow(prefs.getBoolean("dark", false))
    val dark: StateFlow<Boolean> = _dark

    fun setDark(v: Boolean) {
        _dark.value = v
        prefs.edit().putBoolean("dark", v).apply()
    }
}

@Composable
fun Root(themeVm: ThemeViewModel = viewModel()) {
    val dark by themeVm.dark.collectAsStateWithLifecycle()
    MyAppTheme(darkTheme = dark, dynamicColor = false) {
        Scaffold { padding ->
            Column(Modifier.padding(padding)) {
                Text("暗色:$dark")
                Button(onClick = { themeVm.setDark(!dark) }) { Text("切换") }
            }
        }
    }
}

常见坑与最佳实践

  1. 忘记包装 MaterialTheme:根 Composable 直接调用 Button 等会拿到默认主题,与设计不符。务必在 setContent { MyAppTheme { ... } } 包一层。
  2. 只改 primary 忘记 onPrimary:背景变了文字没变,对比度不足。每个 *Container 都要配套 on*Container。
  3. 动态颜色在低版本直接调用:dynamicLightColorScheme 在 API 低于 31 上抛异常。必须先判 SDK_INT。
  4. 自定义字体没声明混排回退:Poppins 不含中文,单独使用会显示方块。FontFamily(Font(R.font.poppins), FontFamily.Default) 才能正常回退。
  5. Typography 只改 bodyLarge:其他档仍用 Roboto,与品牌字体不一致。至少覆盖 headline/title/label 这几档常用项。
  6. 系统栏图标颜色没同步:暗色模式下状态栏图标仍是黑色,看不见。要在 SideEffect 里更新 WindowInsetsControllerCompat。
  7. Shapes 圆角过大导致点击区域错觉:large 圆角接近全圆时,矩形按钮点击区与视觉区严重不符,影响可达性。
  8. 多套主题用 if/else 嵌套 MaterialTheme:嵌套 MaterialTheme 会让内层取不到外层 shapes/typography。需要局部切换颜色时只重写 colorScheme,shapes/typography 透传。
  9. 在深色模式用纯黑背景:Material 3 推荐“深色但带主色调”的背景(如 #1C1B1F),纯黑会让卡片层次感丢失。
  10. 状态栏透明却没处理 WindowInsets:内容会被系统栏遮挡。用 Modifier.windowInsetsPadding(WindowInsets.statusBars) 或 Scaffold 自动处理。

章节小结

本章覆盖了 Material 3 在 Compose 中的主题实现:MaterialTheme 统一管理 ColorScheme/Typography/Shapes 三大支柱;lightColorScheme/darkColorScheme + isSystemInDarkTheme() 实现跟随系统的暗色模式;Android 12+ 的 dynamicLightColorScheme/dynamicDarkColorScheme 启用动态颜色并保留低版本回退;Typography 与自定义 FontFamily 接入品牌字体;Shapes 统一圆角语言;通过组件级 *Defaults.*Colors 做局部覆盖;SideEffect 同步系统栏外观,让主题切换完整闭环。

下一章预告

下一章是本部分也是全书的最后一章:Compose UI 测试(createComposeRule/onNodeWithText/performClick/assertIsDisplayed)、AndroidView 互操作测试、ComposeView 在 Fragment/Activity 中的使用、迁移策略与性能最佳实践,并给出全书结语与下一步学习建议。