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

This article explains advanced concepts of the Kotlin Multiplatform project structure and how they map to the Gradle implementation.

This information will be useful if you:

  • Have code shared among a specific set of targets for which Kotlin doesn't create such a source set by default. In this case, you need a lower-level API that exposes a few new abstractions.
  • Need to work with low-level abstractions of the Gradle build, such as configurations, tasks, publications, and others.
  • Are creating a Gradle plugin for Kotlin Multiplatform builds.

Before diving into advanced concepts, we recommend learning the basics of the multiplatform project structure.

Dependencies and dependsOn

This section describes two kinds of dependencies:

  • dependsOn – a specific Kotlin Multiplatform relation between two Kotlin source sets.
  • Regular dependencies – dependencies on a published library, such as kotlinx-coroutines, or on another Gradle project in your build.

Usually, you'll be working with dependencies and not with the dependsOn relation. However, examining dependsOn is crucial to understanding how Kotlin Multiplatform projects work under the hood.

dependsOn and source set hierarchies

dependsOn is a Kotlin-specific relation between two Kotlin source sets. This could be a connection between common and platform-specific source sets, for example, when the jvmMain source set depends on commonMain, iosArm64Main depends on iosMain, and so on.

Consider a general example with Kotlin source sets A and B. The expression A.dependsOn(B) instructs Kotlin that:

  1. A observes the API from B, including internal declarations.
  2. A can provide actual implementations for expected declarations from B. This is a necessary and sufficient condition, as A can provide actuals for B if and only if A.dependsOn(B) either directly or indirectly.
  3. B should compile to all the targets that A compiles to, in addition to its own targets.
  4. A inherits all the regular dependencies of B.

The dependsOn relation creates a tree-like structure known as a source set hierarchy. Here's an example of a typical project for mobile development with androidTarget, iosArm64 (iPhone devices), and iosSimulatorArm64 (iPhone simulator for Apple Silicon Mac):

DependsOn tree structure

Arrows represent dependsOn relations. These relations are preserved during the compilation of platform binaries. This is how Kotlin understands that iosMain is supposed to see the API from commonMain but not from iosArm64Main:

DependsOn relations during compilation

dependsOn relations are configured with the KotlinSourceSet.dependsOn(KotlinSourceSet) call, for example:

kotlin {
    // Targets declaration
    sourceSets {
        // Example of configuring the dependsOn relation 
        iosArm64Main.dependsOn(commonMain)
    }
}
  • This example shows how dependsOn relations can be defined in the build script. However, the Kotlin Gradle plugin creates source sets and sets up these relations by default, so you don't need to do so manually.
  • dependsOn relations are declared separately from the dependencies {} block in build scripts. This is because dependsOn is not a regular dependency; instead, it is a specific relation among Kotlin source sets necessary for sharing code across different targets.

You cannot use dependsOn to express regular dependencies on a published library or another Gradle project. For example, you can't set up commonMain to depend on the commonMain of the kotlinx-coroutines-core library or call commonTest.dependsOn(commonMain).

Dependencies on other libraries or projects

In multiplatform projects, you can set up regular dependencies either on a published library or on another Gradle project.

Kotlin Multiplatform generally declares dependencies in a typical Gradle way. Similarly to Gradle, you:

  • Use the dependencies {} block in your build script.
  • Choose the proper scope for the dependencies, for example, implementation or api.
  • Reference the dependency either by specifying its coordinates if it's published in a repo, like "com.google.guava:guava:32.1.2-jre", or its path if it's a Gradle project in the same build, like project(":utils:concurrency").

Dependency configuration in multiplatform projects has some special features. Each Kotlin source set has its own dependencies {} block. This allows you to declare platform-specific dependencies in platform-specific source sets:

kotlin {
    // Targets declaration
    sourceSets {
        jvmMain.dependencies {
            // This is jvmMain's dependencies, so it's OK to add a JVM-specific dependency
            implementation("com.google.guava:guava:32.1.2-jre")
        }
    }
}

Common dependencies are trickier. Consider a multiplatform project that declares a dependency on a multiplatform library, for example, kotlinx.coroutines:

