Jetpack-Compose 学习笔记(五)—— State 状态是个啥?又是新概念?

2022-08-19 16:55:33 浏览数 (2)

“断更太久了,差点没捡起来。。最近俄乌开战、X县、冬奥、字节员工猝死、疫情反复等等新闻不断,今年注定又是不平凡的一年!不管咋样,咱还是仰望星空,脚踏实地,关注社会趋势更要不断充实自己!开年第一篇继续学习 Compose ~

系列第五篇,进入 Compose 中有关 State 状态的学习。

前面几篇笔记讲了那么多内容,都是基于静态界面的展示来说的,即给我一个不变的数据,然后将它展示出来。如何在 Compose 中构建一个随数据而变化的动态界面呢?相信看完这篇就知道了。

1、基本知识

众所周知,Compose 彻底舍弃了 xml 文件,我们需要像 Flutter 一样完全用代码去进行界面的编码,这样做很容易会导致一个问题:界面和数据处理逻辑耦合,从而导致 Activity 中代码臃肿且维护性下降。

虽然提出了许多架构思想,如 MVC、MVP、MVVM 等,一定程度上解耦了界面与数据处理逻辑,但是架构本身就具有一定的复杂性,且对于后续维护成本也相对较高,所以 Compose 一开始就将界面与数据分开来,分别称之为 组合State 状态

State 状态:官方文档上说 State 状态是指可以随时间变化的任何值。例如,它可能是存储在 Room 数据库中的值、类的变量,加速度计的当前读数等。怎么理解这个概念呢?我觉得可以简单理解为:我们要展示给用户看的数据。例如,一个商品的展示页面,其实就是根据数据的不同来展示不同的状态,数据正常、数据错误、空数据等不同的数据就是代表了不同的 State 状态。

组合:按照文档上的意思我觉得可以理解为展示给用户的界面,是由多个组合项(Composable组件)组成。

Event事件:指的是从应用外部生成的输入,用于通知程序的某部分发生了变化。如用户的点击,滑动等操作。所以在 Compose 中,Event 事件一般就是引起 State 状态改变的原因。

2、状态的表示

其实可以换一种说法:Compose 中数据的存储和更新如何处理?目前来看的话,可以用 LiveData、StateFlow、Flow、Observable 等表示。可以看出,这些都是一种可观察数据变化的容器,被它们修饰的对象,我们都可以观察到该对象的变化,从而更新界面。没错,都是使用的观察者模式。

在 Compose 的文档中,ViewModel 被推荐为 State状态的管理对象,从而实现将数据与界面展示的 Activity 分离解耦的目的。

2.1 ViewModel

ViewModel 也是 Jetpack 工具库的成员之一,主要用来存储 UI 展示所需要的数据,谷歌推荐的做法是将 Activity 中的数据都放到 ViewModel 里,而且在 Activity、Fragment 重建时 ViewModel 中的数据是不受影响的。还可以通过 ViewModel 来进行 Activity 与 Fragment 之间,或者 Fragment 与 Fragment 之间的通信。

ViewModel 经常与 LiveData 一起使用,但在 Compose 中,推荐使用 MutableState 来具体存储数据的值。

2.2 MutableState<T>

MutableState<T> 是 Compose 中内置的专门用于存储 State 状态的容器,与 LiveData 一样,它可以观察到存储的值的变化。如果项目不是纯 Compose 代码,建议还是用 LiveData,因为 LiveData 是通用的,而 MutableState<T> 是与 Compose 集成了,所以在 Compose 中使用 MutableState 比 LiveData 更简单。

从这里也可看出,Compose 是推荐将 State 状态设置为可观察的,这样当状态发生更改时,Compose 可以自动重组更新界面。

实际上 MutableState<T> 是个接口:

代码语言:javascript复制
// code 1
interface MutableState<T>: State<T> {
 override var value: T
}

对 value 进行的任何更改都会自动重组用于读取此状态的所有 Composable 函数,也就是说,value 值改变了之后,所有引用了 value 的 Composable 函数都会重新绘制更新。

3、一个简单例子

先来看看效果:

其中有两个控件,一个是 Text,用于显示输入的内容;另一个是 TextField,相当于 View 体系中的 EditText。可以看出,Text 显示的内容可以随着下面的 TextField 中输入的内容实时更新。

如果是在 View 体系中,一般实现的方法是在 EditText 添加一个 TextWatcher 类用于监听输入事件,然后在 onTextChanged 方法中对 TextView 设置输入的内容即可。

再来看一下 Compose 是如何实现这一小功能的 。根据官方推荐,得先有一个 ViewModel 进行状态数据的管理:

代码语言:javascript复制
// code 2
class ZhuViewModel: ViewModel() {
 // 状态数据初始化,初始化为空字符串
    var inputStr = mutableStateOf("")
    // 状态更新方法,将新输入的内容赋值给 MutableState<T> 对象的 value 值
    fun onInputChange(inputContent: String) {
        inputStr.value = inputContent
    }
}

