使用 CompositionLocal 实现局部作用域数据

CompositionLocal 是一种通过 Composition 隐式传递数据的工具。在本页中,您将详细了解什么是 CompositionLocal、如何创建自己的 CompositionLocal 以及 CompositionLocal 是否是您用例的合适解决方案。

介绍 CompositionLocal

通常在 Compose 中,数据向下流动 通过 UI 树作为参数传递给每个可组合函数。这使可组合的依赖项变得显式。但是,对于非常频繁和广泛使用的数据(如颜色或类型样式),这可能会很麻烦。请看以下示例

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

为了支持不需要将颜色作为显式参数依赖项传递给大多数可组合项,**Compose 提供了 CompositionLocal,它允许您创建树范围内的命名对象,这些对象可以用作一种隐式方式让数据流经 UI 树。**

CompositionLocal 元素通常在 UI 树的某个节点中提供一个值。该值可以被其可组合的后代使用,而无需在可组合函数中声明 CompositionLocal 作为参数。

CompositionLocal 是 Material 主题在幕后使用的。 MaterialTheme 是一个对象,它提供三个 CompositionLocal 实例:colorSchemetypographyshapes,允许您稍后在 Composition 的任何后代部分中检索它们。具体来说,这些是 LocalColorSchemeLocalShapesLocalTypography 属性,您可以通过 MaterialThemecolorSchemeshapestypography 属性访问它们。

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colorScheme.primary
    )
}

**CompositionLocal 实例的作用域为 Composition 的一部分**,因此您可以在树的不同级别提供不同的值。 current 的值 CompositionLocal 对应于 Composition 中该部分的祖先提供的最接近的值。

**要向 CompositionLocal 提供新值,请使用 CompositionLocalProvider** 及其 provides 中缀函数,该函数将 CompositionLocal 键与 value 关联起来。 CompositionLocalProvidercontent lambda 将在访问 CompositionLocalcurrent 属性时获取提供的值。当提供新值时,Compose 将重新组合读取 CompositionLocal 的 Composition 部分。

例如,LocalContentColor CompositionLocal 包含用于文本和图标的推荐内容颜色,以确保它与当前背景颜色形成对比。在以下示例中, CompositionLocalProvider 用于为 Composition 的不同部分提供不同的值。

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the error color now")
}

图 1. CompositionLocalExample 可组合项的预览。

在最后一个示例中, CompositionLocal 实例在内部由 Material 可组合项使用。要访问 CompositionLocal 的当前值,请使用其 current 属性。在以下示例中, Context 的当前值 LocalContext CompositionLocal 通常在 Android 应用程序中使用,用于格式化文本

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

创建您自己的 CompositionLocal

CompositionLocal 是一种**通过 Composition 隐式传递数据的工具**。

**使用 CompositionLocal 的另一个关键信号是参数是跨领域的,中间实现层应该不知道它的存在**,因为使这些中间层知道会限制可组合项的实用性。例如,查询 Android 权限在幕后由 CompositionLocal 提供。媒体选择器可组合项可以添加新功能以访问设备上受保护的许可内容,而无需更改其 API 并要求媒体选择器的调用者了解从环境中使用的此附加上下文。

但是, CompositionLocal 并不总是最佳解决方案。我们不鼓励过度使用 CompositionLocal,因为它有一些缺点

**CompositionLocal 使可组合项的行为更难推理**。因为它们创建了隐式依赖项,所以使用它们的函数的调用者需要确保每个 CompositionLocal 的值都得到满足。

此外,此依赖项可能没有明确的真相来源,因为它可以在 Composition 的任何部分发生变异。因此,**当出现问题时调试应用程序可能更具挑战性**,因为您需要导航到 Composition 的顶部以查看 current 值在哪里提供。诸如 IDE 中的查找用法Compose 布局检查器 之类的工具提供了足够的信息来缓解此问题。

决定是否使用 CompositionLocal

有一些条件可以使 CompositionLocal 成为您用例的合适解决方案

**CompositionLocal 应该有一个好的默认值**。如果没有默认值,您必须保证开发人员很难进入没有为 CompositionLocal 提供值的情况。不提供默认值会导致问题和挫折,在创建测试或预览使用该 CompositionLocal 的可组合项时,总是需要显式提供它。

**避免将 CompositionLocal 用于不被认为是树范围或子层次结构范围的概念**。当 CompositionLocal 可能被任何后代使用(而不是其中的一部分)时,它才有意义。

如果您的用例不满足这些要求,请在创建 CompositionLocal 之前查看 备选方案 部分。

不良实践的一个例子是创建一个 CompositionLocal 来保存特定屏幕的 ViewModel,以便该屏幕中的所有可组合项都可以获得对 ViewModel 的引用以执行某些逻辑。这是一个不好的做法,因为特定 UI 树下的并非所有可组合项都需要了解 ViewModel。良好的做法是仅将可组合项所需的信息传递给可组合项,遵循 状态向下流动,事件向上流动 的模式。这种方法将使您的可组合项更具可重用性,更易于测试。

创建 CompositionLocal

有两种 API 可以创建 CompositionLocal

  • compositionLocalOf:更改重新组合期间提供的值仅会使读取其 current 值的内容失效。

  • staticCompositionLocalOf:与 compositionLocalOf 不同,Compose 不会跟踪 staticCompositionLocalOf 的读取。更改值会导致提供 CompositionLocal 的整个 content lambda 重新组合,而不是仅重新组合在 Composition 中读取 current 值的位置。

如果提供给 CompositionLocal 的值不太可能改变或永远不会改变,请使用 staticCompositionLocalOf 来获得性能优势。

例如,应用程序的设计系统可能对使用阴影为 UI 组件抬高可组合项的方式有自己的想法。由于应用程序的不同抬高高度应该传播到整个 UI 树,因此我们使用 CompositionLocal。由于 CompositionLocal 值是根据系统主题有条件地派生的,因此我们使用 compositionLocalOf API

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

CompositionLocal 提供值

**CompositionLocalProvider 可组合项将值绑定到给定层次结构的 CompositionLocal 实例**。要向 CompositionLocal 提供新值,请使用 provides 中缀函数,该函数将 CompositionLocal 键与 value 关联起来,如下所示

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

使用 CompositionLocal

CompositionLocal.current 返回由最接近的 CompositionLocalProvider 提供的值,该 CompositionLocalProvider 向该 CompositionLocal 提供值

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

备选方案

对于某些用例来说,CompositionLocal 可能是一个过于复杂的解决方案。如果您的用例不符合决定是否使用 CompositionLocal部分中指定的标准,那么其他解决方案可能更适合您的用例。

传递显式参数

明确可组合项的依赖关系是一个好习惯。我们建议您**仅传递可组合项所需的内容**。为了鼓励可组合项的解耦和重用,每个可组合项应该尽可能少地持有信息。

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

控制反转

另一种避免将不必要的依赖项传递给可组合项的方法是通过控制反转。不是让子级接受依赖项来执行某些逻辑,而是由父级来执行。

请看以下示例,其中子级需要触发请求以加载某些数据

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

根据具体情况,MyDescendant 可能承担了很多责任。此外,将 MyViewModel 作为依赖项传递会导致 MyDescendant 的可重用性降低,因为它们现在耦合在一起。考虑一下不将依赖项传递给子级并使用控制反转原则的替代方案,该原则使祖级负责执行逻辑

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

这种方法可能更适合某些用例,因为它**使子级与其直接祖级解耦**。祖级可组合项往往会变得更加复杂,以利于更灵活的低级别可组合项。

类似地,**@Composable 内容 Lambda 可以以相同的方式使用,以获得相同的好处**

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}