Kotlin Multiplatform

What is Multiplatform Development?

Imagine that you want to build an application that runs on multiple platforms. e.e. Windows and macOS, or Android and iOS. How should you do it?

The first thing you would probably try is to just build the application in your favorite language, and then compile it wherever you want to run it. If it was a simple application, and you were using a standard language that worked identically on platforms, you might be able to do this. e.g. the C++ 14 console applications that you built in CS 246 could probably build and run anywhere! You’re using a well-defined language that is supported across multiple platforms so this probably works fine.

Unfortunately it’s not always that simple. Imagine that you want to add graphics: you would quickly realize that the fastest and most sophisticated graphics libraries are platform-dependent. You can do some amazing things with DirectX, but it only runs on Windows, and doesn’t help you build for macOS or Linux (or iOS or Android).

This is a common problem. A lot of the functionality that you will want to use in your application is tied to the underlying platform - including UI, graphics, sound, networking and so on. This means that any libraries or frameworks that you want to use are also tied that platform. Microsoft C++ developers might have a rich ecosystem of Windows-specific libraries to use, but they aren’t portable to macOS or Linux. SwiftUI is incredible for building macOS applications, but doesn’t work for Windows.

So how to you write sophisticated cross-platform applications?

Option 1. Develop separately for each platform.

One solution is to not bother chasing cross-platform code. Use the best tools and libraries, often the native ones that the vendor provides, and create entirely different applications for each platform. This has the advantage of producing the highest-quality software, but you cannot reuse your code so it’s often a very expensive approach (i.e. hire n teams, where n is the number of platforms).

e.g. You design a mobile app, and have separate projects for Swift/iOS and Kotlin/Android builds.

Option 2: Use technologies that exist on every platform.

Instead of building for each platform, you build for a runtime environment that exists on each of your platforms. This is one of the major justifications for companies targeting the Java JVM: “Write once, run anywhere”, at least for some types of software. This is also one of the main benefits of targeting the web: as long as a compliant browser exists, your webpage, or web-based application can run on every platform.

e.g. Gmail, Netflix and Facebook for web applications; Kotlin applications using cross-platform UI toolkits.

This is an extremely successful strategy for reaching a wide audience, but it faces two main challenges:

  1. You are restricted to the capabilities that that runtime platform offers you. Platform-specific features that require access to native libraries may not be available. This is the situation when writing JVM applications in Kotlin. The Java ecosystem contains a lot of intrinsic functionality, but you will be limited in your ability to access anything platform specific. e.g. this is one of the reason why Java is rarely used to develop commercial quality games: the JVM does not provide low-level access to advanced graphics capabilities of the platform.
  2. The runtime environment may not exist on your target platform. This is rarely a case when talking about web applications, since browsers exists on most platforms. However, the JVM isn’t always available. e.g. Apple doesn’t directly support a JVM and Just-In-Time (JIT) compilation on iOS: everything needs to be Ahead-of-Time (AoT) compiled, which prevents us from directly deploying Kotlin JVM apps on iOS.

Kotlin/JVM helps to address cross-platform compatibility, but it suffers from both of these restrictions.

Kotlin Multiplatform (KMP) offers a solution to this problem, by allowing us to produce native binaries for multiple platforms from the same code base. It helps us organize our code into reusable sections, while supporting the ability to interoperate with native code on each platform when required. This drastically reduces the effort required to write and maintain code across different platforms, and lets us bypass the restrictions of other solutions like the JVM.

What is Kotlin Multiplatform?

Kotlin Multiplatform (KMP) is the Kotlin framework to support compilation on multiple platforms. As we’ve seen, Kotlin can already be used on any platform that has JVM support. KMP extends this to native builds on other platforms where a JVM is unavailable or undesireable for some reason. You use KMP to write native code using Kotlin for:

  • Android
  • iOS, WatchOS, TVOS
  • macOS
  • Windows
  • Linux
  • Web

Support for multiplatform programming is one of Kotlin’s key benefits. Kotlin provides common code that will run everywhere, plus native-platform capabilities.1

