imsk - 基于 Wire 的 KMP 即时通讯应用案例
项目概述
imsk 是一个跨平台即时通讯应用,使用 Kotlin Multiplatform 和 Wire 实现核心业务逻辑,支持 Android 和 iOS 平台。
项目结构
bash
imsk/
├── shared/ # KMP 共享模块
│ ├── src/
│ │ ├── commonMain/ # 共享代码
│ │ │ ├── kotlin/com/imsk/
│ │ │ │ ├── protobuf/ # Proto 定义
│ │ │ │ ├── data/ # 数据层
│ │ │ │ ├── domain/ # 领域层
│ │ │ │ ├── presentation/ # 表现层
│ │ │ │ └── platform/ # 平台抽象
│ │ │ └── proto/ # .proto 文件
│ │ ├── androidMain/ # Android 实现
│ │ └── iosMain/ # iOS 实现
│ └── build.gradle.kts
├── androidApp/ # Android 应用
├── iosApp/ # iOS 应用
└── build.gradle.kts
1. Protocol Buffers 定义
**shared/src/commonMain/proto/imsk.proto
**
ini
syntax = "proto3";
package imsk;
option java_package = "com.imsk.protobuf";
option kotlin_package = "com.imsk.protobuf";
import "google/protobuf/timestamp.proto";
// 消息类型
enum MessageType {
TEXT = 0;
IMAGE = 1;
VOICE = 2;
VIDEO = 3;
FILE = 4;
SYSTEM = 5;
}
// 消息状态
enum MessageStatus {
SENDING = 0;
SENT = 1;
DELIVERED = 2;
READ = 3;
FAILED = 4;
}
// 用户信息
message User {
string user_id = 1;
string username = 2;
string display_name = 3;
string avatar_url = 4;
bool is_online = 5;
google.protobuf.Timestamp last_seen = 6;
string status = 7;
}
// 会话类型
enum ConversationType {
PRIVATE = 0;
GROUP = 1;
CHANNEL = 2;
}
// 会话信息
message Conversation {
string conversation_id = 1;
string title = 2;
ConversationType type = 3;
repeated User participants = 4;
User owner = 5;
ChatMessage last_message = 6;
int32 unread_count = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
string avatar_url = 10;
}
// 聊天消息
message ChatMessage {
string message_id = 1;
string conversation_id = 2;
User sender = 3;
MessageType message_type = 4;
MessageStatus status = 5;
google.protobuf.Timestamp timestamp = 6;
oneof content {
string text = 7;
ImageContent image = 8;
VoiceContent voice = 9;
FileContent file = 10;
}
repeated string read_by = 11; // 已读用户ID
string reply_to = 12; // 回复的消息ID
}
// 图片消息内容
message ImageContent {
string image_url = 1;
string thumbnail_url = 2;
int32 width = 3;
int32 height = 4;
string caption = 5;
int64 file_size = 6;
}
// 语音消息内容
message VoiceContent {
string audio_url = 1;
int32 duration_seconds = 2;
int64 file_size = 3;
}
// 文件消息内容
message FileContent {
string file_url = 1;
string file_name = 2;
string mime_type = 3;
int64 file_size = 4;
}
// 认证相关消息
message AuthRequest {
string device_id = 1;
string auth_token = 2;
}
message AuthResponse {
bool success = 1;
User user = 2;
string error_message = 3;
}
// 消息服务
service MessageService {
// 发送消息
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
// 获取会话列表
rpc GetConversations(GetConversationsRequest) returns (GetConversationsResponse);
// 获取消息历史
rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse);
// 实时消息流
rpc StreamMessages(StreamMessagesRequest) returns (stream ChatMessage);
// 标记消息已读
rpc MarkAsRead(MarkAsReadRequest) returns (MarkAsReadResponse);
}
message SendMessageRequest {
string conversation_id = 1;
ChatMessage message = 2;
}
message SendMessageResponse {
bool success = 1;
string message_id = 2;
google.protobuf.Timestamp server_timestamp = 3;
string error_message = 4;
}
message GetConversationsRequest {
int32 limit = 1;
string cursor = 2;
}
message GetConversationsResponse {
repeated Conversation conversations = 1;
string next_cursor = 2;
bool has_more = 3;
}
message GetMessagesRequest {
string conversation_id = 1;
int32 limit = 2;
string before_message_id = 3;
}
message GetMessagesResponse {
repeated ChatMessage messages = 1;
bool has_more = 2;
}
message StreamMessagesRequest {
string user_id = 1;
}
message MarkAsReadRequest {
string conversation_id = 1;
string message_id = 2;
}
message MarkAsReadResponse {
bool success = 1;
}
2. Gradle 配置
**shared/build.gradle.kts
**
ini
plugins {
kotlin("multiplatform")
id("com.android.library")
id("com.squareup.wire") version "4.9.2"
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
// Wire
implementation("com.squareup.wire:wire-runtime:4.9.2")
implementation("com.squareup.wire:wire-grpc-client:4.9.2")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// DateTime
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
}
val androidMain by getting {
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
}
val iosMain by getting {
dependencies {
// iOS 特定依赖
}
}
}
}
android {
namespace = "com.imsk.shared"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
wire {
kotlin {
javaPackage = "com.imsk.protobuf"
kotlinPackage = "com.imsk.protobuf"
rpcCallStyle = "suspending"
rpcRole = "client"
singleMethodServices = true
boxOneOfsMinSize = 0
}
sourcePath {
srcDir("src/commonMain/proto")
include("**/*.proto")
}
kotlinOutputDir = project.layout.buildDirectory.dir("generated/source/wire")
// 只生成我们需要的类型,减少代码量
roots = [
"imsk.MessageService",
"imsk.User",
"imsk.Conversation",
"imsk.ChatMessage"
]
}
3. 平台抽象层
**shared/src/commonMain/kotlin/com/imsk/platform/Platform.kt
**
kotlin
package com.imsk.platform
import com.squareup.wire.GrpcClient
import kotlinx.coroutines.flow.Flow
/**
* 平台客户端工厂抽象
*/
expect class PlatformClientFactory(
baseUrl: String,
authToken: String?
) {
fun createGrpcClient(): GrpcClient
suspend fun isNetworkAvailable(): Boolean
fun getPlatformInfo(): PlatformInfo
}
/**
* 平台信息
*/
data class PlatformInfo(
val platform: String,
val deviceId: String,
val appVersion: String,
val osVersion: String
)
/**
* 文件操作抽象
*/
expect class FileManager {
suspend fun saveFile(data: ByteArray, fileName: String): String
suspend fun readFile(filePath: String): ByteArray?
suspend fun deleteFile(filePath: String): Boolean
}
/**
* 加密管理抽象
*/
expect class CryptoManager {
suspend fun encrypt(data: ByteArray): ByteArray
suspend fun decrypt(encryptedData: ByteArray): ByteArray
fun generateKey(): String
}
4. Android 平台实现
**shared/src/androidMain/kotlin/com/imsk/platform/AndroidPlatform.kt
**
kotlin
package com.imsk.platform
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import com.squareup.wire.GrpcClient
import com.squareup.wire.okhttp3.OkHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient as OkHttp
import java.util.concurrent.TimeUnit
class PlatformClientFactory actual constructor(
private val baseUrl: String,
private val authToken: String?
) {
private val context: Context
get() = android.app.Application().applicationContext
private val okHttpClient by lazy {
OkHttp.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("User-Agent", "imsk-android/${getAppVersion()}")
.addHeader("Device-Id", getDeviceId())
.addHeader("Platform", "Android")
.apply {
authToken?.let { token ->
addHeader("Authorization", "Bearer $token")
}
}
.build()
chain.proceed(request)
}
.addInterceptor { chain ->
if (!isNetworkAvailable()) {
throw NetworkException("No network connection available")
}
chain.proceed(chain.request())
}
.build()
}
actual fun createGrpcClient(): GrpcClient {
return GrpcClient.Builder()
.baseUrl(baseUrl)
.client(OkHttpClient(okHttpClient))
.build()
}
actual suspend fun isNetworkAvailable(): Boolean {
return withContext(Dispatchers.IO) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
capabilities != null && (
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
)
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
networkInfo != null && networkInfo.isConnected
}
}
}
actual fun getPlatformInfo(): PlatformInfo {
return PlatformInfo(
platform = "Android",
deviceId = getDeviceId(),
appVersion = getAppVersion(),
osVersion = Build.VERSION.RELEASE ?: "Unknown"
)
}
private fun getDeviceId(): String {
return android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
)
}
private fun getAppVersion(): String {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "1.0.0"
} catch (e: Exception) {
"1.0.0"
}
}
}
actual class FileManager actual constructor() {
private val context: Context
get() = android.app.Application().applicationContext
actual suspend fun saveFile(data: ByteArray, fileName: String): String {
return withContext(Dispatchers.IO) {
val file = java.io.File(context.filesDir, fileName)
file.writeBytes(data)
file.absolutePath
}
}
actual suspend fun readFile(filePath: String): ByteArray? {
return withContext(Dispatchers.IO) {
try {
java.io.File(filePath).readBytes()
} catch (e: Exception) {
null
}
}
}
actual suspend fun deleteFile(filePath: String): Boolean {
return withContext(Dispatchers.IO) {
try {
java.io.File(filePath).delete()
} catch (e: Exception) {
false
}
}
}
}
actual class CryptoManager actual constructor() {
actual suspend fun encrypt(data: ByteArray): ByteArray {
// 简化实现 - 实际项目应使用 Android Keystore
return data.map { (it + 1).toByte() }.toByteArray()
}
actual suspend fun decrypt(encryptedData: ByteArray): ByteArray {
return encryptedData.map { (it - 1).toByte() }.toByteArray()
}
actual fun generateKey(): String {
return java.util.UUID.randomUUID().toString()
}
}
class NetworkException(message: String) : Exception(message)
5. iOS 平台实现
**shared/src/iosMain/kotlin/com/imsk/platform/IosPlatform.kt
**
kotlin
package com.imsk.platform
import com.squareup.wire.GrpcClient
import com.squareup.wire.NativeGrpcClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults
import platform.UIKit.UIDevice
actual class PlatformClientFactory actual constructor(
private val baseUrl: String,
private val authToken: String?
) {
actual fun createGrpcClient(): GrpcClient {
return GrpcClient.Builder()
.baseUrl(baseUrl)
.client(
NativeGrpcClient.Builder()
.timeout(30.0)
.addHeader("User-Agent", "imsk-ios/${getAppVersion()}")
.addHeader("Device-Id", getDeviceId())
.addHeader("Platform", "iOS")
.apply {
authToken?.let { token ->
addHeader("Authorization", "Bearer $token")
}
}
.build()
)
.build()
}
actual suspend fun isNetworkAvailable(): Boolean {
// iOS 网络检查需要额外实现,这里返回 true
return true
}
actual fun getPlatformInfo(): PlatformInfo {
return PlatformInfo(
platform = "iOS",
deviceId = getDeviceId(),
appVersion = getAppVersion(),
osVersion = UIDevice.currentDevice.systemVersion
)
}
private fun getDeviceId(): String {
return UIDevice.currentDevice.identifierForVendor?.UUIDString ?: "unknown-ios-device"
}
private fun getAppVersion(): String {
val mainBundle = platform.Foundation.NSBundle.mainBundle
val version = mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String
val build = mainBundle.objectForInfoDictionaryKey("CFBundleVersion") as? String
return "$version ($build)" ?: "1.0.0"
}
}
actual class FileManager actual constructor() {
actual suspend fun saveFile(data: ByteArray, fileName: String): String {
return withContext(Dispatchers.Default) {
// iOS 文件保存实现
val filePath = platform.Foundation.NSTemporaryDirectory() + fileName
platform.Foundation.NSFileManager.defaultManager.createFileAtPath(
contents = data.toNSData(),
attributes = null
)
filePath
}
}
actual suspend fun readFile(filePath: String): ByteArray? {
return withContext(Dispatchers.Default) {
val data = platform.Foundation.NSData.dataWithContentsOfFile(filePath)
data?.toByteArray()
}
}
actual suspend fun deleteFile(filePath: String): Boolean {
return withContext(Dispatchers.Default) {
platform.Foundation.NSFileManager.defaultManager.removeItemAtPath(filePath, null)
true
}
}
}
actual class CryptoManager actual constructor() {
actual suspend fun encrypt(data: ByteArray): ByteArray {
// iOS 加密实现
return data
}
actual suspend fun decrypt(encryptedData: ByteArray): ByteArray {
return encryptedData
}
actual fun generateKey(): String {
return platform.Foundation.NSUUID.UUID().UUIDString
}
}
// 扩展函数用于类型转换
fun ByteArray.toNSData(): platform.Foundation.NSData {
return platform.Foundation.NSData.create(
bytes = this.refTo(0),
length = this.size.toULong()
)
}
fun platform.Foundation.NSData.toByteArray(): ByteArray {
return ByteArray(this.length.toInt()).apply {
this@toByteArray.getBytes(
bytes = this.refTo(0),
length = this.size.toULong()
)
}
}
6. 数据层实现
**shared/src/commonMain/kotlin/com/imsk/data/ChatRepository.kt
**
kotlin
package com.imsk.data
import com.imsk.platform.PlatformClientFactory
import com.imsk.protobuf.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class ChatRepository(
private val platformClientFactory: PlatformClientFactory,
private val messageCache: MessageCache
) {
private val grpcClient by lazy { platformClientFactory.createGrpcClient() }
private val messageService by lazy { grpcClient.create(MessageService::class) }
suspend fun sendTextMessage(
conversationId: String,
text: String,
sender: User,
replyTo: String? = null
): SendMessageResponse {
val message = ChatMessage(
message_id = generateMessageId(),
conversation_id = conversationId,
sender = sender,
message_type = MessageType.TEXT,
status = MessageStatus.SENDING,
timestamp = getCurrentTimestamp(),
content = ChatMessage.Content.Text(text),
reply_to = replyTo ?: ""
)
val request = SendMessageRequest(
conversation_id = conversationId,
message = message
)
val response = messageService.SendMessage(request)
if (response.success) {
messageCache.cacheMessage(message.copy(
status = MessageStatus.SENT,
timestamp = response.server_timestamp
))
} else {
messageCache.cacheMessage(message.copy(
status = MessageStatus.FAILED
))
}
return response
}
suspend fun getConversations(limit: Int = 20, cursor: String? = null): GetConversationsResponse {
val request = GetConversationsRequest(limit = limit, cursor = cursor ?: "")
val response = messageService.GetConversations(request)
// 缓存会话数据
messageCache.cacheConversations(response.conversations)
return response
}
suspend fun getMessages(
conversationId: String,
limit: Int = 50,
beforeMessageId: String? = null
): GetMessagesResponse {
val request = GetMessagesRequest(
conversation_id = conversationId,
limit = limit,
before_message_id = beforeMessageId ?: ""
)
val response = messageService.GetMessages(request)
// 缓存消息
messageCache.cacheMessages(conversationId, response.messages)
return response
}
fun streamMessages(userId: String): Flow<ChatMessage> {
val request = StreamMessagesRequest(user_id = userId)
return messageService.StreamMessages(request)
.catch { e ->
// 处理流错误
println("Message stream error: ${e.message}")
}
.map { message ->
// 缓存接收到的消息
messageCache.cacheMessage(message)
message
}
}
suspend fun markAsRead(conversationId: String, messageId: String): Boolean {
val request = MarkAsReadRequest(
conversation_id = conversationId,
message_id = messageId
)
val response = messageService.MarkAsRead(request)
return response.success
}
private fun generateMessageId(): String {
return "msg_${Clock.System.now().toEpochMilliseconds()}_${kotlin.random.Random.nextInt(1000)}"
}
private fun getCurrentTimestamp(): com.google.protobuf.Timestamp {
val now = Clock.System.now()
return com.google.protobuf.Timestamp(
seconds = now.epochSeconds,
nanos = (now.nanosecondsOfSecond).toInt()
)
}
}
7. ViewModel 实现
**shared/src/commonMain/kotlin/com/imsk/presentation/ChatViewModel.kt
**
kotlin
package com.imsk.presentation
import com.imsk.data.ChatRepository
import com.imsk.data.MessageCache
import com.imsk.protobuf.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class ChatViewModel(
private val chatRepository: ChatRepository,
private val messageCache: MessageCache,
private val currentUser: User
) {
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var messageStreamJob: Job? = null
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ChatEvent>()
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
init {
observeConversations()
observeCurrentMessages()
startMessageStreaming()
loadConversations()
}
fun onAction(action: ChatAction) {
when (action) {
is ChatAction.SendMessage -> sendMessage(action.conversationId, action.text, action.replyTo)
is ChatAction.LoadConversations -> loadConversations()
is ChatAction.SelectConversation -> selectConversation(action.conversationId)
is ChatAction.LoadMoreMessages -> loadMoreMessages(action.conversationId)
is ChatAction.MarkAsRead -> markAsRead(action.conversationId, action.messageId)
ChatAction.RetryFailedMessages -> retryFailedMessages()
ChatAction.Refresh -> refreshData()
}
}
private fun sendMessage(conversationId: String, text: String, replyTo: String?) {
if (text.isBlank()) return
coroutineScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val response = chatRepository.sendTextMessage(
conversationId = conversationId,
text = text,
sender = currentUser,
replyTo = replyTo
)
if (response.success) {
_events.emit(ChatEvent.MessageSent(response.message_id))
} else {
_events.emit(ChatEvent.Error("发送失败: ${response.error_message}"))
}
} catch (e: Exception) {
_events.emit(ChatEvent.Error("发送失败: ${e.message}"))
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
private fun loadConversations() {
coroutineScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val response = chatRepository.getConversations()
// 数据已通过缓存自动更新
} catch (e: Exception) {
_events.emit(ChatEvent.Error("加载会话失败: ${e.message}"))
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
private fun selectConversation(conversationId: String) {
coroutineScope.launch {
_uiState.update {
it.copy(
selectedConversationId = conversationId,
currentMessages = emptyList()
)
}
loadMessages(conversationId)
}
}
private fun loadMessages(conversationId: String) {
coroutineScope.launch {
try {
val response = chatRepository.getMessages(conversationId)
// 数据已通过缓存自动更新
} catch (e: Exception) {
_events.emit(ChatEvent.Error("加载消息失败: ${e.message}"))
}
}
}
private fun observeConversations() {
messageCache.conversationsFlow
.onEach { conversations ->
_uiState.update { it.copy(conversations = conversations) }
}
.launchIn(coroutineScope)
}
private fun observeCurrentMessages() {
_uiState
.map { it.selectedConversationId }
.distinctUntilChanged()
.onEach { conversationId ->
conversationId?.let { id ->
messageCache.getMessagesFlow(id)
.onEach { messages ->
_uiState.update { it.copy(currentMessages = messages) }
}
.launchIn(coroutineScope)
}
}
.launchIn(coroutineScope)
}
private fun startMessageStreaming() {
messageStreamJob?.
...