ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Kotlin Multiplatform Scalability Challenges on a Large Project

I started native Android engineering in 2015, and in 2017 I was inspired by two new multiplatform frameworks: Flutter and Kotlin Multiplatform. Although KMP was harder to build and use compared to Flutter, I believed from the beginning that it had more potential.
This was mainly because it reuses native Android language and practices while offering a most delicate approach to multiplatform features.
Since then, I have continued to follow its progress, experimenting with new features in my open-source projects like Coffegram.

When it became stable, I looked for an opportunity to try it in a real project. Here, I will describe my experience in one real large-scale project.

This article consists of four parts:

  • Background on the project I worked on and the company’s previous experience with KMP in a library.
  • A comparison of two component development approaches: library vs. product feature, which is important for the following sections.
  • A description of methods for migrating components to KMP, including the challenges faced.
  • Finally, an overview of our experiment moving a product feature from native to KMP, along with A/B test results.

First company’s experience with KMP in a library

I mentioned a large project in the title because it introduces challenges you might never encounter in a smaller-scale project.
I consider the project I worked on large because it had more than 1000 Gradle modules in the native Android app (with a similar scale in iOS) and was developed by more than 150 mobile developers (iOS & Android).
Code for each platform is located in two monorepositories and assembled at once.
Each repository is developed by two types of developers:

  • Product developers included in cross-functional product teams
  • Platform developers included in separate 4x2 platform teams (architecture, build tools, design system, and performance for each mobile platform)

Before the current experiment with KMP, part of the team had already gained successful experience.
They created a library for in-app calls using WebRTC and WebSockets.

WebRTC is a complex C++ library that needed an additional business logic layer.
The reason colleagues tried KMP was that the product team lacked an iOS engineer at that time.
So they persuaded management to try KMP for both Android and iOS.
If something went wrong, they planned to rewrite it natively once an iOS engineer was hired, keeping the library native at least on Android in the worst-case scenario.

However, the experiment, the KMP library, and the feature as a whole succeeded. It has been working well in production for more than two years.
The downside was that almost none of the other mobile developers knew how it was created or how to work with KMP.

This library is still located in a separate repository, built separately, and connected to each project as a third-party artifact. And that is almost all we know.

Product feature vs library

In this part I’ll describe why product feature development on KMP differs significantly from a separate library.
To do that we should clearly define the boundaries between them.

Let’s look at the scheme. It is a rethinking of Clean Architecture applied to mobile development.

Typical structure of product feature

It helps us align on the idea that a product feature is a codebase encompassing all layers — from UI through business logic to data retrieval from the backend or a local database.
This differs from a library, which is usually separate from product features and behaves like an external SDK.
Typically, it has minimal dependencies on other project components.
On the contrary, a product feature relies on many core platform libraries, including:

  • Common UI architecture library
  • Product analytics library
  • Performance metrics library
  • Deeplinks library
  • Common DI framework
  • Core network layer

Referring back to the scheme, we determined that all parts except the native UI could be migrated to KMP.

Shareable parts of product feature with KMP

For the UI we intentionally did not explore Compose Multiplatform, opting to keep the native UI.

Core libs migration to KMP: Methods and Challenges

To launch the product feature in KMP, we first needed to migrate core libraries. For that, we had two main approaches:

First: take a native Android library (also called a platform feature) and refactor it to be multiplatform.
Second: write a common adapter over two same native libraries — wrapping Android and iOS. This allows the adapter to be used in common code.

Android Library Migration Example

We used the first approach to migrate the internal MVI Flow library.
Previously, MVI was chosen as the common UI architecture for the entire Android app.
Colleagues created a library for it based on Coroutines Flows for component communication.

While migrating it to KMP, we were fortunate that the library was relatively new and ready for common code extraction — it had already separated platform-independent and dependent code from the beginning.

Pros of this approach: we can reuse the existing native part. This means we only need to support two libraries (KMP and iOS-native) for three platforms (Android, iOS, KMP) instead of three separate implementations.

Main downside: since we migrated the library in a separate KMP repository (to avoid disrupting development for 70+ others), its native version diverged slightly.

In most cases an existing native library may not be ready for easy migration.
If it was built for Android long ago, it may have issues like leaking resources, context dependencies, and Java-based dependencies in many places, making migration a significant challenge.

Another downside: You still need to adapt iOS native code to be compatible with the KMP library.

Adapting MVI for iOS

In our case with MVI on KMP, we still had to adapt the native UI code. We used four layers of abstraction:

  1. In commonMain source set of MVI Flow we had CommonViewModel with a suspend function to receive Intents from UI and a Coroutines Flow for State updates. It was used in both Android & iOS.
  2. In iosMain, the Coroutines Flow was wrapped with CFlow, and iOSViewModel extended CommonViewModel.
  3. In native iOS, it was wrapped into @Published + ViewModel: ObservableObject, enabling its use in SwiftUI.
    However, this was not our case, as iOS engineers were uncertain about using SwiftUI yet.
  4. We adopted this ViewModel for UIKit in presenters.
    The last part of the code took as much effort as the previous three combined, as seen in the screenshot.
UIKit vs SwiftUI wrappers size for MVI from KMP

Network layer unification options

After migrating the UI architecture we switched to a common network layer.
The most challenging part: it is rather thick due to the common network request logic and product feature contracts.
The contracts themselves have a common issue — they differ slightly between the two platforms: they may use different API versions or support different field lists.

Even for a single product feature, migrating to KMP involves handling a lot of network logic, including:

  • JSON parsing
  • Attaching mandatory headers
  • Authorization session lifecycle handling — it should be synchronized across native and multiplatform requests
  • Antifraud mechanisms
  • Request performance metric

