2. KMP项目工程结构搭建:模块划分、共享代码、平台特定代码

本章聚焦KMP(Kotlin Multiplatform)项目的物理模块划分规则,以及如何在Gradle多模块体系中组织共享代码与平台特定代码。理解这一结构是后续实现Compose状态提升的基础——只有正确定义commonMain、androidMain、iosMain等source set,才能让业务逻辑与平台UI层无缝配合。

2.1 原理:KMP的source set体系

KMP利用Gradle的多源集(Source Set)机制为不同目标平台提供专用目录。每个模块内至少包含以下标准源集:

源集名称用途典型目录
commonMain平台无关的业务逻辑、数据模型、接口声明src/commonMain/kotlin
commonTest共享测试代码src/commonTest/kotlin
androidMainAndroid平台具体实现(如Android API调用、依赖注入)src/androidMain/kotlin
iosMainiOS平台具体实现(如UIKit交互、NSUserDefaults)src/iosMain/kotlin
desktopMain(可选)桌面平台(JVM)src/desktopMain/kotlin

实际项目中通常将UI相关的Compose代码也放入commonMain(Compose Multiplatform)。平台特定入口(如Activity、View Controller)则分别放置在androidMain和iosMain。这种划分严格遵循“expect/actual”机制——在commonMain中声明接口或类(expect),在各平台源集中提供具体实现(actual)。

核心原则:业务逻辑、网络请求、数据库操作、状态管理应全部置于commonMain;系统专属功能(传感器、通知、文件读写)放在平台源集,并通过expect/actual暴露给共享层。

2.2 架构总览

下图展示一个典型KMP + Compose Multiplatform项目的模块依赖关系:

KMP工程模块划分图 KMP + Compose Multiplatform 模块结构 shared (commonMain) 数据模型、网络、数据库、状态管理 Compose UI (通用组件) expect 声明 androidApp (androidMain) Android Activity、Compose宿主 actual 实现 (Android API) Android特有依赖 iosApp (iosMain) iOS UIKit/SwiftUI入口 actual 实现 (iOS API) iOS特有依赖 依赖 依赖 expect / actual 桥梁 注意:实际项目中 shared 可拆分为多个子模块(domain、data、network),但原则相同——所有平台无关代码必须放置在 commonMain 源集下。

2.3 实现流程:手动搭建KMP项目结构

推荐使用JetBrains插件“Kotlin Multiplatform Wizard”生成初始模板,然后按需调整。若需手动创建,请遵循以下步骤:

  1. 创建根项目:使用Gradle Kotlin DSL,指定Kotlin与Compose插件版本。
  2. 定义模块:在settings.gradle.kts中包含shared、androidApp、iosApp。
  3. 配置shared模块:声明目标平台(androidNativeX64, iosArm64, iosSimulatorArm64等),添加Compose Multiplatform依赖。
  4. 配置Android模块:依赖shared模块,添加Android Compose和Kotlin Android插件。
  5. 配置iOS模块:创建Xcode项目,引入shared的framework产物,桥接Compose UI。

以下是shared/build.gradle.kts的核心配置片段:

plugins {
    kotlin("multiplatform")
    id("org.jetbrains.compose")
    id("org.jetbrains.kotlin.plugin.compose")
}

kotlin {
    androidTarget()
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { target ->
        target.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(compose.runtime)
                implementation(compose.foundation)
                implementation(compose.material3)
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
                implementation("io.ktor:ktor-client-core:2.3.12")
            }
        }
        val androidMain by getting {
            dependencies {
                implementation(compose.preview)
                implementation("androidx.activity:activity-compose:1.9.2")
            }
        }
        val iosX64Main by getting
        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting
        val iosMain by creating {
            dependsOn(commonMain)
            iosX64Main.dependsOn(this)
            iosArm64Main.dependsOn(this)
            iosSimulatorArm64Main.dependsOn(this)
            dependencies {
                // iOS平台特定依赖(如Ktor iOS引擎)
                implementation("io.ktor:ktor-client-darwin:2.3.12")
            }
        }
    }
}
经验值:若需在iOS上使用Compose,务必在iosMain中加入compose.ui依赖(Compose插件会自动处理)。framework导出时,注意选择isStatic = true以减少启动时间。

2.4 平台特定代码:expect/actual实践

在commonMain中声明期望接口,例如平台剪贴板操作:

// commonMain
expect class ClipboardManager {
    fun copy(text: String)
    fun paste(): String?
}

Android实现:

// androidMain
actual class ClipboardManager(private val context: Context) {
    private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager

    actual fun copy(text: String) {
        clipboard.setPrimaryClip(android.content.ClipData.newPlainText("label", text))
    }

    actual fun paste(): String? {
        val clip = clipboard.primaryClip
        return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).text.toString() else null
    }
}

iOS实现:

// iosMain
actual class ClipboardManager {
    private val pasteboard = UIPasteboard.generalPasteboard

    actual fun copy(text: String) {
        pasteboard.string = text
    }

    actual fun paste(): String? = pasteboard.string
}

2.5 调试与运行

  • Android:直接运行androidApp任务,如同普通Android项目。
  • iOS:先执行linkDebugFrameworkIosArm64(或对应架构任务),将生成的shared.framework放入Xcode项目,然后在Xcode中运行iOS target。
  • 跨平台调试:使用Android Studio或IntelliJ IDEA的KMP调试器,断点可同时进入commonMain和平台代码。注意:iOS调试需在macOS上通过Xcode进行。

2.6 常见错误与解决方案

错误现象原因解决方法
编译报错“Expecting actual declaration in module xxx”平台源集未正确关联,或actual函数签名不一致检查iosMain是否通过dependsOn(commonMain)连接;确保函数名、参数、返回类型完全匹配
iOS链接时找不到shared框架框架名称或路径配置错误在build.gradle.kts中设置baseName = "shared",并在Xcode的Framework Search Paths中添加$(SRCROOT)/../shared/build/bin/iosArm64/debugFramework
Compose预览在Android中不显示缺少compose.preview依赖在androidMain依赖中添加implementation(compose.preview)
Gradle同步超时Kotlin插件版本与Compose版本不兼容使用BOM统一版本:implementation(platform("org.jetbrains.compose:compose-bom:2024.10.01"))

2.7 性能与稳定性优化

  • 模块层次不宜过深:尽量保持共享模块扁平化,避免多层依赖导致增量编译变慢。
  • 谨慎使用expect/actual:每增加一对expect/actual都会增加跨平台编译的复杂度。优先用接口+平台注入替代直接的expect声明。
  • iOS Framework静态链接:始终设置isStatic = true,减少动态链接的启动开销和版本冲突。
  • 开启编译缓存:在gradle.properties中添加:
    kotlin.mpp.stability.nowarn=true
    kotlin.mpp.enableCInteropCommonization=true
    org.gradle.caching=true
警告:切勿将平台特定代码(如Android的android.util.Log)直接写在commonMain中,即使通过条件编译也是反模式。务必使用expect/actual或依赖注入隔离平台依赖。