文章

Jetpack Compose 笔记(5) - 状态与 CompositionLocal

无状态与状态提升

从某种角度来说,Compose 组件是无状态的。这很好理解 —— Compose 的那些组件函数甚至都不会返回一个对象,哪有状态可言?对于传统 View,比如 TextView,可以通过 TextView.getText() 来获取其内部状态,当然,还有很多其他属性。那么 Compose 中又如何实现?

答案就是 状态提升 (State Hoisting)。听起来很高大上,实际上很粗暴。所谓「提升」就是把那些状态属性改成参数提到函数外面去(用变量保存起来),那么就能随时读取它们的值了。

举个例子 🌰:

setContent {
    NameLabel()
}

@Composable
private fun NameLabel() {
    Text("My name is: Chenhe")
}

此时 NameLabel 就是无状态的。Chenhe 作为参数传递进去,就再也取不回来了。将其提升之后如下:

setContent {
    val name by remember { mutableStateOf("Chenhe") }
    NameLabel(name)
}

@Composable
private fun NameLabel(name: String) {
    Text("My name is: $name")
}

现在通过 name 变量可以随时获取「状态」。由此可见,状态提升不仅在保持组件本身无状态的前提下实现了状态的保存读取,同时还提高了复用性与灵活性。

CompositionLocal

基本用法

或许已经有人感觉状态提升的办法有点奇怪,但是又说不出来。之所以有这样朦胧的感觉,是因为 "name" 作为 NameLabel 自己的状态,却无法直接取得,还得通过传参来实现。这其实是合理的,提升后的状态已经不是组件的内部状态了,组件只能被动地接受命令。

那如果我偏要主动读取怎么办?Compose 为此提供了 CompositionLocal,典型用法如下:

val LocalName = compositionLocalOf<String> { error("no value") }

setContent {
    CompositionLocalProvider(LocalName provides "Chenhe") {
        NameLabel()
    }
}

@Composable
private fun NameLabel() {
    Text("My name is: " + LocalName.current)
}

Compose 规范要求,CompositionLocal 对象的变量名需以 local 开头,若是一个 Top Level 级别的对象,则以 Local 开头。

解释一下:

  1. LocalName 是一个套娃对象 - CompositionLocal
  2. 使用前要先通过 CompositionLocalProvider(...){/*content*/} 对一个或多个 CompositionLocal 赋值。content 代码块就是它们的作用域。
  3. 在作用域内可通过 Localxxx.current 取值。
  4. compositionLocalOf {} lambda 参数里可以给出默认值。如果不想提供或无法提供有意义的值,可以直接抛异常。

看起来有点脱裤子放屁。但普通的局部变量只在当前代码块(函数)内有效。CompositionLocal 是可以穿透函数的(不限层级),只需在作用域内就行了,这就是它的独特之处。

小结:CompositionLocal 是具有穿透函数功能的局部变量。

适用场景

「你说的我都懂,但为什么不直接传参呢?」

那就先把小结换个说法:CompositionLocal 是不需要显式传递的函数参数。

是不是有点想法了?回到一开始,为什么 Compose 没有状态?因为 Compose 组件都是函数,不是对象,函数没有属性(成员变量/字段)。我们编写 class 时,对于一些常用的值,是不是倾向于定义为成员变量,而不是不厌其烦地传参?另外,有一些值我们不确定会不会在其他地方需要,也把它定义为成员变量以便读取。那么在 Compose 中... CompositionLocal 承担起了成员变量的角色❗️

另一个角度看,参数由函数定义,调用者有义务给予合适的值。而 CompositionLocal 由外部定义,它是一个约定:我会提供 xxx,正确使用这些值是函数内部的责任。所以我们不用担心 CompositionLocal 的值会不会产生意料之外的影响——只要符合定义的语义,就不会出问题,否则果断甩锅给函数 [手动狗头]。

总结:CompositionLocal 适用于传递(提供)上下文性质/主题属性数据。这些东西大概率会用到,但又不一定被用到——传参不仅麻烦还有可能浪费。我们不关心函数内部如何使用它 —— 只要值没问题,会不会引发错误是函数要注意的,我们只负责提供。

例如 Compose 默认提供了 Top Level 的 LocalContextLocalBackground,还有很多很多其他默认提供的对象方便需要时使用。

上面的结论比较书面化,接地气地来说,如果一个数据是函数里面要的,就适合参数;如果是外面主动给的,就适合 CompositionLocal。

现在你已经知道了两者的区别和适用场景,那就做一个简单的练习吧 😏。问:文字的颜色应该用什么?

是不是又懵了?似乎... 两个都合理。小孩子才做选择,成年人我全都要!看看官方是怎么写的:

@Composable
fun Text(
    text: AnnotatedString,
    color: Color = Color.Unspecified,
  	// ...
    style: TextStyle = LocalTextStyle.current
) {
    val textColor = color.takeOrElse {
        style.color.takeOrElse {
            LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
        }
    }
    // ...
}

首先 color 作为参数,方便指定独特的颜色。如果没有指定,就回退到样式里查找。如果还是没有指定,就遵循约定的主题属性。由此可见,参数与 CompositionLocal 不是二选一的,可以同时使用,通常来说参数的优先级高一些。

创建

[一开始]({{< ref "#基本用法" >}})已经展示了创建方法:compositionLocalOf {},其实还有另一个方法 staticCompositionLocalOf {}。考虑到提供值的时候有可能是一个变量(上面例子中用的是字面量(常量)),有必要用 MutableState 那套机制记录本次取值,好在未来改变的时候触发重组。

提示:不是整个 CompositionLocalProvider 作用域重组,而是只有用到那个变量提供值的作用域重组。

尽管这个记录操作开销不是非常大,Compose 还是提供了规避的措施,这就是 staticCompositionLocalOf 了。有得必有失,没了记录,变量改变的时候就无法精准重组,取而代之的是全部重组。

所以,如果它的值会经常改变,优先使用 compositionLocalOf(),否则优先使用 staticCompositionLocalOf()

Compose 的代码本身就是很好的例子。LocalContext 用的是 staticCompositionLocalOfLocalContentColor 则是 compositionLocalOf