可以看出,ViewModel 中需要对状态进行初始化,并且提供相应的更新方法。同时 ViewModel 中不会出现任何与界面相关的对象,例如 Activity、Fragment、Context 等,为的就是解耦。

界面代码就是 Composable 函数根据 ViewModel 管理的 State 状态进行展示:

代码语言:javascript复制
// code 3
class ZhuStateActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val zhuViewModel by viewModels<ZhuViewModel>()
        setContent {InputShow(zhuViewModel)}
    }
}

@Composable
fun InputShow(viewModel: ZhuViewModel) {
    Column(Modifier.padding(20.dp)) {
        Text(text = viewModel.inputStr.value)
        TextField(
            value = viewModel.inputStr.value,
            onValueChange = { viewModel.onInputChange(it) }
        )
    }
}

TextField 组件相当于 EditText,onValueChange 可获取到用户的输入内容,在这里调用 ViewModel 中更新状态的方法。这样,所有引用了 ViewModel 中 MutableState 类型对象 inputStr 的组合项(Composable 函数),都会自动重绘更新,Text 组件就可以实时更新输入的内容了。

4. remember 关键字

其实在 code 3 中的小功能使用 ViewModel 来管理 State 状态有点小题大做了,可以用 remember 关键字来实现。这个关键字的作用如它的意思一样,“记住” 它所修饰的对象的值。下面的代码就是没有使用 ViewModel 的实现方法:

代码语言:javascript复制
// code 4
class ZhuStateActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {InputShow()}
    }
}

@SuppressLint("UnrememberedMutableState")
@Composable
fun InputShow() {
    val inputStr = mutableStateOf("Hello")
    Column(Modifier.padding(20.dp)) {
        Text(text = inputStr.value)
        TextField(
            value = inputStr.value,
            onValueChange = {
                inputStr.value = it
            }
        )
    }
}

这里没有使用 remember 会有红线提醒,我先使用 SuppressLint 去掉了报错,为的只是举个栗子,并且设置了默认展示 “Hello” 文案。运行一下,你会发现,不管输入什么,都只是展示 “Hello”,好像啥也没有发生。。。

这是为啥?加一些 log 看看:

代码语言:javascript复制
// code 5
@SuppressLint("UnrememberedMutableState")
@Composable
fun InputShow() {
    val inputStr = mutableStateOf("Hello")
    Log.d(TAG, "InputShow: Column inputStr = ${inputStr.value}")
    Column(Modifier.padding(20.dp)) {
        Text(text = inputStr.value)
        TextField(
            value = inputStr.value,
            onValueChange = {
                inputStr.value = it
                Log.d(TAG, "InputShow: onValueChange inputStr = $it")
            }
        )
    }
}

连续输入字母 w、o、r、l、d,打出来的 log 是这样的:

可见在每次输入之后,都会触发 Composable 函数重新绘制,每次都会重新初始化 inputStr 这个状态,而初始值都是一样的,所以看起来就好像输入不起作用。Composable 函数的重新绘制过程也被称之为 重组

重组:使用新的输入Event事件重新调用可组合项以更新 Compose 树的过程。这一过程会再次运行相同的 Composable 组件进行更新。

顺带说一下,Compose 首次运行渲染 Composable 组件时,会为所有被调用的 Composable 组件构建一个树,然后在重组期间会使用新的 Composable 组件去更新树。

再回到这个例子,使用 remember 关键字就可以避免每次重组时都初始化为初始值。使用后的代码为:

代码语言:javascript复制
// code 6
@Composable
fun InputShow() {
    val inputStr = remember{ mutableStateOf("Hello") }
    Column(Modifier.padding(20.dp)) {
        Text(text = inputStr.value)
        TextField(
            value = inputStr.value,
            onValueChange = {
                inputStr.value = it
            }
        )
    }
}

这样就可以正确实现功能了。其实 remember 关键字的使用是由两部分组成:

  1. key arguments:表示这次调用使用的 “键”(key),用圆括号包裹;
  2. calculation :一个 Lambda 表达式,计算得出需要存储的 “值”(value)。

所以,remember 的用法如下所示:

代码语言:javascript复制
// code 7
remember(key) { calculation: () -> T }

remember 关键字可以为 Composable 组件项提供一个数据存储空间,系统会将由 calculation Lambda 表达式计算得出的值存储到组合树中,只有当 remember 的 “键” 发生变化时,才会重新执行 calculation 计算得出 value 并存储起来;否则还是原来的值。

当然 code 6 中并没有设置 remember 的 key,这种情况下,remember 会默认该 key 没有发生变化,不会重新初始化,而是用之前的值。

