稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

用Compose中的CompositionLocal来暗渡陈仓

通过前面的学习我们知道Jetpack Compose是一个声明式的UI框架,采用的是函数式编程思想,直观上来看就是一坨函数从上调到下。当函数需要数据时,会通过函数调用的参数来传递,一般来说这也没有什么问题。但当需要的数据特别多时,特别是对于一些非业务逻辑本身的数据(输入与输出),如上下文环境变量(Context,或者主题之类),都通过函数调用的参数传递就难免会让代码变得非常的庞杂和臃肿。这时候就可以使用CompositionLocal,一种在组合树中隐式的从上到下传递数据的方法,我们来具体地看一下。

注意: Compose中的函数通常叫做composable,可以简单的理解为同一个东西。从根composable开始,一个套一个的调用,自上而下的意思就是指composable的调用顺序,根函数在上,被调用的函数在下。函数调用是可以传递参数的,正常的从上到下的数据传递都是通过函数调用时的参数。

废话不多说,我们先来看一下什么是CompositionLocal以及它能做什么事情。

什么是CompositionLocal

CompositionLocal提供一种自上而下的数据传递方式,隐式的传递,也就是说,不用把数据放在参数里传递给子函数,子函数像在类中的方法访问域变量那样直接访问。我们来看一个粟子。

比如说,页面中有一个文案,可能需要定制字体颜色,常规方式是这样子写:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun MyApp() {
    val colors = colors()
    SomeTextLabel(text, colors.onPrimary)
}

@Composable
fun SomeTextLabel(labelText: String, color: Int) {
    Text(
        text = labelText,
        color = color
    )
}

文案的颜色,以及像背景等等这些东西是与整个App的配置相关的,或者与运行环境(如手机)的主题风格有关的,它并不是应用程序的业务逻辑。它属于上下文环境变量,其变化往往是由于运行环境变化而变化,或者是由于用户更改了应用的配置。这就非常适合使用CompositionLocal来转化为隐式数据传递,进而简化代码,使用后就变成酱婶儿的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
fun MyApp() {
    // 在上级函数中定义主题相关的颜色,并定义为CompositionLocal
    val colors = staticCompositionLocalOf( colors() )
    CompositionLocalProvider(LocalColors provides colors) {
        SomeTextLabel(text)
    }
}

// 被调用的子函数
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = LocalColors.onPrimary // 可以直接访问定义好的颜色
    )
}

通过这个示例可以看出,定义了CompositionLocal以后,在被调用到的所有子函数中(desendants)都可以直接使用,就像访问全局变量那样。这就是CompositionLocal的作用,在指定的作用域中提供隐式的数据。

如何使用CompositionLocal

使用起来非常的方便,就像在类的方法中使用域变量那样,或者像使用全局变量那样,找到上级所定义的的CompositionLocal实例,然后引用其中的变量即可。

其实,已经在不知不觉中经常地使用了CompositionLocal,主题风格相关的MaterialTheme,Android的上下文LocalContext)以及像绘制时常用的LocalDensity),这些都是CompositionLocal实例,是由Compose定义好的顶层实例,在所有的composables中都可以直接使用。

需要理解的是CompositionLocal实例,本身并不是一个数据,它更像是一个集装箱,它本身是一个数据传递的机制,会在后面定义CompositionLocal实例时详细讲解。

另外,需要特别注意的是CompositionLocal是有作用域的,对其所有的子函数生效,并且也可以嵌套的,属性current)引用的是最近一层父函数(closest ancestor)中绑定的值。比如说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // 绑定一个值到LocalContentColor
        CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
            Surface {
                Column {
                    Text("Uses Surface's provided content color")
                    // 重新绑定一个值到LocalContentColor
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // current会指向最后绑定的值,即MaterialTheme.colorScheme.error
    Text(text = "This Text uses the error color now", color = LocalContentColor.current)
}

如何定义一个CompositionLocal

大部分时候Compose中定义好的就够我们用了,但也可以针对具体的场景来自定义一个CompositionLocal。分为两个步骤,创建实例,和绑定数据。

CompositionLocal实例是一个集装箱,用以封装需要从顶层往底层传递的数据,这些数据也不一定非要是常量,也是会改变的,比如应用或者页面上下文环境变化时,或者整个应用的配置发生变化时。

创建CompositionLocal实例

第一步是先创建一个实例,有两种方法创建一个CompositionLocal实例,这两种方式的主要区别就在于当数据变化时如何影响着重组(reComposition):

可以发现,这两个方法的区别就在于影响重组的范围,前一个是影响着读取数据的地方,这与状态(State)一样;后一个则是影响着所有的被调用的composables。

1
2
3
4
5
6
7
// LocalElevations.kt file

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

// 使用Elevations的默认值构造一个全局的CompositinLocal实例
// 这个实例可以被应用中的所有composables访问得到
val LocalElevations = compositionLocalOf { Elevations() }

注意: 为了更好的可读性和可维护性,CompositionLocal实例的命名应该以Local开头,如LocalColors。

把数据绑定到实例中去

光创建实例没什么用,创建完实例后,还需要把数组绑定到CompositionLocal实例中去,并同时指定作用域,这是非常关键的一步,不但决定了CompositionLocal中有什么数据,还决定了谁可以使用这些数据。使用CompositionLocalProvider)来绑定数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// MyActivity.kt file

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

        setContent {
            // 基于系统主题来计算具体的elevation
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // 把上面计算得到的elevation绑定到LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // 这里的子composables都可以直接访问LocalElevations.current
                // 以得到elevation
            }
        }
    }
}

