tennarrates.

KMP: The Missing Introduction

What is Kotlin Multiplatform?

5 min read

You want to share code across platforms, but you do not want to lose the native feel of your app. You have heard that Kotlin Multiplatform (KMP) is different from Flutter or React Native. You might guess that it is just another framework that helps you write once and run everywhere.

While many see it as a framework, KMP is actually a compiler.

This difference is not just a matter of words. It changes how your code actually reaches the device.

A framework usually adds a layer between your code and the platform to handle the rendering and the logic. This layer ships inside your app as a heavy piece of software. When you run a Flutter app, the Flutter engine boots its own language runtime and rendering surface before your app starts. When you run a React Native app, it bundles a JavaScript engine to execute your logic. In both cases, the app carries its own “mini-platform” with it.

KMP does not ship a runtime inside your app. Instead, it makes Kotlin a first-class language for every platform. It does this by emitting the artifacts that those platforms already speak.

The platform is what runs the code

To understand how KMP works, you must first understand what a platform is. In the world of software, a platform is simply the thing that runs your code.

There are two main kinds of platforms.

The first is a machine platform. This is a combination of a CPU and an Operating System (OS). For example, a Mac with an Apple Silicon chip runs the macosArm64 target. This is a machine platform because the code runs directly on the hardware and talks to the macOS system libraries.

The second is a virtual machine platform. This is a piece of software that pretends to be a computer. It has its own instruction set, which is a set of rules the machine follows to execute code.

The Java Virtual Machine (JVM) is a platform because it runs JVM bytecode. The Android Runtime (ART) is another platform because it runs DEX bytecode. Even the web is a platform because it runs JavaScript through a JS engine like V8.

When you build a KMP project, you define targets. A target is a platform you aim your build at. When the compiler runs, it produces an artifact. An artifact is the final file that the platform runs.

To start, you write your common logic in a single source file. You use a special keyword, expect, to tell the compiler that a certain function will be implemented differently on each platform.

kotlin
// src/commonMain/kotlin/Greet.kt
expect fun platformName(): String

fun greet(): String = "Hello from ${platformName()}"

fun main() {
    println(greet())
}

Then, you tell the build system which targets you want to support.

kotlin
// build.gradle.kts (targets)
kotlin {
    jvm()
    androidTarget()
    macosArm64()        // binaries { executable() }
    linuxX64()          // binaries { sharedLib() }
    mingwX64()          // binaries { sharedLib() }
    js(IR) { nodejs(); binaries.executable() }
}

How the compiler transforms your code

The KMP compiler does not just copy your code. It transforms it through a series of steps.

First, the language frontend, known as K2, analyzes your code and performs semantic analysis to make sure the code makes sense. It handles call resolution to find which function you are calling, and type inference to figure out the type of your variables.

Once the frontend finishes its work, it turns your source code into an Intermediate Representation (IR).

The IR is a shared format. It provides a simplified version of your code before it is tied to any specific platform. This is the secret to KMP’s efficiency. Because all targets share the same IR, the compiler can optimize the logic once and then use different backends to finish the job.

The K2 compiler transforms Kotlin into a shared IR before using platform-specific backends to emit native artifacts. The K2 compiler transforms Kotlin into a shared IR before using platform-specific backends to emit native artifacts.

The backend is the part of the compiler that knows how to speak the language of a specific platform.

The JVM backend emits JVM bytecode. This bytecode is stored in a .jar file. A .jar is simply a zip file that contains .class files. These files are the language the JVM already understands.

text
  $ unzip -l build/libs/what-is-multiplatform-jvm.jar
  Archive:  build/libs/what-is-multiplatform-jvm.jar
    Length      Date    Time    Name
  ---------  ---------- -----   ----
           0  02-01-1980 00:00   META-INF/
          25  02-01-1980 00:00   META-INF/MANIFEST.MF
        1220  02-01-1980 00:00   GreetKt.class
         499  02-01-1980 00:00   Platform_jvmKt.class
          53  02-01-1980 00:00   META-INF/what-is-multiplatform.kotlin_module
  ---------                     -------
        1797                     5 files

  $ unzip -l build/outputs/aar/what-is-multiplatform-release.aar
  Archive:  build/outputs/aar/what-is-multiplatform-release.aar
    Length      Date    Time    Name
  ---------  ---------- -----   ----
           0  02-01-1980 00:00   R.txt
         217  02-01-1980 00:00   AndroidManifest.xml
        1652  02-01-1980 00:00   classes.jar
         156  02-01-1980 00:00   META-INF/com/android/build/gradle/aar-metadata.properties
  ---------                     -------
        2025                     4 files

