设计 IM 客户端
问题
如何设计 Android 即时通讯(IM)客户端架构?
答案
整体架构
消息收发流程
消息存储模型
@Entity(tableName = "messages")
data class Message(
@PrimaryKey val clientMsgId: String, // 客户端生成,保证幂等
val serverMsgId: Long? = null, // 服务端分配
val conversationId: String,
val senderId: String,
val content: String,
val type: MessageType, // TEXT, IMAGE, VOICE, VIDEO
val status: MessageStatus, // SENDING, SENT, DELIVERED, READ, FAILED
val timestamp: Long,
val localPath: String? = null // 本地文件路径(图片/语音)
)
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
fun getMessages(convId: String, limit: Int, offset: Int): Flow<List<Message>>
@Upsert
suspend fun upsert(message: Message)
}
心跳与重连
class ConnectionManager(private val scope: CoroutineScope) {
private var heartbeatJob: Job? = null
private var retryCount = 0
fun connect() {
webSocket = okHttpClient.newWebSocket(request, listener)
startHeartbeat()
}
private fun startHeartbeat() {
heartbeatJob = scope.launch {
while (isActive) {
delay(30_000) // 30 秒心跳间隔
webSocket?.send(HEARTBEAT_PING)
// 超时未收到 Pong 则断开重连
val pongReceived = withTimeoutOrNull(5_000) { waitForPong() }
if (pongReceived == null) reconnect()
}
}
}
private fun reconnect() {
heartbeatJob?.cancel()
// 指数退避重连:1s, 2s, 4s, 8s, ... max 60s
val delay = minOf(1000L * (1 shl retryCount), 60_000L)
scope.launch {
delay(delay)
retryCount++
connect()
}
}
}
常见面试问题
Q1: IM 如何保证消息不丢不重?
答案:
- 不丢:发送方等待服务端 ACK,超时重发;接收方收到消息后发送接收 ACK,服务端未收到 ACK 则重推
- 不重:客户端生成唯一
clientMsgId,服务端和客户端都基于此 ID 去重。重发的消息clientMsgId不变,服务端检测到已存在则直接返回 ACK 不再存储
Q2: IM 客户端如何做消息分页加载?
答案:
使用 游标分页(基于 timestamp 或 serverMsgId),而非 OFFSET 分页,避免数据变化导致重复或遗漏。向上滑动加载历史消息时,查询 timestamp < lastVisibleMessageTimestamp 的前 N 条消息。Room 的 PagingSource + Jetpack Paging 3 可以很好地实现这一功能。