Kotlin Multiplatform Kotlin Multiplatform

  • Common Kotlin includes the language, core libraries, and basic tools. Code written in common Kotlin works everywhere on all supported platforms, including JVM, Native (iOS, Android, Windows, Linux, macOS), Web (JS). Common multiplatform libraries cover everyday tasks such as HTTP, serialization, and managing coroutines. These libraries can be used on any platform.
  • Kotlin also includes platform-specific versions of Kotlin libraries and tools (Kotlin/JVM, Kotlin/JS, Kotlin/Native). This includes native compilers for each of these platforms that produce a suitable target (e.g. bytecode for Kotlin/JVM, JS for Kotlin/JS). Through these platforms you can access the platform native code (JVM, JS, and Native) and leverage all native capabilities.
Info

“Platform-specific” functionality includes the user-interface. If you want a 100% “native look-and-feel” to your application, you would want to build the application using the native UI toolkit for that platform. e.g. Swift and SwiftUI for iOS. Cross-platform toolkits that we’ve used in this course, like JavaFX and Compose solve the problem of cross-platform compatibiltiy by providing a UI framework that runs everywhere, at the cost of being somewhat “non-native” feeling.

KMP allows you to build projects that use a combination of common and native libraries, and which can build to any one of the supported platforms - from the same codebase.

Kotlin multiplatform organizes the source code in hierarchies, with common-code at the base, and branches representing platform specific modules. All platform-specific source sets depend upon the common source set by default.

Common code can depend on many libraries that Kotlin provides for typical tasks like making HTTP calls, performing data serialization, and managing concurrency. Further, the platform-specific versions of Kotlin provide libraries we can use to can leverage the platform-specific capabilities of the target platforms2.

Code shared across targets Code shared across targets

For example, in the diagram above, commonMain code is available to all platforms (leaf nodes). desktopMain code is available to the desktop targets (linuxX64Main, mingwX64Main and macosX64Main) but not the other platforms like iosArm64Main.

Platform-specific APIs

In some cases, it may be desirable to define and access platform-specific APIs in common. This is particularly useful for areas where certain common and reusable tasks are specialized for leveraging platform-specific capabilities.

Kotlin multiplatform provides the mechanism of expected and actual declarations to achieve this objective. For instance, the common source set can declare a function as expected and the platform-specific source sets will be required to provide a corresponding function with the actual declaration3:

img img

Here, as we can see, we are using a function declared as expected in the common source set. The common code does not care how it’s implemented. So far, the targets provide platform-specific implementations of this function.

We can use these declarations for functions, classes, interfaces, enumerations, properties, and annotations.

Creating a KMP Project

In IntelliJ IDEA, select Kotlin - Multiplatform - Library.

kmp-project kmp-project

This generates a project with the kotlin-multiplatform Gradle plugin. This plugin is added to our build.gradle file.

plugins {
    kotlin("multiplatform") version "1.4.0"
}

This kotlin-multiplatform plugin configures the project for creating an application or library to work on multiple platforms.

Our project will contain shared, and native specific source folders.

kmp-folders kmp-folders

It also generates source sets and Gradle build targets for each platform.

image-20220320170516513 image-20220320170516513

Writing Common Code

Let’s write a cross-platform version of the calculator application that we used at the very start of the course. We’ll define some common code and place it in the commonMain folder. This will be available to all of our platforms. Notice that this is basic Kotlin code, with no platform specific code included.

fun add(num1: Double, num2: Double): Double {
    val sum = num1 + num2
    writeLogMessage("The sum of $num1 & $num2 is $sum", LogLevel.DEBUG)
    return sum
}

fun subtract(num1: Double, num2: Double): Double {
    val diff = num1 - num2
    writeLogMessage("The difference of $num1 & $num2 is $diff", LogLevel.DEBUG)
    return diff
}

fun multiply(num1: Double, num2: Double): Double {
    val product = num1 * num2
    writeLogMessage("The product of $num1 & $num2 is $product", LogLevel.DEBUG)
    return product
}

fun divide(num1: Double, num2: Double): Double {
    val division = num1 / num2
    writeLogMessage("The division of $num1 & $num2 is $division", LogLevel.DEBUG)
    return division
}

The writeLogMessage() function should be platform specific, since each OS wil handle this differently. We will add a top-level declaration to our common code defining how that function should look:

enum class LogLevel {
    DEBUG, WARN, ERROR
}

internal expect fun writeLogMessage(message: String, logLevel: LogLevel)

The expect keyword tells the compiler that the definition will be handled at the platform level, in another module. For example, we can flesh this out in the jvmMain module for Kotlin/JVM platform. The build for that platform will use the platform-specific version of this function.

internal actual fun writeLogMessage(message: String, logLevel: LogLevel) {
    println("Running in JVM: [$logLevel]: $message")
}

