Kotlin Multiplatform: A Multi-Platform for Native Developers
Introduction
Kotlin Multiplatform (KMP) has been a hot topic among mobile teams for the past few years, and the conversations have only increased since Google announced at IO 2024 that they are leveraging KMP for many of their own applications and updating Jetpack frameworks with KMP in mind. While these discussions are often driven by Kotlin/Android developers, the impact of KMP on native iOS development is equally significant.
The collaboration between iOS and Android developers is crucial for the continued advancement of KMP, benefiting overall product development efforts. This post aims to introduce you to KMP, highlighting its strengths and weaknesses from a high-level. One of KMP’s most compelling features is its flexible approach, which this post will highlight by focusing on sharing business logic while maintaining native UI development. Although KMP supports a write once and run everywhere approach through declarative UI with Compose, this post will concentrate on the more flexible strategy of sharing business logic to showcase its versatility.
Another Multiplatform Framework?!
To truly understand KMP’s importance, its crucial to understand the other multiplatform options that everyone will weigh KMP against. In-fact, you will find articles and people on the internet that claim KMP is just another framework entering a crowded field of multiplatform attempts and will just like the others fall short of greatness.
While the success of KMP is yet to be known, it is certainly not like the other options available today. The flexibility alone of KMP differentiates itself from every other multiplatform framework. Unlike other frameworks, you are leveraging a language that is already used by one of the platforms (Android) and you have the freedom to easily choose where you start and stop shared code. As someone who is otherwise risk adverse, this makes me much more comfortable than going all in on a framework that could result in a significant or total re-write if the framework doesn’t work out as hoped.
React Native
React Native enables cross-platform mobile app development by utilizing an embedded JavaScript engine to execute your app’s code. To optimize UI updates, it leverages a virtual DOM, ensuring that only modified UI elements are re-rendered when data changes. For seamless interaction with device features like the camera or geolocation, React Native employs a bridge to communicate with the native platform, and its core components (like View, Text, Image) directly map to their native iOS and Android counterparts.
Flutter
Flutter is a cross-platform UI toolkit built using Dart, enabling developers to craft visually appealing apps for multiple platforms from a single codebase. To interact with native device features like the camera or geolocation, Flutter utilizes platform channels for seamless communication. Unlike traditional frameworks, Flutter bypasses native UI components and employs its own high-performance rendering engine, Skia, to directly control every pixel on the screen, while employing Ahead-of-Time (AOT) compilation to produce native machine code for optimized performance.
Kotlin Multiplatform
Kotlin Multiplatform enables the development of cross-platform mobile applications by allowing you to share core business logic written in Kotlin across Android and iOS. While Kotlin code compiles directly to native machine code for iOS, UI development remains platform-specific using native tools like Swift or Kotlin (for Android). To facilitate communication between the shared Kotlin module and the iOS platform, Kotlin/Native generates Objective-C headers, providing a bridge for Swift or Objective-C code to interact with the compiled Kotlin logic.
Kotlin/iOS Interoperability
KMP & Objective-C
Much of the iOS ecosystem’s underlying frameworks and libraries are built upon Objective-C. Generating Objective-C headers creates a crucial bridge between your Kotlin code and the iOS world. Objective-C acts as a common language that can be understood by both Swift and the wider iOS ecosystem.
However, these necessary Objective-C headers create language limitations and make your shared Kotlin code noticeably less friendly for iOS developers. Here are a few examples of the most common pain points and in a future section I will detail how to best address and remediate these shortcomings.
Generics
Objective-C lacks a robust concept of generics like those found in Kotlin and Swift. The Kotlin framework needs to generate wrappers to expose Kotlin generic types in a format Objective-C can understand. Great overview found here.
Simple Class Generics — work as expected across iOS and Android.
class GenericClass<T>(val value: T) //value must be unwrapped in swift
class GenericClass<T:Any>(val value: T) //value is non-null here by making T a type of Any
Variances — Though Variances are mapped to Obj-C Headers, they are lost when they are mapped to Swift.
class GenericClassCovariant<out T>(val value: T)
class GenericClassContravariant<in T>(val value: T)
Bounds — Bounds are lost and cannot be enforced.
class GenericClass<T:BaseType>(val value: T) //BaseType is lost
Functions/Methods — Generic types are not supported at all and are completely lost.
fun <T> someFunction(value: T): T = value // will be mapped to Any
Enums/Sealed Classes
Kotlin enums are converted into Objective-C classes with static properties, which limits features like exhaustive switching. While Kotlin's sealed classes offer similar functionality to Swift enums with associated values, direct translation to Objective-C isn't possible due to language limitations.
Default Arguments
While both Kotlin and Swift support default arguments for functions, the same is not true for Objective-C. As a result, all Kotlin functions are exported without the default arguments.
Another problem is that Kotlin and Swift have some significant semantic differences regarding default arguments. Kotlin's default arguments can access the values of previous function parameters and the this expression (which is self in Swift). Neither is possible in Swift because the default arguments are evaluated in isolation, with have access only to the global scope.
Overloading
Kotlin, Objective-C, and Swift all have different levels of support for function overloading:
- Kotlin distinguishes overloads by their parameter types.
- Objective-C uses argument labels (parameter names in Kotlin) but does not use the parameter types.
- Swift combines these two approaches and allows overloads to have the same parameter types if some argument labels differ.
Because of the differences between Kotlin and Objective-C, Kotlin has to expose some functions under a different name to avoid name collisions. The changed names are based on the original Kotlin name, but they have the _ suffix (or multiple suffixes if there are multiple collisions).
Kotlin adds this suffix to the end of the function identifier for functions without parameters. Functions with parameters have the suffix at the end of the last argument label.
Concurrency & Coroutines
Suspend Functions:
The problem with suspend functions is that Objective-C has no equivalent feature. Therefore, suspend functions have to be exposed as Obj-C callback functions, which can be called from Swift using the async/await syntax. However, this is just Swift's syntax sugar. Swift still handles these functions as callback functions that do not support cancellation.
Flows:
The problem with Flows in the context of Swift interoperobility is that it is a regular Kotlin interface. While Swift also has its own implementation of Reactive Streams API in the form of AsyncSequence, these two are incompatible. Therefore, there is not only no direct interop between them, but there is also no easy way to manually cast one to the other.
Making KMP Smooth for All
The success of KMP within your organization and beyond hinges on proper architecture and thoughtful implementation. While reducing the amount of code written is a key objective, the overall experience and effort involved in achieving this goal are equally critical. If the journey is not enjoyable, any initial successes are likely to be short-lived.
Fortunately, the KMP team and Google are continuously enhancing the KMP experience. By leveraging useful plugins and adhering to best practices in architecture, you can create a development environment that is both exciting and enjoyable for all your developers. This not only ensures long-term success but also fosters a positive and productive atmosphere within your team.
Swift/Kotlin Language Gaps Addressed With SKIE
SKIE (pronounced as sky) is a special Kotlin native compiler plugin that brings back support for some aforementioned language features that are otherwise limited by KMP. It does this by modifying the Xcode Framework produced by the Kotlin compiler. Thanks to that, you don't have to change how you distribute and consume your Kotlin Multiplatform frameworks.
- Enums/Sealed Classes – SKIE generates Swift Enums for Kotlin Enums and Swift Enums that wrap Kotlin Sealed Classes. Thus, fixing exhaustive switching.
- Default Arguments — SKIE instead generates Kotlin overloads that simulate the semantics of a function with default arguments.
- Overloading – SKIE generates Swift overloaded functions avoiding the issues with Objective-C.
- Suspend Functions — SKIE solves this limitation by generating actual Swift async functions (that can be cancelled) and provides a custom runtime that bridges the concurrency contexts of both platforms.
- Flows — SKIE automatically converts Flows to custom Swift classes that implement AsyncSequence, eliminating the need to convert between the two types
Architecting with Native Platforms in Mind
Pictured below is an example architecture I have used in the past with success. Again, this assumes that your approach is to share everything with the exception of the UI and any native SDK use.
Model View Intent (MVI) is an ideal pattern as it allows for the native ViewModels to intercept any intents or side effects that require extra native processing before or after the share module uses it. It also drives a common communication pattern that both iOS and Android will use natively.
The Kotlin Shared Module should contain most, if not all, Network & Data Storage. As a result, the corresponding Data Classes/Models and session management should be in the Shared Module.
Native ViewModels observe and consume view-ready state from Kotlin Shared ViewModels. Native ViewModels may provide custom reducers where necessary.
Native Applications to inject Native Class Abstractions where needed — such as Notification Management, Location Providers, etc through the use of Expect/Actuals or other custom interfaces.
iOS Devs & Android Studio
While Android Studio is great, it can be heavy and not to mention some iOS developers seem to take offense with anything that says Android on their machine. IntelliJ has recently introduced Fleet which can serve as an alternative IDE that is much more lightweight and enables Kotling and Swift checks/completion.
FLEET
- Lightweight
- Supports Swift or Kotlin with checks and auto completion
- Similar to VS Code in that its built to be the IDE for much more.
- No support for swiftUI or Compose Previews
Cross Platform Mindset
In some organizations there is a tendency to lead features on one platform or another. This is often due to engineering strengths or product team preferences. With KMP this mindset must shift in both product and engineering to build quality features that reach a wider audience from the start.
Embrace platform differences
A cross-platform mindset must be established within product as well as in engineering. Product should be encouraged to embrace native platform experiences while striving to bring the same core experiences to all platforms. The use of KMP shouldn’t cause one platform or another not to reach its full potential.
One team, multiple platforms
I believe KMP necessitates the merging of platform specific teams into one team aligned on common feature goals. The engineers of these features teams should work together to build a pattern that maximizes shared code and enables them to have more time building great native UI experiences.
Align CI/CD Processes
CI/CD processes across platforms can vary drastically in many organizations. Just as features are built to maximize shared components, the CI/CD pipeline should do the same. This is the first touch point of developers, QA, and product with your product and set the tone for the cross-platform mindset.