28、实战项目四:即时通讯 UI(WebSocket + 列表性能优化 + 输入框 + 表情面板)

终于到了第四个实战项目。说实话,即时通讯这个场景,几乎是每个移动开发者都绕不开的坎。不管是做社交、客服,还是企业内部协作,聊天界面都是标配。我自己在早期做 IM 时踩过不少坑,尤其是列表卡顿和输入框被键盘顶飞的问题,一度让我怀疑人生。

这一章,我们就来手撸一个即时通讯的 UI 层。核心包括四个部分:WebSocket 连接管理聊天列表的性能优化输入框的交互细节,以及表情面板的实现。嗯,内容不少,但每一步我都会把关键点讲透。

整体架构思路

先看一张图,理清我们这一章要搭建的模块关系:

即时通讯 UI 模块架构 WebSocket 连接 消息数据源 UI 状态管理 聊天列表(LazyColumn) 输入框 + 表情面板 性能优化:diff + 缓存 + 异步加载

说白了,整个流程就是:WebSocket 收到消息 → 数据源更新 → UI 状态刷新 → 列表或输入框响应。每个环节都有坑,我们一个一个来填。

WebSocket 连接管理

在 Android 里做 WebSocket,我推荐直接用 OkHttp 自带的 WebSocket 支持。不需要额外引入库,省心。

先看一个基础封装:

class ChatWebSocket(private val url: String) {
    private val client = OkHttpClient()
    private var webSocket: WebSocket? = null

    fun connect(
        onMessage: (String) -> Unit,
        onError: (Throwable) -> Unit
    ) {
        val request = Request.Builder().url(url).build()
        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onMessage(webSocket: WebSocket, text: String) {
                onMessage(text)
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                onError(t)
                // 自动重连逻辑
                reconnect(onMessage, onError)
            }
        })
    }

    fun send(message: String) {
        webSocket?.send(message)
    }

    fun disconnect() {
        webSocket?.close(1000, "用户主动断开")
    }

    private fun reconnect(onMessage: (String) -> Unit, onError: (Throwable) -> Unit) {
        // 延迟 3 秒重连
        Handler(Looper.getMainLooper()).postDelayed({
            connect(onMessage, onError)
        }, 3000)
    }
}
个人经验:重连逻辑一定要加指数退避,否则服务器压力大时,客户端疯狂重连会雪上加霜。我早期项目里就吃过这个亏,后来改成 1s、2s、4s、8s 递增,稳定多了。

聊天列表性能优化

聊天列表和普通列表不一样。消息会不断插入,而且每条消息的布局可能不同(文字、图片、语音、表情)。如果处理不好,滑动起来就像幻灯片。

在 Jetpack Compose 里,我们用 LazyColumn 配合 keycontentType 来优化。

1. 使用稳定的 key

每个消息项必须有一个唯一且稳定的 key。我习惯用消息的 id 或者服务器返回的 msgId

LazyColumn(
    modifier = Modifier.fillMaxSize()
) {
    items(
        items = messages,
        key = { it.id }
    ) { message ->
        MessageItem(message)
    }
}

为什么 key 这么重要?因为 Compose 靠 key 来识别哪些项需要重组。没有 key 或者 key 不稳定,会导致整个列表频繁重组,卡顿就来了。

2. 使用 contentType 区分不同布局

如果消息类型多样,比如文本、图片、系统提示,可以用 contentType 告诉 LazyColumn 哪些项可以复用布局。

items(
    items = messages,
    key = { it.id },
    contentType = { it.type }
) { message ->
    when (message.type) {
        "text" -> TextMessageItem(message)
        "image" -> ImageMessageItem(message)
        "system" -> SystemMessageItem(message)
    }
}
避坑指南:我曾经在 contentType 里返回了动态变化的字符串,结果 LazyColumn 的缓存机制完全失效。后来统一用枚举或者固定字符串,性能才恢复正常。

3. 异步加载图片和表情

聊天列表里如果有图片,一定要用异步加载库,比如 Coil 或 Glide。不要在列表项里直接做网络请求或大文件解码。

