How Kotlin Multiplatform Turns One Codebase into Native Android & iOS Binaries
This article demystifies Kotlin Multiplatform’s compiler architecture, explaining how shared Kotlin code is transformed into platform‑specific binaries via a common intermediate representation, the roles of Kotlin/JVM and Kotlin/Native backends, expect/actual contracts, memory model evolution, and practical build and integration steps for Android and iOS.
Core mental model: shared blueprint, platform‑specific construction
Imagine building two identical modern offices in Shanghai and Beijing. You don’t ship a finished building; you create a shared blueprint ( commonMain) and let two local construction teams interpret it with their own materials. The Android team uses the Kotlin/JVM compiler to produce JVM bytecode, while the iOS team uses the Kotlin/Native compiler to emit native ARM machine code.
Key point : The result is two functionally identical but materially different native binaries.
The overarching KMP principle is “write once, compile natively”, which differs fundamentally from bridge‑based approaches like React Native or Flutter.
Compilation pipeline: from Kotlin source to native binary
Kotlin’s compiler consists of a frontend and multiple backends. The frontend performs lexical analysis, syntax analysis, semantic analysis, and type checking, then emits a platform‑agnostic Intermediate Representation (IR) that captures program logic without any platform details.
Lexical & syntax analysis : tokenizes source code and builds an abstract syntax tree.
Semantic analysis & type checking : validates correctness of types and calls.
The IR is then handed to the appropriate backend.
Scenario 1: Android (Kotlin/JVM backend)
Kotlin/JVM backend receives the IR and generates .class files (standard JVM bytecode).
Android’s build tools (D8/R8) convert the .class files into DEX bytecode.
DEX files are packaged together with resources into an APK or AAB.
Conclusion : On Android, KMP‑shared code follows the same path as hand‑written Kotlin code, ending as efficient DEX executed directly by ART, with no performance penalty.
Scenario 2: iOS (Kotlin/Native backend)
Kotlin/Native backend receives the IR.
The IR is lowered to LLVM IR, which LLVM then optimizes and compiles to native ARM64 machine code.
The resulting machine code, together with the Kotlin/Native runtime and an auto‑generated Objective‑C/Swift header, is packaged into a standard iOS Framework (e.g., shared.framework).
Conclusion : On iOS the Kotlin code becomes a native binary indistinguishable from Swift/Objective‑C code, running without a VM or JIT.
Cross‑platform differences: expect/actual contract
KMP solves platform‑specific APIs with expect / actual. In the shared module you declare an expect class or function, providing only the API shape. Each platform module supplies a matching actual implementation.
// In commonMain
expect class PlatformLogger {
fun log(tag: String, message: String)
}Android implementation:
// In androidMain
import android.util.Log
actual class PlatformLogger actual constructor() {
actual fun log(tag: String, message: String) {
Log.d(tag, message) // Android Logcat
}
}iOS implementation:
// In iosMain
import platform.Foundation.NSLog
actual class PlatformLogger actual constructor() {
actual fun log(tag: String, message: String) {
NSLog("%s: %s", tag, message) // iOS NSLog
}
}The compiler guarantees that every expect has a corresponding actual and rewrites calls to the platform‑specific implementation during linking.
Concurrency and memory management
Before Kotlin 1.7.20, Kotlin/Native used a strict “freezing” model: mutable objects could be accessed by only one thread, and sharing required calling .freeze(), after which the object became immutable and any mutation threw InvalidMutabilityException. This model eliminated data races but made concurrent code cumbersome.
Kotlin 1.7.20 introduced a new memory manager that removes freezing, adds a concurrent garbage collector, and lets kotlinx.coroutines work like on the JVM. No more InvalidMutabilityException, and coroutine dispatchers behave naturally across threads.
Interop with Swift/Objective‑C
Kotlin/Native automatically generates an Objective‑C header ( .h) for every public class or function, enabling seamless calls from Swift or Objective‑C.
// In commonMain
class Greeting {
fun greet(): String = "Hello from KMP!"
}Generated header (simplified):
NS_ASSUME_NONNULL_BEGIN
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Greeting")))
@interface SharedGreeting : NSObject
- (instancetype)init __attribute__((swift_name("init()")));
- (NSString *)greet __attribute__((swift_name("greet()")));
@end
NS_ASSUME_NONNULL_ENDSwift usage:
import shared
let greeting = Greeting()
print(greeting.greet()) // Hello from KMP!Data types such as String are automatically mapped to NSString, and the interop incurs negligible overhead.
.klib library format
The .klib file is Kotlin’s multi‑platform library container. For Kotlin/Native it stores serialized IR and LLVM bitcode; for JVM/JS it plays a role similar to a .jar or JavaScript bundle. When a project depends on a .klib, the compiler extracts the pre‑compiled content, avoiding recompilation and speeding up builds.
End‑to‑end workflow
Configure Gradle with targets (android, ios, jvm, etc.).
The KMP Gradle plugin compiles commonMain and each platform‑specific source set.
Android output: an AAR that is merged into the final APK/AAB.
iOS output: a Framework that can be integrated via CocoaPods (the plugin can generate a podspec).
This pipeline lets KMP blend smoothly into the mature build ecosystems of both platforms.
Common pitfalls and practical advice
Build speed : Kotlin/Native compilation (especially first or clean builds) is slower than JVM; leverage Gradle caching.
Debugging : Android Studio now offers decent KMP debugging, but iOS‑specific issues often require Xcode.
Library selection : Prefer libraries that explicitly support KMP (e.g., Ktor, SQLDelight, kotlinx.serialization). Otherwise wrap missing functionality with expect/actual.
Swift/Obj‑C interop limits : Complex Kotlin generics may not map cleanly; keep the exposed API simple and use primitive types where possible.
Where to start sharing : Begin with platform‑agnostic layers—data repositories, domain use‑cases, and utility classes—then expand gradually.
Conclusion
Kotlin Multiplatform achieves true native performance by separating business logic (captured as IR) from platform implementation, and by dividing compilation into a common frontend and platform‑specific backends. The same Kotlin blueprint becomes native Android DEX and native iOS ARM binaries, delivering “write once, compile natively” without runtime bridges.
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
