持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第6天,点击查看活动详情
一、状态(State)
上文说到,声明式 UI 的一个重要理念是:你只负责根据当前状态描述当前的 UI,框架负责在状态改变时更新 UI。
Compose 中有一个很重要的概念:状态。
状态可以理解为一系列参数,这些参数记录了当前页面需要显示的信息。
我们看这样一段代码:
@Composable
fun HelloContent() {
OutlinedTextField(value = "Kevin", onValueChange = {})
}
这个控件很简单,通过 OutlinedTextField 函数构建出类似 EditText 的控件,这个控件是一个带边框的输入框。
运行程序,显示如下:
但此时如果我们想要编辑这个输入框中的内容,会发现根本无法修改其中的内容:
这是因为 OutlinedTextField 只会展示 value 中的内容,而我们在 onValueChange 中什么也没做,所以 value 一直没有改变。所以我们应该在 onValueChange 中更新 value 的值。
代码修改如下:
@Composable
fun HelloContent() {
var name = "Kevin"
OutlinedTextField(value = name, onValueChange = {
name = it
})
}
这里我们定义了一个局部变量 name,OutlinedTextField 的 value 设置为 name,onValueChange 中,将最新的值赋值给 name。
此时我们再运行代码,会发现还是无法修改输入框中的内容,运行效果和刚才一模一样。
这又是怎么回事呢?
我们在 onValueChange 中加一些日志:
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
var name = "Kevin"
OutlinedTextField(value = name, onValueChange = {
Log.d("~~~", "name before change: $name")
name = it
Log.d("~~~", "name after change: $name")
})
}
再次运行代码,当我们尝试在输入框中输入一个 1 时,会看到以下日志:
D/~~~: name before change: Kevin
D/~~~: name after change: Kevin1
D/~~~: name before change: Kevin1
D/~~~: name after change: Kevin
可以看出,输入 1 之后,onValueChange 回调了两次,第一次把 name 改成了 Kevin1,第二次把 name 改回了 Kevin。
如果我们把 name 声明为全局变量,也会看到一样的现象,无法修改输入框中的内容,Log 输出也是一样的:
var name = "Kevin"
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
OutlinedTextField(value = name, onValueChange = {
Log.d("~~~", "name before change: $name")
name = it
Log.d("~~~", "name after change: $name")
})
}
这又是什么原因呢?
第一次回调很好理解:当修改输入框中的内容后,onValueChange 就会回调一次,将 name 被修改成 Kevin1。
说实话,第二次回调的原因我也不是很清楚。只能斗胆猜测一下:
第二次回调可能是因为:OutlinedTextField 内部记录了当前显示的 value,触发了第一次 onValueChange 回调后,OutlinedTextField 内部会触发重绘(有的文章中叫重组),而由于 onValueChange 只修改了 name 变量,并未修改 OutlinedTextField 内部记录的 value,所以绘制时还是使用的内部记录的 value:Kevin。此时 onValueChange 又监听到一次 value 的更改,导致 name 又被修改回了 Kevin。
需要注意的是,在 OutlinedTextField 内部重绘时,HelloContent() 函数没有被重新调用。因为从 Logcat 控制台没有看到输出 ~~~: HelloContent
。
我跟到源码中调试了一下,只能看到 value 确实从 Kevin 被改成了 Kevin1,又被改回了 Kevin。还是没有找到导致这个改变的具体原因。
第一次回调:
第二次回调:
所以这段解释只是我个人的推测,我没有找到官方的解释,由于能力有限,我也没能从源码中看出具体原因。如果有了解的大佬希望能在评论区不吝赐教。
想要解决这个问题,就需要通过 Compose 的状态将 value 保存起来:
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
val name = mutableStateOf("Kevin")
OutlinedTextField(value = name.value, onValueChange = {
Log.d("~~~", "name before change: ${name.value}")
name.value = it
Log.d("~~~", "name after change: ${name.value}")
})
}
通过 mutableStateOf 构建一个可变的状态,使用时,通过 .value
获取到状态中保存的值。修改时也需要修改状态的 .value
。
注:编译器会在直接使用 mutableStateOf 的地方报错,报错内容是:Creating a state object during composition without using remember。但不影响程序运行,关于 remember 的内容我们马上就会讲到。
此时运行程序,还是会发现无法修改输入框中的内容。Logcat 控制台输出如下:
D/~~~: name before change: Kevin
D/~~~: name after change: Kevin1
D/~~~: HelloContent
D/~~~: name before change: Kevin
D/~~~: name after change: Kevin
和之前的输出略有区别。onValueChange 还是回调了两次,第一次和之前一样,从 Kevin 修改为 Kevin1,而第二次是从 Kevin 修改成 Kevin。
除此之外,HelloContent 在第二次 onValueChange 之前被打印了一次。这是使用 mutableStateOf 前不曾出现的。
说明 mutableStateOf 起了些作用,但是不多。
出现这种现象的原因是:当 state 的值发生改变后,控件会触发重绘,重绘时回调了整个 HelloContent() 函数。此时 name 又被初始化为 mutableStateOf("Kevin"),所以第二次绘制时,onValueChange 不是从 Kevin1 变成 Kevin,而是从 Kevin 变成 Kevin。
了解了原因,我们就知道怎么修改了,将 name 设置为全局 state 就可以了:
val name = mutableStateOf("Kevin")
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
OutlinedTextField(value = name.value, onValueChange = {
Log.d("~~~", "name before change: ${name.value}")
name.value = it
Log.d("~~~", "name after change: ${name.value}")
})
}
运行程序,效果如下:
终于可以正常输入了。
Log 控制台输出如下:
~~~: name before change: Kevin
~~~: name after change: Kevin1
~~~: HelloContent
可以看出,onValueChange 先被调用,将 Kevin 设置成 Kevin1,然后 HelloContent 触发重绘,将 Kevin1 设置到 OutlinedTextField 中。
除了这种全局 state 的方式,还可以用 remember 的方式实现这个效果:
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
val name = remember { mutableStateOf("Kevin") }
OutlinedTextField(value = name.value, onValueChange = {
Log.d("~~~", "name before change: ${name.value}")
name.value = it
Log.d("~~~", "name after change: ${name.value}")
})
}
运行效果和 Log 输出都和之前是一样的。所以我们可以推测出,remember 的作用是:将函数中的 state 变量转换成类似全局变量的效果,对其修改后可以永久生效。
注:remember 的作用并不是将其中的值变成全局变量,只是能达到全局变量的效果,即:修改后可以永久生效。
remember 译为 “记住”,意思是在其修改后可以记住其修改,这和全局变量的效果是类似的。从源码中看到 remember 的原理大概是将其中的值缓存起来。
此时好奇的同学就会问了,如果只用 remember,不用 mutableStateOf,可以修改输入框中的内容吗?
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
var name = remember { "Kevin" }
OutlinedTextField(value = name, onValueChange = {
Log.d("~~~", "name before change: $name")
name = it
Log.d("~~~", "name after change: $name")
})
}
根据我们的推测。remember 的作用是将函数中的 state 变量转换成类似全局变量的效果,而前面我们已经试过将 name 设置成全局变量,并不能修改输入框中的内容。
测试结果证实了我们的猜想,只用 remember 是无法修改输入框中的内容的。
所以 remember 和 mutableStateOf 需要搭配使用:
val name = remember { mutableStateOf("Kevin") }
二、属性委托
由于 mutableStateOf("Kevin")
构建出的是一个 MutableState 对象,使用时需要通过其 getValue、setValue 方法对其记录的值进行读取,这样不是很方便。从源码中可以看出,MutableState 实现了属性委托的两个方法: operator 修饰的 getValue 和 operator 修饰的 setValue。
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}
所以在 Kotlin 中,可以通过属性委托的方式来使用 MutableState。
@Composable
fun HelloContent() {
Log.d("~~~", "HelloContent")
var name by remember { mutableStateOf("Kevin") }
OutlinedTextField(value = name, onValueChange = {
Log.d("~~~", "name before change: $name")
name = it
Log.d("~~~", "name after change: $name")
})
}
通过 by 关键字,将 name 的值委托给 MutableState。读取和修改都会更方便。
顺便提一下,如果使用 by 关键字时报这个错:Type 'TypeVariable(T)' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate
,如图所示:
这时在程序顶部添加以下 import 就可以了:
import androidx.compose.runtime.*
这是我在使用 Compose 时遇到的一个小坑,感觉代码提示不是很完善,或许以后的 Compose 版本会优化这一点。
三、状态提升
在声明式 UI 的编程思想中,控件最好只根据状态来描述 UI,不要维护状态的修改。
所以 Google 推荐的做法是,每个控件包含两个实现,一个处理状态的修改、一个处理 UI 的展示。将一个控件中,维护状态的部分单独拆出来,这个过程就被称为状态提升。
比如,上文中的例子可以拆成这样:
@Composable
fun HelloScreen() {
var name by remember { mutableStateOf("Kevin") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
OutlinedTextField(value = name, onValueChange = onNameChange)
}
贴一张官方的图:
HelloContent 控件交互产生的事件(event),通过 onNameChange 传递到 HelloScreen 中,HelloScreen 根据事件触发状态的更改,再将更改后的状态(state)通过 name 传递到 HelloContent 中。
这就是状态提升,好处是保证控件状态的单一可信源、易复用、解耦等等。
四、状态恢复
先看一下不处理状态恢复导致状态丢失的例子:
可以看到,我先把 Kevin 修改为 Kevin1,当屏幕发生旋转后,Activity 默认情况下会重新创建,之前记录的状态就消失了,页面又显示了初始的 Kevin 字符串。
如果我们需要保存和恢复页面的状态,应该怎么做呢?
以前的做法是在 onSaveInstanceState 和 onRestoreInstanceState 中处理状态的保存和恢复。在 Compose 中可以通过 rememberSaveable 来处理状态的保存和恢复。
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("Kevin") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
OutlinedTextField(value = name, onValueChange = onNameChange)
}
唯一的修改是把 remember 改成了 rememberSaveable,运行效果如下:
可以看到,状态没有被丢失了。
只要可以放到 Bundle 中的数据都可以被 rememberSaveable 正确保存和恢复,包括基本数据类型和 Parcelize 数据等。
通过 MapSaver 可以自定义数据的保存与恢复:
data class City(val name: String, val country: String)
val CitySaver = run {
val nameKey = "Name"
val countryKey = "Country"
mapSaver(
save = { mapOf(nameKey to it.name, countryKey to it.country) },
restore = { City(it[nameKey] as String, it[countryKey] as String) }
)
}
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}
可以看到,mapSaver 需要传入两个参数,一个 save,一个 restore,分别表示状态的存储与恢复,定义好 mapSaver 后,将其传入 rememberSaveable 的 stateSaver 参数中即可。
mapSaver 通过 map 的方式处理状态的保存与恢复,这意味着每个数据都需要定义一个对应其 value 的 key,如果不想定义 key,也可以通过 ListSaver 处理状态的保存与恢复:
data class City(val name: String, val country: String)
val CitySaver = listSaver<City, Any>(
save = { listOf(it.name, it.country) },
restore = { City(it[0] as String, it[1] as String) }
)
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}
listSaver 同样需要两个参数,一个 save,一个 restore,save 时将数据转成 list,restore 时从 list 从依次将数据取出。
这个例子是从 Google 官网抄来的,在我看来,这个例子不是很适合用 ListSaver,准确地说,非列表数据都不推荐使用 ListSaver。通过定义 key 的方式将数据存到 map 中更容易理解,不然阅读代码的人还需要知道第一个数据时 name,第二个数据是 country,比较容易出错。
五、小结
想要理解 Compose 的状态,需要理解声明式 UI 的思想:你只负责根据当前状态描述当前的 UI,框架负责在状态改变时更新 UI。
说来惭愧,我对 Compose 的状态机制实际上也是一知半解,本文中的一些例子我也写明了我的一些疑惑点。希望能有会的大佬教教我。写这篇文章出来的目的也是为了分享我对 Compose 状态的一些探索,希望能和大家交流,不喜轻喷...
文中还介绍了状态提升,也就是将控件中控制状态维护的部分单独抽离出来,使状态的改变和状态的展示分离。
最后介绍了状态的恢复,通过 rememberSaveable 可以轻松实现状态的恢复。需要注意的是,并不是只有屏幕旋转时才需要状态恢复。严格来讲,onSaveInstanceState 和 onRestoreInstanceState 被调用的场景都需要处理状态恢复,包括应用配置更改、应用被系统杀掉等等。