需要注意的点: remember 虽然会将数据或对象存储在组合项中,但当调用 remember 的可组合项从组合树中移除后,它会忘记该数据或对象。所以,不要在有添加或移除 Composable 组件的情况下,使用 remember 将重要内容存储在 Composable 组件中,因为添加和移除都会使得数据丢失。

5. 状态提升

状态提升的概念是对于 Composable 组件来说的,根据 Composable 组件中是否含有 State 状态可分为 有状态可组合项无状态可组合项。如 code 6 中的 InputShow 组合项就是一个有状态可组合项。

5.1 有状态与无状态

Flutter 中的 Widget 也是分为 StatefulWidget 和 StatelessWidget,想不到 Compose 也借用了这个设计思想。

有状态可组合项是一种具有可随时间变化状态的 Composable 组件。再说具体一点,就是 Composable 组件里有类似于 remember 存储的状态,而且该组件会在内部保持和改变自己的状态。调用方不需要控制状态。缺点是,具有内部状态的可组合项复用性往往不高,也更难以测试。

无状态可组合项就是指无法直接更改任何状态的 Composable 组件。因为不包含任何状态数据,所以它更容易测试,复用性也更高。

如果需要将有状态组合项转变为无状态组合项,则需要 状态提升

5.2 状态提升怎么做?

Compose 中的状态提升是一种将状态移至可组合项的调用方以使可组合项无状态的模式。常规的状态提升模式是将状态变量替换为两个参数:

  1. value: T:要显示的当前值;
  2. onValueChange: (T) -> Unit:请求更改值的事件,其中的 T 是新值

这种方式提升的状态具有一些重要的属性:

  1. 单一可信来源: 状态提升并不是将状态复制,而是将状态移动到上层的可组合项中,这样可确保只有一个可信来源,减少数据不一致所导致的 bug;
  2. 封装: 只有有状态可组合项可以修改其状态,可以理解为是内部“自治”的;
  3. 可共享: 提升后的状态可以与多个可组合项共享;
  4. 可拦截: 无状态可组合项的调用方可以在更改状态之前决定忽略或者修改事件;
  5. 解耦: 无状态可组合项的状态可以存储在任何位置,如 ViewModel 中。

具体怎么做可以看下面的一个小栗子。

5.3 状态提升小栗子

根据上述所说,很容易就可以得知 code 6 的 InputShow Composable 组件是一个有状态的可组合项,它包含一个状态变量 inputStr,所以,我们要将这个 MutableState 用两个参数进行替换,一个是要显示的当前值;另一个是 Lambda 表达式,用于请求更改值的事件,就可以将其改写为一个无状态可组合项。如下 code 8 所示:

代码语言:javascript复制
// code 8 无状态可组合项 InputShow
@Composable
fun InputShow(inputText: String, onInputChange: (String)-> Unit) {
    Column(Modifier.padding(20.dp)) {
        Text(text = inputText)
        TextField(
            value = inputText,
            onValueChange = onInputChange
        )
    }
}

那状态提升到哪里去了呢?通常会提升到它的父组件中,那么父组件就是一个有状态的可组合项了,这个例子中 InputShow 的父组件这里定义为 InputShowContainer:

代码语言:javascript复制
// code 9
@Composable
fun InputShowContainer() {
    val (inputStr, setInput) = remember{ mutableStateOf("") }
    InputShow(inputStr, setInput)
}

嗯?MutableState 的声明与之前的不太一样了,多出来的这个 setInput 也是一个 Lambda 表达式,用于更新值。其实,声明 MutableState 对象的方法总共有三种:

  1. val mutableState = remember{ mutableStateOf(default) }
  2. val value by remember{ mutableStateOf(default) }
  3. val (value, setValue) = remember{ mutableStateOf(default) }

所以这里用的是第三种声明方法。这样,InputShow 组合项就经过状态提升变为了无状态的可组合项了。官方在这里还特意说明,在 Composable 组件中创建 State<T>(或其他有状态对象)时,务必对其执行 remember 操作,否则它会在每次重组时重新初始化。

6. 状态存储的其他方式

由前述所说,remember 关键字可存储组合项中的状态,但是一旦组合项被移动,这些状态就丢失了,那如果涉及到横竖屏切换等 Activity 重建的应用场景,该怎么办呢?虽然保存在 ViewModel 中可以解决问题,但总有点小题大做了。下面是状态存储的一些其他的方式。

6.1 rememberSaveable

这个与 remember 类似,主要用于 Activity 或进程重建时,恢复界面状态。还是上面 code 6 的栗子,可以试试横竖屏切换或其他配置项更改,会发现使用 remember 关键字时,切换后就回到初始空白值了。改为 rememberSaveable 后切换后输入的内容可以保存下来而不会被重置。

