KMP for Mobile Native Developer — Part. 2: Project and Concepts

Santiago Mattiauda
8 min readFeb 22, 2024

--

Introduction

In this article, we will see the structure of a multiplatform project and some concepts introduced when sharing code in KMP.

Each Kotlin Multiplatform project includes three modules:

  • shared is a Kotlin module that contains common logic for Android and iOS applications: the code shared across platforms. It uses Gradle as the build system to automate the compilation process.
  • composeApp is a Kotlin module that compiles into an Android application. It uses Gradle as the build system. The composeApp module depends on and utilizes the shared module as a regular Android library.
  • iosApp is an Xcode project that compiles into an iOS application. It depends on and utilizes the shared module as an iOS framework. The shared module can be used as a regular framework or as a dependency of CocoaPods. By default, the Kotlin Multiplatform wizard creates projects that use the regular framework dependency.

The shared module consists of three sets of sources: androidMain, commonMain, and iosMain. A source set is a grouping of logically related files in Gradle, where each group has its own dependencies. In Kotlin Multiplatform, different sets of sources in a shared module can target different platforms. The common source set utilizes shared Kotlin code, while platform source sets employ Kotlin-specific code for each target. For androidMain, Kotlin/JVM is used, and for iosMain, Kotlin/Native is used.

When the shared module is integrated into an Android library, the common Kotlin code is interpreted as Kotlin/JVM. In the case of integration into an iOS framework, the common Kotlin is interpreted as Kotlin/Native.

Let’s delve a bit deeper into source sets and targets, and how these will give us guidelines on which platform we’re going to share our code.

Components of a KMP Project

Targets

The targets specify the platforms for which Kotlin compiles the shared code, such as Android and iOS in the case of mobile projects.

In KMP, a target is an identifier that describes a type of compilation. It defines the format of the produced binaries, the available language constructs, and the allowed dependencies.

kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
}

As we can see in the previous code, the targets can specify platform-specific configurations. For example, for Android, we are indicating kotlinOptions so that the jvmTarget is Java 1.8.

There is a default hierarchy within the targets, where if our definition is as follows:

kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
}

The resulting hierarchy looks like this:

The green source sets are created and active in the project, while the gray ones from the default template are ignored. For example, the Kotlin Gradle plugin hasn’t generated code for watchOS because there are no watch targets defined in the project.

Next, we’ll see how to access these sources and how to define specific dependencies within them.

Source sets

A Kotlin source set is a group of files with its own targets, dependencies, and compilation settings. It is the primary way to share code in multiplatform projects.

Each source set in a multiplatform project:

  • Has a unique name within the project.
  • Contains a set of files and resources, typically located in a directory with the name of the source set.
  • Specifies the targets for which it compiles the code. These targets determine what language features and dependencies are available for this set of sources.
  • Defines its own dependencies and compilation settings.

Kotlin offers several predefined source sets, including commonMain, present in all multiplatform projects, which collects all declared targets.

Within the src directory in our shared module in a Kotlin Multiplatform project, we will find our source sets defined. For example, in a project with commonMain, iosMain, and androidMain, the source sets would have the following structure:

In Gradle scripts, you access the source sets by name within the kotlin.sourceSets {} blocks:

kotlin {
// Targets
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
// Sourcets
sourceSets {
commonMain.dependencies {
//.....
}
androidMain.dependencies {
//.....
}
iosMain.dependencies {
//.....
}
}
}

And within our source sets, we will be able to define platform-specific code for each supported platform.

DependsOn

dependsOn is a specific relationship in Kotlin between two source sets. This can be a connection between common source sets and platform-specific ones, for example, when jvmMain depends on commonMain, or iosArm64Main depends on iosMain, and so forth.

In a more general example with Kotlin source sets A and B, the expression A.dependsOn(B) indicates the following:

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

The dependsOn relationship creates a tree-like structure known as a source set hierarchy.

kotlin {
// Targets declaration
sourceSets {
// Example of configuring the dependsOn relation
iosArm64Main.dependsOn(commonMain)
}
}

Dependencies on Other Libraries or Projects

In multiplatform projects, you can configure dependencies from a published library or from another Gradle project.

