案例4:即时通讯App完整实现¶
📋 案例概述¶
项目简介¶
构建一个功能完整的即时通讯应用,支持单聊、群聊、消息发送(文字、图片、语音)、消息状态显示等功能。本案例展示WebSocket实时通信、消息本地存储、多媒体消息处理等高级技术。
预计开发时间: 8-10小时 难度等级: ⭐⭐⭐⭐⭐ 涉及知识点: WebSocket、消息队列、多媒体处理、实时UI更新、端到端加密
功能特性¶
- 💬 实时文字消息收发
- 🖼️ 图片消息发送与预览
- 🎤 语音消息录制与播放
- 👥 群聊功能
- ✅ 消息已读/未读状态
- 📝 消息撤回与删除
- 🔔 新消息通知
- 📎 文件传输支持
🏗️ 项目架构¶
Text Only
app/
├── data/
│ ├── local/
│ │ ├── ChatDatabase.kt
│ │ ├── MessageDao.kt
│ │ ├── ConversationDao.kt
│ │ └── entity/
│ │ ├── MessageEntity.kt
│ │ ├── ConversationEntity.kt
│ │ └── AttachmentEntity.kt
│ ├── remote/
│ │ ├── WebSocketManager.kt
│ │ ├── ChatApiService.kt
│ │ └── dto/
│ │ ├── MessageDto.kt
│ │ └── WebSocketMessage.kt
│ ├── repository/
│ │ └── ChatRepository.kt
│ └── model/
│ ├── Message.kt
│ ├── Conversation.kt
│ └── MessageType.kt
├── service/
│ └── ChatService.kt
├── ui/
│ ├── conversation/
│ │ ├── ConversationListScreen.kt
│ │ ├── ConversationListViewModel.kt
│ │ └── components/
│ │ ├── ConversationItem.kt
│ │ └── UnreadBadge.kt
│ ├── chat/
│ │ ├── ChatScreen.kt
│ │ ├── ChatViewModel.kt
│ │ └── components/
│ │ ├── MessageList.kt
│ │ ├── MessageItem.kt
│ │ ├── ChatInput.kt
│ │ ├── VoiceRecorder.kt
│ │ └── ImagePicker.kt
│ └── components/
│ ├── Avatar.kt
│ ├── Timestamp.kt
│ └── LoadingDots.kt
└── utils/
├── AudioRecorder.kt
├── AudioPlayer.kt
└── EncryptionUtils.kt
🛠️ 技术实现¶
1. 数据模型¶
Message.kt¶
Kotlin
// 消息类型枚举:文字、图片、语音、文件、系统消息
enum class MessageType {
TEXT, IMAGE, VOICE, FILE, SYSTEM
}
// 消息发送状态:发送中、已发送、已送达、已读、发送失败
enum class MessageStatus {
SENDING, SENT, DELIVERED, READ, FAILED
}
// 消息数据模型
data class Message(
val id: String,
val conversationId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String?,
val type: MessageType,
val content: String,
val attachmentUrl: String? = null,
val attachmentDuration: Int? = null, // 语音消息时长
val timestamp: Long,
val status: MessageStatus,
val isRecalled: Boolean = false,
val localPath: String? = null // 本地文件路径
)
// 会话数据模型,表示一个聊天会话
data class Conversation(
val id: String,
val type: ConversationType, // SINGLE 单聊, GROUP 群聊
val name: String,
val avatar: String?,
val lastMessage: Message?, // 最后一条消息,用于会话列表展示
val unreadCount: Int, // 未读消息数
val participants: List<String>, // 参与者ID列表
val updatedAt: Long
)
enum class ConversationType {
SINGLE, GROUP
}
MessageEntity.kt¶
Kotlin
// Room数据库实体,对应messages表
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey // 主键,使用消息ID
val id: String,
val conversationId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String?,
val type: String,
val content: String,
val attachmentUrl: String?,
val attachmentDuration: Int?,
val timestamp: Long,
val status: String,
val isRecalled: Boolean,
val localPath: String?
)
// 扩展函数:将数据库实体转换为领域模型
fun MessageEntity.toMessage(): Message = Message(
id = id,
conversationId = conversationId,
senderId = senderId,
senderName = senderName,
senderAvatar = senderAvatar,
type = MessageType.valueOf(type),
content = content,
attachmentUrl = attachmentUrl,
attachmentDuration = attachmentDuration,
timestamp = timestamp,
status = MessageStatus.valueOf(status),
isRecalled = isRecalled,
localPath = localPath
)
2. WebSocket管理器¶
Kotlin
// 单例WebSocket管理器,负责维护与服务器的实时连接
@Singleton
class WebSocketManager @Inject constructor(
private val messageDao: MessageDao,
private val gson: Gson
) {
private var webSocket: WebSocket? = null
// 使用SharedFlow广播收到的消息,支持多个订阅者
private val _incomingMessages = MutableSharedFlow<WebSocketMessage>()
val incomingMessages = _incomingMessages.asSharedFlow()
// 使用StateFlow跟踪连接状态,UI层可实时感知
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState = _connectionState.asStateFlow()
// 配置OkHttp客户端,每30秒发送ping帧保持连接活跃
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.build()
// 建立WebSocket连接,携带用户ID和令牌进行身份验证
fun connect(userId: String, token: String) {
val request = Request.Builder()
.url("wss://your-chat-server.com/ws?userId=$userId&token=$token")
.build()
// 创建WebSocket并注册回调监听器
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connectionState.value = ConnectionState.CONNECTED
}
// 收到服务器消息时,反序列化并通过SharedFlow发射
override fun onMessage(webSocket: WebSocket, text: String) {
val message = gson.fromJson(text, WebSocketMessage::class.java)
_incomingMessages.tryEmit(message)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.value = ConnectionState.DISCONNECTING
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.value = ConnectionState.DISCONNECTED
}
// 连接失败时触发自动重连机制
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_connectionState.value = ConnectionState.ERROR
// 自动重连逻辑
reconnect(userId, token)
}
})
}
// 发送消息:序列化为JSON通过WebSocket发送,返回是否成功
fun sendMessage(message: WebSocketMessage): Boolean {
val json = gson.toJson(message)
return webSocket?.send(json) ?: false
}
// 主动断开连接,发送正常关闭码1000
fun disconnect() {
webSocket?.close(1000, "User logout")
webSocket = null
}
// 使用SupervisorJob确保单个协程失败不影响其他协程
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun reconnect(userId: String, token: String) {
// 延迟5秒后重连,避免频繁重试(避免使用 GlobalScope)
scope.launch {
delay(5000)
connect(userId, token)
}
}
}
// WebSocket连接状态枚举
enum class ConnectionState {
CONNECTED, DISCONNECTED, DISCONNECTING, ERROR
}
3. Repository层¶
Kotlin
// 聊天数据仓库:协调WebSocket、本地数据库和远程API的消息操作
class ChatRepository @Inject constructor(
private val webSocketManager: WebSocketManager,
private val messageDao: MessageDao,
private val conversationDao: ConversationDao,
private val api: ChatApiService
) {
// 监听收到的消息(来自WebSocket的实时消息流)
val incomingMessages = webSocketManager.incomingMessages
// 获取会话列表
fun getConversations(): Flow<List<Conversation>> {
return conversationDao.getAllConversations()
.map { entities -> entities.map { it.toConversation() } }
}
// 获取消息列表
fun getMessages(conversationId: String): Flow<List<Message>> {
return messageDao.getMessagesByConversation(conversationId)
.map { entities -> entities.map { it.toMessage() } }
}
// 发送文字消息
suspend fun sendTextMessage(
conversationId: String,
content: String
): Result<Message> = withContext(Dispatchers.IO) {
try {
val message = createMessage(conversationId, MessageType.TEXT, content)
// 先保存到本地数据库(状态为"发送中"),实现离线优先策略
messageDao.insert(message.toEntity().copy(status = MessageStatus.SENDING.name))
// 构造WebSocket消息并发送到服务器
val wsMessage = WebSocketMessage(
type = "message",
data = mapOf(
"conversationId" to conversationId,
"content" to content,
"messageType" to "TEXT"
)
)
if (webSocketManager.sendMessage(wsMessage)) {
messageDao.updateStatus(message.id, MessageStatus.SENT.name)
Result.success(message.copy(status = MessageStatus.SENT))
} else {
messageDao.updateStatus(message.id, MessageStatus.FAILED.name)
Result.failure(Exception("发送失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// 发送图片消息
suspend fun sendImageMessage(
conversationId: String,
imageUri: Uri,
context: Context
): Result<Message> = withContext(Dispatchers.IO) {
try {
// 压缩图片,减少传输体积和存储占用
val compressedFile = compressImage(context, imageUri)
// 通过REST API上传图片到文件服务器
val uploadResult = api.uploadImage(
MultipartBody.Part.createFormData(
"file",
compressedFile.name,
compressedFile.asRequestBody("image/jpeg".toMediaType())
)
)
val message = createMessage(
conversationId,
MessageType.IMAGE,
"[图片]",
attachmentUrl = uploadResult.url,
localPath = compressedFile.absolutePath
)
messageDao.insert(message.toEntity())
// 通过WebSocket通知对方有新图片消息(图片已上传至服务器)
webSocketManager.sendMessage(WebSocketMessage(
type = "message",
data = mapOf(
"conversationId" to conversationId,
"messageType" to "IMAGE",
"attachmentUrl" to uploadResult.url
)
))
Result.success(message)
} catch (e: Exception) {
Result.failure(e)
}
}
// 发送语音消息
suspend fun sendVoiceMessage(
conversationId: String,
audioFile: File,
duration: Int
): Result<Message> = withContext(Dispatchers.IO) {
try {
val uploadResult = api.uploadAudio(
MultipartBody.Part.createFormData(
"file",
audioFile.name,
audioFile.asRequestBody("audio/m4a".toMediaType())
)
)
val message = createMessage(
conversationId,
MessageType.VOICE,
"[语音]",
attachmentUrl = uploadResult.url,
attachmentDuration = duration,
localPath = audioFile.absolutePath
)
messageDao.insert(message.toEntity())
webSocketManager.sendMessage(WebSocketMessage(
type = "message",
data = mapOf(
"conversationId" to conversationId,
"messageType" to "VOICE",
"attachmentUrl" to uploadResult.url,
"duration" to duration
)
))
Result.success(message)
} catch (e: Exception) {
Result.failure(e)
}
}
// 撤回消息
suspend fun recallMessage(messageId: String): Result<Unit> {
return try {
api.recallMessage(messageId)
messageDao.markAsRecalled(messageId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
// 标记消息已读:更新本地状态 + 发送已读回执给对方
suspend fun markMessagesAsRead(conversationId: String) {
messageDao.markAllAsRead(conversationId) // 更新本地消息状态
conversationDao.resetUnreadCount(conversationId) // 清零未读计数
// 发送已读回执,让对方知道消息已被阅读
webSocketManager.sendMessage(WebSocketMessage(
type = "read_receipt",
data = mapOf("conversationId" to conversationId)
))
}
// 工厂方法:创建消息对象,自动填充当前用户信息和时间戳
private fun createMessage(
conversationId: String,
type: MessageType,
content: String,
attachmentUrl: String? = null,
attachmentDuration: Int? = null,
localPath: String? = null
): Message {
return Message(
id = generateMessageId(),
conversationId = conversationId,
senderId = getCurrentUserId(),
senderName = getCurrentUserName(),
senderAvatar = getCurrentUserAvatar(),
type = type,
content = content,
attachmentUrl = attachmentUrl,
attachmentDuration = attachmentDuration,
timestamp = System.currentTimeMillis(),
status = MessageStatus.SENDING,
localPath = localPath
)
}
}
4. UI层实现¶
ChatScreen.kt¶
Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
conversationId: String,
viewModel: ChatViewModel = hiltViewModel(),
onBackClick: () -> Unit,
onImagePreview: (String) -> Unit
) {
// 收集ViewModel中的状态流,转换为Compose可观察的State
val messages by viewModel.messages.collectAsState()
val conversation by viewModel.conversation.collectAsState()
val connectionState by viewModel.connectionState.collectAsState()
// 记住列表滚动状态,用于控制自动滚动
val listState = rememberLazyListState()
// 自动滚动到底部
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
// 标记已读
LaunchedEffect(conversationId) {
viewModel.markAsRead(conversationId)
}
// Scaffold布局:顶部标题栏 + 底部输入栏 + 中间消息列表
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(conversation?.name ?: "")
// 根据WebSocket连接状态显示在线/连接中
Text(
text = when (connectionState) {
ConnectionState.CONNECTED -> "在线"
else -> "连接中..."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
}
)
},
// 底部消息输入栏:支持文字、图片、语音三种输入方式
bottomBar = {
ChatInputBar(
onSendText = { viewModel.sendTextMessage(it) },
onSendImage = { viewModel.sendImageMessage(it) },
onSendVoice = { file, duration ->
viewModel.sendVoiceMessage(file, duration)
}
)
}
) { padding ->
MessageList(
messages = messages,
listState = listState,
onImageClick = onImagePreview,
onVoiceClick = { viewModel.playVoiceMessage(it) },
onRecallClick = { viewModel.recallMessage(it) },
modifier = Modifier.padding(padding)
)
}
}
MessageList.kt¶
Kotlin
@Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState,
onImageClick: (String) -> Unit,
onVoiceClick: (Message) -> Unit,
onRecallClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
// 使用LazyColumn高效渲染消息列表(仅渲染可见区域)
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(
items = messages,
key = { _, message -> message.id } // 使用消息ID作为key,优化重组性能
) { index, message ->
// 判断是否需要显示时间戳(避免相邻消息重复显示时间)
val showTimestamp = shouldShowTimestamp(messages, index)
MessageItem(
message = message,
isMe = message.senderId == getCurrentUserId(),
showTimestamp = showTimestamp,
onImageClick = onImageClick,
onVoiceClick = onVoiceClick,
onRecallClick = onRecallClick
)
}
}
}
@Composable
fun MessageItem(
message: Message,
isMe: Boolean,
showTimestamp: Boolean,
onImageClick: (String) -> Unit,
onVoiceClick: (Message) -> Unit,
onRecallClick: (String) -> Unit
) {
// 根据消息发送者决定对齐方向:自己的消息靠右,对方消息靠左
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) {
if (showTimestamp) {
Text(
text = formatMessageTime(message.timestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(8.dp))
}
// 消息行布局:头像 + 气泡,根据发送方向排列
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = if (isMe) Arrangement.End else Arrangement.Start
) {
// 对方消息:左侧显示头像
if (!isMe) {
AsyncImage(
model = message.senderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
placeholder = painterResource(R.drawable.ic_avatar_placeholder)
)
Spacer(modifier = Modifier.width(8.dp))
}
MessageBubble(
message = message,
isMe = isMe,
onImageClick = onImageClick,
onVoiceClick = onVoiceClick,
onRecallClick = onRecallClick
)
if (isMe) {
Spacer(modifier = Modifier.width(8.dp))
AsyncImage(
model = message.senderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
placeholder = painterResource(R.drawable.ic_avatar_placeholder)
)
}
}
}
}
@Composable
fun MessageBubble(
message: Message,
isMe: Boolean,
onImageClick: (String) -> Unit,
onVoiceClick: (Message) -> Unit,
onRecallClick: (String) -> Unit
) {
// 根据发送者区分气泡颜色:自己的消息用主题色,对方的用浅色背景
val backgroundColor = if (isMe) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
}
// 对应的文字颜色,确保在不同背景上的可读性
val contentColor = if (isMe) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
// 气泡容器:限制最大宽度280dp,圆角16dp
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(RoundedCornerShape(16.dp))
.background(backgroundColor)
.padding(12.dp)
) {
// 根据消息类型渲染不同内容
when {
message.isRecalled -> {
Text(
text = "消息已撤回",
style = MaterialTheme.typography.bodyMedium,
color = contentColor.copy(alpha = 0.6f),
fontStyle = FontStyle.Italic
)
}
message.type == MessageType.TEXT -> {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = contentColor
)
}
message.type == MessageType.IMAGE -> {
AsyncImage(
model = message.attachmentUrl,
contentDescription = "图片消息",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onImageClick(message.attachmentUrl!!) },
contentScale = ContentScale.Crop
)
}
message.type == MessageType.VOICE -> {
VoiceMessagePlayer(
duration = message.attachmentDuration ?: 0,
isMe = isMe,
onClick = { onVoiceClick(message) }
)
}
}
}
// 在自己发送的消息下方显示状态图标(发送中/已送达/已读等)
if (isMe && !message.isRecalled) {
MessageStatusIcon(status = message.status)
}
}
ChatInputBar.kt¶
Kotlin
@Composable
fun ChatInputBar(
onSendText: (String) -> Unit,
onSendImage: (Uri) -> Unit,
onSendVoice: (File, Int) -> Unit
) {
var text by remember { mutableStateOf("") } // 输入框文本状态
var isRecording by remember { mutableStateOf(false) } // 是否正在录音
val context = LocalContext.current
// 注册系统图片选择器,选择后回调发送图片
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let { onSendImage(it) }
}
Column {
// 录音面板:录音完成后回调发送语音消息
if (isRecording) {
VoiceRecorderPanel(
onRecordComplete = { file, duration ->
onSendVoice(file, duration)
isRecording = false
},
onCancel = { isRecording = false }
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { imagePicker.launch("image/*") }) {
Icon(Icons.Outlined.Image, contentDescription = "发送图片")
}
IconButton(
onClick = { isRecording = true },
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = { isRecording = true }
)
}
) {
Icon(Icons.Outlined.Mic, contentDescription = "语音")
}
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
placeholder = { Text("输入消息...") },
maxLines = 4
)
// 发送按钮:文本非空时才可点击,发送后清空输入框
IconButton(
onClick = {
if (text.isNotBlank()) {
onSendText(text)
text = ""
}
},
enabled = text.isNotBlank()
) {
Icon(Icons.Default.Send, contentDescription = "发送")
}
}
}
}
5. 语音录制与播放¶
Kotlin
// 语音录制工具类,封装Android MediaRecorder
class AudioRecorder @Inject constructor(
private val context: Context
) {
private var mediaRecorder: MediaRecorder? = null
private var outputFile: File? = null
private var startTime: Long = 0
// 开始录音,返回输出文件引用
fun startRecording(): File {
// 在缓存目录创建临时音频文件(M4A格式)
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
// Android 12+使用新构造函数,低版本使用旧API
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC) // 音频源:麦克风
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) // 输出格式:MPEG-4
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) // 编码器:AAC
setOutputFile(outputFile!!.absolutePath)
prepare() // 准备录制
start() // 开始录制
}
startTime = System.currentTimeMillis()
return outputFile!!
}
// 停止录音,返回文件和时长(秒)
fun stopRecording(): Pair<File, Int> {
mediaRecorder?.apply {
stop()
release()
}
mediaRecorder = null
// 计算录音时长(毫秒转秒)
val duration = ((System.currentTimeMillis() - startTime) / 1000).toInt()
return Pair(outputFile!!, duration)
}
// 取消录音,释放资源并删除临时文件
fun cancelRecording() {
mediaRecorder?.apply {
stop()
release()
}
mediaRecorder = null
outputFile?.delete()
}
}
// 语音播放工具类,支持网络和本地音频播放
class AudioPlayer @Inject constructor() {
private var mediaPlayer: MediaPlayer? = null
// 通过StateFlow暴露播放状态,UI可实时响应
private val _isPlaying = MutableStateFlow(false)
val isPlaying = _isPlaying.asStateFlow()
// 播放网络音频(异步准备,适合远程URL)
fun play(audioUrl: String, onComplete: () -> Unit = {}) {
stop() // 先停止当前播放
mediaPlayer = MediaPlayer().apply {
setDataSource(audioUrl)
setOnPreparedListener { start() } // 准备完成后自动播放
setOnCompletionListener {
_isPlaying.value = false
onComplete()
}
prepareAsync() // 异步准备,避免阻塞主线程
}
_isPlaying.value = true
}
// 播放本地音频文件(同步准备,本地文件无需异步)
fun playLocal(file: File, onComplete: () -> Unit = {}) {
stop()
mediaPlayer = MediaPlayer().apply {
setDataSource(file.absolutePath)
setOnCompletionListener {
_isPlaying.value = false
onComplete()
}
prepare() // 同步准备
start()
}
_isPlaying.value = true
}
// 停止播放并释放资源
fun stop() {
mediaPlayer?.apply {
stop()
release()
}
mediaPlayer = null
_isPlaying.value = false
}
fun pause() {
mediaPlayer?.pause()
_isPlaying.value = false
}
fun resume() {
mediaPlayer?.start()
_isPlaying.value = true
}
}
6. ViewModel实现¶
Kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository,
private val audioPlayer: AudioPlayer,
@ApplicationContext private val appContext: Context,
savedStateHandle: SavedStateHandle // 从导航参数中获取会话ID
) : ViewModel() {
private val conversationId: String = savedStateHandle["conversationId"] ?: ""
// 将Flow转为StateFlow,Lazily策略表示有订阅者时才开始收集
val messages = repository.getMessages(conversationId)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val conversation = repository.getConversation(conversationId)
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val connectionState = repository.connectionState
init {
// 在ViewModel生命周期内持续监听WebSocket收到的实时消息
viewModelScope.launch {
repository.incomingMessages.collect { wsMessage ->
handleIncomingMessage(wsMessage)
}
}
}
fun sendTextMessage(content: String) {
viewModelScope.launch {
repository.sendTextMessage(conversationId, content)
}
}
fun sendImageMessage(uri: Uri) {
viewModelScope.launch {
repository.sendImageMessage(conversationId, uri, appContext)
}
}
fun sendVoiceMessage(file: File, duration: Int) {
viewModelScope.launch {
repository.sendVoiceMessage(conversationId, file, duration)
}
}
// 播放语音消息:优先使用本地缓存文件,否则从网络URL播放
fun playVoiceMessage(message: Message) {
message.localPath?.let {
audioPlayer.playLocal(File(it))
} ?: message.attachmentUrl?.let {
audioPlayer.play(it)
}
}
fun recallMessage(messageId: String) {
viewModelScope.launch {
repository.recallMessage(messageId)
}
}
fun markAsRead(conversationId: String) {
viewModelScope.launch {
repository.markMessagesAsRead(conversationId)
}
}
// 根据WebSocket消息类型分发处理
private fun handleIncomingMessage(wsMessage: WebSocketMessage) {
when (wsMessage.type) {
"message" -> {
// 处理收到的新消息:保存到本地数据库并更新UI
}
"read_receipt" -> {
// 收到已读回执:更新对应消息的状态为已读
}
"recall" -> {
// 收到撤回通知:标记消息为已撤回
}
}
}
// ViewModel销毁时停止音频播放,避免资源泄漏
override fun onCleared() {
super.onCleared()
audioPlayer.stop()
}
}
🔧 依赖配置¶
Kotlin
dependencies {
// WebSocket实时通信 - OkHttp内置WebSocket支持
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Room本地数据库 - 消息离线存储
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1") // 协程支持
kapt("androidx.room:room-compiler:2.6.1") // 编译时注解处理
// Coil图片加载 - Compose专用异步图片加载库
implementation("io.coil-kt:coil-compose:2.5.0")
// Gson - JSON序列化/反序列化(WebSocket消息编解码)
implementation("com.google.code.gson:gson:2.10.1")
// Hilt依赖注入框架
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
}
🎯 练习任务¶
基础任务¶
- ✅ 完成WebSocket连接管理
- ✅ 实现文字消息收发
- ✅ 添加消息本地存储
进阶任务¶
- 🔄 实现端到端加密
- 🔄 添加消息引用/回复功能
- 🔄 实现消息搜索功能
挑战任务¶
- 🎯 实现视频通话功能(WebRTC)
- 🎯 添加消息阅后即焚功能
- 🎯 实现群聊@功能
📚 学习要点¶
- WebSocket通信: 实时双向通信的实现
- 消息同步: 本地与远程消息的同步策略
- 多媒体处理: 图片压缩、语音录制播放
- 状态管理: 消息发送状态的实时更新
- 离线支持: 消息的本地缓存和重发机制