The network core logic also had differences between iOS and Android.
One platform could have some dead features with unused headers still being sent.
Other features could work differently.

Like many others, we tried using a standard solution — Ktor library.
I won’t describe its implementation but will highlight the issues and needs it could not help us with:

1. Ktor in iOS works with the Darwin Engine based on NSUrlSession.
We couldn’t use it with our preconfigured iOS native network layers because parts of the logic were built on NSUrlSession, others on Alamofire library, while some were just callbacks on each request.

We looked for a way to use the Darwin Engine based on a preconfigured Alamofire instead of NSUrlSession and even raised an issue.

2. During the migration process, we unexpectedly discovered (due to communication gaps in a large team) that the platform performance team was actively researching a migration from HTTP/2 to HTTP/3 over the QUIC protocol.
They planned to migrate all requests to QUIC soon.

However, QUIC was not supported in Ktor or even in the native Android OkHttp library directly (we used Cronet).

At that time, OkHttp was planned for KMP support in 5.0.0-alpha.4.
But from 5.0.0-alpha.13 onward, this support was dropped in favor of Ktor.

Currently, work on QUIC in Ktor is in progress. You can track progress on GitHub or this issue.

Since our experiment couldn’t wait, we agreed to use Ktor without QUIC for a single feature.
We rewrote the core logic for it and planned to revisit the decision later.

To sum up, the network library was written from scratch, incorporating research from both platform implementations.

Native Libs Wrapping Downsides

Mostly, we wrapped other platform libraries with a small API.

But why didn’t we try to wrap native network implementations?

Firstly, we would need to support this adapter after every major change in the native part. This means we would have three dependent libraries, making maintenance difficult.

Secondly, wrapping two different native implementations is harder than migrating one (e.g., Android, as it is already in Kotlin).
As we have seen in network logic, native libraries can differ significantly, causing platform-specific behaviors to leak into the common interface.

Of course, this approach has some benefits:

First, it usually does not require adaption of the native iOS code.
The native iOS application continues using the native iOS library directly, while the common code interacts with it through an adapter.
Any updates to the native iOS library will be automatically reflected in both implementaion, ensuring consistency across.

Secondly, in the future, we can use the adapter to replace the implementation.
For example, if we wrap the network layer, we could later switch to Ktor or another multiplatform library when they support QUIC, without additional costs.

The experiment

It’s time to proceed to the main part of the article: an A/B test on a real product feature that we migrated from native to KMP, measuring the results in production.

Feature choice

We began by defining criteria to ensure the feature was representative yet not too difficult to migrate:

  • It uses the MVI Flow library, allowing us to migrate its business logic as is.
  • The feature is covered by performance metrics, which we compared in an A/B test with the native implementation.
  • It requires authorization, including session-refresh logic and necessary headers, making it relevant to most features.
  • The feature was experimental not only for KMP but also for Compose UI on Android.
    Since Compose and SwiftUI seem to be converging in modern mobile development, we tested them together.
  • It is covered by UI E2E tests, helping us verify that the migration didn’t break business logic.
  • It is small in size (2 server requests, 2 screens), making it easy to implement and roll back if needed.
  • It has minimal internal dependencies, reducing the need to migrate core libraries beforehand.
  • It has no dependencies on other product features.

Experiment requirements

The primary measurable metrics were the existing performance metrics automatically tracked by the split service (more on it).

  • On iOS: Native UI (UIKit) + Native logic vs. Native UI (UIKit) + KMP logic.
  • On Android: Native UI (XML) + Native logic vs. Native UI (Compose) + KMP logic.

Additionally, platform teams set specific requirements:

  • The Build Tools (aka Speed) team, responsible for local and CI build times, required that KMP have no impact on build speed.

The Mobile Architecture team focused on the developer experience when using KMP, particularly regarding:

  • Incremental changes
  • Debugging errors in common code
  • Publishing new versions

It was expected that most challenges would arise from the iOS build process.

Results

🟢 A/B test — No performance issues

We expected KMP to be slower on iOS, but it was not. Both platforms showed neutral (gray) results, meaning no significant difference in performance metrics.

🟢 Native project build time — No impact

We added KMP to the main projects as a built library (via Artifactory), so it did not affect build time at all.

🟢 Incremental KMP development experience

For local development, we used Maven Local for Android and a local framework for iOS.

  • On Android, performance was similar to a regular Android module, making it fast for feature development and debugging before checking on iOS.
  • Kotlin for iOS builds was initially slow but has improved significantly over time with Kotlin/Native enhancements.

🟢 Debugging experience

Debugging was smooth, both in understanding UI state changes through MVI Flow logs and handling crashes.
On iOS, stack traces directly pointed to Kotlin code, making debugging easier.

🟡 New version publication

Challenges arose from:

  • The prior KMP library setup.
  • The iOS limitation of supporting only one KMP framework per app (KT-42250).

A common solution is to use a single umbrella module for the iOS framework, incorporating all other KMP modules. We had to:

  • Change the Calls publication format to klib.
  • Include WebRTC transitively.
  • Build a single bundle containing the migrated feature and core code.
  • Use this as a single framework (with a unified namespace) in the iOS app.

While the process was clear, implementing it correctly took time and required collaboration with the Calls team.

🟡 iOS build issues and workarounds

The native iOS app had many modules and used a custom build tool.

Unlike standard tutorials, we couldn’t simply add the KMP framework to Xcode and auto-include it.
Instead, we had to adapt it for the custom build tool with help from the iOS platform team.

1 year later

Colleagues removed the remnants of the experiment from the code, shifting the development focus toward a backend-driven UI framework.
Its client-side implementation is based on KMP with native UI components.

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Responses (2)

Write a response