Dependency configuration in Kotlin Multiplatform follows a structure similar to Gradle, where:

  • You use the dependencies {} block in the build script.
  • You select the appropriate scope for dependencies, such as implementation or api.
  • You reference the dependency by specifying its coordinates if it’s published in a repository, for example, "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0", or its path if it's a Gradle project in the same build, like project(":utils:concurrency").

Dependency configuration in multiplatform projects has some particularities. Each Kotlin source set has its own dependencies {} block, allowing you to declare platform-specific dependencies in specific source sets.

kotlin {
// Targets declaration
sourceSets {
androidMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
}
}
}

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.8.0")
}
}
}

Dependency resolution

In the dependency resolution process in multiplatform projects, three important points stand out:

  1. Propagation of Multiplatform Dependencies: Dependencies declared in the commonMain source set are automatically propagated to other source sets that have dependsOn relationships with commonMain. For example, if a dependency is added to commonMain, it extends to other source sets like iosMain, jvmMain, iosSimulatorArm64Main, and iosX64Main. This approach avoids duplication of dependencies across multiple source sets and simplifies dependency management.
  2. Intermediate and Final Dependency Resolution State: The commonMain source set represents an intermediate state in dependency resolution, while dependencies declared in each source set represent the final state. After resolution, each multiplatform library is internally visualized as a collection of its individual source sets. This allows for more granular dependency management and ensures consistency throughout the project.
  3. Dependency Resolution by Compatible Targets: Kotlin resolves dependencies by ensuring that the source sets of a dependency are compatible with those of the consumer. For example, if a source set compiles for certain targets like androidTarget, iosX64, and iosSimulatorArm64, the dependency must offer source sets that are also compatible with those targets. This compatibility ensures that dependencies are usable across all target platforms of the project.

In summary, dependency resolution in multiplatform projects relies on the automatic propagation of dependencies from commonMain, the intermediate and final resolution state represented by individual source sets, and target compatibility between dependencies and consumers. This enables efficient and consistent dependency management in Kotlin multiplatform projects.

Sharing Code Across Platforms

If you have business logic that is common to all platforms, you can avoid writing the same code for each one. Simply share it in the common source set.

Some dependencies for source sets are automatically established, so you don’t need to manually specify the dependsOn relationships:

  • Between platform-specific source sets that depend on the common source set, such as jvmMain, macosX64Main, and others.
  • Between the main and test source sets of a particular target, such as androidMain and androidUnitTest.

If you need to access platform-specific APIs from shared code, use Kotlin’s expect/actual declarations mechanism, which we already explored in the previous post.

Implementation UUID in Android/iOS

Sharing Code on Similar Platforms

In multiplatform projects, it’s often necessary to create multiple native targets that could reuse much of the common logic and third-party APIs.

For example, in a project targeting iOS, it’s common to have two iOS-related targets: one for iOS ARM64 devices and another for x64 simulator. Although they have separate platform-specific source sets, different code is rarely needed for both, and their dependencies are usually very similar. Therefore, iOS-specific code could be shared between them.

In this scenario, it would be beneficial to have a shared set of sources for both iOS targets, allowing Kotlin/Native code to directly access common APIs from both iOS devices and simulator.

To achieve this, you can share code between native targets in your project using the hierarchical structure in one of the following ways:

Conclusion

Multiplatform projects in Kotlin offer a powerful and efficient way to develop applications for multiple platforms. The ability to share code across different platforms helps avoid duplication and simplifies development. This is achieved through the use of common and platform-specific source sets, which organize code efficiently and ensure compatibility with various target platforms.

Dependency resolution plays a crucial role in managing multiplatform projects. By automatically propagating dependencies from common source sets to platform-specific ones, the need to manually specify dependsOn relationships is avoided, simplifying project setup.

Furthermore, the ability to share code between native targets on similar platforms, such as iOS, using the hierarchical structure, offers greater flexibility and efficiency in development. This allows for the reuse of common logic and third-party APIs across different targets, reducing duplication and improving project coherence.

In summary, multiplatform projects in Kotlin not only facilitate the creation of applications for different platforms but also provide a flexible and well-defined project structure. This structure allows for effective code organization, consistent dependency management, and efficient code sharing, all of which contribute to a smooth and efficient development experience.

Next articles

In the following articles, we will go into more detail about the implementations within KMP, and these will be some of the topics we will cover:

References

--

--