跨平台项目结构的高级概念
本文解释 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 源代码集 A
和 B
. 表达式 A.dependsOn(B)
会指示 Kotlin:
A
可以看到来自B
的 API, 包括内部声明.A
可以为来自B
的预期声明提供实际实现. 这是一个必须而且充分的条件, 因为, 当而且仅当, 存在直接或间接的A.dependsOn(B)
关系时,A
才可以为B
提供actuals
实现.B
应该编译到A
编译到的所有编译目标, 此外再加上它自己的编译目标.A
继承B
的所有的常规依赖项.
dependsOn
关系会创建一个树形结构, 也叫做源代码集层级. 下面是一个通常的移动开发项目的例子, 针对的目标平台是 androidTarget
, iosArm64
(iPhone 设备), 和 iosSimulatorArm64
(Apple Silicon Mac 上的 iPhone 模拟器):
图中的箭头表示 dependsOn
关系. 在编译平台二进制文件时, 也会保持这些关系. 所以 Kotlin 能够理解, iosMain
应该能够看到来自 commonMain
的 API, 但不能看到来自 iosArm64Main
的 API:
dependsOn
关系使用 KotlinSourceSet.dependsOn(KotlinSourceSet)
调用来配置, 例如:
这个示例演示如何在构建脚本中定义
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), 例如,
implementation
或api
.引用依赖项, 如果它是已经发布到仓库中, 可以指定它的座标(coordinate), 例如
"com.google.guava:guava:32.1.2-jre"
, 如果它是同一个构建内的一个 Gradle 项目, 可以指定它的路径, 例如project(":utils:concurrency")
.
跨平台项目中的依赖项配置有一些特别的功能. 每个 Kotlin 源代码集有它独自的 dependencies {}
代码块. 因此你可以在平台相关的源代码集中声明平台相关的依赖项:
共通的依赖项要复杂一些. 考虑一个跨平台项目, 声明了对一个跨平台库的依赖项, 例如, kotlinx.coroutines
:
在依赖解析中, 有 3 个重要概念:
跨平台依赖项会沿着
dependsOn
结构向下传播. 如果你对commonMain
添加一个依赖项, 它会自动添加到声明了对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
的编译目标.
但是, coroutines 中的
iosX64Main
之类的源代码集, 不兼容于使用它的commonMain
源代码集. 即使iosX64Main
编译到commonMain
的编译目标之一, 也就是,iosX64
, 但是它不编译到androidTarget
或iosSimulatorArm64
.依赖解析的结果直接影响可以访问
kotlinx-coroutines-core
中的哪些代码:
声明自定义的源代码集
有些情况下, 在你的项目中可能需要自定义的中间源代码集. 考虑一个项目, 编译到 JVM, JS, 和 Linux 平台, 你想要只在 JVM 和 JS 平台之间共用一些源代码. 这种情况下, 你应该为这组编译目标寻找一个特定的源代码集, 具体方法请参见 跨平台项目结构的基础知识.
Kotlin 不会自动创建这样的源代码集. 因此你应该使用 by creating
构造来手动创建它:
但是, Kotlin 仍然如何处理或者编译这个源代码集. 如果你画一个源代码集关系图, 这个源代码集将是孤立的, 没有添加任何编译目标的标签:
为了解决这个问题, 请添加几个 dependsOn
关系, 将 jvmAndJsMain
包含到层级结构中:
这里, jvmMain.dependsOn(jvmAndJsMain)
会对 jvmAndJsMain
添加 JVM 编译目标, jsMain.dependsOn(jvmAndJsMain)
会对 jvmAndJsMain
添加 JS 编译目标.
最终的项目结构如下:
编译
与单一平台的项目不同, Kotlin Multiplatform 项目需要多次编译器运行来构建所有的 artifact. 每次编译器运行都是一个 Kotlin 编译.
例如, 在前面提到的 Kotlin 编译过程中, 用于 iPhone 设备的二进制文件的生成方式如下:
Kotlin 编译会在编译目标之下分组. 默认情况下, Kotlin 为每个编译目标创建 2 个编译, main
编译用于产品源代码, test
编译用于测试源代码.
在构建脚本中, 编译通过类似的方式访问. 你首先选择一个 Kotlin 编译目标, 然后访问其中的 compilations
容器, 最后通过名称选择你需要的编译: