返回

Compose 技巧:优雅处理亮暗主题自定义属性

Android

Jetpack Compose 主题化:为亮暗主题设置不同的自定义属性值

咱们在用 Jetpack Compose 开发界面时,经常需要根据当前是亮色主题还是暗色主题,来显示不同的颜色、尺寸或者其他样式。MaterialThemeColorScheme 帮我们处理了大部分基础控件的样式切换,这很方便。

但问题来了,如果我们想定义一些 自定义 的属性,比如一个特殊的背景色 mySpecialBackground,或者一个特定的边框宽度 myBorderWidth,并希望它们也能随着主题(亮/暗)自动切换,该怎么做呢?

直接尝试像下面这样往 lightColorSchemedarkColorScheme 里加自定义字段是行不通的:

// 这样做会报错,ColorScheme 没有 myAttr 这个参数
// private val LightColorScheme = lightColorScheme(
//     // ... 标准颜色
//     myAttr = Color(0xFF6650a4),
// )

// private val DarkColorScheme = darkColorScheme(
//     // ... 标准颜色
//     myAttr = Color(0xFF459867)
// )

因为 lightColorSchemedarkColorScheme 函数的参数是预先定义好的,专门用来设置 Material Design 规范里的那些颜色角色(如 primary, background, surface 等)。它们的设计目标不是让你随意添加自定义键值对。

问题根源:ColorScheme 的固定结构

ColorScheme 是 Material Design 颜色系统在 Compose 中的具体实现。它包含了一系列具有特定语义的颜色槽位。你的 App 界面元素,特别是 Material 组件(Button, Card, TextField 等),会默认从 MaterialTheme.colorScheme 读取对应的颜色值来绘制自己。

这种固定结构保证了系统的一致性,但也意味着你不能直接往里面塞东西。你需要一个更灵活的机制来扩展主题,传递自定义数据。

解决方案:扩展你的主题系统

解决这个问题的核心思路是:利用 CompositionLocal 来创建和传递你自己的、包含自定义属性的主题数据结构。

方案一:使用 CompositionLocal 创建自定义主题属性 (推荐)

这是最干净、最符合 Compose 设计思想的做法。它允许你把自定义的主题值(不只是颜色,可以是任何类型)跟标准 MaterialTheme 一起向下传递给 Composable 树。

原理和作用

CompositionLocal 是 Compose 提供的一种机制,用于在 Composable 树中隐式地传递数据,避免了层层手动传递参数的麻烦。你可以定义一个 CompositionLocal,然后在顶层(通常是你的主题 Composable 函数里)提供一个值。之后,在树中任何位置的子 Composable 都可以方便地通过 .current 访问到这个值。

利用这个特性,我们可以:

  1. 定义一个包含自定义属性的数据类。
  2. 为亮色和暗色主题分别创建这个数据类的实例。
  3. 创建一个 CompositionLocal 来持有这个数据类的实例。
  4. 在你的主题 Composable 函数里,根据当前是亮色还是暗色,通过 CompositionLocalProvider 提供对应的主题数据实例。
  5. 在需要使用自定义属性的 Composable 里,直接通过 CompositionLocal 访问当前主题下的值。

操作步骤

1. 定义包含自定义属性的数据结构

创建一个类(通常用 data class)来存放你的自定义主题值。它可以包含颜色、尺寸、形状、甚至是字体样式等。

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

// 定义你的自定义属性集合
@Immutable // 标记为不可变,有助于 Compose 优化
data class ExtendedColors(
    val mySpecialBackground: Color,
    val success: Color,
    val warning: Color,
    // 你可以添加其他类型的属性
    val specialBorderWidth: Dp = 1.dp
)

// 为亮色主题定义具体的值
val lightExtendedColors = ExtendedColors(
    mySpecialBackground = Color(0xFFF0F0F0), // 亮模式下的特殊背景色
    success = Color(0xFF4CAF50),          // 成功状态颜色
    warning = Color(0xFFFF9800),          // 警告状态颜色
    specialBorderWidth = 1.5.dp           // 亮模式下的边框宽度
)

// 为暗色主题定义具体的值
val darkExtendedColors = ExtendedColors(
    mySpecialBackground = Color(0xFF303030), // 暗模式下的特殊背景色
    success = Color(0xFF81C784),          // 成功状态颜色 (通常暗模式下更亮)
    warning = Color(0xFFFFB74D),          // 警告状态颜色 (通常暗模式下更亮)
    specialBorderWidth = 2.dp             // 暗模式下的边框宽度
)
  • @Immutable 注解 : 告诉 Compose 编译器这个类及其属性是不可变的。如果所有属性都是稳定类型(如 Color, Dp, Float, 原始类型等),这能帮助 Compose 跳过不必要的重组,提升性能。
  • 属性类型 : 可以是 Color, Dp, Float, Int, 甚至是 TextStyle 或其他你自定义的 @Immutable 类。

