Edit Page

跨平台项目结构的高级概念

最终更新: 2024/03/21

本文解释 Kotlin Multiplatform 项目结构的高级概念, 以及如何对应到 Gradle 实现.

如果你正在进行下面的工作, 这些信息会对你很有用:

  • 在一组特定的编译目标之间共用代码, 但 Kotlin 默认不会为这些编译目标创建源代码集. 在这种情况下, 你需要一个低层的 API, 它公开一些新的抽象.
  • 需要使用 Gradle 构建的低层抽象,例如配置, 任务, 发布, 等等.
  • 正在为 Kotlin Multiplatform 构建创建 Gradle plugin.

在深入研究高级概念之前, 我们建议先学习 跨平台项目结构的基础知识.

依赖关系与 dependsOn

本节介绍 2 种类型的依赖关系:

  • dependsOn – 2 个 Kotlin 源代码集之间的, 特定的 Kotlin Multiplatform 关系.
  • 通常的依赖项 – 对已发布的库的依赖, 例如 kotlinx-coroutines, 或对你的构建中的其他 Gradle 项目的依赖.

通常, 你会使用 依赖项 而不是 dependsOn 关系. 但是, 为了理解 Kotlin Multiplatform 项目的底层工作方式, 研究 dependsOn 是至关重要的.

dependsOn 与源代码集层级关系

dependsOn 是 2 个 Kotlin 源代码集之间的, Kotlin 特有的关系. 它可以是共通源代码集与平台相关源代码集之间的连接, 例如, 可以表示 jvmMain 源代码集依赖于 commonMain, iosArm64Main 依赖于 iosMain, 等等.

考虑一个一般的例子, 存在 Kotlin 源代码集 AB. 表达式 A.dependsOn(B) 会指示 Kotlin:

  1. A 可以看到来自 B 的 API, 包括内部声明.
  2. A 可以为来自 B 的预期声明提供实际实现. 这是一个必须而且充分的条件, 因为, 当而且仅当, 存在直接或间接的 A.dependsOn(B) 关系时, A 才可以为 B 提供 actuals 实现.
  3. B 应该编译到 A 编译到的所有编译目标, 此外再加上它自己的编译目标.
  4. A 继承 B 的所有的常规依赖项.

dependsOn 关系会创建一个树形结构, 也叫做源代码集层级. 下面是一个通常的移动开发项目的例子, 针对的目标平台是 androidTarget, iosArm64 (iPhone 设备), 和 iosSimulatorArm64 (Apple Silicon Mac 上的 iPhone 模拟器):

DependsOn tree structure

图中的箭头表示 dependsOn 关系. 在编译平台二进制文件时, 也会保持这些关系. 所以 Kotlin 能够理解, iosMain 应该能够看到来自 commonMain 的 API, 但不能看到来自 iosArm64Main 的 API:

DependsOn relations during compilation

dependsOn 关系使用 KotlinSourceSet.dependsOn(KotlinSourceSet) 调用来配置, 例如:

kotlin {
    // 编译目标的声明
    sourceSets {
        // 配置 dependsOn 关系的示例
        iosArm64Main.dependsOn(commonMain)
    }
}
  • 这个示例演示如何在构建脚本中定义 dependsOn 关系. 但是, Kotlin Gradle plugin 会默认创建源代码集并设置它们的关系, 因此你不需要手动配置.
  • 在构建脚本中, dependsOn 关系在 dependencies {} 代码块之外的地方声明. 这是因为 dependsOn 不是一种通常的依赖项; 相反, 它是 Kotlin 源代码集之间的一种特别关系, 在不同的编译目标之间共用代码时需要这些依赖关系.

你不能使用 dependsOn 来表达对已发布的库或对另一个 Gradle 项目的通常的依赖. 例如, 你不能设置 commonMain 依赖于 kotlinx-coroutines-core 库的 commonMain, 也不能调用 commonTest.dependsOn(commonMain).

对其他库或项目的依赖

在跨平台项目中, 你可以设置通常的依赖项, 可以依赖到已发布的库, 或依赖到另一个 Gradle 项目.

Kotlin Multiplatform 一般会通过通常的 Gradle 方式来声明依赖项. 与 Gradle 类似, 你应该:

  • 在你的构建脚本中使用 dependencies {} 代码块.
  • 为依赖项选择适当的范围(scope), 例如, implementationapi.
  • 引用依赖项, 如果它是已经发布到仓库中, 可以指定它的座标(coordinate), 例如 "com.google.guava:guava:32.1.2-jre", 如果它是同一个构建内的一个 Gradle 项目, 可以指定它的路径, 例如 project(":utils:concurrency").

跨平台项目中的依赖项配置有一些特别的功能. 每个 Kotlin 源代码集有它独自的 dependencies {} 代码块. 因此你可以在平台相关的源代码集中声明平台相关的依赖项:

kotlin {
    // 编译目标的声明
    sourceSets {
        jvmMain.dependencies {
            // 这是 jvmMain 的依赖项, 因此可以添加 JVM 相关的依赖项
            implementation("com.google.guava:guava:32.1.2-jre")
        }
    }
}

共通的依赖项要复杂一些. 考虑一个跨平台项目, 声明了对一个跨平台库的依赖项, 例如, kotlinx.coroutines:

kotlin {
    androidTarget()     // Android
    iosArm64()          // iPhone 设备
    iosSimulatorArm64() // Apple Silicon Mac 上的 iPhone 模拟器

    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        }
    }
}

