28、实战项目四:即时通讯 UI(WebSocket + 列表性能优化 + 输入框 + 表情面板)
终于到了第四个实战项目。说实话,即时通讯这个场景,几乎是每个移动开发者都绕不开的坎。不管是做社交、客服,还是企业内部协作,聊天界面都是标配。我自己在早期做 IM 时踩过不少坑,尤其是列表卡顿和输入框被键盘顶飞的问题,一度让我怀疑人生。
这一章,我们就来手撸一个即时通讯的 UI 层。核心包括四个部分:WebSocket 连接管理、聊天列表的性能优化、输入框的交互细节,以及表情面板的实现。嗯,内容不少,但每一步我都会把关键点讲透。
整体架构思路
先看一张图,理清我们这一章要搭建的模块关系:
说白了,整个流程就是: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)
}
}
聊天列表性能优化
聊天列表和普通列表不一样。消息会不断插入,而且每条消息的布局可能不同(文字、图片、语音、表情)。如果处理不好,滑动起来就像幻灯片。
在 Jetpack Compose 里,我们用 LazyColumn 配合 key 和 contentType 来优化。
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)
}
}
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) }
)
}
}
}
整合到一起
最后,我们把所有模块拼起来。核心思路是: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 会让列表从底部开始显示,新消息插入时自动滚动到底部。但如果你同时用了 LazyColumn 的 scrollToItem,要注意索引计算方式会反过来。
总结
这一章我们走完了即时通讯 UI 的核心流程。从 WebSocket 连接、消息数据管理,到列表性能优化、输入框交互,再到表情面板,每一步都有实战中积累的细节。
我个人觉得,IM 项目是最能检验 Compose 基本功的。列表性能、状态管理、异步处理,全都会遇到。如果你能把这一章的内容吃透,后面做其他复杂 UI 也会轻松很多。
嗯,代码都在上面了。建议你动手敲一遍,尤其是重连逻辑和列表 key 的设置,踩过坑才能真正记住。
公众号:蓝海资料掘金营,微信deep3321