Jetpack Compose 之 CompositionLocal

743 阅读7分钟

CompositionLocal

Compose 中的“隐式向下传递数据”

要在 Jetpack Compose 里使用主题,只需保证 Compose 可组合函数在 MaterialTheme 作用域内被调用,这样我们就可以在组件中使用 MaterialTheme.colors.primary 等属性,从而读取主题信息。

@Composable
fun PrimaryBox() {
    Box(
        modifier = Modifier
            .background(MaterialTheme.colorScheme.primary)
            .size(50.dp)
    )
}

@Preview
@Composable
fun PrimaryBoxPreview() {
    MaterialTheme(colorScheme = lightColorScheme(primary = YELLOW)) {
        Row {
            PrimaryBox()

            MaterialTheme(colorScheme = lightColorScheme(primary = BLUE)) {
                Column {
                    PrimaryBox()
                    PrimaryBox()
                }
            }
        }
    }
}
PrimaryBox.png

写起来简单且合理对吧,但你有没有想过,这种在 Compose 中“隐式向下传递数据”背后的实现原理?

什么东西?...脑子有点懵,别说原理了,“隐式向下传递数据”指的是???

没关系,让我们一步一步慢慢捋捋。

在 Compose 中,每个组件都是一个 Composable 函数,它们被调用并生成一个个 LayoutNode,这些 LayoutNode 节点共同组成一颗 UI 树。

Compisition阶段.gif

通常情况下,数据以函数参数的形式在 UI 树中从上往下流动。 举个例子,假设有一个新闻列表页面:

NewsPage.png
@Composable
fun NewsPage(newsList: List<News>) {
    LazyColumn {
        items(newsList) { news ->
            NewsItem(news.title, news.category, news.coverUrl, news.readCount)
        }
    }
}

@Composable
fun NewsItem(title: String, category: String, coverUrl: String, readCount: Int) {
    Row {
        Column {
            Text(category) // 分类
        	Text(title) // 标题
        }
        Text(readCount) // 阅读量
        AsyncImage(model = coverUrl) // 封面
    }
}

界面数据以函数参数的形式,显式地在 UI 树中从上往下流动:

数据向下流动.jpg

主题也是数据,它又是怎么在 UI 树中流动的呢?

我们再回头看一下最初的代码:

@Composable
fun PrimaryBox() {
    Box(
        modifier = Modifier
            .background(MaterialTheme.colorScheme.primary)
            .size(50.dp)
    )
}

@Preview
@Composable
fun PrimaryBoxPreview() {
    MaterialTheme(colorScheme = lightColorScheme(primary = YELLOW)) {
        Row {
            PrimaryBox()
            MaterialTheme(colorScheme = lightColorScheme(primary = BLUE)) {
                Column {
                    PrimaryBox()
                    PrimaryBox()
                }
            }
        }
    }
}
隐式数据流动.jpg

虽然我们没有显式地将主题声明为各个组件的参数,使其在 UI 树中向下流动,但是从上图不难看出,MaterialTheme 会将主题数据隐式地向子树传递。

注意,这和全局变量不同,因为它是以树为作用范围,而不是全局的。

这是怎么做到的呢?说了半天终于要请出今天的主角了——CompositionLocal,这是一种通过组合(Composition)隐式向下传递数据的工具。对于那些使用非常广泛或频繁的数据,比如主题、上下文 Context、语言等,要将其作为函数参数显式传递给 UI 树的每个组件非常麻烦,同时我们也不希望将这些数据作为全局变量,因为它不够灵活,作用范围不能局限在 UI 树中的某个子树,这个时候 CompositionLocal 就派上用场了。

百闻不如一见,我决定用一个简单的例子来展示一下 CompositionLocal。

创建自己的 CompositionLocal

现在假设我们要创建一个最简化的主题,里面只有一个颜色 colorImportant,我们要在 UI 树中隐式向下传播这个颜色。

1. 创建 CompositionLocal 对象

// 1.调用 compositionLocalOf 创建一个 ProvidableCompositionLocal 对象,并提供默认值
val LocalColorImportant: ProvidableCompositionLocal<Color> =
    compositionLocalOf {  /* default value */ RED }

首先,调用 compositionLocalOf 方法创建一个 CompositionLocal 对象,通常情况下,我们会将变量命名为 "Local...",并且定义为顶级属性,因为我们希望所有组件都能访问到这个颜色。

