实战项目二:即时通讯App:实现登录、好友列表、消息发送与接收,综合运用WebSocket与REST API

好,终于到了这个系列最硬核的部分。说实话,前面讲的那些Retrofit封装、Flow的冷热转换、WebSocket的底层原理,都是为这一刻准备的。今天我们要亲手搭建一个即时通讯App的核心模块——登录、好友列表、消息收发。

这个项目我去年在重构公司内部IM SDK时做过类似的架构。当时踩了不少坑,尤其是WebSocket重连和消息时序的问题,差点让我怀疑人生。今天我把这些经验都揉进这个实战里,你跟着走一遍,基本就能应对大部分IM场景了。

本章核心目标:用REST API处理登录和好友列表,用WebSocket处理实时消息。两者通过Repository层统一管理,上层ViewModel只跟Flow打交道。

整体架构设计

先看一张架构图,理清脉络:

即时通讯App 网络层架构 UI层 (Activity/Fragment) ViewModel (StateFlow) Repository REST API (Retrofit) 登录 / 好友列表 WebSocket (OkHttp) 实时消息收发

看到没?UI层只跟ViewModel交互,ViewModel只跟Repository交互。Repository下面才是真正的数据源——REST API和WebSocket。这种分层的好处是:哪天你想把Retrofit换成Ktor,或者把WebSocket换成Socket.IO,只需要改Repository的实现,上层完全不受影响。

第一步:定义API接口

先搞定REST API部分。登录和好友列表都是典型的请求-响应模式,用Retrofit最合适。

interface ApiService {
    @POST("auth/login")
    suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>

    @GET("friends")
    suspend fun getFriends(): ApiResponse<List<Friend>>
}

data class LoginRequest(
    val username: String,
    val password: String
)

data class LoginResponse(
    val token: String,
    val userId: String
)

data class Friend(
    val id: String,
    val name: String,
    val avatar: String?,
    val lastMessage: String?,
    val unreadCount: Int
)

这里我用了统一的ApiResponse包装类。为什么?因为后端返回的数据结构通常有固定的外层——code、message、data。统一解析能省掉大量重复的try-catch。

个人习惯:我会在ApiResponse里加一个isSuccess扩展属性,配合Result类使用。这样在ViewModel里处理错误时,代码会干净很多。

第二步:WebSocket消息模型

WebSocket这边,消息格式需要跟后端约定好。我一般用JSON,因为解析方便,而且跟REST API的序列化工具可以复用。

sealed class WsMessage {
    @Serializable
    data class Text(
        val from: String,
        val to: String,
        val content: String,
        val timestamp: Long
    ) : WsMessage()

    @Serializable
    data class Ack(
        val messageId: String,
        val status: String  // "sent" | "delivered" | "read"
    ) : WsMessage()

    @Serializable
    data class Ping(
        val timestamp: Long
    ) : WsMessage()
}

为什么用sealed class?因为WebSocket的消息类型不止一种。除了文本消息,还有ACK确认、心跳Ping、系统通知等。用sealed class配合when表达式,编译器会帮你检查所有分支是否覆盖完整——这个特性我在项目里用了好几年,从来没漏过case。

第三步:WebSocket管理器

这是整个IM架构里最核心的部分。我直接给你看代码,然后解释关键点。

class WebSocketManager(
    private val okHttpClient: OkHttpClient,
    private val baseUrl: String,
    private val tokenProvider: () -> String?
) {
    private val _messages = MutableSharedFlow<WsMessage>(
        replay = 0,
        extraBufferCapacity = 64,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val messages: SharedFlow<WsMessage> = _messages.asSharedFlow()

    private var webSocket: WebSocket? = null
    private val job = Job()

    fun connect() {
        val token = tokenProvider() ?: return
        val request = Request.Builder()
            .url("$baseUrl/ws/chat?token=$token")
            .build()

        webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                startHeartbeat()
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                val message = Json.decodeFromString<WsMessage>(text)
                _messages.tryEmit(message)
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                scheduleReconnect()
            }

            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
                stopHeartbeat()
            }
        })
    }

    fun send(message: WsMessage) {
        val json = Json.encodeToString(message)
        webSocket?.send(json)
    }

    private fun startHeartbeat() {
        // 每30秒发送一次Ping
        CoroutineScope(Dispatchers.IO + job).launch {
            while (isActive) {
                delay(30_000)
                send(WsMessage.Ping(System.currentTimeMillis()))
            }
        }
    }

    private fun scheduleReconnect() {
        // 指数退避重连
        CoroutineScope(Dispatchers.IO + job).launch {
            var delayMs = 1000L
            while (isActive) {
                delay(delayMs)
                connect()
                delayMs = (delayMs * 2).coerceAtMost(30_000L)
            }
        }
    }

    fun disconnect() {
        job.cancel()
        webSocket?.close(1000, "User logout")
    }
}

这里有几个设计要点,我挨个说:

  • SharedFlow代替Channel:我早期用Channel,但发现多个订阅者时消息会被消费掉。SharedFlow可以一对多广播,ViewModel和通知服务都能收到。
  • tryEmit而非emit:因为onMessage回调在OkHttp的线程池里,不是协程上下文。用tryEmit可以避免挂起,如果缓冲区满了就丢弃旧消息——IM场景下,丢旧消息比丢新消息合理。
  • 指数退避重连:1秒、2秒、4秒...最大30秒。我曾经遇到过服务器重启,客户端疯狂重连把网关打爆的情况。加上退避策略后,服务器恢复后客户端能自动连上,而且不会造成雪崩。