这么看的话,rememberSaveable 有点像是 override fun onSaveInstanceState(outState: Bundle)方法了,确实是这样的,任何可以存储在 Bundle 对象中的数据都可以通过 rememberSaveable 进行保存。无法用 Bundle 进行保存的数据,可以用下面的方式进行存储。

6.2 Parcelize

最简单的解决方法就是在对象上添加 @Parcelize 注解,对象就可以转化为可打包状态且可以捆绑。还记得 Java 中的 Serializable 接口吗?是一样的作用,都是将实例对象编码成字节流进行存储。

在日常 Android 开发中如果不涉及到本地化存储或者网络传输的情况,推荐使用 Parcelable,因为相比于 Serializable 它不会产生大量临时对象,没有使用反射,效率更高。但很多时候不想写 Parcelable 接口的模板代码,那么就可以使用这个注解!下面是样例及使用步骤:

代码语言:javascript复制
// code 10
// app/build.gradle
plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-parcelize'    // 第一步:添加此插件
}

@Parcelize    // 第二步:添加注解及 Parcelable 接口
data class City(val name: String, val country: String) : Parcelable

// 这样就可以将其保存到状态中
val cityBean = rememberSaveable{ mutableStateOf(City("0112","西京"))}

终于,Parcelable 和 Serializable 接口一样好用了!

6.3 MapSaver

Compose 还考虑到有些情况下 Parcelize 不适用的场景,那么还可以使用 MapSaver 来定义自己的存储和恢复规则,规定如何把对象转为可保存到 Bundle 中的值。

代码语言:javascript复制
// code 11
data class Book(val name: String, val author: String)

val BookSaver = run {
    val nameKey = "Name"
    val authorKey = "Author"
    mapSaver(
        save = { mapOf(nameKey to it.name, authorKey to it.author) },
        restore = { Book(it[nameKey] as String, it[authorKey] as String) }
    )
}

val chosenBook = rememberSaveable( stateSaver = BookSaver ) {
 mutableStateOf(Book("三体","刘慈欣"))
}

核心在 BookSaver 这个 Saver 对象,通过 save 这个 lambda 可以将 Book 对象转化为一个 Map 进行存储;要使用的时候就通过 restore 这个 lambda 将 Map 又恢复为一个 Book 对象。

6.4 ListSaver

MapSaver 需要自己去定义 Key 值,但使用 ListSaver 就可以不用自己定义 Key,本质上是把对象放在一个 List 中存储,所以它是使用索引作为 Key。

代码语言:javascript复制
// code 12
val BookListSaver = listSaver<Book, Any>(
    save = { listOf(it.name, it.author) },
    restore = { Book(it[0] as String, it[1] as String) }
)

使用起来与 MapSaver 一样,只不过存储的数据结构不一样罢了。实际上,MapSaver 底层也是用 ListSaver 实现的。

总结

最后来个总结吧。

  • Compose 为了实现解耦将界面和数据分离开来,分别称之为 组合 与 State 状态。为了达到状态改变自动重组界面的目的,引入了 MutableState<T> 来存储 State 状态的容器。
  • MutableState<T> 的 value 一旦改变,所有引用它的 Composable 组件都会重组,从而保证了数据与显示的一致性。此外,为了保证每次重组时 State 状态不会被初始化为初值,Compose 引入 remember 关键字来将数据存储在相应的 Composable 组件中。
  • remember 关键字是根据传入的键是否改变来返回相应的值。键改变了则返回初值;键未变则返回上次存储的值。不设置键,则默认键始终不变,即始终取上次的值。
  • 为了解决 remember 关键字不能在 Activity 重建等场景下保存数据而引入了 rememberSaveable、MapSaver、ListSaver 等状态保存及恢复的方法。
  • Compose 把 Composable 组件分为有状态与无状态两类,内部含有 State 状态的就为有状态可组合项;反之则为无状态组合项。无状态组合项复用性更高,而有状态组合项可以自己管理State状态。通过状态提升可以将有状态组合项转化为无状态组合项。
  • Compose 推荐使用 ViewModel 来管理状态,包括状态的更新以及存储等。

参考文献

  1. 官方文档——在Jetpack Compose 中使用状态 https://developer.android.google.cn/codelabs/jetpack-compose-state?
  2. Compose 状态与组合 新小梦 https://juejin.cn/post/6937560914254102565
  3. 【背上Jetpack之ViewModel】即使您不使用MVVM也要了解ViewModel ——ViewModel 的职能边界 Flywith24 https://juejin.cn/post/6844904100493017095
  4. Jetpack Compose学习之mutableStateOf与remember是什么 柚子君下 https://blog.csdn.net/weixin_43662090/article/details/116120540
  5. 官方文档——状态和 Jetpack Compose https://developer.android.google.cn/jetpack/compose/state

赠人玫瑰,手留余香。欢迎点赞转发~

0 人点赞