← 返回学习路线
🧩

第 3 章:共享模块架构

深入理解 commonMain 的代码组织,掌握 expect/actual 的典型用法,用三层架构构建可维护的共享模块

🗂️ Source Set 层次结构

KMP 的 Source Set(源集)形成一棵层次树,子集可以使用父集中声明的依赖:

Source Set 层次图 commonMain / \ nativeMain jvmMain / \ appleMain linuxMain / \ iosMain macosMain / \ iosX64 iosArm64 iosSimulatorArm64

这个层次结构很有用:如果你需要在 iOS 和 macOS 上共享同一段代码(比如都用 CoreData),可以放在 appleMain 而不需要重复在 iosMain 和 macosMain 各写一遍。

3.1 创建中间源集

shared/build.gradle.kts kotlin { sourceSets { // 创建 Apple 平台中间源集(iOS + macOS 共享) val appleMain by creating { dependsOn(commonMain.get()) } iosMain.get().dependsOn(appleMain) macosMain().dependsOn(appleMain) } }
Kotlin 2.0 自动推断中间源集: 从 Kotlin 2.0 起,编译器可以自动推断 appleMain 等中间源集,通常无需手动创建。但了解这个机制有助于处理复杂的多目标场景。

🔀 expect / actual 实践

3.2 常用 expect/actual 场景

场景一:获取平台信息

commonMain interface PlatformInfo { val name: String val version: String val isDebug: Boolean } expect fun getPlatformInfo(): PlatformInfo
androidMain actual fun getPlatformInfo(): PlatformInfo = object : PlatformInfo { override val name = "Android" override val version = android.os.Build.VERSION.RELEASE override val isDebug = BuildConfig.DEBUG }
iosMain actual fun getPlatformInfo(): PlatformInfo = object : PlatformInfo { override val name = "iOS" override val version = UIDevice.currentDevice.systemVersion() override val isDebug = Platform.isSimulator() }

场景二:日志记录

commonMain expect object Logger { fun d(tag: String, message: String) fun e(tag: String, message: String, throwable: Throwable? = null) } // 在 commonMain 中直接使用 fun fetchData() { Logger.d("Repo", "Fetching data...") }
androidMain actual object Logger { actual fun d(tag: String, message: String) { android.util.Log.d(tag, message) } actual fun e(tag: String, message: String, throwable: Throwable?) { android.util.Log.e(tag, message, throwable) } }
iosMain actual object Logger { actual fun d(tag: String, message: String) { println("[D/$tag] $message") } actual fun e(tag: String, message: String, throwable: Throwable?) { println("[E/$tag] $message ${throwable?.message}") } }

场景三:文件存储路径

commonMain / androidMain / iosMain // commonMain expect fun getFilesDir(): String // androidMain(需要 Context,通过 Koin 注入) actual fun getFilesDir(): String = androidContext().filesDir.absolutePath // iosMain actual fun getFilesDir(): String = NSFileManager.defaultManager .URLsForDirectory(NSDocumentDirectory, NSUserDomainMask) .firstOrNull() ?.path() ?: ""

3.3 expect/actual 注意事项

不要过度使用 expect/actual: 如果某段逻辑能用纯 Kotlin 标准库写出来,就不需要 expect/actual。只有在确实依赖平台 API 时才使用。过多的 expect/actual 会让项目难以维护。

📚 Kotlinx 系列库

3.4 Kotlinx.Serialization — JSON 解析

commonMain — 数据模型定义 import kotlinx.serialization.* @Serializable data class Article( val id: Int, val title: String, @SerialName("published_at") // JSON key 与字段名映射 val publishedAt: String, val author: Author?, val tags: List<String> = emptyList() ) @Serializable data class Author( val name: String, val email: String ) // 手动序列化/反序列化 val json = Json { ignoreUnknownKeys = true } val article: Article = json.decodeFromString(jsonString) val jsonStr: String = json.encodeToString(article)

3.5 Kotlinx.Coroutines — 异步并发

commonMain — 协程基础用法 import kotlinx.coroutines.* import kotlinx.coroutines.flow.* // Flow:冷流,订阅时才执行 fun getArticlesFlow(): Flow<List<Article>> = flow { while (true) { val articles = api.getArticles() emit(articles) delay(30_000) // 每 30 秒刷新 } } // StateFlow:热流,保存最新状态 class ArticleRepository { private val _articles = MutableStateFlow<List<Article>>(emptyList()) val articles: StateFlow<List<Article>> = _articles.asStateFlow() suspend fun refresh() { val data = api.getArticles() _articles.value = data } } // Dispatchers 在 KMP 中的使用 suspend fun doWork() = withContext(Dispatchers.Default) { // CPU 密集型任务(所有平台可用) }

3.6 Kotlinx.DateTime — 多平台日期时间

commonMain import kotlinx.datetime.* // 获取当前时刻(UTC) val now: Instant = Clock.System.now() // 转换时区 val beijing: LocalDateTime = now.toLocalDateTime(TimeZone.of("Asia/Shanghai")) // 日期计算 val yesterday: Instant = now - 1.days val nextWeek: Instant = now + 7.days // 解析 ISO 8601 字符串 val date: Instant = Instant.parse("2024-01-15T08:30:00Z") // 格式化(用于 UI 展示) fun Instant.toDisplayString(): String { val local = toLocalDateTime(TimeZone.currentSystemDefault()) return "${local.year}-${local.monthNumber.toString().padStart(2,'0')}-${local.dayOfMonth.toString().padStart(2,'0')}" }

🏗️ 三层架构实践

在 KMP 中,推荐采用经典的三层架构组织 shared 模块中的代码:

shared 模块目录结构 shared/src/commonMain/kotlin/ └── com/example/newsapp/ ├── data/ │ ├── remote/ │ │ ├── NewsApi.kt # Ktor 接口 │ │ └── dto/ArticleDto.kt # 网络层数据模型 │ ├── local/ │ │ └── NewsLocalDataSource.kt # SQLDelight 封装 │ └── NewsRepositoryImpl.kt # Repository 实现 │ ├── domain/ │ ├── model/ │ │ └── Article.kt # 领域模型(干净,无注解) │ ├── repository/ │ │ └── NewsRepository.kt # Repository 接口 │ └── usecase/ │ └── GetArticlesUseCase.kt │ └── presentation/ └── home/ ├── HomeViewModel.kt └── HomeUiState.kt

3.7 领域层:干净的业务模型

domain/model/Article.kt // 领域模型:不含任何框架注解,纯 Kotlin data class data class Article( val id: String, val title: String, val summary: String, val imageUrl: String?, val publishedAt: Instant, val isBookmarked: Boolean = false ) // Repository 接口:定义数据操作契约 interface NewsRepository { fun getArticles(): Flow<List<Article>> suspend fun refreshArticles() suspend fun toggleBookmark(articleId: String) }

3.8 数据层:Repository 实现

data/NewsRepositoryImpl.kt class NewsRepositoryImpl( private val remoteApi: NewsApi, private val localDataSource: NewsLocalDataSource ) : NewsRepository { override fun getArticles(): Flow<List<Article>> { // 先返回本地缓存,再从网络刷新 return localDataSource.getAllArticles() .map { dbArticles -> dbArticles.map { it.toDomain() } } } override suspend fun refreshArticles() { val dtos = remoteApi.getArticles() localDataSource.upsertArticles(dtos.map { it.toEntity() }) } override suspend fun toggleBookmark(articleId: String) { localDataSource.toggleBookmark(articleId) } } // DTO → Domain 映射扩展函数 private fun ArticleDto.toDomain(): Article = Article( id = this.id.toString(), title = this.title, summary = this.content.take(200), imageUrl = this.thumbnail, publishedAt = Instant.parse(this.publishedAt) )

3.9 展示层:UseCase + ViewModel

domain/usecase/GetArticlesUseCase.kt class GetArticlesUseCase(private val repository: NewsRepository) { operator fun invoke(): Flow<List<Article>> = repository.getArticles() .map { articles -> articles.sortedByDescending { it.publishedAt } } }

📋 Version Catalog

3.10 统一版本管理

Gradle Version Catalog 是 KMP 项目管理依赖的最佳方式。所有版本号集中在一个文件,避免版本号散落在多个 build.gradle.kts 中。

gradle/libs.versions.toml — 完整示例 [versions] kotlin = "2.0.21" agp = "8.5.2" # Android Gradle Plugin compose-bom = "2024.11.00" compose-plugin = "1.7.0" ksp = "2.0.21-1.0.28" ktor = "3.0.0" sqldelight = "2.0.2" koin = "4.0.0" coroutines = "1.9.0" serialization = "1.7.3" datetime = "0.6.1" coil = "3.0.0-rc02" skie = "0.9.0" [libraries] # Kotlin kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } # SQLDelight sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } # Koin koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } # Coil coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } [plugins] kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } skie = { id = "co.touchlab.skie", version.ref = "skie" }

🔗 模块依赖管理

3.11 Koin 依赖注入 — 共享模块声明

commonMain — Koin 模块声明 import org.koin.core.module.dsl.* import org.koin.dsl.* val sharedModule = module { // 网络层 single { HttpClientFactory.create(get()) } single { NewsApiImpl(get()) as NewsApi } // 数据层 single { NewsLocalDataSource(get()) } single { NewsRepositoryImpl(get(), get()) as NewsRepository } // UseCase factory { GetArticlesUseCase(get()) } // ViewModel viewModel { HomeViewModel(get()) } }
androidMain — Android Koin 初始化 class MyApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@MyApplication) modules(sharedModule, androidModule) } } } val androidModule = module { // Android 特有的 actual 依赖 single<SqlDriver> { AndroidSqliteDriver( schema = AppDatabase.Schema, context = androidContext(), name = "news.db" ) } }

🎯 实践任务

  1. shared/commonMain 中创建一个 Article 领域模型和 ArticleRepository 接口
  2. 使用 expect/actual 实现跨平台日志类 AppLogger
  3. libs.versions.toml 中添加 Ktor 和 SQLDelight 的版本声明
  4. 配置 Koin 模块,将 Repository 注入到 ViewModel