我曾经踩过的坑:心跳和重连逻辑一定要放在独立的CoroutineScope里,不要用GlobalScope。否则Activity销毁后,WebSocket还在后台跑,内存泄漏是小事,关键是用户以为退出了,结果消息还在收——隐私问题就大了。

第四步:Repository层整合

Repository是连接REST和WebSocket的桥梁。看代码:

class ChatRepository(
    private val apiService: ApiService,
    private val webSocketManager: WebSocketManager
) {
    // 登录:REST API
    suspend fun login(username: String, password: String): Result<LoginResponse> {
        return try {
            val response = apiService.login(LoginRequest(username, password))
            if (response.code == 0) {
                Result.success(response.data)
            } else {
                Result.failure(ApiException(response.message))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    // 好友列表:REST API
    suspend fun getFriends(): Result<List<Friend>> {
        return try {
            val response = apiService.getFriends()
            if (response.code == 0) {
                Result.success(response.data)
            } else {
                Result.failure(ApiException(response.message))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    // 发送消息:WebSocket
    fun sendMessage(to: String, content: String) {
        val message = WsMessage.Text(
            from = currentUserId,
            to = to,
            content = content,
            timestamp = System.currentTimeMillis()
        )
        webSocketManager.send(message)
    }

    // 接收消息:WebSocket
    fun observeMessages(): Flow<WsMessage> = webSocketManager.messages

    // 连接WebSocket
    fun connectWebSocket() = webSocketManager.connect()

    // 断开WebSocket
    fun disconnectWebSocket() = webSocketManager.disconnect()
}

你发现没有?Repository层把两种通信方式完全屏蔽了。ViewModel调用sendMessage时,它根本不知道底层走的是WebSocket还是REST——这就是抽象的魅力。

第五步:ViewModel层实现

ViewModel负责把数据转换成UI可观察的状态:

class ChatViewModel(
    private val repository: ChatRepository
) : ViewModel() {

    private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
    val loginState: StateFlow<LoginState> = _loginState.asStateFlow()

    private val _friends = MutableStateFlow<List<Friend>>(emptyList())
    val friends: StateFlow<List<Friend>> = _friends.asStateFlow()

    private val _messages = MutableStateFlow<List<WsMessage.Text>>(emptyList())
    val messages: StateFlow<List<WsMessage.Text>> = _messages.asStateFlow()

    init {
        // 观察WebSocket消息,更新消息列表
        viewModelScope.launch {
            repository.observeMessages().collect { message ->
                when (message) {
                    is WsMessage.Text -> {
                        _messages.value = _messages.value + message
                    }
                    is WsMessage.Ack -> {
                        // 更新消息状态
                    }
                    else -> { /* 忽略其他类型 */ }
                }
            }
        }
    }

    fun login(username: String, password: String) {
        viewModelScope.launch {
            _loginState.value = LoginState.Loading
            val result = repository.login(username, password)
            _loginState.value = when {
                result.isSuccess -> {
                    repository.connectWebSocket()
                    LoginState.Success(result.getOrThrow())
                }
                else -> LoginState.Error(result.exceptionOrNull()?.message ?: "登录失败")
            }
        }
    }

    fun loadFriends() {
        viewModelScope.launch {
            val result = repository.getFriends()
            if (result.isSuccess) {
                _friends.value = result.getOrDefault(emptyList())
            }
        }
    }

    fun sendMessage(to: String, content: String) {
        repository.sendMessage(to, content)
    }

    override fun onCleared() {
        super.onCleared()
        repository.disconnectWebSocket()
    }
}

这里有个细节:observeMessages()返回的是SharedFlow,但我们在ViewModel里把它转成了StateFlow。为什么?因为UI层需要知道当前的消息列表状态,而SharedFlow没有"当前值"的概念。StateFlow配合collect,每次新消息来就追加到列表里,UI自动刷新。

第六步:UI层绑定

最后是Activity里的使用方式。我用的是repeatOnLifecycle模式,这是Google官方推荐的做法:

class ChatActivity : AppCompatActivity() {
    private val viewModel: ChatViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_chat)

        // 观察登录状态
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.loginState.collect { state ->
                    when (state) {
                        is LoginState.Success -> navigateToMain()
                        is LoginState.Error -> showError(state.message)
                        else -> { /* 忽略 */ }
                    }
                }
            }
        }

        // 观察好友列表
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.friends.collect { friends ->
                    friendAdapter.submitList(friends)
                }
            }
        }

        // 观察消息列表
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.messages.collect { messages ->
                    messageAdapter.submitList(messages)
                }
            }
        }
    }
}

为什么用repeatOnLifecycle?因为当Activity进入后台时,collect会暂停;回到前台时自动恢复。这样既省电,又不会丢失消息——因为SharedFlow有缓冲区,后台期间的消息会在恢复后一次性收到。

总结一下这个架构的精髓:

  • REST API处理非实时操作(登录、好友列表)
  • WebSocket处理实时操作(消息收发)
  • Repository做统一封装,ViewModel不关心底层
  • Flow贯穿整个链路,从数据源到UI都是响应式
  • WebSocket重连、心跳、缓冲区溢出策略都考虑到了

这套架构我后来在三个项目里复用过,包括一个日活百万的社交App。每次遇到IM需求,我都是这套模板改改就上。你把它吃透了,以后做聊天、推送、实时协作类的功能,基本就是复制粘贴再微调的事。

好,实战项目二就到这里。代码都在上面了,建议你照着敲一遍,然后跑起来试试。遇到WebSocket断连的问题别慌——看看心跳日志,检查一下token有没有过期,八成能解决。


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