在Compose中使用状态提升?我提升个P...Provider

”总所周知“,在 Compose 中有个思想叫做状态提升,在之前的文章Compose学习笔记2 - LaunchedEffect、状态与 状态管理中我们曾提及过。

状态提升的目的是为了让我们的组件尽可能的”无状态“,无状态的优点:

  • 可复用,组件只负责组件的职责,不持有或者少持有状态
  • 可测试,组件不持有状态,更接近于纯函数,相同输入必然有相同输出

状态提升的想法很好,但是实践的时候可能并不美妙。

可能有点丑陋的状态提升

我们快速的写一个 TodoList,来演示一下状态提升可能存在的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Composable
fun TestStateHoisting() {
// 在顶层组件声明状态与改变状态的函数(称之为事件)
val list = useList<Todo>()
fun addTodo(todo: Todo) {
list.add(todo)
}
fun delTodo(id: String) {
list.removeIf { it.id == id }
}
Surface {
Column {
//事件传递
Header(::addTodo)
TodoList(todos = list, ::delTodo)
}
}
}

data class Todo(val name: String, val id: String)

@Composable
fun Header(addTodo: (Todo) -> Unit) {
val (input, setInput) = useState("")
Row {
OutlinedTextField(
value = input,
onValueChange = setInput,
)
TButton(text = "add") {
addTodo(Todo(input, NanoId.generate()))
setInput("")
}
}
}

@Composable
fun TodoList(todos: List<Todo>, delTodo: (String) -> Unit) {
Column {
todos.map {
TodoItem(item = it, delTodo)
}
}
}

@Composable
fun TodoItem(item: Todo, delTodo: (String) -> Unit) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = item.name)
TButton(text = "del") {
delTodo(item.id)
}
}
}

@Composable
fun TButton(
text: String,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Button(onClick = onClick, enabled = enabled, modifier = modifier.padding(PaddingValues(4.dp))) {
Text(text = text)
}
}

这是一个非常完整的 ”状态提升“ 示例,但是它有一点点丑陋。例子中这种组织、管理状态的思想称之为:单向数据流,即状态(数据)从父组件向下流向子组件,数据只有一个唯一可信源,就是来自父组件的状态。子组件从过向上传递事件(通过调用由父组件传递的改变状态的函数实现传递),来改变状态。

使用状态提升,在面对一些复杂场景,例如多个不同层级的组件,需要将所有状态提升到共有的顶层组件,然后通过 props 在组件之间传递。一来代码量上提升很多,二来如果涉及修改,就会比较麻烦。

有的中间组件可能并不需要使用这些状态,或者函数。例如 TodoList 组件,在它的实现中它其实并不关心 delTodo 函数到底是什么,它也不会使用这个函数。但是为了传递到目标组件还是需要在 props 中进行声明,显得非常的笨重。

使用 useContext 来解耦组件之间的状态、事件传递

上面的例子我们只传递了两层,Root -> TodoList -> TodoItem,实际开发可能会存在更多的状态传递层级,还用这种方式显然有些笨拙了。

我们还有其他方法么?当然,我们还可以使用 ViewModel,通过它持有状态、改变状态的函数,这都很好,很符合开发 Android 的既往路线。

但是我们还可以试一试更好玩的方法,使用junerver/ComposeHooks 中的 useContext 函数,在无需创建 vm 文件的情况下,更函数式的处理状态。

改造第一步:创建上下文

首先使用 createContext 创建一个上下文对象,同时传入默认值:

1
2
3
4
5
val TodoContext = createContext(tuple(
emptyList<Todo>(), // 对应list状态
{ _: Todo -> }, // 对应 addTodo函数
{ _: String -> } // 对应 delTodo函数
))

这里我们传入的都是空值、空函数,tuple函数是我自定义的快速创建 Triple 的函数。

改造第二步:使用上下文对象提供的 Provider 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Composable
fun TestStateHoisting() {
val list = useList<Todo>()
fun addTodo(todo: Todo) {
list.add(todo)
}

fun delTodo(id: String) {
list.removeIf { it.id == id }
}
// 在这个组件之下的所有组件都能使用我们暴露出的这三个内容
TodoContext.Provider(
value = tuple(
list,
::addTodo,
::delTodo
)
) {
Surface {
Column {
// Header、TodoList 都改造成无参组件
Header()
TodoList()
}
}
}
}

改造第三步:改造子组件,使用 useContext 函数获取需要的状态、函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Composable
fun Header() {
// 传入上下文,使用解构声明拿到对应顺序的函数
val (_, addTodo) = useContext(context = TodoContext)
val (input, setInput) = useState("")
Row {
OutlinedTextField(
value = input,
onValueChange = setInput,
)
TButton(text = "add") {
addTodo(Todo(input, NanoId.generate()))
setInput("")
}
}
}

@Composable
fun TodoList() {
// 拿到的todos本身就是状态,可以直接使用
val (todos) = useContext(context = TodoContext)
Column {
todos.map {
TodoItem(item = it)
}
}
}

@Composable
fun TodoItem(item: Todo) {
// 不使用的解构声明对象,可以使用`_` 作为占位符
val (_, _, delTodo) = useContext(context = TodoContext)
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = item.name)
TButton(text = "del") {
delTodo(item.id)
}
}
}

完成:现在我们的组件互相之间不再耦合,无需传递状态、函数

对比改造前后,我们再也不用关心状态的传递,后续代码更新也不用担心牵一发而动全身。

总结:

  1. 使用 createContex 创建上下文对象
  2. 使用 上下文对象.Provider 作为根组件
  3. 在需要使用状态、函数的组件中使用 useContext(上下文对象)获取

探索更多

项目开源地址:junerver/ComposeHooks

MavenCentral:hooks

1
implementation("xyz.junerver.compose:hooks:1.0.3")

欢迎使用、勘误、pr、star。