Exploring KMM.

I wrote the original version of this post in 2020, just after Kotlin Multiplatform Mobile’s alpha release. It’s now reached beta, so I revisited the project to bring everything up to date.

Teams that ship mobile apps for Android and iOS duplicate a staggering amount of code. Two copies of the same business logic, API calls, and local storage. Cross-platform frameworks like React Native and Flutter continue to gain traction in response, particularly at startups that don’t have the time or money to build everything twice. However, these frameworks struggle to match the polish and performance of flagship native apps.

Kotlin Multiplatform Mobile (KMM) aims to deliver the best of both worlds: shared business logic written in Kotlin with thin UI layers for Android and iOS, allowing you to take advantage of the latest and greatest each platform has to offer.

iOS app in Swift and Android app in Kotlin both use shared code in Kotlin

For teams already maintaining separate iOS and Android codebases, Kotlin Multiplatform opens the door to sharing individual features or modules. You can ramp up code sharing at your own pace without the need to rewrite entire screens or introduce a third programming language to your mobile stack.

Kilometer

I developed a simple app called “Kilometer” to explore KMM. It connects to the Strava API and displays the most recent activities from the authenticated user. The source code is published on GitHub.

Some screenshots of the Kilometer app running on iOS

The shared code covers all the API interactions, data mapping, and persistence. Each client app applies the MVVM pattern with platform-specific navigation logic, view models, and views. The views are defined with SwiftUI and Jetpack Compose.

I’m not a shared code maximalist. It’s possible to share view models and there are reasonable arguments to do so. However, I’ve found it simpler to draw a line neatly above the responsibilities of the UI layer. With this approach, the project nets out around 75% Kotlin and 25% Swift.

Let’s dig deeper on a few themes and features.

Getting started

Android Studio’s KMM plugin includes a configuration wizard that will set up a basic project structure. It also generates an Xcode project, allowing you to launch the iOS app in a simulator from Android Studio, or jump to Xcode to edit the Swift code. Practically, I always have both IDEs open to work on KMM projects.

Shared code is built as a Framework for Xcode and a Gradle module for Android

When built for iOS, the shared code is packaged as a framework using a Gradle task provided by the SDK (embedAndSignAppleFrameworkForXcode). For Android, it behaves just like a regular Gradle module.

For next steps beyond basic project setup, I found it extremely useful to reference community projects like PeopleInSpace.

Foundations

All the key dependencies required to connect to a backend service are available in stable versions: Ktor Client for networking, Kotlinx Serialization for parsing JSON, and SQLDelight if you need to maintain a local database.

The number of libraries available to support multiplatform development has expanded significantly, including official support for popular tools like Apollo GraphQL and Realm. All the community projects in my dependencies file have continued to be maintained, too.

Concurrency

Concurrency is one area where KMM has truly leapt forward in the last year. The original Kotlin/Native memory manager had a steep learning curve. It was easy to write shared code that would run as expected on Android’s JVM but produce a runtime InvalidMutabilityException on iOS.

Thankfully, the days of freezing objects are over. The new memory manager previewed in 2021 and recently became the default in Kotlin 1.7.20. Its behaviour closely mirrors Android, with fewer restrictions on sharing objects between threads. This lifts a huge blocker to wider adoption.

Platform APIs

Expect/actual syntax enables calling platform APIs from shared code. In the commonMain source set you can define an expect class or function, which is an interface to platform-specific actual implementations in androidMain and iosMain.

Three sourcesets in shared code: commonMain, iosMain, androidMain

The vast majority of shared code I’ve written is platform-agnostic business logic in common source sets, but expect/actual has come in handy for things like persistence, formatting, and logging.

Let’s walk through a simple example of persistence with platform APIs. This expect class has a string property we want to persist.

// commonMain

expect class UserState {
    var userId: String
    
    [...]
}

For Android, we can use SharedPreferences. Constructors for actual classes don’t need to match, allowing us to pass a Context just for this implementation.

// androidMain

actual class UserState(context: Context) {

    private val prefs: SharedPreferences = 
        PreferenceManager.getDefaultSharedPreferences(context)

    actual var userId: String
        get() = prefs.getString("id", "")!!
        set(userId) = prefs.edit { putString("id", userId) }
        
    [...]
}    

A limitation of interacting with the iOS platform in iosMain is that we only have access to Objective-C headers, not their Swift equivalents. So in this example, we need to use NSUserDefaults rather than UserDefaults.

// iosMain

actual class UserState {

    private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults

    actual var userId: String
        get() = defaults.stringForKey("id") ?: ""
        set(userId) = defaults.setValue(userId, "id")
        
    [...]        
}

I also ended up using expect/actual heavily for formatting pace, distance, and time strings. It would be reasonable to assume String.format() is a Kotlin language feature, but it’s borrowed from the JVM and there’s no native replacement yet. Via expect/actual, it’s still possible to use the JVM formatter for Android, and iOS has an equivalent NSString.stringWithFormat().

Interop

Types defined in shared code are automatically mapped for iOS. I opted to use Kotlin’s sealed classes to represent the results of operations on interfaces to the shared code. Although Swift and Objective-C don’t support sealed classes, a type check does the job to handle results as either ResultData or ResultError.

sealed class Result<T> {
    data class Data<T>(val data: T) : Result<T>()
    data class Error<Nothing>(val error: Throwable) : Result<Nothing>()
}

Coroutines supports exposing asynchronous functions from shared code. Suspending functions can be dispatched as usual on Android. In the iOS framework, they simply get converted to callbacks, or starting with Swift 5.5 there’s a second function generated for async await.

let result = try await stravaActivity.activities()
if let result = result as? ResultData<NSArray> {
    let items = result.data as! [ActivityCard]
    state = .data(items)
}
[...]

It’s pretty clunky casting from Objective-C types like NSArray, but this is easily isolated to view models and tested.

Exception handling

Kotlin and Swift have a huge amount in common, but the languages take opposite approaches to exception handling. Kotlin only has unchecked exceptions, while Swift only has checked exceptions. Functions in shared code can add a @Throws annotation to allow exceptions to be handled in Swift.

In practice, I’ve preferred to expose result types that wrap exceptions from the shared code.

Testing

I ended up using Kotest for fluent assertions in the shared and Android sources, but otherwise stuck to standard testing tools. If you’re coming from the Android world, you might miss frameworks like Mockito for native and iOS sources. Luckily, it turns out ChatGPT is pretty good at generating manual mocks from interfaces.

To support testing within the iOS codebase, it’s important to define all the functionality exposed from the edge of the shared code in interfaces.

The future

KMM has been usable in production for some time. I worked at Memrise in 2020 when we were already sharing core offline logic between client apps, and there are huge names like CashApp and Netflix on the early adopters list.

With the new memory manager and beta status, KMM is a viable option for new projects, worthy of consideration alongside twin native apps and cross-platform frameworks. It’s especially compelling for teams of iOS and Android specialists, eliminating duplicate effort but retaining the flexibility of building natively. I’m optimistic for a future where mobile teams share more code.