实战项目二:即时通讯App:实现登录、好友列表、消息发送与接收,综合运用WebSocket与REST API
好,终于到了这个系列最硬核的部分。说实话,前面讲的那些Retrofit封装、Flow的冷热转换、WebSocket的底层原理,都是为这一刻准备的。今天我们要亲手搭建一个即时通讯App的核心模块——登录、好友列表、消息收发。
这个项目我去年在重构公司内部IM SDK时做过类似的架构。当时踩了不少坑,尤其是WebSocket重连和消息时序的问题,差点让我怀疑人生。今天我把这些经验都揉进这个实战里,你跟着走一遍,基本就能应对大部分IM场景了。
本章核心目标:用REST API处理登录和好友列表,用WebSocket处理实时消息。两者通过Repository层统一管理,上层ViewModel只跟Flow打交道。
整体架构设计
先看一张架构图,理清脉络:
看到没?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有没有过期,八成能解决。