🗂️ 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"
)
}
}
🎯 实践任务
- 在
shared/commonMain中创建一个Article领域模型和ArticleRepository接口 - 使用 expect/actual 实现跨平台日志类
AppLogger - 在
libs.versions.toml中添加 Ktor 和 SQLDelight 的版本声明 - 配置 Koin 模块,将 Repository 注入到 ViewModel