在创建 CompositionLocal 对象的时候,我们可以提供一个默认值,这个默认值有什么用呢?回想一下上面 MaterialTheme 的例子,在 MaterialTheme 作用域内,所有组件都可以使用 MaterialTheme.colorScheme.primary 访问主题的 Primary 颜色,那如果在 MaterialTheme 作用域外,MaterialTheme.colorScheme.primary 会报错吗?还是会返回默认主题的 Primary 颜色?

@Preview
fun PrimaryBoxPreview() {
-    MaterialTheme(colorScheme = lightColorScheme(primary = YELLOW)) {
		Box(
			modifier = Modifier
				.background(MaterialTheme.colorScheme.primary)
				.size(50.dp)
		)
-    }
}

答案是不会报错,作为使用者,你不自行提供自定义主题,读取颜色时就会获取到 Material Design 默认主题的颜色,这就是 CompositionLocal 默认值的作用。

CompositionLocal默认值.png

当然,现在你作为 CompositionLocal 的提供者/设计者,可以根据实际需求自行决定是否提供默认值,如果你希望使用者必须自行为 CompositionLocal 提供值,那么可以不提供默认值,而是简单的抛错误:compositionLocalOf<T> { error("No default value provided") }

2. 提供并传递 CompositionLocal 值

@Preview
@Composable
fun ImportantBoxPreview() {
    Row {
        ImportantBox()
        // 2.使用 CompositionLocalProvider 向子组件传递 CompositionLocal 值
        CompositionLocalProvider(LocalColorImportant.provides(BLUE)) {
            ImportantBox()
            CompositionLocalProvider(
                LocalColorImportant provides YELLOW,
                // LocalXxx provides ...
            ) {
                ImportantBox()
            }
        }
    }
}

有了 CompositionLocal 对象之后,我们就可以调用 CompositionLocalProvider() 向子组件传递新的 CompositionLocal 值了。

@Composable
fun CompositionLocalProvider(
    vararg values: ProvidedValue<*>, 
    content: @Composable () -> Unit
)

CompositionLocalProvider 有一个参数 values,很明显它就是隐式向下传播的数据,由于我们可能会一次性向下传递多个数据,所以 values 是一个可变参数,它的类型是 ProvidedValue<*>,我们可以调用 ProvidableCompositionLocal 的中缀函数 provides 来创建 ProvidedValue 对象。

CompositionLocalProvider(LocalXXX provides 具体值) {
    /* content */
}

现在我们已经完成了 CompositionLocal 数据的隐式向下传递,CompositionLocalProvider 作用域中的所有子组件都可以访问到我们提供的值。

3. 读取 CompositionLocal 值

读取 CompositionLocal 的值非常简单,只需要使用 CompositionLocal 的 current 属性即可,它会返回 UI 树中最近父级 CompositionLocalProvider 提供的值,若没有找到,则返回默认值。

@Composable
fun ImportantBox() {
    Box(
        modifier = Modifier
        	// 3.通过 CompositionLocal 的 current 属性获取值
            .background(LocalColorImportant.current)
            .size(50.dp)
    )
}

完整代码

// 1.调用 compositionLocalOf 创建一个 ProvidableCompositionLocal 对象,并提供默认值
val LocalColorImportant: ProvidableCompositionLocal<Color> =
    compositionLocalOf {  /* default value */ RED }

@Preview
@Composable
fun ImportantBoxPreview() {
    Row {
        ImportantBox()
        // 2.使用 CompositionLocalProvider 向子组件传递 CompositionLocal 值
        CompositionLocalProvider(LocalColorImportant.provides(BLUE)) {
            ImportantBox()
            CompositionLocalProvider(LocalColorImportant provides YELLOW) {
                ImportantBox()
            }
        }
    }
}

@Composable
fun ImportantBox() {
    Box(
        modifier = Modifier
        	// 3.通过 CompositionLocal 的 current 属性获取值
            .background(LocalColorImportant.current)
            .size(50.dp)
    )
}
ImportantBoxPreview.png

你可以简单的把这个过程看作是:在 UI 树中的某个节点,存了一个或多个键值对形式的数据,这些数据会顺着子树往下流,在下游的某个子节点可以通过“键”拿到上游最近往这个键存进去的数据。CompositionLocal 就相当于键,而 CompositionLocalProvider(LocalXXX provides ...) 相当于存数据,LocalXXX.current 相当于取数据。