2. 创建 CompositionLocal

使用 staticCompositionLocalOfcompositionLocalOf 来创建一个 CompositionLocal 实例。staticCompositionLocalOf 性能稍好,但要求提供的值绝不能在组合过程中改变(对于主题切换是安全的)。

import androidx.compose.runtime.staticCompositionLocalOf

// 创建 CompositionLocal,需要提供一个默认值
// 如果在没有 Provider 的情况下访问,会得到这个默认值
// 这里我们提供 lightExtendedColors 作为默认值,
// 或者你可以提供一个抛出错误的 lambda,强制要求必须提供值
val LocalExtendedColors = staticCompositionLocalOf {
    lightExtendedColors
    // 或者,如果你想强制使用者必须在 Theme 中提供值:
    // error("No ExtendedColors provided")
}

3. 在主题 Composable 中提供值

修改你的应用主题 Composable 函数(通常叫 MyAppTheme 或类似名字)。在这个函数内部,根据 darkTheme 参数的值,使用 CompositionLocalProvider 来提供对应的自定义属性实例(lightExtendedColorsdarkExtendedColors)。

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme // 标准的 lightColorScheme
import androidx.compose.material3.darkColorScheme  // 标准的 darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color // 假设你定义了标准颜色

// --- 假设你已经定义了标准的亮暗 ColorScheme ---
private val LightColors = lightColorScheme(
    primary = Color(0xFF6200EE),
    secondary = Color(0xFF03DAC6),
    background = Color.White,
    surface = Color.White,
    // ... 其他标准颜色
)

private val DarkColors = darkColorScheme(
    primary = Color(0xFFBB86FC),
    secondary = Color(0xFF03DAC6),
    background = Color(0xFF121212),
    surface = Color(0xFF121212),
    // ... 其他标准颜色
)
// --- 标准 ColorScheme 定义结束 ---


@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(), // 判断是亮色还是暗色
    content: @Composable () -> Unit
) {
    // 1. 选择标准 ColorScheme
    val colorScheme = if (darkTheme) DarkColors else LightColors

    // 2. 选择自定义的 ExtendedColors 实例
    val extendedColors = if (darkTheme) darkExtendedColors else lightExtendedColors

    // 3. 使用 CompositionLocalProvider 包裹 MaterialTheme
    //    在这里提供你的自定义颜色实例
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        // 4. 应用标准的 MaterialTheme
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography, // 假设你有 Typography 定义
            shapes = Shapes,       // 假设你有 Shapes 定义
            content = content      // 这里是你的 App UI 内容
        )
    }
}

4. 在 Composable 中使用自定义属性

现在,在你的任何 Composable 函数(只要它位于 MyAppThemecontent 内部),你都可以像访问 MaterialTheme.colorScheme 一样,通过 LocalExtendedColors.current 来访问你的自定义属性。

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun MyCustomCard() {
    // 通过 .current 获取当前主题下的 ExtendedColors 实例
    val currentExtendedColors = LocalExtendedColors.current

    Box(
        modifier = Modifier
            .padding(16.dp)
            .background(currentExtendedColors.mySpecialBackground) // 使用自定义背景色
            .border(
                width = currentExtendedColors.specialBorderWidth, // 使用自定义边框宽度
                color = MaterialTheme.colorScheme.primary // 也可以混合使用标准颜色
            )
            .padding(16.dp)
    ) {
        Text(
            text = "Hello Custom Theme!",
            // 你甚至可以在 ExtendedColors 里定义 TextStyle 或直接用 Color
            color = if (isSystemInDarkTheme()) currentExtendedColors.success else currentExtendedColors.warning
        )
    }
}

// 在你的 App 主界面或者任何地方使用 MyCustomCard
@Composable
fun MainScreen() {
    MyAppTheme { // 确保被 MyAppTheme 包裹
        MyCustomCard()
    }
}

当系统主题(或 darkTheme 参数)切换时,MyAppTheme 会重组,CompositionLocalProvider 会提供新的 ExtendedColors 实例,而 MyCustomCard 中访问 LocalExtendedColors.current 就会自动得到更新后的值,从而实现样式的切换。