Our goal is to define as much functionality as we can in the commonMain module, but recognize that we sometimes need to use platform-specific code for the results that we want to achieve.

Writing Common Unit Tests

Let’s write a few tests for our common calculator functions:

@Test
fun testAdd() {
    assertEquals(4.0, add(2.0, 2.0))
}

@Test
fun testSubtract() {
    assertEquals(0.0, subtract(2.0, 2.0))
}

@Test
fun testMultiply() {
    assertEquals(4.0, multiply(2.0, 2.0))
}

@Test
fun testDivide() {
    assertEquals(1.0, divide(2.0, 2.0))
}

There’s nothing unusual - we can easily write unit tests against common code. However, when we run them, we get a new window asking us to select a target. Select one or more targets for your tests.

img img

Kotlin/Native

Kotlin/Native attempts to compile the Kotlin source directly to native binaries specific to the supported target platform. Kotlin/Native is primarily designed to allow compilation for platforms on which virtual machines are not desirable or possible, such as embedded devices or iOS.

Kotlin/Native provides an LLVM based backend for the Kotlin/Native compiler and native implementations of the Kotlin standard library. The Kotlin/Native compiler itself is known as Konan. LLVM is basically a compiler infrastructure that we can use to develop a front end for any programming language and a back end for any instruction set architecture.

It provides a portable, high-level assembly language optimized for various transformations that serve as a language-independent intermediate representation. Originally implemented for C and C++, today there are several languages with a compiler that supports LLVM, including Kotlin:

img img

Kotlin/Native supports a number of platforms that we can conveniently select through the Gradle configuration:

  • Linux (x86_64, arm32, arm64, MIPS, MIPS little-endian)
  • Windows (mingw x86_64, x86)
  • Android (arm32, arm64, x86, x86_64)
  • iOS (arm32, arm64, simulator x86_64)
  • macOS (x86_64)4
  • tvOS (arm64, x86_64)
  • watchOS (arm32, arm64, x86)
  • WebAssembly (wasm32)

Now, we should notice that in our Gradle configuration, there is a check for the host operating system. This is used to determine what native platform to target i.e. you need to be on macOS to build for that platform and so on.

kotlin {
    val hostOs = System.getProperty("os.name")
    val isMingwX64 = hostOs.startsWith("Windows")
    val nativeTarget = when {
        hostOs == "Mac OS X" -> macosX64("native")
        hostOs == "Linux" -> linuxX64("native")
        isMingwX64 -> mingwX64("native")
        else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
    }
}

Interoperability

Kotlin/Native also supports two-way interoperability with native programming languages for different operating systems. The compiler creates:

  • an executable for many platforms
  • a static library or dynamic library with C headers for C/C++ projects
  • an Apple framework for Swift and Objective-C projects

Kotlin/Native supports interoperability to use existing libraries directly from Kotlin/Native:

It is easy to include compiled Kotlin code in existing projects written in C, C++, Swift, Objective-C, and other languages. It is also easy to use existing native code, static or dynamic C libraries, Swift/Objective-C frameworks, graphical engines, and anything else directly from Kotlin/Native.

Finally, Multiplatform projects allow sharing common Kotlin code between multiple platforms, including Android, iOS, JVM, JavaScript, and native. Multiplatform libraries provide required APIs for common Kotlin code and help develop shared parts of a project in Kotlin in one place and share it with some or all target platforms.

Getting Started

https://kotlinlang.org/docs/native-get-started.html

Create a native application Create a native application

Open the build.gradle.kts file, the build script that contains the project settings. To create Kotlin/Native applications, you need the Kotlin Multiplatform Gradle plugin installed. Ensure that you use the latest version of the plugin:

plugins {
    kotlin("multiplatform") version "1.6.10"
}

Build your project. It will produce a native executable under

build/bin/native/debugExecutable/<your_app_name>.kexe

Example: Native Interop

This tutorial demonstrates how to use IntelliJ IDEA to create a command-line application. You’ll learn how to create a simple HTTP client that can run natively on specified platforms using Kotlin/Native and the libcurl library.

https://kotlinlang.org/docs/native-app-with-c-and-libcurl.html

The full code for this sample is here: https://github.com/Kotlin/kotlin-hands-on-intro-kotlin-native

  1. Create the project.

