跨平台项目结构的先进概念
这篇文章解释了 Kotlin 跨平台项目结构的高级概念,以及它们如何映射到 Gradle 的实现。 如果你需要处理 Gradle 构建的低级抽象(如配置、任务、发布等)或创建用于 Kotlin 跨平台构建的 Gradle 插件,这些信息将非常有用。
如果你有以下需求,这篇文章将对你有所帮助:
需要在 Kotlin 未创建源代码集的一组目标之间共享代码。
想要为 Kotlin 跨平台构建创建 Gradle 插件,或需要处理 Gradle 构建的低级抽象,例如配置、任务、发布等。
关于跨平台项目中的依赖管理,一个关键的理解点是 Gradle 风格的项目或库依赖与 Kotlin 特有的源代码集之间的 dependsOn
关系之间的区别:
dependsOn
是通用源代码集与平台特定源代码集之间的关系,它启用了 源代码集层次结构 并在跨平台项目中共享代码。 对于默认的源代码集,层次结构会自动管理,但在特定情况下你可能需要更改它。库和项目依赖通常按常规方式工作,但为了在跨平台项目中正确管理它们,你需要了解 Gradle 依赖项是如何解析的 ,以便将它们转化为用于编译的细粒度 源代码集 → 源代码集 依赖项。
dependsOn
和 源代码集层次结构
通常,你会处理 依赖项 ,而不是 dependsOn
关系。 然而,研究 dependsOn
对于理解 Kotlin 跨平台项目的底层工作原理至关重要。
dependsOn
是两个 Kotlin 源代码集之间特有的关系。 它可以是通用源代码集与平台特定源代码集之间的连接,例如,当 jvmMain
源代码集依赖于 commonMain
, iosArm64Main
依赖于 iosMain
,等等。
考虑一个包含 Kotlin 源代码集 A
和 B
的一般示例。表达式 A.dependsOn(B)
告诉 Kotlin:
A
可以访问B
的 API,包括内部声明。A
可以为B
中的预期声明提供实际实现。这是一个必要且充分的条件,因为A
只有在A.dependsOn(B)
(直接或间接)时才能为B
提供actuals
。B
应该编译到A
编译的所有目标,以及它自己的目标。A
继承B
的所有常规依赖项。
dependsOn
关系创建了一个类似树状结构的源代码集层次结构。 下面是一个典型的移动开发项目示例,其中包含 androidTarget
、 iosArm64
(iPhone 设备)和 iosSimulatorArm64
(Apple Silicon Mac 的 iPhone 模拟器):
箭头表示 dependsOn
关系。 这些关系在平台二进制文件的编译过程中得以保留。 这就是 Kotlin 理解 iosMain
可以访问 commonMain
的 API,但不能访问 iosArm64Main
的原因:
dependsOn
关系通过 KotlinSourceSet.dependsOn(KotlinSourceSet)
调用进行配置,例如:
此示例展示了如何在构建脚本中定义
dependsOn
关系。 不过,Kotlin Gradle 插件会默认创建源代码集并设置这些关系,因此你不需要手动执行此操作。dependsOn
关系是在构建脚本中的dependencies {}
块之外单独声明的。 这是因为dependsOn
不是常规依赖项,而是 Kotlin 源代码集之间的一种特定关系,用于在不同目标之间共享代码。
你不能使用 dependsOn
来声明对已发布库或其他 Gradle 项目的常规依赖。 例如,你不能将 commonMain
设置为依赖于 kotlinx-coroutines-core
库的 commonMain
,也不能调用 commonTest.dependsOn(commonMain)
。
声明自定义源代码集
在某些情况下,你可能需要在项目中创建一个自定义的中间源代码集。 考虑一个编译到 JVM、JS 和 Linux 的项目,你希望仅在 JVM 和 JS 之间共享某些源代码。 在这种情况下,你应该为这一对目标找到一个特定的源代码集,如 跨平台项目结构的基础知识 中所述。
Kotlin 不会自动创建这样的源代码集,这意味着你需要使用 by creating
构造手动创建它:
然而,Kotlin 仍然不知道如何处理或编译这个源代码集。 如果你绘制一个图表,这个源代码集将是孤立的,并且不会有任何目标标签:
要修复这个问题,可以通过添加多个 dependsOn
关系将 jvmAndJsMain
包含到层次结构中:
在这里, jvmMain.dependsOn(jvmAndJsMain)
将 JVM 目标添加到 jvmAndJsMain
,而 jsMain.dependsOn(jvmAndJsMain)
则将 JS 目标添加到 jvmAndJsMain
。
最终的项目结构将如下所示:
对其他库或项目的依赖关系
在跨平台项目中,你可以设置常规依赖项,无论是在已发布的库上还是在另一个 Gradle 项目上。
Kotlin 跨平台通常以典型的 Gradle 方式声明依赖项。与 Gradle 类似,你可以:
在构建脚本中使用
dependencies {}
块。为依赖项选择适当的范围,例如
implementation
或api
。引用依赖项时,如果它在存储库中发布,可以指定其坐标, 例如
"com.google.guava:guava:32.1.2-jre"
;如果它是同一构建中的一个 Gradle 项目,可以使用其路径, 例如project(":utils:concurrency")
。
在跨平台项目中配置依赖项具有一些特殊特性。每个 Kotlin 源代码集都有自己的 dependencies {}
块。 这使得你可以在特定于平台的源代码集中声明特定于平台的依赖项:
通用依赖项更为复杂。考虑一个跨平台项目,该项目声明了对一个跨平台库的依赖, 例如 kotlinx.coroutines
:
在依赖解析中有三个重要概念:
跨平台依赖会沿着
dependsOn
结构传播。 当你向commonMain
添加一个依赖时,它会自动添加到所有直接或间接声明了dependsOn
关系的源代码集中。在这种情况下,依赖项确实被自动添加到所有
*Main
源代码集中:iosMain
、jvmMain
、iosSimulatorArm64Main
和iosX64Main
。所有这些源代码集都继承了来自commonMain
源代码集的kotlin-coroutines-core
依赖,因此你不必手动复制和粘贴到所有这些源代码集中:源代码集 → 跨平台库 依赖项,如上例中的
commonMain
到org.jetbrians.kotlinx:kotlinx-coroutines-core:1.7.3
, 代表了依赖解析的中间状态。解析的最终状态始终由 源代码集 → 源代码集 依赖项表示。为了推断细粒度的 源代码集 → 源代码集 依赖项,Kotlin 读取与每个跨平台库一起发布的源代码集结构。 在此步骤之后,每个库将内部表示为其源代码集的集合,而不是整体。 例如,
kotlinx-coroutines-core
的示例如下: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
目标匹配。
然而,像 iosX64Main
这样的源代码集与消费者的 commonMain
不兼容。 尽管 iosX64Main
编译到 commonMain
的目标之一,即 iosX64
, 它不编译到 androidTarget
或 iosSimulatorArm64
。
依赖解析的结果直接影响了 kotlinx-coroutines-core
中哪些代码是可见的:
在源代码集之间对齐公共依赖的版本
在 Kotlin 跨平台项目中,公共源代码集会被编译多次以生成 klib,并作为每个配置的 编译 的一部分。 为了生成一致的二进制文件,每次编译公共代码时都应使用相同版本的跨平台依赖项。 Kotlin Gradle 插件帮助对齐这些依赖项,确保每个源代码集的实际依赖版本相同。
在上面的示例中,假设你想将 androidx.navigation:navigation-compose:2.7.7
依赖项添加到 androidMain
源代码集中。你的项目明确为 commonMain
源代码集声明了 kotlinx-coroutines-core:1.7.3
依赖项,但 Compose Navigation 库版本 2.7.7 需要 Kotlin 协程 1.8.0 或更高版本。
由于 commonMain
和 androidMain
一起编译,Kotlin Gradle 插件会在两个版本的协程库之间进行选择,并将 kotlinx-coroutines-core:1.8.0
应用到 commonMain
源代码集中。 但为了确保公共代码在所有配置的目标上都能一致编译,iOS 源代码集也需要限制为相同的依赖版本。 因此,Gradle 也将 kotlinx.coroutines-*:1.8.0
依赖项传播到 iosMain
源代码集中。
依赖项在 *Main
源代码集和 *Test
源代码集 之间分别对齐。 *Test
源代码集的 Gradle 配置包含了所有 *Main
源代码集的依赖项,但反之不然。 因此,你可以使用较新的库版本来测试项目,而不会影响主代码。
例如,你在 *Main
源代码集中使用了 Kotlin 协程 1.7.3 依赖项,并将其传播到项目中的每个源代码集中。 然而,在 iosTest
源代码集中,你决定将版本升级到 1.8.0 以测试新的库版本。 根据相同的算法,这个依赖项将被传播到整个 *Test
源代码集树中,因此每个 *Test
源代码集都将使用 kotlinx.coroutines-*:1.8.0
依赖项进行编译。
编译
与单平台项目不同,Kotlin 跨平台项目需要多次启动编译器来构建所有的工件。 每次编译器启动称为 Kotlin 编译。
例如,以下是生成 iPhone 设备二进制文件的 Kotlin 编译过程:
Kotlin 编译被归类到目标下。默认情况下,Kotlin 为每个目标创建两个编译:一个是用于生产源代码的 main
编译,另一个是用于测试源代码的 test
编译。
在构建脚本中,编译的访问方式类似。你首先选择一个 Kotlin 目标,然后访问内部的 compilations
容器,最后通过名称选择所需的编译: