Theodo apps

Experience Report : Lessons Learned After Migrating an Android/iOS Feature to KMP

In a previous article, we explored how to progressively migrate an existing iOS/Android app to Kotlin Multiplatform (KMP). If you've followed that guide, you probably have a working KMP codebase by now… and a growing list of frustrating issues.

That’s exactly what happened to me when I migrated one feature. Today, I want to share what I’ve learned from this adventure — so you’re prepared. Because while KMP delivers on many promises, the path is far from smooth. Some common types aren't supported, popular frameworks become incompatible and platform interop sometimes defies intuition.

Let’s dig into the gotchas — types, reactivity systems, resources, lifecycle handling — and more. The good news? Some parts are easier than you’d expect. So, are you ready for the real-world finale of your KMP transition?

The Pain Points

Kotlin-Swift Interop

Kotlin isn’t directly interoperable with Swift — only with Objective-C. That decision dates back to 2018 and was made to support the widest range of projects. The full list of limitations it introduces can be found here: Kotlin Swift Interopedia.

Thankfully, basic interop works fine. For advanced cases (like enums, sealed classes, and Flows), use SKIE to improve Swift mappings.

Missing Java/Android Types

Several commonly used types on Kotlin/Android are missing from KMP so you have to find a Kotlin alternative:

  • java.util.UUID →  use kotlin uuid. Arrived in experimental version in Kotlin 2.0.20
  • SortedSet → use Set and sort when needed
  • java.net.URL → use community solution like this one eygraber/uri-kmp

When Android types are missing, alias them on Android so you can use the habitual implementation and provide equivalents on iOS:

actual typealias Bundle = android.os.Bundle

On iOS, you can use a Map to implement Bundle.

Rx / LiveData

RxJava, LiveData reactive systems don’t be availables in KMP. The fix: migrate to coroutines and Flow layer by layer, with adapters to bridge old code.

This isn't technically hard, just tedious. But if your presentation layer chains observables heavily, complexity will slow you down.

The trickiest part for me? Adapting tests. Rx and coroutine-based tests behave differently. But tests are your best defense against regressions — don’t skip them, take your time to adapt them.

Design System Migration

  • Fonts load differently in Compose Android vs. Compose Multiplatform. In multi-module setups, be sure resources are correctly scoped.
  • Lottie isn’t KMP-compatible. Use Kottie or Compottie.
  • If you are using Rive for your animations, as far as I know, there’s no official multiplatform version yet. You’ll need to adapt the different runtimes manually. rive-cpp works with Skia, so it’s worth exploring how much effort would be needed to make it work with CMP.
  • Remote image loading? Migrate to Coil. If you used Landscapist, migration is simple.

Resources

In Compose, resources are only usable on iOS via local integration — not through .xcframework.

For multi-module projects, you must declare resource generation in the iOS umbrella module:

compose.resources {
    generateResClass = always
}

For fonts, there's an additional rule: they need to be declared in the top-level module and injected into others. See YouTrack CMP-4111.

Font resource behavior also seemed inconsistent — I made multiple tries with fonts in submodule, sometimes it worked, sometimes it did not. If you’ve figured this out, let me know.

Views

The biggest issue? No Compose previews in the shared module.

My workaround: keep views in the shared module, but leave previews in the Android app. That way, I also avoided moving paparazzi snapshot tests.

You can preview Compose Multiplatform views using JetBrains’ IDE Fleet — but it didn’t work with my complex project. Also, JetBrains has since dropped Fleet. I hope this feature makes its way to Android Studio soon.

Navigation

Migrating navigation logic depends on what you used before.

I was using fragment-based navigation on Android and switched to Compose navigation. You might find options closer to your existing setup.

Tuist Integration

Using Tuist for your iOS project? You’ll need to explicitly declare your KMP .framework in Tuist.

By default, Kotlin generates a .framework (not .xcframework) — which means it's only valid for a single architecture.

You’ll likely need to customize your Gradle task and the Tuist generate command. It’s tricky. Got a better solution? Let me know.

Alternatively, there’s the community solution for generating .xcframeworks — though it naturally takes more time than building a .framework for a single architecture.

Maybe a solution to try: use .framework in debug, .xcframework in release.

Also, if you use direct integration in Tuist, remember to persist config in tuist files or tuist generate will overwrite it.

KMP in an iOS Micro-feature Architecture

Initially, I had to build the app twice to see changes from the KMP module.

Why? Because I linked KMP to the main app instead of linking it directly to the feature submodules that actually used it.

Lifecycle Management

Android objects often tie to lifecycle owners (Activities, Fragments).

When migrating to Compose and KMP, you must rethink lifetimes on iOS. Expect small regressions (e.g., textField focus not resetting on screen return).

To help with the simplest lifecycle usecases, use Koin scopes.

And if you want to bind your Kotlin ViewModel lifecycle to SwiftUI views — brace yourself. Check François’s article for help.

Sharing State Between iOS and KMP

Example: the authentication token.

I retrieve it in the native iOS part, but I want to use it in KMP network calls. I built a class accessible from both sides to expose the token.

More broadly: create cross-platform components via dependency injection. Let the platforms implement them and use them in shared code.

The Easier Parts

From Retrofit to KtorFit

Retrofit isn’t KMP-compatible — and won’t be.

But KtorFit is. Migration is straightforward.

Room

Used Room on Android? Google provides a migration guide and a working Fruitties sample. Besides, Room is now KMP-stable !

Coroutines / Flow

If you already use coroutines and Flow on Android, much of your logic is ready to migrate. Otherwise, migrate to them first. Look at the previous “Rx/Livedata” part.

Recommendations

It’s significantly easier to build a new feature in Kotlin Multiplatform for both platforms than to migrate an existing one.

If you can, start with something new. It’s a low-risk way to validate whether KMP fits your team’s needs before committing to a full-scale migration. You’ll gain firsthand experience and better anticipate the work required for the rest of your codebase.

Already have unit tests? Good.

If not, prioritize them — because refactoring without tests is just guessing. With tests in place, your migration becomes faster, safer and more robust.

When moving classes from the Android package into the shared module, keep the same package name. It’ll save you from rewriting every usage reference across your project.

Use interfaces whenever they make refactoring cleaner. Abstract now to simplify later.

And most importantly: migration is refactoring. You need to master your IDE’s tooling — or suffer the consequences.

Final Thoughts

Migrating a native app to Kotlin Multiplatform isn’t trivial — but it’s also not insurmountable.

With a pragmatic, incremental approach and solid prep, the migration becomes an opportunity to clean up your architecture, enforce consistent patterns, and level up your codebase.

There’s no universal recipe. Every project has its quirks, constraints, and edge cases.

But with solid test coverage, smart use of refactoring tools and a well-thought-out migration strategy, you can minimize regressions and maximize the benefits of moving to Kotlin Multiplatform.

Already gone through a KMP migration? I’d love to hear what your toughest challenges were — because every migration is a journey.

Développeur mobile ?

Rejoins nos équipes