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

2023-11-05

系列第五篇,进入 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> 是个接口:

// code 1
interface MutableState<T>: State<T> {
	override var value: T
}

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

3、一个简单例子

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

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

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

// 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 状态进行展示:

// 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 的实现方法:

// 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 看看:

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

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

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

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

// 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 的用法如下所示:

// 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 所示:

// 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:

// 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 接口的模板代码,那么就可以使用这个注解!下面是样例及使用步骤:

// 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 中的值。

// 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。

// 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

更多内容,欢迎关注我的同名公众号留言交流~

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

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

随机推荐

  • 提升用户体验:Vue与compressor.js实现高效文件压缩

    前言 上传文件是一个常见的需求 并且文件大小往往成为限制因素之一 为了提升用户体验和节省带宽消耗 上传时的文件压缩便显得格外重要 本文将介绍基于 Vue 框架和 compressor js 的上传时文件压缩实现方法 通过在上传过程中对文件进
  • Apollo代码学习(六)—模型预测控制(MPC)

    Apollo代码学习 模型预测控制 前言 模型预测控制 预测模型 线性化 单车模型 滚动优化 反馈矫正 总结 前言 非专业选手 此篇博文内容基于书本和网络资源整理 可能理解的较为狭隘 起点较低 就事论事 如发现有纰漏 请指正 非常感谢 查看
  • 前端路上的旅行

    http www w3cplus com front end trip on road html 什么是前端 什么是前端 大部分指的是Web前端开发 这个词是从网页制作演变过来的 名称上有着很明显的时代特征 在互联网的演化过程中 网页制作是
  • 着色器glsl

    着色器使用glsl的类C语言写成的 着色器开头声明版本 接着是输入和输出变量 uniform和main函数 每个着色器入口点是main函数 在该函数中处理输入变量 将结果存放到输出变量中 着色器结构如下 version version nu
  • 走进开源:认识开源许可

    想要查看前面的笔记请翻阅我的CSDN博客 作者码字不易 喜欢的话点赞 加个关注吧 后期还有很多干货等着你 最近一直在研究开源项目 发现对开源项目的开源许可有一些模糊 现在记录下来算是一个巩固 1 走进开源 开源 一词对应英文 Open So
  • Unity project 发布成Standalone EXE版本后,它的Debug infomation在什么位置

    一 Unity工程发布成Standalone Exe后 它的Debug 信息保存位置如下 二 示例 三 log文件
  • Openwrt开发笔记(3)—— 修改路由的网关地址和无线SSID 密码

    修改网关地址 一般来说默认的网关地址是192 168 1 1 如果我们想要定制自己的网关地址的话 可以选择在刷机后使用web配置页进行修改 或者通过指令进行修改 对于项目开发者而言 在源码中修改更加符合要求 轩面介绍指令修改和源码修改的2中
  • MQTT协议-使用CONNECT报文连接阿里云

    使用网络调试助手发送CONNECT报文连接阿里云 参考 https blog csdn net daniaoxp article details 103039296 在前面文章介绍了如何组装CONNECT报文 以及如何计算剩余长度 CONN
  • anaconda换源和pip换源

    在开发中我们经常会用到第三方的包 但是由于墙的限制导致了下载国外的包 普遍偏慢 这里我们可以修改为国内的镜像源 下载速度自然就会快起来 一 pip换源 国内的镜像源有很多 但是好用的就是阿里和清华的 这里我们选择清华的镜像源 别问为什么 问
  • 全网最强,Python接口自动化测试实战-接口参数关联(购物实例)

    目录 导读 前言 一 Python编程入门到精通 二 接口自动化项目实战 三 Web自动化项目实战 四 App自动化项目实战 五 一线大厂简历 六 测试开发DevOps体系 七 常用自动化测试工具 八 JMeter性能测试 九 总结 尾部小
  • element ui的upload 手动上传头像(复制就能用)

    之前在网上看了好多 结果给的代码都不全 整了快一天 才整好 心态都崩了 想砸电脑 这里贴出来愿后来人省力 下面的代码除了最下面的axios请求需要和自己的匹配之外 其他的可以直接复制使用了 服务器接口处理函数 这里只是处理函数 其余的部分没
  • 2020软件测试最新视频教程大合集汇总

    软件测试入门教程分享给你 软件测试工程师需要适当掌握一些技能 如操作系统 WIN UNIX这个你可以有针对性的去找着学数据库 常见的有SQLSERVER ORACLE 熟悉常用的编程语言 C C JAVA测试工具 这个就比较多一些 建议循序
  • js逆向——破解百度翻译

    针对进行了ajax加载的网页 有两种方法去爬取它当中的内容 1 使用selenium进行模拟浏览器进行选择元素 然后进行爬取 这种方法最简单 但是牺牲了速度 爬虫关键就在速度 因此针对大量的数据的话 就远远不能满足了 2 使用js逆向破解
  • 解决:IE浏览器打开就自动最小化,无法打开

    方法一 先把所有的IE窗口关了 只打开一个IE窗口 最大化这个窗口 关了它 OK 以后的默认都是最大化的了 方法二 先关闭所有的IE浏览器窗口 用鼠标右键点击快速启动栏的IE浏览器图标 在出现的快捷菜单中点击 属性 系统随即弹出 启动Int
  • JavaScript cookie实现html的select标签刷新后不回到默认值而是保持之前选择值

  • devops-1-docker安装

    TOC源码安装docker yum安装 查看信息 curl o etc yum repos d Centos 7 repo http mirrors aliyun com repo Centos 7 repo yum mache cat e
  • 关于运行PR提示vcruntime140.dll无法继续执行代码的4个解决方法分享

    关于运行安 PR提示vcruntime140 dll无法继续执行代码的困扰 小编将为您提供详细的解决方法 在此之前 我们需要了解一下vcruntime140 dll文件的作用 vcruntime140 dll 是 Visual C Redi
  • 一个U盘制作多个系统镜像方法 ——适用于ARM架构UOS和KYLIN银河麒麟系统

    一个U盘制作多个系统镜像方法 适用于ARM架构UOS和KYLIN银河麒麟系统 一 背景 作为一个喜欢收集系统镜像的同学 加起来收集的ARM架构和X86架构系统镜像少则10个 每装一次系统都要刻录一次U盘 且等待刻录的时间也挺让人着急 为了尽
  • 使用EF Core 连接远程oracle 不需要安装oracle客户端方法

    连接字符串 Data Source DESCRIPTION ADDRESS PROTOCOL TCP HOST IP地址 PORT 1521 CONNECT DATA SERVICE NAME orcl Persist Security I
  • Jetpack-Compose 学习笔记(五)—— State 状态是个啥?又是新概念?

    系列第五篇 进入 Compose 中有关 State 状态的学习 前面几篇笔记讲了那么多内容 都是基于静态界面的展示来说的 即给我一个不变的数据 然后将它展示出来 如何在 Compose 中构建一个随数据而变化的动态界面呢 相信看完这篇就知