The Native backend is different. It uses a technology called LLVM to produce native binaries. These are the same binaries that a C or Swift programmer produces. Depending on the target, the backend emits a Mach-O binary for macOS, an ELF binary for Linux, or a PE binary for Windows.

text
  $ file libdemo.dylib libdemo.a
  libdemo.dylib: Mach-O 64-bit dynamically linked shared library arm64
  libdemo.a:     current ar archive random library

  $ xcodebuild -create-xcframework -library libdemo.dylib -output demo.xcframework
  xcframework successfully written out to: /private/tmp/apple-artifacts/demo.xcframework

  $ find demo.xcframework -type f
  demo.xcframework/macos-arm64/libdemo.dylib
  demo.xcframework/Info.plist

  $ file Demo.framework/Demo        # dylib placed in the bundle layout
  Demo.framework/Demo: Mach-O 64-bit dynamically linked shared library arm64

  $ file MyApp.ipa                  # zip of a .app bundle
  MyApp.ipa: Zip archive data, at least v1.0 to extract, compression method=store
text
  $ file build/bin/mingwX64/debugShared/what_is_multiplatform.dll \
         build/bin/linuxX64/debugShared/libwhat_is_multiplatform.so
  build/bin/mingwX64/debugShared/what_is_multiplatform.dll:      PE32+ executable (DLL) (GUI) x86-64, for MS Windows
  build/bin/linuxX64/debugShared/libwhat_is_multiplatform.so:    ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=23f87243221f7b4022ca208d9c08046fe320342e, with debug_info, not stripped

The JS backend transpiles Kotlin to JavaScript. This means it translates Kotlin code into JavaScript code. This allows your logic to run in any environment that supports JavaScript, such as a Node.js backend or a web browser.

text
  > Task :jsNodeDevelopmentRun
  Hello from JavaScript
  BUILD SUCCESSFUL in 9s

The power of this approach is that one source code becomes many different artifacts. A single build can turn one Kotlin file into a jar, an aar, a Mach-O executable, and a JS module.

text
  $ file build/bin/macosArm64/debugExecutable/what-is-multiplatform.kexe
  build/bin/macosArm64/debugExecutable/what-is-multiplatform.kexe: Mach-O 64-bit executable arm64

  $ java -cp build/libs/what-is-multiplatform-jvm.jar:<kotlin-stdlib-2.2.0.jar> GreetKt
  Hello from JVM

  $ ./build/bin/macosArm64/debugExecutable/what-is-multiplatform.kexe
  Hello from Kotlin/Native

  $ gradle jsNodeDevelopmentRun
  > Task :jsNodeDevelopmentRun
  Hello from JavaScript

We can prove this by running the same code under three different platforms. You will see the same greeting, but the code is running on three entirely different platforms.

text
  Compiled from "Greet.kt"
  public final class GreetKt {
    public static final java.lang.String greet();
    public static final void main();
    public static void main(java.lang.String[]);
      Code:
         0: invokestatic  #43                 // Method main:()V
         3: return
  }

Why this is “multiplatform” in the true sense

The fundamental difference between KMP and other cross-platform tools is whose platform runs the code.

Tools like Flutter and React Native use a “carried platform” approach. They ship a platform layer inside the app, including their own runtime and rendering surface. When the user opens the app, the app must first boot this layer before your logic can run. This adds size to the app and can introduce a small delay at startup.

KMP uses a “borrowed platform” approach. It uses the platform that is already there.

KMP borrows the platform's existing runtime instead of shipping its own. KMP borrows the platform's existing runtime instead of shipping its own.

It does not ship a runtime of its own. Therefore, the artifacts it produces are the platform’s own artifacts. A KMP binary for macOS is just a macOS binary. It does not contain a “KMP engine”.

We can see this by looking at the dependencies of a KMP native binary. If you use a tool like otool to see which libraries the binary links to, you will see only the system libraries of the OS. It links to the system C library and the Foundation framework. It does not link to any KMP-specific runtime library.

text
  build/bin/macosArm64/debugExecutable/what-is-multiplatform.kexe:
  	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)
  	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 2200.27.0)
  	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 2280.0.0)
  	/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 5027.0.69)
  	/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 5027.0.69)

KMP is multiplatform in the true sense because it does not try to be a platform of its own. It does not build a wall between your code and the OS. Instead, it is a tool that makes Kotlin a first-class language for the platform the user already has.