“Don’t Repeat Yourself” is a foundational principle in software development. We avoid duplicating code because it’s inefficient and it makes extra space for bugs and inconsistencies to creep into our apps.
Yet the most widely distributed apps in the world – the ones on our phones – have mostly been written twice. There are two Monzo apps, two Spotify apps, two Netflix apps, and so on. Teams that build native mobile apps spend thousands of hours solving the same problems for Android and iOS, rarely achieving parity in user experience between the two platforms.
To understand how we got here, we need to examine our workflows for writing, compiling, and executing code.
Let’s start with “native code”. What does that actually mean? When I was in high school, I got my hands on a book called Sams Teach Yourself C for Linux Programming in 21 days. There’s something behind this oddly specific title. When you write a program in C, it gets compiled into machine code for a specific operating system and CPU architecture – e.g. Linux running on x86.
To run a program on multiple platforms, you need to compile different binary builds and adapt any code that deals with operating system features like file system access or UI toolkits. This is essentially how iOS apps are built. It works well enough because iOS is a vertically integrated platform with a small set of devices to support. Building and distributing code to run on a wider range of hardware gets complicated.
In 1996, Sun Microsystems released the Java Virtual Machine, hoping to solve this problem. You write code in Java*, compile it just once to an intermediate bytecode format, and then you can run that package on any device with a JVM. It could be a desktop computer, server, or smart fridge.
* Other JVM languages are available.
Code portability made the JVM extremely popular. It now boasts a huge developer community and ecosystem of open-source libraries. JVM languages like Kotlin and Scala have become common choices for backend development, and Android’s ART runtime is essentially a JVM implementation powering the apps on 3 billion mobile devices.
The word “native” means something slightly different in the context of mobile apps. Native apps are built with the standard platform toolchains and UI frameworks: Android apps developed in Kotlin with Jetpack Compose, or iOS apps developed in Swift with SwiftUI.
Android and iOS use different languages and different compilation strategies. With the default developer tools, apps are built on parallel tracks and there’s little scope for sharing code. (This is part of the reason we ended up with a duopoly in the first place. Nobody wanted to build their app a third time for a contender like Windows Phone!)
What about C/C++?
Okay, that’s not quite true. It’s always been possible to interact with native C/C++ using the Android NDK, or via Objective-C wrappers on iOS. This is a popular approach for porting games. It also works particularly well when you have a narrow interface to some performance critical code.
At SoundCloud in 2014, we wanted to support new low-bandwidth audio codecs, optimise time-to-play performance, and work around bugs in the platform media players. It was such a foundational part of our user experience that we decided to invest in developing our own player library in C, which we shared between our Android and iOS apps. This worked pretty well.
However, very few companies have adopted C/C++ as a general-purpose approach for sharing code in mobile apps. Lower-level languages just aren’t a good fit for the challenges of the mobile domain: handling streams of asynchronous events, calling web services, building responsive user interfaces, and integrating with evolving platform APIs. The developer experience friction of pointer arithmetic and manual memory management has simply outweighed the cost of duplicating code in higher level languages like Kotlin and Swift.
Cross-platform frameworks emerged to challenge the status quo. Facebook released React Native* in 2015 and Google followed with Flutter in 2017. Several others came before, but these two options have gained the most traction. They’ve been particularly successful for startup businesses that simply can’t invest in large teams with pairs of mobile developers.
* The “native” in React Native refers to its use of platform UI toolkits. It’s not native app development or native code. I promise this is the last different meaning of native.
These days, cross-platform apps are pretty good – you’ve probably used one without knowing about it. However, relatively few flagship mobile products have migrated. Why aren’t more teams taking advantage of these technologies when they tackle such a universal challenge?
One reason is that cross-platform frameworks optimise for sharing the full stack from UI to networking code (or at least entire experiences – some screens within the Facebook app are built with React Native). They demand a level of commitment that’s too risky for most established teams.
The theme of the last decade of native app development has been convergence. In the early days, nobody was too concerned about sharing code. As an Android specialist, the iOS developer experience was completely alien to me, and the whole discipline of mobile engineering was so underdeveloped that it was hard to tell how much we had in common.
Today, the two platforms are remarkably similar. Android and iOS developers in tandem adopted reactive patterns, unidirectional data flows, MVVM architectures, and declarative UIs. The code we write today in Kotlin and Swift is more duplicative than ever. When we write modularised, platform-agnostic business logic we’re almost keystroke by keystroke duplicating thought and effort.
Kotlin Multiplatform is the latest contender. It reached beta in 2022, but it’s already used in production for apps with tens of millions of users. It presents a fundamentally different model, enabling you to share business logic, databases, or networking layers without locking you in to any specific patterns or UI toolkits.
Code is written in Kotlin and compiled to target different platforms with different compiler backends. A Kotlin/Native framework is compiled for iOS, and separately bytecode is compiled for Android.
JetBrains pitch Kotlin Multiplatform Mobile as a way to “share the logic of your iOS and Android apps while keeping the UX native”. It has some other interesting properties too:
- A language familiar to native app developers
- Incremental adoption
- Access to platform APIs from any layer of your app
- Native performance
Instead of putting distance between the developer and the platform, Kotlin Multiplatform’s strength is flexibility. You can easily access platform APIs, build native mobile UIs with tools like Jetpack Compose, and share code wherever it makes sense for you and your team.
Share business logic
Teams that already maintain large Android and iOS codebases can go multiplatform incrementally. The simplest way to begin is to share a single module of business logic or a handful of utility functions. The resulting iOS framework can be packaged with CocoaPods or Swift Package Manager, and behaves a lot like any other dependency.
At Memrise in 2020, we had some core “game loop” logic that had to work offline and behave exactly consistently across all our apps. We developed a multiplatform module in a separate repository with a build process that produced versioned artifacts for our iOS, Android, and web apps.
Some slightly more challenging places to start might be sharing API models or analytics events.
Share entire app layers
At the other end of the spectrum, you can build thin Android and iOS UI layers, sharing as much code as possible in a mobile monorepo. I wrote about this approach and some technical details on platform interoperability in Exploring KMM.
This is where Kotlin Multiplatform more directly competes with traditional cross-platform frameworks. Even with separate platform native UIs, ViewModels and navigation, it should be possible to share 50-75% of the code in a non-trivial app.
Android developers are particularly well placed to evaluate Kotlin Multiplatform. There’s a moderate learning curve on avoiding JVM-specific language features, but it’s largely building upon familiar patterns and tools. However, my advice is to find an iOS champion and partner on defining suitable workflows and interfaces.
More broadly, I suspect the platforms will continue to converge and we ought to be operating increasingly as mobile engineers rather than platform specialists. Sharing code and collaborating across the Android/iOS boundary reduces waste, and the teams that get this right will move much faster.