跳转至

案例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")
}

🎯 练习任务

基础任务

  1. ✅ 完成WebSocket连接管理
  2. ✅ 实现文字消息收发
  3. ✅ 添加消息本地存储

进阶任务

  1. 🔄 实现端到端加密
  2. 🔄 添加消息引用/回复功能
  3. 🔄 实现消息搜索功能

挑战任务

  1. 🎯 实现视频通话功能(WebRTC)
  2. 🎯 添加消息阅后即焚功能
  3. 🎯 实现群聊@功能

📚 学习要点

  1. WebSocket通信: 实时双向通信的实现
  2. 消息同步: 本地与远程消息的同步策略
  3. 多媒体处理: 图片压缩、语音录制播放
  4. 状态管理: 消息发送状态的实时更新
  5. 离线支持: 消息的本地缓存和重发机制

🔗 相关章节