New project. Native application in IntelliJ IDEA New project. Native application in IntelliJ IDEA

  1. Update the build.gradle file.

    kotlin {
        def hostOs = System.getProperty("os.name")
        def isMingwX64 = hostOs.startsWith("Windows")
        def nativeTarget
            if (hostOs == "Mac OS X") nativeTarget = macosX64('native')
            else if (hostOs == "Linux") nativeTarget = linuxX64("native")
            else if (isMingwX64) nativeTarget = mingwX64("native")
            else throw new FileNotFoundException("Host OS is not supported in Kotlin/Native.")
    
        nativeTarget.with {
            binaries {
                executable {
                    entryPoint = 'main'
                }
            }
        }
    }
    
  2. Create a definition file.

    When writing native applications, you often need access to certain functionalities that are not included in the Kotlin standard library, such as making HTTP requests, reading and writing from disk, and so on.

    Kotlin/Native helps consume standard C libraries, opening up an entire ecosystem of functionality that exists for pretty much anything you may need. Kotlin/Native is already shipped with a set of prebuilt platform libraries, which provide some additional common functionality to the standard library. We’ll link in a standard C library.

    Create a directory named src/nativeInterop/cinterop.

    Create a file libcurl.def with the following contents.

headers = curl/curl.h
headerFilter = curl/*

compilerOpts.linux = -I/usr/include -I/usr/include/x86_64-linux-gnu
linkerOpts.osx = -L/opt/local/lib -L/usr/local/opt/curl/lib -lcurl
linkerOpts.linux = -L/usr/lib/x86_64-linux-gnu -lcurl

This defined kotlin header files to be created from the C headers on our system.

  1. Add interoperrability to your builds.

Add this to your build.gradle file.

nativeTarget.with {
    compilations.main { // NL
        cinterops {     // NL
            libcurl     // NL
        }               // NL
    }                   // NL
    binaries {
        executable {
            entryPoint = 'main'
        }
    }
  1. Write the application code.

Update the source file Main.kt with the following source.

import kotlinx.cinterop.*
import libcurl.*

fun main(args: Array<String>) {
    val curl = curl_easy_init()
    if (curl != null) {
        curl_easy_setopt(curl, CURLOPT_URL, "https://example.com")
        curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L)
        val res = curl_easy_perform(curl)
        if (res != CURLE_OK) {
            println("curl_easy_perform() failed ${curl_easy_strerror(res)?.toKString()}")
        }
        curl_easy_cleanup(curl)
    }
}

If you build it, you should get a native executable, linked to the curl libraries.

You should be able to run it to see output!

$ ./httpclient.kexe www.example.com
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
   ....
        

Kotlin Multiplatform Mobile (KMM)

The most common requirement for KMP is to support both Android and iOS targets. Kotlin Multiplatform Mobile (KMM) leverages KMP to simplify the development of cross-platform mobile applications. You can share common code between iOS and Android apps and write platform-specific code only where it’s necessary, typically to support a native UI or when working with platform-specific APIs5.

A basic KMM project consists of three components:

  • Shared module – a Kotlin module that contains common logic for both Android and iOS applications. Builds into an Android library and an iOS framework. Uses Gradle as a build system.
  • Android application – a Kotlin module that builds into the Android application. Uses Gradle as a build system.
  • iOS application – an Xcode project that builds into the iOS application.

Basic Multiplatform Mobile project structure Basic Multiplatform Mobile project structure

Kotlin supports two-way interop with iOS: Kotlin can call into iOS libraries, and vice-versa using the Objective-C bindings. (Swift bindings are being developed). Android is a native target for Kotlin, and is much easier to support.

KMM is exciting because we can use Kotlin for both targets, and share probably 50-75% of the code between platform. The native code is just those modules that are very specific to each platform, typically the UI. A KMM application could potentially offer identical functionality on Android and iOS, while delivering a complately native UI experience with Jetpack Compose on Android, and SwiftUI on iOS.

For examples, see the list of KMM Samples.


  1. Taken from Kotlin Multiplatform documentation. https://kotlinlang.org/docs/multiplatform.html ↩︎

  2. Exerpts from https://www.baeldung.com/kotlin/multiplatform-programming↩︎

  3. Diagram courtesy of https://www.baeldung.com/kotlin/multiplatform-programming↩︎

  4. You can build macOS x86 on either Intel or ARM systems. A macOS x86 build will run under Rosetta on an ARM-based Mac. ARM as a target is current under development, and is expected “soon”. ↩︎

  5. Think of KMM as a specialized version of KMP ↩︎