Jetpack Compose (四) ——— Compose 状态

632 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第6天,点击查看活动详情

一、状态(State)

上文说到,声明式 UI 的一个重要理念是:你只负责根据当前状态描述当前的 UI,框架负责在状态改变时更新 UI。

Compose 中有一个很重要的概念:状态。

状态可以理解为一系列参数,这些参数记录了当前页面需要显示的信息。

我们看这样一段代码:

@Composable
fun HelloContent() {
    OutlinedTextField(value = "Kevin", onValueChange = {})
}

这个控件很简单,通过 OutlinedTextField 函数构建出类似 EditText 的控件,这个控件是一个带边框的输入框。

运行程序,显示如下:

但此时如果我们想要编辑这个输入框中的内容,会发现根本无法修改其中的内容:

compose.gif

这是因为 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 的内容我们马上就会讲到。

Creating a state object during composition without using 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}")
    })
}

运行程序,效果如下:

compose.gif

终于可以正常输入了。

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,如图所示:

runtime.*

这时在程序顶部添加以下 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 中。

这就是状态提升,好处是保证控件状态的单一可信源、易复用、解耦等等。

四、状态恢复

先看一下不处理状态恢复导致状态丢失的例子:

compose.gif

可以看到,我先把 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,运行效果如下:

compose.gif

可以看到,状态没有被丢失了。

只要可以放到 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 被调用的场景都需要处理状态恢复,包括应用配置更改、应用被系统杀掉等等。