在依赖解析中, 有 3 个重要概念:

  1. 跨平台依赖项会沿着 dependsOn 结构向下传播. 如果你对 commonMain 添加一个依赖项, 它会自动添加到声明了对 commonMain 直接或间接的 dependsOn 关系的所有源代码集.

    在这个例子中, 依赖项实际会被自动添加到所有的 *Main 源代码集: iosMain, jvmMain, iosSimulatorArm64Main, 和 iosX64Main. 所有这些源代码集会从 commonMain 源代码集继承 kotlin-coroutines-core 依赖项, 因此你不需要将依赖项手动的复制粘贴到这些源代码集中:

    Propagation of multiplatform dependencies

    依赖项传播机制允许你通过选择特定的源代码集, 来指定接受到声明的依赖项的范围. 例如, 如果你希望在 iOS 上使用 kotlinx.coroutines, 但不在 Android 上使用, 你可以只对 iosMain 添加这个依赖项.

  2. 源代码集 → 跨平台库 依赖项, 例如上面的 commonMainorg.jetbrians.kotlinx:kotlinx-coroutines-core:1.7.3 的依赖, 表示依赖解析的中间状态. 解析的最终状态始终表示为 源代码集 → 源代码集 依赖项.

    最终的 源代码集 → 源代码集 依赖项不是指 dependsOn 关系.

    为了推断细粒度的 源代码集 → 源代码集 依赖项, Kotlin 会读取和每个跨平台库一起发布的源代码集结构. 完成这一步之后, 每个库的内部表达不是一个整体, 而是它的源代码集的集合. 请看 kotlinx-coroutines-core 的例子:

    Serialization of the source set structure

  3. Kotlin 对每个依赖关系解析为依赖项中源代码集的集合. 这个集合中的每个依赖项源代码集必须拥有 兼容的编译目标. 依赖项源代码集拥有兼容的编译目标是指, 它至少编译到 与使用它的源代码集相同的编译目标.

    例如, 示例项目中的 commonMain 编译到 androidTarget, iosX64, 和 iosSimulatorArm64:

    • 首先, 它解析到一个对 kotlinx-coroutines-core.commonMain 的依赖项. 因为 kotlinx-coroutines-core 编译到所有可能的 Kotlin 编译目标. 因此, 它的 commonMain 会编译到所有可能的编译目标, 包括这里要求的 androidTarget, iosX64, 和 iosSimulatorArm64.
    • 其次, commonMain 依赖 kotlinx-coroutines-core.concurrentMain. 因为 kotlinx-coroutines-core 中的 concurrentMain 编译到除 JS 之外的所有的编译目标, 它匹配使用它的项目中的 commonMain 的编译目标.

    但是, coroutines 中的 iosX64Main 之类的源代码集, 不兼容于使用它的 commonMain 源代码集. 即使 iosX64Main 编译到 commonMain 的编译目标之一, 也就是, iosX64, 但是它不编译到 androidTargetiosSimulatorArm64.

    依赖解析的结果直接影响可以访问 kotlinx-coroutines-core 中的哪些代码:

    Error on JVM-specific API in common code

声明自定义的源代码集

有些情况下, 在你的项目中可能需要自定义的中间源代码集. 考虑一个项目, 编译到 JVM, JS, 和 Linux 平台, 你想要只在 JVM 和 JS 平台之间共用一些源代码. 这种情况下, 你应该为这组编译目标寻找一个特定的源代码集, 具体方法请参见 跨平台项目结构的基础知识.

Kotlin 不会自动创建这样的源代码集. 因此你应该使用 by creating 构造来手动创建它:

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        // 创建名为 "jvmAndJs" 的源代码集
        val jvmAndJsMain by creating {
            // ...
        }
    }
}

但是, Kotlin 仍然如何处理或者编译这个源代码集. 如果你画一个源代码集关系图, 这个源代码集将是孤立的, 没有添加任何编译目标的标签:

Missing dependsOn relation

为了解决这个问题, 请添加几个 dependsOn 关系, 将 jvmAndJsMain 包含到层级结构中:

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        val jvmAndJsMain by creating {
            // 不要忘记添加对 commonMain 的 dependsOn
            dependsOn(commonMain.get())
        }

        jvmMain {
            dependsOn(jvmAndJsMain)
        }

        jsMain {
            dependsOn(jvmAndJsMain)
        }
    }
}

这里, jvmMain.dependsOn(jvmAndJsMain) 会对 jvmAndJsMain 添加 JVM 编译目标, jsMain.dependsOn(jvmAndJsMain) 会对 jvmAndJsMain 添加 JS 编译目标.

最终的项目结构如下:

Final project structure

如果手动配置 dependsOn 关系, 会禁止自动使用默认的层级结构模板. 关于这样的情况, 以及处理方法, 详情请参见 附加配置.

编译

与单一平台的项目不同, Kotlin Multiplatform 项目需要多次编译器运行来构建所有的 artifact. 每次编译器运行都是一个 Kotlin 编译.

例如, 在前面提到的 Kotlin 编译过程中, 用于 iPhone 设备的二进制文件的生成方式如下:

Kotlin compilation for iOS

Kotlin 编译会在编译目标之下分组. 默认情况下, Kotlin 为每个编译目标创建 2 个编译, main 编译用于产品源代码, test 编译用于测试源代码.

在构建脚本中, 编译通过类似的方式访问. 你首先选择一个 Kotlin 编译目标, 然后访问其中的 compilations 容器, 最后通过名称选择你需要的编译:

kotlin {
    // 声明并配置 JVM 编译目标
    jvm {
        val mainCompilation: KotlinJvmCompilation = compilations.getByName("main")
    }
}