卷 III · CH 12 · 深度 12/18

聊天列表难在身份、滚动与窗口变化

能显示十条气泡不算完成;插入、分页、键盘、大屏与读屏器同时出现时,列表才开始像产品。

LAZY LISTADAPTIVEACCESSIBILITY
▷ 先答一下

消息列表在顶部插入一页历史记录,哪种 key 最安全?

  1. 当前索引
  2. 消息文本
  3. 稳定且唯一的消息 ID
  4. 每次组合生成的随机 UUID

Lazy 只创建附近内容,不替你定义身份

LazyColumn 只组合可见区域附近的 item,但默认身份仍靠位置。顶部插入历史后,旧第 0 项变成第 20 项;若没有业务 key,remember 状态、动画与图片请求可能跟错消息。索引不是身份,文本也可能重复,随机 key 则让每次都像全新内容。

LazyColumn(
    state = listState,
    reverseLayout = true
) {
    items(
        items = messages,
        key = { it.id.value },
        contentType = { it.kind }
    ) { message ->
        MessageRow(message)
    }
}

contentType 告诉 Lazy 容器哪些项目结构可复用。日期分隔、文字消息、图片消息的布局差异很大,分类能避免拿错误结构反复测量。key 保身份,content type 保复用类别,职责不同。

可以带走的判断:列表性能的第一步不是缓存,而是给每个业务对象一个稳定身份。

新消息不等于永远自动滚底

用户正在底部看最新消息时,新消息可自动保持底部;用户已经向上翻历史时,强行滚动会抢走阅读位置。需要从 LazyListState 派生「是否接近底部」,只在符合条件时滚动,否则显示「有新消息」入口。

键盘弹出、输入框高度变化、系统栏 inset 都会改变可用空间。使用 imePadding、安全区域 inset 与稳定滚动政策,而不是用固定屏幕高度算偏移。图片消息还应先有宽高比占位,避免加载后列表整体跳动。

分页的真相仍该落到数据层

Paging 3 可以把 Room 与网络 RemoteMediator 接起来:UI 观察本地分页,Mediator 在边界取远端页并事务写库。这样加载、刷新、重试有统一状态,旋转后不必从头请求。聊天记录若有严格顺序,要用服务端游标或序号分页,不能只靠客户端时间戳。

大屏与折叠屏上,导航列表和对话详情可以同时出现。不要把「手机竖屏」写死成业务状态;根据窗口尺寸和可用空间选一栏、双栏或支撑布局。Android 17 时代,窗口可变是常态,不再是平板特例。

可访问性是信息结构测试

读屏器需要知道一条气泡是谁、何时、什么状态,而不是把头像、姓名、正文、双勾拆成无意义的四个焦点。用语义合并与清晰 contentDescription;触控目标留足尺寸;颜色不能是发送失败的唯一信号;动态字号下不要锁死高度。

性能回答先说 key、不可变 item、分页与图片占位,再说基准。不要用「LazyColumn 天生高性能」收口;测量、组合、图片解码和状态读取都可能成为瓶颈。
⌨ 自己跑一遍

做一个可观察身份的最小列表。顶部插入新记录后,已展开状态应继续跟着原消息 ID:

插入前:m2 expanded=true,位置 1
插入后:m2 expanded=true,位置 2
身份跟消息走,不跟索引走
@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages, key = { it.id }) { message ->
            var expanded by rememberSaveable(message.id) {
                mutableStateOf(false)
            }
            MessageRow(
                message = message,
                expanded = expanded,
                onToggle = { expanded = !expanded }
            )
        }
    }
}

在 Android Studio 预览或模拟器里放三条消息,展开第二条,再往列表头插一条。先删掉 key 对比,然后恢复。用 Layout Inspector 查看重组与节点身份。

▸ 在现实里

公开评论里曾有人反馈大量消息同时到达时顺序和滚动体验不佳。这里不能据此推断具体实现,但它提醒你:聊天质量横跨服务端排序、离线合并、本地查询、稳定 key、分页与滚动政策,绝不是单个列表组件的题。

✗ 这个直觉是错的

「用了 LazyColumn 就不需要关心性能。」Lazy 只控制组合范围;错误 key、大对象复制、同步图片解码和高频顶层状态仍会卡。

先保证身份稳定与数据分页,再缩小状态读取、延迟图片工作,并用 release 基准验证。可访问性与自适应从信息结构开始,不是最后补丁。

◇ 面试收口

答案是第 3 项。消息 ID 在插入、重排和分页后保持稳定且唯一。索引会移动,文本会重复,随机 UUID 每次重组都变;后三者都会让运行时误认业务身份。

这一章的一句话

一条消息必须同时拥有稳定的数据顺序、稳定的 UI 身份和适应变化窗口的布局。

下一章把 UI 之外的全部接起来:如果 ViewModel 只是把 Repository 的函数一一转发,所谓 MVVM 并没有产生架构,只增加了一个文件。