@[TOC](Android实战项目④ OkHttp WebSocket开发即时通讯App 完整源码详解)# Android实战项目④ OkHttp WebSocket开发即时通讯App 完整源码详解> 系列第4篇。学习WebSocket全双工通信、MockInterceptor模拟后端、SharedFlow事件流和消息气泡UI。---## 项目结构04-im-chat/app/src/main/java/├── com/example/imchat/MainActivity.kt├── com/example/imchat/MainApplication.kt├── com/example/imchat/data/local/ChatDatabase.kt├── com/example/imchat/data/local/MessageDao.kt├── com/example/imchat/data/local/entity/MessageEntity.kt├── com/example/imchat/data/remote/ChatApi.kt├── com/example/imchat/data/remote/MockInterceptor.kt├── com/example/imchat/data/remote/WebSocketManager.kt├── com/example/imchat/data/remote/dto/Dtos.kt├── com/example/imchat/di/AppModule.kt├── com/example/imchat/domain/model/Contact.kt├── com/example/imchat/domain/model/Message.kt├── com/example/imchat/navigation/NavGraph.kt├── com/example/imchat/ui/component/ContactItem.kt├── com/example/imchat/ui/component/MessageBubble.kt├── com/example/imchat/ui/screen/ChatScreen.kt├── com/example/imchat/ui/screen/ContactListScreen.kt├── com/example/imchat/ui/theme/Theme.kt├── com/example/imchat/viewmodel/ChatViewModel.kt├── com/example/imchat/viewmodel/ContactViewModel.kt文件数 : 20个Kotlin文件---## 完整源码 + 详解### MainActivity.ktkotlinpackage com.example.imchatimport android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.navigation.compose.rememberNavControllerimport com.example.imchat.navigation.ChatNavGraphimport com.example.imchat.ui.theme.IMChatThemeimport dagger.hilt.android.AndroidEntryPoint@AndroidEntryPointclass MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { IMChatTheme { ChatNavGraph(rememberNavController()) } } }}---### MainApplication.ktkotlinpackage com.example.imchatimport android.app.Applicationimport dagger.hilt.android.HiltAndroidApp@HiltAndroidAppclass MainApplication : Application()---### NavGraph.ktkotlinpackage com.example.imchat.navigationimport androidx.compose.runtime.Composableimport androidx.navigation.NavHostControllerimport androidx.navigation.NavTypeimport androidx.navigation.compose.NavHostimport androidx.navigation.compose.composableimport androidx.navigation.navArgumentimport com.example.imchat.ui.screen.ChatScreenimport com.example.imchat.ui.screen.ContactListScreen@Composablefun ChatNavGraph(navController: NavHostController) { NavHost(navController, startDestination = "contacts") { composable("contacts") { ContactListScreen(onContactClick = { id -> navController.navigate("chat/$id") }) } composable("chat/{chatId}", arguments = listOf(navArgument("chatId") { type = NavType.StringType })) { ChatScreen(onBack = { navController.popBackStack() }) } }}---### ChatViewModel.ktkotlinpackage com.example.imchat.viewmodelimport androidx.lifecycle.SavedStateHandleimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.imchat.data.local.MessageDaoimport com.example.imchat.data.local.entity.MessageEntityimport com.example.imchat.data.remote.WebSocketManagerimport com.example.imchat.domain.model.Messageimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass ChatViewModel @Inject constructor( private val messageDao: MessageDao, private val webSocketManager: WebSocketManager, savedStateHandle: SavedStateHandle) : ViewModel() { private val chatId: String = savedStateHandle.get<String>("chatId") ?: "" val connectionState = webSocketManager.connectionState val messages: StateFlow<List<Message>> = messageDao.getMessages(chatId) .map { entities -> entities.map { Message(it.id, it.chatId, it.senderId, it.content, it.timestamp, it.isMine) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _inputText = MutableStateFlow("") val inputText: StateFlow<String> = _inputText.asStateFlow() init { webSocketManager.connect() // 监听 WebSocket 收到的消息 viewModelScope.launch { webSocketManager.messages.collect { text -> val entity = MessageEntity(chatId = chatId, senderId = "other", content = text, timestamp = System.currentTimeMillis(), isMine = false) messageDao.insertMessage(entity) } } } fun onInputChanged(text: String) { _inputText.value = text } fun sendMessage() { val text = _inputText.value.trim() if (text.isEmpty()) return viewModelScope.launch { val entity = MessageEntity(chatId = chatId, senderId = "user_001", content = text, timestamp = System.currentTimeMillis(), isMine = true) messageDao.insertMessage(entity) webSocketManager.send(text) // 发送到 WebSocket (echo 服务会回传) _inputText.value = "" } } override fun onCleared() { super.onCleared() webSocketManager.close() }}---### ContactViewModel.ktkotlinpackage com.example.imchat.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.imchat.data.remote.ChatApiimport com.example.imchat.domain.model.Contactimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass ContactViewModel @Inject constructor(private val api: ChatApi) : ViewModel() { private val _contacts = MutableStateFlow<List<Contact>>(emptyList()) val contacts: StateFlow<List<Contact>> = _contacts init { loadContacts() } private fun loadContacts() { viewModelScope.launch { try { _contacts.value = api.getContacts().map { Contact(it.id, it.name, it.avatar, it.lastMessage, it.isOnline) } } catch (_: Exception) {} } }}---### Contact.ktkotlinpackage com.example.imchat.domain.modeldata class Contact( val id: String, val name: String, val avatar: String = "", val lastMessage: String = "", val isOnline: Boolean = false)---### Message.ktkotlinpackage com.example.imchat.domain.modeldata class Message( val id: Long = 0, val chatId: String, val senderId: String, val content: String, val timestamp: Long = System.currentTimeMillis(), val isMine: Boolean = false)---### ChatApi.ktkotlinpackage com.example.imchat.data.remoteimport com.example.imchat.data.remote.dto.ContactDtoimport com.example.imchat.data.remote.dto.LoginResponseimport com.example.imchat.data.remote.dto.MessageDtoimport retrofit2.http.*interface ChatApi { @POST("login") suspend fun login(@Body body: Map<String, String>): LoginResponse @GET("contacts") suspend fun getContacts(): List<ContactDto> @GET("messages/{chatId}") suspend fun getMessages(@Path("chatId") chatId: String): List<MessageDto>}---### MockInterceptor.ktkotlinpackage com.example.imchat.data.remoteimport okhttp3.*import okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.ResponseBody.Companion.toResponseBody/** * Mock 拦截器 --- 拦截所有请求并返回模拟数据 * 无需真实后端服务器即可运行项目 */class MockInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val path = request.url.encodedPath val responseBody = when { path.contains("/login") -> """{"token":"mock_jwt_token_12345","userId":"user_001","name":"学习者"}""" path.contains("/contacts") -> """[ {"id":"c_001","name":"张三","avatar":"","lastMessage":"你好!","isOnline":true}, {"id":"c_002","name":"李四","avatar":"","lastMessage":"明天见","isOnline":false}, {"id":"c_003","name":"王五","avatar":"","lastMessage":"收到","isOnline":true}, {"id":"c_004","name":"赵六","avatar":"","lastMessage":"好的","isOnline":false} ]""" path.contains("/messages") -> """[ {"id":1,"chatId":"c_001","senderId":"c_001","content":"你好!最近怎么样?","timestamp":${System.currentTimeMillis() - 60000}}, {"id":2,"chatId":"c_001","senderId":"user_001","content":"挺好的,在学 Android 开发","timestamp":${System.currentTimeMillis() - 30000}}, {"id":3,"chatId":"c_001","senderId":"c_001","content":"加油!Kotlin 很有趣的","timestamp":${System.currentTimeMillis()}} ]""" else -> """{"status":"ok"}""" } return Response.Builder() .code(200) .request(request) .protocol(Protocol.HTTP_2) .message("OK") .body(responseBody.toResponseBody("application/json".toMediaType())) .build() }}---### WebSocketManager.ktkotlinpackage com.example.imchat.data.remoteimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.delayimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport okhttp3.*import javax.inject.Injectimport javax.inject.Singleton/** * WebSocket 管理器 --- 处理实时消息收发 * * 知识点: * - OkHttp WebSocketListener: WebSocket 事件回调 * - SharedFlow: 将 WebSocket 消息转为 Flow * - 指数退避重连: 连接断开后自动重连 */@Singletonclass WebSocketManager @Inject constructor(private val client: OkHttpClient) { private var webSocket: WebSocket? = null private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 100) val messages: SharedFlow<String> = _messages.asSharedFlow() private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() enum class ConnectionState { CONNECTED, DISCONNECTED, CONNECTING } private var retryCount = 0 fun connect(url: String = "wss://echo.websocket.events") { _connectionState.value = ConnectionState.CONNECTING val request = Request.Builder().url(url).build() webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(ws: WebSocket, response: Response) { _connectionState.value = ConnectionState.CONNECTED retryCount = 0 } override fun onMessage(ws: WebSocket, text: String) { _messages.tryEmit(text) } override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { _connectionState.value = ConnectionState.DISCONNECTED reconnect(url) } override fun onClosing(ws: WebSocket, code: Int, reason: String) { _connectionState.value = ConnectionState.DISCONNECTED ws.close(1000, null) } }) } fun send(message: String): Boolean = webSocket?.send(message) ?: false fun close() { webSocket?.close(1000, "正常关闭") _connectionState.value = ConnectionState.DISCONNECTED } private fun reconnect(url: String) { CoroutineScope(Dispatchers.IO).launch { val delayMs = minOf(1000L * (1 shl retryCount), 30_000L) delay(delayMs) retryCount++ connect(url) } }}---### Dtos.ktkotlinpackage com.example.imchat.data.remote.dtoimport kotlinx.serialization.Serializable@Serializable data class LoginResponse(val token: String, val userId: String, val name: String)@Serializable data class ContactDto(val id: String, val name: String, val avatar: String = "", val lastMessage: String = "", val isOnline: Boolean = false)@Serializable data class MessageDto(val id: Long, val chatId: String, val senderId: String, val content: String, val timestamp: Long)---### ChatDatabase.ktkotlinpackage com.example.imchat.data.localimport androidx.room.Databaseimport androidx.room.RoomDatabaseimport com.example.imchat.data.local.entity.MessageEntity@Database(entities = [MessageEntity::class], version = 1, exportSchema = false)abstract class ChatDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao}---### MessageDao.ktkotlinpackage com.example.imchat.data.localimport androidx.room.*import com.example.imchat.data.local.entity.MessageEntityimport kotlinx.coroutines.flow.Flow@Daointerface MessageDao { @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY timestamp ASC") fun getMessages(chatId: String): Flow<List<MessageEntity>> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMessage(message: MessageEntity)}---### MessageEntity.ktkotlinpackage com.example.imchat.data.local.entityimport androidx.room.Entityimport androidx.room.PrimaryKey@Entity(tableName = "messages")data class MessageEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val chatId: String, val senderId: String, val content: String, val timestamp: Long, val isMine: Boolean = false)---### ChatScreen.ktkotlinpackage com.example.imchat.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.foundation.lazy.rememberLazyListStateimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.automirrored.filled.ArrowBackimport androidx.compose.material.icons.automirrored.filled.Sendimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.imchat.data.remote.WebSocketManagerimport com.example.imchat.ui.component.MessageBubbleimport com.example.imchat.viewmodel.ChatViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun ChatScreen(viewModel: ChatViewModel = hiltViewModel(), onBack: () -> Unit) { val messages by viewModel.messages.collectAsState() val input by viewModel.inputText.collectAsState() val connState by viewModel.connectionState.collectAsState() val listState = rememberLazyListState() LaunchedEffect(messages.size) { if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) } Scaffold(topBar = { TopAppBar( title = { Column { Text("聊天") Text(when(connState) { WebSocketManager.ConnectionState.CONNECTED -> "已连接"; WebSocketManager.ConnectionState.CONNECTING -> "连接中..."; else -> "未连接" }, style = MaterialTheme.typography.labelSmall) } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") } } ) }) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { LazyColumn(modifier = Modifier.weight(1f).padding(horizontal = 16.dp), state = listState, verticalArrangement = Arrangement.spacedBy(8.dp)) { items(messages, key = { it.id }) { msg -> MessageBubble(msg) } } // 输入栏 Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { OutlinedTextField(value = input, onValueChange = { viewModel.onInputChanged(it) }, placeholder = { Text("输入消息...") }, modifier = Modifier.weight(1f), singleLine = true) IconButton(onClick = { viewModel.sendMessage() }) { Icon(Icons.AutoMirrored.Filled.Send, "发送") } } } }}---### ContactListScreen.ktkotlinpackage com.example.imchat.ui.screenimport androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.imchat.ui.component.ContactItemimport com.example.imchat.viewmodel.ContactViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun ContactListScreen(viewModel: ContactViewModel = hiltViewModel(), onContactClick: (String) -> Unit) { val contacts by viewModel.contacts.collectAsState() Scaffold(topBar = { TopAppBar(title = { Text("消息") }) }) { padding -> LazyColumn(modifier = Modifier.padding(padding)) { items(contacts, key = { it.id }) { contact -> ContactItem(contact, onClick = { onContactClick(contact.id) }) HorizontalDivider() } } }}---### Theme.ktkotlinpackage com.example.imchat.ui.themeimport android.os.Buildimport androidx.compose.foundation.isSystemInDarkThemeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.platform.LocalContext@Composablefun IMChatTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val cs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val ctx = LocalContext.current; if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) } else if (darkTheme) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = cs, content = content)}---### ContactItem.ktkotlinpackage com.example.imchat.ui.componentimport androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.CircleShapeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.unit.dpimport com.example.imchat.domain.model.Contact@Composablefun ContactItem(contact: Contact, onClick: () -> Unit) { ListItem( modifier = Modifier.clickable(onClick = onClick), headlineContent = { Text(contact.name) }, supportingContent = { Text(contact.lastMessage, maxLines = 1) }, leadingContent = { Box { Surface(modifier = Modifier.size(48.dp).clip(CircleShape), color = MaterialTheme.colorScheme.primaryContainer) { Box(contentAlignment = Alignment.Center) { Text(contact.name.first().toString(), style = MaterialTheme.typography.titleMedium) } } if (contact.isOnline) { Surface(modifier = Modifier.size(12.dp).align(Alignment.BottomEnd).clip(CircleShape), color = Color(0xFF4CAF50)) {} } } } )}---### MessageBubble.ktkotlinpackage com.example.imchat.ui.componentimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport com.example.imchat.domain.model.Messageimport java.text.SimpleDateFormatimport java.util.*/** 消息气泡 --- 区分自己发送和对方发送 */@Composablefun MessageBubble(message: Message) { val alignment = if (message.isMine) Alignment.End else Alignment.Start val bgColor = if (message.isMine) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant val textColor = if (message.isMine) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant val shape = if (message.isMine) RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) else RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) { Box(modifier = Modifier.background(bgColor, shape).padding(12.dp).widthIn(max = 280.dp)) { Text(message.content, color = textColor) } Text(SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(message.timestamp)), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) }}---### AppModule.ktkotlinpackage com.example.imchat.diimport android.content.Contextimport androidx.room.Roomimport com.example.imchat.data.local.ChatDatabaseimport com.example.imchat.data.local.MessageDaoimport com.example.imchat.data.remote.ChatApiimport com.example.imchat.data.remote.MockInterceptorimport dagger.Moduleimport dagger.Providesimport dagger.hilt.InstallInimport dagger.hilt.android.qualifiers.ApplicationContextimport dagger.hilt.components.SingletonComponentimport kotlinx.serialization.json.Jsonimport okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.OkHttpClientimport retrofit2.Retrofitimport retrofit2.converter.kotlinx.serialization.asConverterFactoryimport javax.inject.Singleton@Module@InstallIn(SingletonComponent::class)object AppModule { @Provides @Singleton fun provideOkHttpClient() = OkHttpClient.Builder().addInterceptor(MockInterceptor()).build() @Provides @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit { val json = Json { ignoreUnknownKeys = true } return Retrofit.Builder().baseUrl("https://mock.api.com/").client(client) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())).build() } @Provides @Singleton fun provideChatApi(retrofit: Retrofit): ChatApi = retrofit.create(ChatApi::class.java) @Provides @Singleton fun provideDatabase(@ApplicationContext ctx: Context): ChatDatabase = Room.databaseBuilder(ctx, ChatDatabase::class.java, "chat.db").build() @Provides fun provideMessageDao(db: ChatDatabase): MessageDao = db.messageDao()}---## 运行方式1. Android Studio打开 projects/04-im-chat 目录2. Gradle同步完成后Run运行> 系列导航: Android实战系列 1-6 篇。
Android实战项目④ OkHttp WebSocket开发即时通讯App 完整源码详解
明天就是Friday2026-04-22 22:56
相关推荐
吉哥机顶盒刷机2 小时前
好物分享:DNA-Android-4.0.5安卓固件解包、打包工具三棱球2 小时前
App逆向学习笔记(三)——Android开发入门课安卓机器3 小时前
rom定制系列------魅族16x 解锁bl root与Flyme9安卓10线刷固件 传感器修复wellc5 小时前
MySQL Workbench菜单汉化为中文CYY956 小时前
Android 打印 SO 库的异常日志找藉口是失败者的习惯7 小时前
深入理解 Android 无障碍服务summerkissyou19877 小时前
Android-SurfaceView-打开车机SurfaceFlinger和HWC的日志Fate_I_C7 小时前
Android函数式编程代码规范文档