前言
在 Jetpack Compose 中,State 本身就充当了数据容器的作用,当其包含的数据发生变化时,可以触发相应 Compose 函数的重组,实现 UI 变化。而 Jetpack 系列组件中,LiveData/Flow 也扮演了类似的角色,而当 State 和 LiveData 结合使用的时候,正确的姿势又该是什么? 这里学习一下。
LiveData
之前在 Android 处理流式响应 一文中,用 Jetpack Compose 列表处理类似 ChatGPT 聊天场景的流式响应时,其实遇到过一个问题,LiveData 持有的数据更新之后,LazyColumn 对应的对应的 Compose 函数只会触发一次调用,即便可以看到数据在一直变化,但是 UI 始终不刷新。
具体情况如下
@Composable
fun ChatScreen(
viewModel: ChatViewModel = viewModel(),
@PreviewParameter(ChatUIWithKeyboardPre::class, 1) chatUIWithKeyboard: ChatUIWithKeyboard
) {
var inputValue by remember { mutableStateOf("") }
val msg by viewModel.messageList.observeAsState(ArrayList<ChatMessage>())
val kk by viewModel.kkk.observeAsState("11")
viewModel.messageList.observe(LocalLifecycleOwner.current) {
if (it.size > 1) {
Log.e("ChatListPage", "list = ${it[1].text}")
}
}
Column(modifier = Modifier.fillMaxSize()) {
Text(text = kk, modifier = Modifier.size(1.dp))
LazyColumn(
) {
items(msg.size, key = { it }, contentType = {msg[it].sender}) { index ->
ChatMessageItem(message = msg[index])
}
}
// 输入框和发送按钮
InputArea(inputValue = inputValue, viewModel = viewModel, messageText = {
inputValue = it
}) {
chatUIWithKeyboard.hideKeyboard()
}
}
}
从日志中可以看到 messageList 这个 LiveData 的输出和预期是一致的,但是 LazyColumn 的重组却和预期有巨大的差异。当时还以为是 Compose 的问题,无奈之下用了一个非常 trick 的方式,在页面中添加了一个 1dp 大小的 Text 组件 。
Text(text = kk, modifier = Modifier.size(1.dp))
同时绑定了一个无意义的 LivaData,这个 LiveData 的内容会和 messageList 的值一起刷新,就这样借着 这个页面上看不见的 1dp Text 强行实现了列表的刷新。
private fun sendData(sb: StringBuffer, mockResponse: CharArray) {
viewModelScope.launch {
for (c in mockResponse) {
val history = _messageList.value ?: ArrayList()
val lastMsg = history.last()
sb.append(c)
kkk.postValue(sb.toString())
if (lastMsg.sender == "Bot") {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history[history.size -1 ] = newMsg
_messageList.value = history
} else {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history.add(newMsg)
_messageList.value = history
}
delay(10)
Log.d(TAG, "history ${_messageList.value}")
}
}
}
这里除了正常的处理流式相应的数据之外,还会额外更新 kkk 这个 LiveData 的值,从而用曲线救国的方式实现聊天列表的刷新。
然而,最新升级新版本的 Compose 版本之后,发现这么干不好使了。
把 1dp 的 Text 组件放大之后,他的内容的确在变化。但是 LazyColumn 还是老样子,只能刷新一次。 Google 修复了这个漏洞 ,我代码中存在的 Bug 还是原形毕露了 。
State 的变化
凭借多年掉在坑里的经验,感觉 State 似乎没有感知到 LiveData 的变化。那么在 Compose 中 LiveData 和 State 之间是如何实现通信的呢?带着这个问题,只能阅读源码去分析和解决问题了。
val msg by viewModel.messageList.observeAsState(ArrayList<ChatMessage>())
我们从连接 LiveData 和 State 的这个 observeAsState 扩展函数出发。
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
val state = remember {
@Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
mutableStateOf(if (isInitialized) value as T else initial)
}
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}
如果忽略 LiveData 和 lifecycleOwer 之间关于组件生命周期的部分,这里关于 observeAsState 的调用可以简化成如下的样子。
val msg by remember { mutableStateOf(ArrayList<ChatMessage>()) }
mutableStateOf 这个函数的实现通过层层调用链的调整,最终会落到这里
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
...
}
这个类有两个参数,
- value:T 即我们传入的具体类型,在我们上面的例子里就是
ArrayList<ChatMessage>() - policy: SnapshotMutationPolicy ,这个参数有默认值
private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
override fun equivalent(a: Any?, b: Any?) = a == b
override fun toString() = "StructuralEqualityPolicy"
}
顾名思义,就是用来定义 SnapshotMutation 是否变化的策略,而这里只直接判断两个对象是否相等。
我们再回到 SnapshotMutableStateImpl 的实现,可以看到 value 这个参数有自定义访问器。
- get 方法返回的是 next 这个参数的值,而 next 的默认值就是我们在参数实例化这个类的时候传入的初始值,存储在 StateStateRecord 这样一个数据类里。
- set 方法,会根据传入的 value 和之前在 next 容器中的值作比较,只有 equivalent 方法确定其发生变化之后才会进行对 next 的更新。
到这里,其实就很清楚了,State 是以泛型对象的实例发生变化来确定是否更新其内部维护的数据,如果泛型对象本身没有变化,通过 get 方法返会的值就不会有变化,这样就无法促成相应的 Compose 函数进行重组了。
引用和赋值
了解了以上信息,我们再回过头去 review 之前的代码,就会很容发现问题。
if (lastMsg.sender == "Bot") {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history[history.size -1 ] = newMsg
_messageList.value = history
} else {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history.add(newMsg)
_messageList.value = history
}
这里我们更新 LiveData 的时候,始终用的是同一个变量,history 只在初次调用的时候初始化过一次 val history = _messageList.value ?: ArrayList(), 这样虽然从 LiveData 的角度出发值是有变化的,但是从 State 的角度出发是没有变更的。因而,自然无法触发 Compose 函数的重组,找到了问题原因解决方案自然就很简单了。
if (lastMsg.sender == "Bot") {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history[history.size -1 ] = newMsg
_messageList.value = ArrayList(history)
} else {
val newMsg = ChatMessage("Bot", sb.toString(), false)
history.add(newMsg)
_messageList.value = history
}
只需要在后续更新的时候,返回一个新的 ArrayList ,确保 State 内部 equivalent 能感知到对象的变化即可。当然,这样每次都需要创建一个新的 ArrayList 临时变量,在列表数据频繁变化的场景,可能会有性能问题,但是似乎也没有办法,毕竟 observeAsState 这个接口暂时没有支持让开发者主动传入自定义 SnapshotMutationPolicy ,貌似没有办法改变其内部判断数据是否不相等的策略。
经过这样的修改之后,LazyColumn 终于可以正常刷新了,也不用在借助 1dp 的 Text 暗度陈仓了。 以上内容具体代码可以参考 ChatActivity
View 体系的列表
我们可以简单回顾一下在传统的 View 体系中,结合 ViewModel+LiveData 刷新列表的操作。
viewModel.messageList.observe(LocalLifecycleOwner.current) {
adapter.updateDatas(it)
}
fun updateDatas(inputs: List<ChatMessage>) {
datas.clear()
datas.addAll(inputs)
notifyDataSetChanged()
}
其实,关于这里并没有比 Compose 的场景更优。这里本质上是在 LiveData 和 Adapter 内各自维护了一份列表,LiveData 内数据更新的时候,由开发者完成了数据的交换。
很多初学者在使用 RecyclerView + Adapter 的时候,很容易因为使用用一份数据,把 updateDatas 定义成下面这样
fun updateDatas(inputs: List<ChatMessage>) {
datas = inputs
notifyDataSetChanged()
}
导致列表的数据刷新出现异常。而从这个坑里爬出来之后,就学会用两个列表来维护 RecyclerView 的数据了。
总结
Jetpack Compose 依赖 State 内数据的变化实现对应的 Compose 函数发生重组来实现 UI 的刷新,而当 State 内封装的数据变成非基础类型的数据时,务必要确保每次更新整个对象,这样才能让按照预期实现 UI 的刷新。