函数CompositionLocalProvider接收一个CompositionLocal实例和一个composable lambda,这个lambda就是这个实例的作用域,也即此lambda所调用的所有composables都可以使用访问此实例的数据。参数LocalElevations就是实例,可以看前面的创建代码。LocalElevations provides elevation这是infix符号式写法,相当于LocalElevations.provides(elevation),elevation则是具体的数据,这意思就是在此lambda的作用域中,CompositionLocal实例LocalElevations会提供数据elevation。

1
2
3
4
5
6
7
@Composable
fun SomeComposable() {
    // 使用的地方直接通过LocalElevations.current就能访问到
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

这里多说一下,compositionLocalOf是更为精细化的控制(fine-grained control),因为,当数据发生变化时,在作用域中只有读取了数据的composables才会被重组;而staticCompositionLocalOf影响范围较大,因为(当数据发生变化 时)整个作用域内(即CompositionLocalProvider的lambda)的composables都会被重组。

什么时候该用CompositionLocal

CompositionLocal提供了一种新的数据传递方式,当数据需要在一定作用域内(Scoped)分享时,就可以考虑使用它。但并不是所有的场景都适用,具体来说,要符合以下标准:

  • 数据要是非业务逻辑数据,也就是非代码所直接需要的输入和输出。
  • 数据要有一个极其合理的初始值(默认值)。
  • 数据不一定要是常量,也可以变化,但是业务逻辑并不是数据变化的原因。
  • 数据的使用有一定的作用域。并且在作用域内所有的composables都可能会使用此数据。这一点很重要,如果仅仅是某个特定的composable使用,那就直接传参就好了。

适合使用CompositionLocal的数据有应用的主题风格,应用的配置信息,平台提供的上下文变量,平台的配置信息,或者对于一个局部来说是上下文变量的数据(比如说一个Dialog用到的数据)。除此之外,绝大部分时候不应该使用CompositionLocal。特别是涉及业务逻辑代码本身强相关的数据(输入与输出),一定不能使用CompositionLocal,这会让代码极难理解和维护,造成极难调试的Bug。

CompositionLocal的一个大的特点就是有明确的作用域,那么如何选择合适的作用域呢?总的来说应该让作用域越小越好。视数据的 影响范围和使用范围来决定其作用域:

  • 整个应用级别的(App level),如主题风格,应用配置,平台的配置信息,或者会话数据(user sessions)。应该在根composable,即setContent处绑定数据。
  • 整个页面级别的(Screen level),如平台上下文变量(Context或者Density),导航,或者页面内部的定制参数。
  • 组件级别的(Component level),页面中某一个局部,比如Dialog的主题风格,或者Dialog并不直接操作,但却依赖的数据。

任何工具都是为了适合其的场景而生的,要了解清楚什么场景适合使用非常重要,切忌滥用。

它与状态(State)的区别是什么

从前面的文章降Compose十八掌之『鸿渐于陆』| State我们了解到,状态(State)是为了重组时数据不丢失,也就是说在composable多次运行时,数据能够得以留存。反过来状态变化了,也会触发重组,因为要刷新UI。状态是时间上的概念(使用此状态的composables)多次运行,状态持有的数据都得以保存,不会变成初始值。如果想要使用状态,必须当作参数传递过去。

而CompositionLocal是为了能在不同的composables中共享数据,不用参数传递,它是空间上的概念。

它们是为了解决不同的问题而设计出来的。可以把状态绑定到CompositionLocal中去,但其实没必要这么做,因为内部实现上已经会把绑定的数据封装为状态,因为CompositionLocal的数据是可能发生变化的,并且当变化时,也是要触发重组的。

它与全局变量的区别是什么

Kotlin语言是支持顶级全局变量(Top level globals)的,也就是与class平齐,在任何class之外的全局变量,其作用域是整个进程,任何导入(import)此变量的地方都可以访问。Compose是基于Kotlin语言的,所以Compose也是可以使用全局变量的。

全局变量的缺点是没有作用域,容易失控,仅应该用于常量,并且它也不能用于composables的重组。虽然全局变量可以跨越空间,让所有函数都能访问。全局变量是编程语言层面的东西,仅适用于常量的定义,也即整个应用程序运行期间确定不会发生改变的值。

这正是CompositionLocal有价值的地方,它能限定作用域,又可以触发重组,允许数据值改变,又可以跨越空间。这是Compose框架层面提供的工具,自然更适合在Compose中使用。

总结

调用函数时传递参数可谓是『明修栈道』,而CompositionLocal则是『暗渡陈仓』,提供了一种在组合(Composition)中自上而下隐式传递数据的一种方式,可以让函数调用只关心与逻辑相关的输入数据。恰当的使用CompositionLocal可以有效的管控上下文环境变量,并极大地简化代码,让代码更加的优雅和简洁。

References

Comments