动态更新 CompositionLocal 值

我们还可以可以动态更新 CompositionLocal 的值,只需要在为 CompositionLocal 提供值的时候,提供一个 State 对象,后续当 State 对象发生变化时,读取了 CompositionLocal.current 的地方会自动进行重组。

val LocalColorImportant = compositionLocalOf { RED }

@Preview
@Composable
fun ImportantButtonPreview() {
    var importantColor by remember { mutableStateOf(BLUE) }
    // 给 CompositionLocal 提供传递一个 State 对象
    CompositionLocalProvider(LocalColorImportant provides importantColor) {
        Button(
            colors = ButtonDefaults.buttonColors(containerColor = LocalColorImportant.current),
            onClick = { importantColor = randomColor() } // 更新 State 对象
        ) { Text(text = "Random Important Color") }
    }
}

random important color.gif

利用这个特性可以轻松实现主题预览/换肤等功能。

compositionLocalOf VS staticCompositionLocalOf

除了 compositionLocalOf,还有一个非常类似的函数 staticCompositionLocalOf,同样可以创建一个 ProvidableCompositionLocal 对象,它们有什么区别呢?

前面提到,使用 compositionLocalOf,当 CompositionLocal 值发生变化时,只有读取了 CompositionLocal.current 的地方会自动进行重组。而如果使用 staticCompositionLocalOf,当 CompositionLocal 值发生变化时,整个 content 范围都会进行重组,无论组件是否读取了 CompositionLocal.current

我们来试验一下:

val LocalInt = compositionLocalOf<Int> { error("No default value provided for LocalInt") }

@Composable
fun CompositionLocalTest() {
    var counter by remember { mutableIntStateOf(0) }

    Button(onClick = { counter++ }) {
        Text(text = "+1")
    }

    CompositionLocalProvider(LocalInt provides counter) {
        Parent()
    }
}

@Composable
fun Parent() {
    Log.e("CompositionLocal", "Parent: ${LocalInt.current}")
    Child()
}

@Composable
fun Child() {
    Log.e("CompositionLocal", "Child")
}

这里创建一个简单的 LocalInt,点击时将提供的值 +1。可组合项 Parent 调用了 Child,前者在打印日志时读取了 LocalInt.current

compositionLocalOf.gif

通过 logcat 观察到,每次点击触发 LocalInt 更新,读取了 LocalInt.current 的可组合项 Parent 会发生重组,没有读取 LocalInt.current 的子节点 Child 则被跳过。

现在把 compositionLocalOf 换成 staticCompositionLocalOf 再试试:

- val LocalInt = compositionLocalOf<Int> { error("No default value provided for LocalInt") }
+ val LocalInt = staticCompositionLocalOf<Int> { error("No default value provided for LocalInt") }

staticCompositionLocalOf.gif

由于使用了 staticCompositionLocalOf,当 LocalInt 改变时,content 范围内的所有节点都会被重组(无论是否读取了 LocalInt)。

所以,到底应该在什么时候应该使用 staticCompositionLocalOf 呢?用它有什么好处吗?按官方的说法,当 CompositionLocal 的值极少发生改变或根本不会改变时,就应该使用 staticCompositionLocalOf,以此获得更好的性能。比如,我想设计一个 LocalApplicationContext,很明显,它在整个 app 的生命周期内都不会发生改变,那么我们就应该使用 staticCompositionLocalOf

val LocalApplicationContext = staticCompositionLocalOf<Context> { ... }

小结

CompositionLocal 是 Compose 中的一种数据传递机制,它可以让我们在 UI 树中隐式向下传递数据,而不需要显式地将数据作为参数传递给每个组件。使用方法非常简单,首先使用 compositionLocalOf 创建 CompositionLocal 对象,然后用 CompositionLocalProvider 为 CompositionLocal 提供具体的值,后续使用 CompositionLocal.current 读取值就 OK 了。当 CompositionLocal 很少改变或不会改变,则应该用 staticCompositionLocalOf 来优化性能。

另外,其实官方已经为我们提供许多常用的 CompositionLocal,比如 LocalContextLocalDensityLocalLifecycleOwner 等,大家可以自行探索。

Localxxx.png


参考