Code duplication.

“Don’t Repeat Yourself” is perhaps the original adage 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.

Write code, compile, and execute

Native

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.

C code, compiled to a native binary, to run on Linux 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.

JVM

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.

JVM languages, compiled to intermediate bytecode, to run on the JVM

Code portability made the JVM extremely successful. It now boasts a huge developer community and ecosystem of open-source libraries. JVM languages like Kotlin and Scala have become popular choices for backend development, and Android’s ART runtime is essentially a JVM implementation powering the apps on 3 billion mobile devices.

Mobile

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, and iOS apps developed in Swift with SwiftUI.

Android apps written in Kotlin to run on ART; iOS apps written in Swift and compiled natively

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

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 more 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 frameworks seek to abstract away the underlying platform, allowing you to write mobile apps once in a third language like JavaScript or Dart. There are some downsides. For example, you might have to wait longer to use new platform features. It pays to investigate all the limitations up front, because it’ll be extremely difficult to change approach later.

Dart code, compiled with the Flutter engine, to run on Android or iOS

JavaScript interpreters are the only code execution environments allowed on iOS, so virtual machines like the JVM aren’t an option. All cross-platform frameworks ultimately produce native code or JavaScript (packaged up inside an app).

JavaScript code, packaged with an interpreter, to run on Android or iOS

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 specific screens within the Facebook app are built with React Native). They demand a level of all-in commitment that’s too risky for most established teams.

Convergence

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

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.

Kotlin code, compiled to both native code and JVM bytecode, to run on Android and iOS

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:

  1. A language familiar to native app developers
  2. Incremental adoption
  3. Access to platform APIs from any layer of your app
  4. Native performance
  5. The option to build for additional targets like JavaScript or WebAssembly

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.

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.

Shared Kotlin code interfaces with the Android and iOS platforms directly

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 any non-trivial app.

The opportunity

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. 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 ultimately move faster.