额外的安全和性能建议

  • 不可变性 (@Immutable) : 尽量确保你的自定义主题数据类及其所有属性都是不可变的。这对于 Compose 的性能优化至关重要。使用 val,并确保集合类型(如 List, Map)也是不可变的(例如,使用 ImmutableList from kotlinx.collections.immutable)。
  • 默认值策略 : 仔细考虑 staticCompositionLocalOf 的默认值。
    • 提供一个有意义的默认值(比如 lightExtendedColors)可以让你在没有 MyAppTheme 包裹的情况下(例如在预览 Composable 时)也能渲染,但可能隐藏忘记包裹主题的问题。
    • 使用 error("...") 作为默认值可以强制开发者必须在 MyAppTheme 中提供值,否则运行时会崩溃,有助于早期发现问题。选择哪个取决于你的项目需求和团队规范。
  • 组织结构 : 如果自定义属性很多,可以考虑将它们分组到不同的数据类和 CompositionLocal 中(例如 LocalExtendedColors, LocalExtendedDimensions, LocalExtendedTypography)。这有助于保持代码清晰和模块化。
// 进阶:组织多个自定义主题扩展

@Immutable
data class ExtendedDimensions(
    val largePadding: Dp = 24.dp,
    val mediumPadding: Dp = 16.dp,
    // ... 其他尺寸
)
val lightExtendedDimensions = ExtendedDimensions() // 假设亮暗尺寸相同
val darkExtendedDimensions = ExtendedDimensions(largePadding = 26.dp) // 或不同

val LocalExtendedDimensions = staticCompositionLocalOf { lightExtendedDimensions }

@Immutable
data class ExtendedTypography(
    val specialCaption: TextStyle = TextStyle(/*...*/)
    // ... 其他字体样式
)
// ... 定义亮暗 ExtendedTypography 实例 ...
val LocalExtendedTypography = staticCompositionLocalOf { /* ... */ }

// 在 MyAppTheme 中提供所有的 CompositionLocal
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColors else LightColors
    val extendedColors = if (darkTheme) darkExtendedColors else lightExtendedColors
    val extendedDimensions = if (darkTheme) darkExtendedDimensions else lightExtendedDimensions
    // ... 选择 extendedTypography ...

    CompositionLocalProvider(
        LocalExtendedColors provides extendedColors,
        LocalExtendedDimensions provides extendedDimensions,
        // LocalExtendedTypography provides ...
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            shapes = Shapes,
            content = content
        )
    }
}

// 使用时:
@Composable
fun AnotherComponent() {
    val colors = LocalExtendedColors.current
    val dimensions = LocalExtendedDimensions.current
    // val typography = LocalExtendedTypography.current

    Box(Modifier.padding(dimensions.mediumPadding).background(colors.success)) {
        // ...
    }
}

方案二:在 Composable 内部直接判断 (不推荐,仅限极少数情况)

这种方法比较直接,但通常不推荐用于定义主题级别的属性,因为它破坏了主题的封装性,并且容易导致代码重复和不一致。

原理和作用

直接在需要根据主题改变样式的 Composable 内部,使用 isSystemInDarkTheme() 或传递进来的 darkTheme 布尔值来判断当前主题,然后手动选择不同的值。

代码示例

import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme // 依然可以获取标准颜色
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color

@Composable
fun WarningBanner(message: String) {
    val isDark = isSystemInDarkTheme()

    // 直接在 Composable 内部根据主题选择颜色
    val backgroundColor = if (isDark) Color(0xFFCF6679) else Color(0xFFB00020) // 假设是错误警告色
    val textColor = if (isDark) Color.Black else Color.White

    Box(modifier = Modifier.background(backgroundColor)) {
        Text(text = message, color = textColor)
    }
}

缺点分析

  • 违反关注点分离 : 主题逻辑(什么颜色用于什么模式)散布在各个 UI 组件中,而不是集中在主题定义里。
  • 代码重复 : 如果多个地方需要用到同一个主题相关的自定义值,你就得在每个地方都写一遍 if (isDark) ... else ... 的逻辑。
  • 难以维护 : 当你需要修改某个自定义主题色时,可能需要查找并修改散落在各处的代码。
  • 可测试性差 : UI 组件直接依赖了 isSystemInDarkTheme() 这个全局状态,或者需要手动传递 darkTheme 标志,单元测试时可能需要额外处理。

这种方法可能只适用于极其个别、一次性的、且确实不适合放入全局主题的场景。对于系统性的、需要在多处复用的自定义主题属性,强烈推荐使用 CompositionLocal 的方案 。它更符合 Compose 的声明式和组合式思想,使得主题扩展更优雅、更易于管理。