@Composable
fun ImageMessageItem(message: Message) {
    AsyncImage(
        model = message.imageUrl,
        contentDescription = "聊天图片",
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(max = 200.dp)
    )
}

输入框与键盘交互

输入框这块,最头疼的就是键盘弹起时布局被顶飞,或者表情面板和键盘切换时出现闪跳。

我的做法是:imePadding() 配合 WindowInsets 来控制布局偏移

Column(
    modifier = Modifier
        .fillMaxSize()
        .imePadding()  // 关键:自动处理键盘避让
) {
    // 聊天列表
    LazyColumn(
        modifier = Modifier
            .weight(1f)
            .fillMaxWidth()
    ) { ... }

    // 输入区域
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
    ) {
        TextField(
            value = inputText,
            onValueChange = { inputText = it },
            modifier = Modifier.weight(1f)
        )
        IconButton(onClick = { sendMessage() }) {
            Icon(Icons.Default.Send, contentDescription = "发送")
        }
    }
}

这里有个细节:imePadding() 只对当前 Column 生效,不会影响全局布局。如果你用 Scaffold,记得在 content 里加 imePadding()

表情面板实现

表情面板说白了就是一个网格列表,点击后把表情插入到输入框。但要注意两点:

  • 表情面板要能平滑切换(和键盘互斥)
  • 表情要支持搜索或分类

我一般用 LazyVerticalGrid 来展示表情,点击后通过回调更新输入框内容。

@Composable
fun EmojiPanel(
    onEmojiSelected: (String) -> Unit
) {
    val emojis = listOf("😀", "😂", "😍", "👍", "🎉", "❤️", "🔥", "💯")

    LazyVerticalGrid(
        columns = GridCells.Fixed(6),
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
    ) {
        items(emojis) { emoji ->
            Text(
                text = emoji,
                fontSize = 28.sp,
                modifier = Modifier
                    .padding(4.dp)
                    .clickable { onEmojiSelected(emoji) }
            )
        }
    }
}
小技巧:表情面板的高度最好固定,不要随内容变化。否则和键盘切换时,布局会上下跳动,用户体验很差。我一般固定 200dp 或 250dp。

整合到一起

最后,我们把所有模块拼起来。核心思路是:WebSocket 收到消息 → 更新 ViewModel 中的消息列表 → LazyColumn 自动重组

class ChatViewModel : ViewModel() {
    private val _messages = mutableStateListOf<Message>()
    val messages: List<Message> get() = _messages

    private val webSocket = ChatWebSocket("wss://your-server.com/chat")

    init {
        webSocket.connect(
            onMessage = { text ->
                val msg = parseMessage(text)
                _messages.add(msg)
            },
            onError = { /* 处理错误 */ }
        )
    }

    fun sendMessage(text: String) {
        val msg = Message(id = UUID.randomUUID().toString(), content = text, type = "text")
        _messages.add(msg)
        webSocket.send(text)
    }
}

在 Compose 里,直接观察 messages 即可:

@Composable
fun ChatScreen(viewModel: ChatViewModel) {
    val messages by viewModel.messages

    Column(modifier = Modifier.imePadding()) {
        LazyColumn(
            modifier = Modifier.weight(1f),
            reverseLayout = true  // 新消息在底部
        ) {
            items(messages, key = { it.id }) { msg ->
                MessageItem(msg)
            }
        }

        ChatInputBar(onSend = { viewModel.sendMessage(it) })
    }
}
注意:reverseLayout = true 会让列表从底部开始显示,新消息插入时自动滚动到底部。但如果你同时用了 LazyColumnscrollToItem,要注意索引计算方式会反过来。

总结

这一章我们走完了即时通讯 UI 的核心流程。从 WebSocket 连接、消息数据管理,到列表性能优化、输入框交互,再到表情面板,每一步都有实战中积累的细节。

我个人觉得,IM 项目是最能检验 Compose 基本功的。列表性能、状态管理、异步处理,全都会遇到。如果你能把这一章的内容吃透,后面做其他复杂 UI 也会轻松很多。

嗯,代码都在上面了。建议你动手敲一遍,尤其是重连逻辑和列表 key 的设置,踩过坑才能真正记住。


公众号:蓝海资料掘金营,微信deep3321