当 Jetpack Compose State 遇到 LiveData

973 阅读5分钟

前言

在 Jetpack Compose 中,State 本身就充当了数据容器的作用,当其包含的数据发生变化时,可以触发相应 Compose 函数的重组,实现 UI 变化。而 Jetpack 系列组件中,LiveData/Flow 也扮演了类似的角色,而当 State 和 LiveData 结合使用的时候,正确的姿势又该是什么? 这里学习一下。

LiveData

chat.gif

之前在 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 版本之后,发现这么干不好使了。

bad.jpg

把 1dp 的 Text 组件放大之后,他的内容的确在变化。但是 LazyColumn 还是老样子,只能刷新一次。 Google 修复了这个漏洞 ,我代码中存在的 Bug 还是原形毕露了 。

no.webp

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 的刷新。