kotlin {
    androidTarget()     // Android
    iosArm64()          // iPhone devices 
    iosSimulatorArm64() // iPhone simulator on Apple Silicon Mac

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

There are three important concepts in dependency resolution:

  1. Multiplatform dependencies are propagated down the dependsOn structure. When you add a dependency to commonMain, it will be automatically added to all source sets that declare dependsOn relations directly or indirectly in commonMain.

    In this case, the dependency was indeed automatically added to all the *Main source sets: iosMain, jvmMain, iosSimulatorArm64Main, and iosX64Main. All these source sets inherit the kotlin-coroutines-core dependency from the commonMain source set, so you don't have to copy and paste it in all of them manually:

    Propagation of multiplatform dependencies

    The propagation mechanism allows you to choose a scope that will receive the declared dependency by selecting a specific source set. For example, if you want to use kotlinx.coroutines on iOS but not on Android, you can add this dependency to iosMain only.

  2. The source set → multiplatform library dependencies, like commonMain to org.jetbrians.kotlinx:kotlinx-coroutines-core:1.7.3 above, represent the intermediate state of dependency resolution. The final state of resolution is always represented by the source set → source set dependencies.

    The final source set → source set dependencies are not dependsOn relations.

    To infer granular source set → source set dependencies, Kotlin reads the source set structure that is published alongside each multiplatform library. After this step, each library will be represented internally not as a whole, but as a collection of its source sets. See this example for kotlinx-coroutines-core:

    Serialization of the source set structure

  3. Kotlin takes each dependency relation and resolves it to a collection of source sets from a dependency. Each dependency source set in that collection must have compatible targets. A dependency source set has compatible targets if it compiles to at least the same targets as the consumer source set.

    Consider an example where commonMain in the sample project compiles to androidTarget, iosX64, and iosSimulatorArm64:

    • First, it resolves a dependency on kotlinx-coroutines-core.commonMain. This happens because kotlinx-coroutines-core compiles to all possible Kotlin targets. Therefore, its commonMain compiles to all possible targets, including the required androidTarget, iosX64, and iosSimulatorArm64.
    • Second, commonMain depends on kotlinx-coroutines-core.concurrentMain. Since concurrentMain in kotlinx-coroutines-core compiles to all the targets except for JS, it matches the targets of the consumer project's commonMain.

    However, source sets like iosX64Main from coroutines are incompatible with the consumer's commonMain. Even though iosX64Main compiles to one of the targets of commonMain, namely, iosX64, it doesn't compile either to androidTarget or to iosSimulatorArm64.

    The results of the dependency resolution directly affect which of the code in kotlinx-coroutines-core is visible:

    Error on JVM-specific API in common code

Declaring custom source sets

In some cases, you might need to have a custom intermediate source set in your project. Consider a project that compiles to the JVM, JS, and Linux, and you want to share some sources only between the JVM and JS. In this case, you should find a specific source set for this pair of targets, as described in The basics of multiplatform project structure.

Kotlin doesn't create such a source set automatically. This means you should create it manually with the by creating construction:

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        // Create a source set named "jvmAndJs"
        val jvmAndJsMain by creating {
            // …
        }
    }
}

However, Kotlin still doesn't know how to treat or compile this source set. If you drew a diagram, this source set would be isolated and wouldn't have any target labels:

Missing dependsOn relation

To fix this, include jvmAndJsMain in the hierarchy by adding several dependsOn relations:

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        val jvmAndJsMain by creating {
            // Don't forget to add dependsOn to commonMain
            dependsOn(commonMain.get())
        }

        jvmMain {
            dependsOn(jvmAndJsMain)
        }

        jsMain {
            dependsOn(jvmAndJsMain)
        }
    }
}

Here, jvmMain.dependsOn(jvmAndJsMain) adds the JVM target to jvmAndJsMain, and jsMain.dependsOn(jvmAndJsMain) adds the JS target to jvmAndJsMain.

The final project structure will look like this:

Final project structure

Manual configuration of dependsOn relations disables automatic application of the default hierarchy template. See Additional configuration to learn more about such cases and how to handle them.

Compilations

Contrary to single-platform projects, Kotlin Multiplatform projects require multiple compiler launches to build all the artifacts. Each compiler launch is a Kotlin compilation.

For example, here's how binaries for iPhone devices are generate during this Kotlin compilation mentioned earlier:

Kotlin compilation for iOS

Kotlin compilations are grouped under targets. By default, Kotlin creates two compilations for each target, the main compilation for production sources and the test compilation for test sources.

Compilations in build scripts are accessed in a similar manner. You first select a Kotlin target, then access the compilations container inside, and finally choose the necessary compilation by its name:

kotlin {
    // Declare and configure the JVM target
    jvm {
        val mainCompilation: KotlinJvmCompilation = compilations.getByName("main")
    }
}