Convert Your Native Project to Kotlin Multiplatform – Developer’s Guide
Learn to migrate your Android and iOS apps to Kotlin Multiplatform in 10 easy steps. From preparing repositories to migrating unit tests, it’s all covered in this guide.
Table of contents
In one of our previous posts, we discussed why it is worth migrating your existing Android application to Kotlin Multiplatform to share logic with iOS. We already know how the migration process may look like, how to make it iterative, without interfering with regular development, and what the potential risks are and how to mitigate them.
In this article, I would like to dive deeper into the technical details of the migration process. I’m going to show you some important technical decisions you’ll have to make and several common challenges you might face. For all of them, you’ll receive my recommendations on how to approach them, based on my experience of more than 3 years spent on Kotlin Multiplatform development across several different projects. So please take your seat and enjoy the read!
1. Prepare your repositories
Before we open Android Studio and begin moving classes to the Multiplatform module, we need to prepare our repositories. If you already have separate applications for Android and iOS operating systems, they are most likely in two separate Git repositories. These codebases don’t know anything about each other, but this will change when we introduce Kotlin Multiplatform to the project. With KMP, both native applications use the same Kotlin code, which implements the common logic. We need to make this code accessible to them, and there are several ways to do that.
Option 1: A separate repository for the Multiplatform module
In the first approach, we treat this new module more like an independent library. It is located in a separate repository, with both native applications using it as an external dependency.
- On the Android side, we export the Multiplatform code to an Android library and distribute it using Maven.
- On the iOS side, we export the Multiplatform code to an Apple framework and distribute it using CocoaPods or Swift Package Manager (SPM).
To simplify and automate this process, we rely on the KMMBridge tool, which is a Gradle plugin added to the Multiplatform module. It supports distribution using Maven, CocoaPods, and Swift Package Manager right out of the box.
This approach is the most complex and I recommend it mainly for large projects with a large number of developers. Since we treat our Multiplatform module as a library, it is the most effective when we also have a dedicated team working alongside the Android and iOS teams. This way, the Multiplatform library can be properly documented, versioned, and released in regular cycles.
Option 2: Put a shared module in the Android repository
Not every project is large enough, however, to justify creating a separate team to develop a Multiplatform library. Our second option is to put the Multiplatform module inside the Android project repository. From the Android perspective, there is no big difference between the Android and Multiplatform modules. They use the same Kotlin language, they are both Gradle modules, and they can both be directly integrated within the project. This means we don’t need to build and distribute an Android library anymore.
On the iOS side, the distribution process looks the same as in the previous setup with a separate repository. We use the KMMBridge tool to export the Multiplatform module to an Apple framework and publish it using CocoaPods or Swift Package Manager.
This solution suits projects where only the Android team will be responsible for the development of the Multiplatform codebase. In this case, Android developers use the Multiplatform module just like a regular Android module, simplifying development. The iOS team, on the other hand, still uses it as an external dependency. Adding versioning and documentation to the Multiplatform module is also a worthwhile consideration here, as the iOS team can still treat it as a library. However, if the Android and iOS teams cooperate closely and communicate directly, this might not be necessary.
Option 3: Merge applications into a monorepo
Having a Multiplatform module inside the Android app repository creates asymmetry between the teams. The Android engineers develop the common code while their iOS colleagues only use it. In some projects, this is a desired situation, but in others, not so much. This is why we have a third option where we don’t create any new team, and all the developers are equally responsible for the development of the Multiplatform codebase.
In this approach, we merge our separate repositories into a single monorepo and add a Multiplatform module to it. Because all the code sits within the same repository, we don’t need any remote artifact distribution solutions like Maven, CocoaPods, or SPM. Both Android and iOS applications depend on the Multiplatform module directly. On the Android side, we connect it as a Gradle module to the application while, on the iOS side, we configure it using Xcode Build Phases.
Having a monorepo is ideal when Android and iOS developers want to cooperate closely, acting more like a single team. At Droids On Roids, we apply this strategy for new projects with Kotlin Multiplatform, but it is also worth considering when we migrate existing applications. It not only simplifies and speeds up development but also promotes knowledge exchange between engineers.
However, if creating a monorepo is something you would like to avoid, we can achieve similar results by using Git submodules. This solution enables us to keep the code in a separate repository while also including it in other repositories. This way, we can connect the Multiplatform module to both native applications directly, without using remote distribution mechanisms like Maven, CocoaPods, or SPM.
2. Think about modularization
No matter which repository configuration you choose, you might want to modularize your shared code. We often do this in our projects to benefit from code encapsulation, better build performance, and easier parallel work in a team or even across multiple teams. Kotlin Multiplatform is no different here. Since it relies on Gradle just like Android does, we can apply exactly the same modularization strategies.In contrast, iOS doesn’t use Gradle. This means we can’t connect the Multiplatform module to the iOS project the same way we connect it to the Android one. Instead, our shared Kotlin code is packed into an iOS framework, which can be connected using Xcode Build Phases, CocoaPods, or SPM.
The way this framework is packed is very important in multi-module projects. Let’s say we have two Multiplatform modules, such as login and home. There is also a third module called networking, which holds a common HTTP client. Both the login and home modules depend on the networking module. When the Android app imports the login and home modules separately, they still use the same HTTP client from the networking module.
The situation looks different on iOS. If we generate two separate frameworks from login and home, each of them contains an exact copy of all the code it depends on. This means our features don’t use the same HTTP client; instead, they each have a separate copy of it.
In multi-module projects where modules have many dependencies on other modules, this behavior can cause several issues. The biggest one is the lack of state sharing across modules. If multiple modules use another module to hold a common state, it will not work after compiling to iOS. Each module will have its own independent copy of the state instead of using the shared one. Furthermore, the application size will grow unnecessarily due to code duplication across different frameworks.
The umbrella module
This is why we should introduce an umbrella module to multi-module KMP projects. The umbrella module is the only one that generates the iOS framework. As a result, the iOS application has only one dependency, the umbrella framework, and it doesn’t depend on other individual modules. The Android app, on the other hand, can depend on individual modules via Gradle, or it can also use just the umbrella for consistency with the iOS.
The umbrella module itself depends on all the Multiplatform modules and decides which ones should be accessible from the native app. For those that are supposed to be strictly used internally by the Multiplatform code, we add them as implementation dependencies to the umbrella module. In contrast, modules that should be public are added as API dependencies and additionally exported from the iOS framework. Below, you can find an example from the build.gradle.kts file of the umbrella module.
3. Make your Kotlin code Swift-friendly
On the iOS side, Kotlin translates to Objective-C code, which guarantees support for a wide range of projects in the Apple ecosystem. However, it also requires developers to design their public Kotlin API with caution, as some language features might be missing after translation to Objective-C.
- First, developers should get familiar with the official Kotlin-Swift interopedia. It shows how all Kotlin features look when used from Swift code after translation to Objective-C.
- It is also worth checking out a great talk from KotlinConf 2024, which explains how this interoperability works and what we should do to make our code more Swift-friendly.
- Next, you should add the SKIE plugin to your common module. It is a Kotlin Multiplatform plugin that generates Swift code instead of Objective-C code for certain language features. It makes it easier to use things like Coroutines, Flow, or sealed classes.
- Lastly, watch for news about direct Kotlin-Swift interoperability. It is on the Kotlin Multiplatform roadmap for 2024, so it might already be available by the time you read this article it is already there.
4. Setup dependency injection
Almost every modern mobile application uses some DI solution. In the Android world, the most popular choice is the official Hilt library made by Google. Hilt is built on top of Dagger, which is an older Java solution. Because it is based on Java, we can’t use it in the Multiplatform code.
To enable us to use DI with KMP, we need to switch to the Koin library. If you haven’t heard about it before, Koin is a very lightweight and pragmatic dependency injection framework built 100% with Kotlin. It is a very mature technology, developed and improved over many years, and is the second most popular choice among Android developers, right after Hilt.
The best way to use Koin in a Kotlin Multiplatform app is to use it only internally within the shared module. We create a separate KoinApplication instance and expose public get functions only for those classes we want to be accessible from native applications.
This approach comes with several benefits:
- We don’t force the Android app to use Koin. We can use it in the app, but we don’t have to. We can still rely on Hilt and only connect our SharedModule to it.
- All native applications use SharedModule the same way. Koin works in the common Kotlin code, but it can’t be used directly in the Swift code. By hiding it inside the Multiplatform module, we expose just a simple, framework-agnostic API that is consistent across different platforms.
- We might not need a DI solution in native applications at all. If we decide to move everything up to ViewModel classes to the common Kotlin, we can access them from the UI just by using public getters from the SharedModule without Hilt, Koin, or any other DI solution.
5. Migrate a model
No matter what app you have, it always contains some model classes. The model represents business objects like User, Product, or Article. These are classes that hold data and eventually implement some general business rules.
In a properly architected application, the model is the most independent part of the project. It should not have any dependencies on other parts of the application like backend services, databases, system APIs, or the UI. This makes the model a great starting point for the KMP migration. Let’s say we have a simple Product model that looks as follows:
Moving this class to a Multiplatform module requires no changes, and we can use it in the iOS app right away, such as inside the Repository class to create a new product, for example.
Replace Java types with Kotlin types
When Kotlin code is written in a native Android app, it is common practice to use some Java dependencies in the model. Very common examples are LocalDate and LocalDateTime. As these types come from the Java language, they cannot be used in Multiplatform code.
Luckily, the Kotlin team implemented their versions of these types in pure Kotlin. They are available in the additional kotlinx-datetime library. All we need to do is replace the imports in our model.
Provide platform specific implementation for unsupported types
Not every Java type has its Kotlin equivalent. For instance, in many projects, I often find developers using the URL type. It is not present in Kotlin, but we can quickly address this by providing a platform-specific implementation using Kotlin’s expect/actual mechanism.
First, in the common source set of our KMP module, we define an expected URL class. In simple words, we say that we expect to have a URL class, but the actual implementation should be provided by the specific platform.
Then we go to the androidMain source set and implement this expectation using the Java URL type.
The same can be easily achieved on the iOS side using Apple’s NSURL type. We put the following code in the iosMain source set.
6. Migrate data sources
By migrating the model of the app, the team already has a solid foundation on how to work with Kotlin Multiplatform. Developers know how to put Kotlin code in the Multiplatform module, how to use it in the iOS app, and how to deal with platform-specific implementation using the expect/actual mechanism.
Now, we can move on to more complex parts of the application. Our data sources, both remote and local, are good candidates. Their migration strongly depends on the technology we use for backend communication and local storage. Below, I explain how to migrate several of the most popular solutions often found in Android applications.
REST
For REST communication, newer Android applications usually use the Ktor Client along with the Kotlin Serialization library. These are official tools from JetBrains, implemented in pure Kotlin, which makes them KMP compatible out of the box. When a project uses these tools, migrating to KMP is just about moving the networking code to the Multiplatform module.
However, there are still many Android projects that don’t use Ktor and rely on the Java-based Retrofit library combined with serializers like Moshi or Gson, which are also Java libraries. In that case, the only available option is to refactor your networking code to use Ktor and Kotlin Serialization. Luckily, this process is quite simple. After setting up the new libraries, it mainly involves changing annotations in your DTO models and refactoring Retrofit interfaces into Ktor classes.
GraphQL
Next to REST communication, more and more applications use GraphQL technology to exchange data with backend services. If your Android app is one of them, you most likely rely on the Apollo Kotlin library as a GraphQL client. The good thing is that this library is already pure Kotlin and fully KMP compatible. So, in this case, you can move all your networking code straight to the Multiplatform module.
Firebase
Some companies and teams decide not to build backend services on their own for their mobile products. Instead, they choose cloud solutions that offer configurable and ready-to-use backend platforms. The most popular choice in the mobile world is the Firebase platform from Google. We also use it for monitoring our apps with Crashlytics and Analytics.
The Firebase Android SDK is a Java library, so we can’t use it directly in the Multiplatform module. Google hasn’t migrated it to pure Kotlin yet, but there is an open-source Firebase Kotlin SDK that does the job. Its API is very similar to the original Firebase Android SDK, but includes the addition of modern Kotlin features like Coroutines and Serialization.
Jetpack DataStore and Room
A big advantage of mobile apps compared to web applications is their ability to operate without a network connection. We usually call this offline mode, and we use local storage solutions to save data received from the backend.
In the Android world, there are two solutions that are the most common choices for this task: Jetpack DataStore and Jetpack Room. Both of them are official libraries from Google, and both are KMP compatible. Thanks to this, we can stick to the same tools we already use and just move the implementation to the Multiplatform module.
Other local storage solutions
In case you are not interested in using Jetpack libraries for some reason, there are many other storage solutions that also work with Kotlin Multiplatform. Two of the most prominent ones are SQLDelight, which generates code from SQL files, and Realm, which is a NoSQL, object-oriented storage solution.
7. Migrate native APIs and SDKs
Next to backend and local storage, there is one more thing that native applications often communicate with – system APIs and some native third-party SDKs. Properly architected apps always hide these kinds of interactions behind some abstractions. A very common example is checking the internet connection. So, let’s say we have a simple NetworkConnection interface in our Android app.
This interface is implemented by the AndroidNetworkConnection class, which uses the ConnectivityManager API offered by the Android system.
Platform-specific code directly in Multiplatform module
The migration of such an implementation to KMP is pretty straightforward. We move the interface to the commonMain source set, while the Android-specific implementation goes to the androidMain source set. The only remaining part is providing an iOS-specific implementation.
KMP offers two-way interoperability with the iOS platform, meaning all the native APIs are accessible directly from Kotlin. We can create an IosNetworkConnection class in the iosMain source set and directly use network APIs offered by the iOS system.
Platform-specific code provided by native apps
The only challenge with the previous approach is that all the native APIs are accessible via their Objective-C implementation. This is why the syntax looks a bit strange in the example above. It works well for simple scenarios, but to integrate more complex APIs or SDKs, you can consider an alternative approach.
Instead of using the androidMain and iosMain source sets, we implement the NetworkConnection interface directly in the native applications. This means that we can use pure Swift to build the iOS specific code.
In this approach, a shared module only exposes an interface and allows native apps to provide their own implementation during initialization. We can easily achieve this by passing the implementation as a parameter to the init function of the SharedModule object.
8. Migrate business logic
Until now, we’ve only been migrating the outermost parts of the application to KMP. At this point, your native applications, for both mobile platforms, should not have their own backend communication, local storage, or interfaces/protocols for system APIs and external SDKs. All of these are shared by the Multiplatform module. We also migrated the model representing business objects in our application. Now it’s time for business logic.
The way an application’s logic is implemented in different projects may vary. Popular building blocks we often see are Repositories and Use Cases, but you may also find others like Managers, Services, Interactors, etc. If you followed the previous migration steps, moving the business logic to the shared module is pretty straightforward. All you need to do is move the code from the Android app to the commonMain source set of the shared module. All the components this logic uses, like interfaces for system APIs and external SDKs, or different data sources, are already there.
9. Migrate presentation logic
By migrating all the backend, storage, APIs, SDKs, and logic code, you should already be sharing around 50-60% of the code across Android and iOS applications. That is a pretty good number, but we can do even better. Our last step in the KMP migration is the presentation layer. There are a number of ways to implement it, but nowadays the vast majority of apps rely on ViewModel classes, so I’m going to show you how to make them work in KMP.
Use Jetpack ViewModel
In Android applications, our ViewModel classes usually inherit from the Jetpack ViewModel. This is an official solution from Google that offers valuable functionalities like access to the CoroutineScope and proper lifecycle handling. The good news is that this class was migrated to KMP by Google and can be used in Multiplatform code the same way we use it on Android. So moving our ViewModels to KMP is just about transferring the code to the shared module without any extra changes involved.
The lifecycle of the ViewModel is handled automatically on Android as our Activities, Fragments, and Destinations conform to the ViewModelStoreOwner interface. The same is not true for the iOS UIViewController and View, so we need to handle it ourselves. Luckily, the solution is quite simple. If you use SwiftUI and the UIHostingController, you can create a small wrapper class that implements the ViewModelStoreOwner interface and thus handles the lifecycle of the given ViewModel. It guarantees that the ViewModel’s CoroutineScope is canceled when the ViewModel is no longer needed.
Collect data Flow in the UI
On Android, our ViewModels expose streams of data to the UI. We usually use Coroutine Flow for this purpose. An example ViewModel with a list of products may look like this:
For Compose, nothing changes. We still collect this Flow using available functions like collectAsState. The situation looks a bit different in the iOS project. The SKIE plugin transforms Kotlin Flow into a Swift AsyncSequence. SwiftUI doesn’t have a built-in function to observe AsyncSequence, but we can create a simple ViewModifier to achieve that.
Using a dedicated extension, we can bind a selected Flow from the ViewModel to a specific @State property in the SwiftUI View.
Handle parcelable data
During the migration of the presentation layer, we may face a final challenge when dealing with parcelable data. On Android, there is no option to pass full objects as navigation arguments to other screens. Some applications handle this by passing only primitive types like IDs and loading full objects from the data layer. Other applications use a Parcelable mechanism, which serializes and deserializes objects during navigation.
This becomes problematic when we want to use the same approach with our Multiplatform models, as these Android dependencies cannot be used in the common Kotlin code. One option is to stop passing objects through navigation, but this would require extra refactoring, which might not be worth it in larger projects.
So, let’s say we want to stick to that solution but still keep our models in Multiplatform. This is where the expect/actual mechanism comes to the rescue. As a first step, we create our own expected definitions of the Parcelize annotation and Parcelable interface in the commonMain source set.
Then, in the androidMain source set, we implement these expectations. That way, when our common Kotlin code is compiled to the Android target, it uses the actual Android-specific implementations which we had before in the native app.
For iOS, we don’t need to implement the annotation, as we marked it as @OptionalExpectation. This mechanism is not applicable to interfaces, so we need to provide an actual one. We simply define an empty Parcelable interface in the iosMain source set, which will have no effect after compilation.
10. Migrate Unit Tests
There is one more part of the codebase that every good application has – Unit Tests. They are present in each layer we discussed earlier and may contain a significant amount of code. For Unit Testing on Android, we usually rely on the JUnit framework, alongside MockK for mocking, and an assertion library like Truth, AssertJ or Kotest Assertions.
Keep using Android specific tools at the beginning
JUnit, MockK, and Truth are Java-specific libraries. Even though some of them, like MockK, are written in Kotlin, they still rely on JVM mechanisms like reflection. This means we can’t use these tools in the commonTest source set of our Multiplatform module.
An effective option to start with is to move all the Unit Tests to the androidTest source set instead of the commonTest. This way, we have full access to all the Android-specific tools, so we don’t have to spend a lot of effort rewriting our Unit Tests with different ones.
Then gradually migrate to Kotlin tools
Later on, we can think about moving our Unit Tests to the commonTest source set, switching Java libraries to Multiplatform solutions. As a replacement for JUnit, Kotlin offers its own Kotlin Test framework, which has a very similar syntax but is less feature-rich, especially compared to JUnit5. Its simplicity might be considered a benefit, but if you are looking for a more advanced solution, there is an open-source Kotest framework, which was built specifically for Kotlin Multiplatform.
Replacing an assertion library shouldn’t be a problem. For projects which rely on Truth or AssertJ, I recommend trying the AssertK library. It has almost the same syntax and functions but is implemented in pure Kotlin. If the project uses Kotest Assertions, we don’t need to switch as this library is already Multiplatform, just like the Kotest Framework.
The hardest part is moving away from MockK, which has become a very popular and advanced solution in the Android world. There are several open-source mocking libraries that work with Kotlin Multiplatform, but none of them offer as many functionalities as MockK does, so I can’t really recommend them at this moment.
My personal preference in that case is to think about changing the testing style and moving from using mocks to using fakes as test doubles. Fakes are a recommended testing approach by Google and many other industry leaders. They behave like real implementations, are more reusable, promote the simplification of architecture, and most importantly, don’t require any third-party tool to create them.
Migrate native projects to KMP with Droids On Roids
At Droids On Roids, our Android and iOS developers collaborate seamlessly to build Kotlin Multiplatform code. With a comprehensive understanding of both native and cross-platform solutions, we ensure a smooth migration process to KMP. We know how to overcome common challenges with proven solutions and best practices. See our Kotlin Multiplatform app development services.
Want to team up? Let’s talk about your project!
Summary
If you have a native mobile app and want to improve its development or maintenance, as well as cut costs, you might have considered cross-platform solutions like Flutter or React Native. This usually involves hiring a new team of developers and completely rewriting the application – a significant and risky investment not suitable for all businesses.
Kotlin Multiplatform is a groundbreaking technology that bridges the gap between native and cross-platform development. It is the only cross-platform solution that seamlessly integrates with existing native applications, making it a much safer option than its competitors.
To convert to Kotlin Multiplatform, you don’t have to overhaul your tech stack but can continue using the existing native tools you already employ. All we need to do is gradually move more and more Kotlin code from the Android application to the Multiplatform module, integrate it into the iOS applicatioon, and remove the corresponding Swift implementation.
The complexity of this process strongly depends on how your application is written, how well it is architected, and how many Java-based tools you are using. Fortunately, for all these challenges, we have proven and battle-tested solutions, which we’ve discuss in this article. So, if you’ve read carefully, you should be well-prepared to start your migration to Kotlin Multiplatform.
About the author
Ready to take your business to the next level with a digital product?
We'll be with you every step of the way, from idea to launch and beyond!