Using Multiplatform Kotlin
This is a chapter from the book Advanced Kotlin. You can find it on LeanPub or Amazon.
Kotlin is a compiled programming language, which means you can write some code in Kotlin and then use the Kotlin compiler to produce code in another language. Kotlin can currently be compiled into JVM bytecode (Kotlin/JVM), JavaScript (Kotlin/JS), or machine code (Kotlin/Native). This is why we say Kotlin is a multiplatform language: the same Kotlin code can be compiled to multiple platforms.
This is a powerful feature. Not only can Kotlin be used to write applications for multiple platforms, but we can also reuse the same code between different platforms. For instance, you can write code that will be used on a website, as well as on Android and iOS-native clients. Let’s see how can we make our own multiplatform module.
Multiplatform module configuration
Gradle projects are divided into modules. Many projects have only one module, but they can have more than one. Each module is a different folder with its own build.gradle(.kts)
file. Modules using Kotlin need to use the appropriate Kotlin plugin. If we want a Kotlin/JVM module, we use the kotlin("jvm")
plugin. To make a multiplatform plugin, we use kotlin("multiplatform")
. In multiplatform modules, we define different source sets:
- Common source set, which contains Kotlin code that is not specific to any platform, as well as declarations that don’t implement platform-dependent APIs. Common source sets can use multiplatform libraries as dependencies. By default, the common source set is called "commonMain", and its tests are located in "commonTest".
- Target source sets, which are associated with concrete Kotlin compilation targets. They contain implementations of platform-dependent declarations in the common source set for a specific platform, as well as other platform-dependent code. They can use platform-specific libraries, including standard libraries. Platform source sets can represent the projects we implement, like backend or Android applications, or they can be compiled into libraries. Some example target source sets are "jvmMain", "androidMain" and "jsTest".
A multiplatform module that is used by multiple other modules is often referred to as a "shared module"0.
In build.gradle(.kts)
of multiplatform modules, we define source sets inside the kotlin
block. We first configure each compilation target, and then for each source set we define dependencies inside the sourceSets
block, as presented in the example below. This example shows a possible complete build.gradle.kts
configuration, targeting JVM and JS.
plugins {
kotlin("multiplatform") version "1.8.21"
}
group = "com.marcinmoskala"
version = "0.0.1"
kotlin {
jvm {
withJava()
}
js(IR) {
browser()
binaries.library()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:
kotlinx-coroutines-core:1.6.4")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jvmMain by getting
val jvmTest by getting
val jsMain by getting
val jsTest by getting
}
jvmToolchain(11)
}
We define source set files inside folders in src
that have the same name as the source set name. So, common files should be inside the src/commonMain
folder, and JVM test files should be inside src/jvmTest
.
This is how we configure common modules. Transforming a project that uses no external libraries from Kotlin/JVM to multiplatform is quite simple. The problem is that in the common module, you cannot use platform-specific libraries, so the Java stdlib and Java libraries cannot be used. You can use only libraries that are multiplatform and support all your targets, but it’s still an impressive list, including Kotlin Coroutines, Kotlin Serialization, Ktor Client and many more. To deal with other cases, it’s useful to define expected and actual elements.
Expect and actual elements
The common source set (commonMain
) needs to operate on elements that, for each platform, have platform-specific implementations. Consider the fact that in commonMain
you need to generate a random UUID string. For that, different classes are used on different platforms. On JVM, we could use java.util.UUID
, while on iOS we could use platform.Foundation.NSUUID
. To use these classes to generate a UUID in the common module, we can specify the expected randomUUID
function in commonMain
and specify its actual implementations in each platform’s source set.
// commonMain
expect fun randomUUID(): String
// jvmMain
import java.util.*
actual fun randomUUID() = UUID.randomUUID().toString()
// One of iOS source sets
import platform.Foundation.NSUUID
actual fun randomUUID(): String = NSUUID().UUIDString()
The compiler ensures that every declaration marked with the expected keyword in commonMain
has the corresponding declarations marked with the actual keyword in all platform source sets.
All Kotlin essential elements can be expected in commonMain
, so we can define expected functions, classes, object declarations, interfaces, enumerations, properties, and annotations.
Actual definitions can be type aliases that reference types that fulfill expected declaration expectations.
More examples of expected and actual elements are presented later in this chapter.
In many cases, we don’t need to define expected and actual elements as we can just define interfaces in commonMain
and inject platform-specific classes that implement them. An example will be shown later in this chapter.
Expected classes are essential for multiplatform development because they specify elements with platform-specific implementations that are used as foundations for elements’ implementations. Kotlin's Standard Library is based on expected elements, without which it would be hard to implement any serious multiplatform library.
Now let's review the possibilities that Kotlin's multiplatform capabilities offer us.
Possibilities
Companies rarely write applications for only a single platform1. They would rather develop a product for two or more platforms because products often rely on several applications running on different platforms. Think of client and server applications communicating through network calls. As they need to communicate, there are often similarities that can be reused. Implementations of the same product for different platforms generally have even more similarities, especially their business logic, which is often nearly identical. Projects like this can benefit significantly from sharing code.
Lots of companies are based on web development. Their products are websites, but in most cases these products need a backend application (also called server-side). On websites, JavaScript is king and practically has a monopoly. On the backend, a very popular option is Java. Since these languages are very different, it is common for backend and web development to be separated, but this might change now that Kotlin is becoming a popular alternative to Java for backend development. For instance, Kotlin is a first-class citizen with Spring, the most popular Java framework. Kotlin can be used as an alternative to Java in every framework, and there are also many Kotlin backend frameworks, such as Ktor. This is why many backend projects are migrating from Java to Kotlin. A great thing about Kotlin is that it can also be compiled into JavaScript. There are already many Kotlin/JS libraries, and we can use Kotlin to write different kinds of web applications. For instance, we can write a web frontend using the React framework and Kotlin/JS. This allows us to write both the backend and the website in Kotlin. Even better, we can have parts that compile to both JVM bytecode and JavaScript. These are shared parts where we can put, for instance, universal tools, API endpoint definitions, common abstractions, etc.
This capability is even more important in the mobile world as we rarely build only for Android. Sometimes we can live without a server, but we generally also need to implement an iOS application. Each application is written for a different platform using different languages and tools. In the end, the Android and iOS versions of the same application are very similar. They might be designed differently, but they nearly always have the same logic inside. Using Kotlin’s multiplatform capabilities, we can implement this logic only once and reuse it between these two platforms. We can make a shared module in which we implement business logic, which should be independent of frameworks and platforms anyway (Clean Architecture). Such common logic can be written in pure Kotlin or using other multiplatform modules, and it can then be used on different platforms.
Shared modules can be used directly in Android. The experience of working with multiplatform and JVM modules is great because both are built using Gradle. The experience is similar to having these common parts in our Android project.
For iOS, we compile these common parts to an Objective-C framework using Kotlin/Native, which is compiled into native code3 using LLVM2. We can then use the resulting code from Swift in Xcode or AppCode. Alternatively, we can implement our whole application using Kotlin/Native.
We can use all these platforms together. Using Kotlin, we can develop for nearly all kinds of popular devices and platforms, and code can be reused between them however we want. Here are just a few examples of what we can write in Kotlin:
- Backend in Kotlin/JVM, for instance, in Spring or Ktor
- Website in Kotlin/JS, for instance, in React
- Android in Kotlin/JVM, using the Android SDK
- iOS Frameworks that can be used from Objective-C or Swift using Kotlin/Native
- Desktop applications in Kotlin/JVM, for instance, in TornadoFX
- Raspberry Pi, Linux, or macOS programs in Kotlin/Native
Here is a visualization of a typical application:
Defining shared modules is also a powerful tool for libraries. In particular, libraries that are not highly platform-dependent can easily be moved to a shared module, thus developers can use them from all languages running on the JVM, or from JavaScript, or natively (so from Java, Scala, JavaScript, CoffeeScript, TypeScript, C, Objective-C, Swift, Python, C#, etc.).
Writing multiplatform libraries is harder than writing libraries for just a single platform as they often require platform-specific parts for all platforms. However, there are already plenty of multiplatform libraries for network communication (like Ktor client), serialization (like kotlinx.serialization), date and time (like kotlinx-datetime), database communication (like SQLDelight), dependency injection (like Kodein-DI) and much more. Even more importantly, there are already libraries that let us implement UI elements in shared modules, like Jetpack Compose. With all these, you can implement completely functional applications for multiple platforms using only shared modules.
This is the theory, but let's get into the practice. Let's see some practical examples of multiplatform projects.
These are the essentials of Kotlin Multiplatform. In the next parts of this series, we will review a practical example of a multiplatform library and a multiplatform mobile project. So, stay tuned!
The term "shared module" is also used for single-platform modules that are used by multiple other modules; however, in this chapter I will use the term "shared module" to specifically reference "shared multiplatform modules".
In Kotlin, we view the JVM, Android, JavaScript, iOS, Linux, Windows, Mac, and even embedded systems like STM32 as separate platforms.
Like Swift or Rust.
Native code is code that is written to run on a specific processor. Languages like C, C++, Swift, and Kotlin/Native are native because they are compiled into machine code for each processor they need to run on.