Migrating Applications to Kotlin Multiplatform: a step-by-step guide

In this article, I’ll share my experience and insights on migrating Android apps to Koltin Multiplatform (KMP).
KMP is becoming popular among Android developers. This technology allows you to run Kotlin code on Android, iOS, and many other mobile, web, and desktop platforms. With recent advancements, you can create a common Graphical user interface (GUI) using Compose Multiplatform.
However, what if you need to adapt an existing Android application to KMP? Is it feasible, efficient, or time-consuming? What challenges and limitations should you expect? Let’s explore the answers through my research.
Typical Android Project Structure
To demonstrate the migration process, I worked on a project that follows a standard Android architecture:
A standard Android setup:
- Architecture: MVVM (Model-View-ViewModel).
- Data Layer: Repositories interacting with DataStore.
- ViewModel: Directly uses repositories (no UseCase layer).
- UI: Built with XML, without Compose.
Libraries used:
- Koin: A library for dependency injection (DI).
- Coroutines: For performing asynchronous and multithreaded work.
- AndroidX DataStore: For local data storage.
- Retrofit: For network requests.
- Gson: For JSON parsing.
The main objective of the research was to assess the effort needed to migrate the app’s logic to KMP for further use on iOS. We decided to implement the graphical interface natively on iOS.
Setting Up The Environment
The Kotlin Multiplatform Wizard is a helpful tool for creating KMP projects. For this migration, I created a separate project and copied the “iosApp” folder (iOS application to which the KMP library is connected) and the “shared” module (KMP code) to the Android project.
Setup steps:
- Install the KMP Plugin in Android Studio to run iOS applications.
Note: This requires macOS and Xcode. - Ensure compatibility between Kotlin, Gradle, the Android Gradle Plugin (AGP), and Xcode versions.
- Install JetBrains Fleet, a preview IDE tailored for KMP development.
Running iOS app from Android Studio with KMP Plugin would look like this:
JetBrains Fleet features:
- Syntax highlighting and tips for both Kotlin and Swift.
- Easy navigation between Kotlin and Swift code.
- Debugger breakpoints that execute across both languages in the same debug session.
- User-friendly interface for those who dislike Xcode.
JetBrains Fleet view:
Migrating Dependency Injection (DI)
Koin is a DI library implemented in pure Kotlin and is compatible with KMP. You can declare the Koin dependency in commonMain.dependencies.
Since the shared module structure contains both common code and platform-specific code, you can declare a specific Android dependency for Koin:
shared/build.gradle.kts
androidMain.dependencies {
implementation("io.insert-koin:koin-android:3.4.3")
}
This allows you to use context injection in the Android part: shared/src/androidMain/kotlin
import org.koin.android.ext.koin.androidContext
internal actual fun getDataStoreModule(): Module = module {
single {
createDataStore(androidContext())
}
}
Besides Koin, there are other DI libraries for KMP.
However, you won’t find Dagger and Hilt among them. This may be inconvenient for Android developers.
Here is an article about it.
Migrating Local Data Storage
Some Jetpack libraries already support KMP, including DataStore, which we use in our Android app. That means we can use such libraries in KMP in the same manner as they are used in Android development.
Changes in AuthDataStoreImpl.
The AuthDataStoreImpl class in Android looked like this:
class AuthDataStoreImpl(
private val context: Application,
): AuthDataStore {
override suspend fun getBearerToken(): String? {
return context.dataStore.data.map { preferences ->
preferences[stringPreferencesKey(name = BEARER_TOKEN_KEY)]
}.firstOrNull()
}
override suspend fun setBearerToken(bearerToken: String) {
context.dataStore.edit { preferences ->
preferences[stringPreferencesKey(name = BEARER_TOKEN_KEY)] = bearerToken
}
}
After migration to the shared KMP module (commonMain), AuthDataStoreImpl looks like this:
internal class AuthDataStoreImpl (
private val dataStore: DataStore<Preferences>,
): AuthDataStore {
override suspend fun getBearerToken(): String? {
return dataStore.data.map { preferences ->
preferences[stringPreferencesKey(name = BEARER_TOKEN_KEY)]
}.firstOrNull()
}
override suspend fun setBearerToken(bearerToken: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(name = BEARER_TOKEN_KEY)] = bearerToken
}
}
The main change is that the constructor now takes DataStore, instead of Context. This is no longer Android but a KMP module, and it does not have the Android SDK.
However, the creation of AuthDataStoreImpl requires more changes. It should be created differently for Android and iOS.
Common logic for DataStore creation.
commonMain
private val pathToDataStore = mutableMapOf<String, DataStore<Preferences>>()
@OptIn(InternalCoroutinesApi::class)
private val lock = SynchronizedObject()
@OptIn(InternalCoroutinesApi::class)
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
synchronized(lock) {
val path = producePath()
val dataStore = pathToDataStore[path]
if (dataStore != null ) {
dataStore
} else {
PreferenceDataStoreFactory.createWithPath(produceFile = { path.toPath() })
.also { pathToDataStore[path] = it }
}
}
internal expect fun getDataStoreModule(): Module
- createDataStore function accepts a lambda producePath to determine the file path.
- The use of lock and pathToDataStore ensures that there is only one DataStore object per path. This is a requirement of the library for correct operation.
Note: getDataStoreModule function is declared as an expected function that returns a Koin module. We need to make actual implementations for each platform.
Android Implementation
androidMain
internal actual fun getDataStoreModule(): Module = module {
single {
createDataStore(androidContext())
}
}
private fun createDataStore(context: Context): DataStore<Preferences> = createDataStore(
producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)
- This snippet uses the createDataStore function from the commonMain folder, passing the file path from Context.
- Since this is the Android implementation, the Android SDK is available here.
iOS Implementation
iosMain
internal actual fun getDataStoreModule(): Module = module {
single {
createDataStore()
}
}
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
fun createDataStore(): DataStore<Preferences> = createDataStore(
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
}
)
The iOS implementation has the iOS SDK available. For example, NSFileManager in the form of Kotlin wrappers. So you can write your usual Kotlin code, but using the iOS SDK.
Summary of local storage migration to KMP
To migrate the local data storage to the KMP shared module the following steps should be followed:
- Replace Context with DataStore in the constructor. All other logic for writing and reading data remains unchanged.
- Set up platform-specific implementations for createDataStore.
Migrating Network Interaction
Network interaction is complex because popular Android libraries such as Retrofit and OkHttp are not compatible with KMP. Instead, Ktor is the primary HTTP library for KMP. Fortunately, Ktorfit allows us to migrate without completely rewriting our network logic.
What is Ktorfit?
From the documentation: “Ktorfit is an HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform (JavaScript, JVM, Android, iOS, Linux) using KSP and Ktor clients inspired by Retrofit“.
Migrating API Interfaces
Previously, the SandboxDashboard interface in Android used Retrofit:
import retrofit2.http.*
interface SandboxDashboard {
@POST("authenticate-user/")
suspend fun authenticateStep1(
@Header("Authorization") bearerToken: String,
@Header("App-Id") appId: String,
@Header("Device-Id") deviceId: String,
@Body authModel: AuthStep1Request,
): AuthStep1Response
}
To use it in KMP, we move it to a shared module and replace only the import from Retrofit with Ktorfit.
import de.jensklingenberg.ktorfit.http.*
interface SandboxDashboard {
@POST( "authenticate-user/" )
suspend fun authenticateStep1 (
@Header("Authorization") bearerToken: String,
@Header("App-Id") appId: String,
@Header("Device-Id") deviceId: String,
@Body authModel: AuthStep1Request,
): AuthStep1Response
}
No other changes are needed! The semantics remain the same.
Migrating API Client Creation
Retrieving API client objects differs between platforms.
This is how interface objects were obtained in Android (Retrofit + OkHttp):
private fun <T> getApiClient(baseUrl: String, api: Class<T>): T {
val httpClient = getOkHttpClient()
return Retrofit.Builder()
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(baseUrl)
.build()
.create(api)
}
private fun getOkHttpClient() : OkHttpClient {
val interceptor = HttpLoggingInterceptor()
val httpClient = OkHttpClient.Builder()
interceptor.level = HttpLoggingInterceptor.Level.BODY
if (BuildConfig.DEBUG) {
httpClient.addInterceptor(interceptor)
}
return httpClient.build()
}
Usage in Dependency Injection:
single {
getApiClient(
"YOUR_URL",
SandboxDashboard::class.java,
)
}
With KMP we use Ktor instead of OkHttp:
internal inline fun <T> getApiClient(baseUrl: String, createApi: Ktorfit.() -> T) : T {
val ktorfit = ktorfit {
baseUrl(baseUrl)
httpClient(HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(DefaultRequest) {
contentType(ContentType.Application.Json)
}
install(Logging) {
level = LogLevel.ALL
}
})
}
return ktorfit.createApi()
}
Usage in Dependency Injection is quite similar:
single {
getApiClient(
“YOUR_URL”,
Ktorfit::createSandboxDashboard,
)
}
Key differences in KMP implementation:
- Uses Ktor instead of OkHttp.
- Creates API clients differently using Ktorfit.
Migrating JSON Parsing
Android app uses the Gson library, which does not support KMP. So, you will have to migrate the data models to Kotlinx.Serialization.
Android
import com.google.gson.annotations.*
data class AuthStep1Request(
@SerializedName("email")
val email: String,
@SerializedName("password")
val password: String,
)
KMP
import kotlinx.serialization.*
@Serializable
data class AuthStep1Request(
@SerialName("email")
val email: String,
@SerialName("password")
val password: String,
)
Note: Only the annotations need to be updated.
Tip: If the project has many data models, in Android Studio you can quickly apply template changes using Command + Shift + R.
Summary of Network Migration to KMP
Let’s summarize:
- Ktorfit allows you to quickly migrate Retrofit interfaces.
- The method for creating API client interface objects needs to be rewritten in Ktor.
- It is necessary to migrate all data models from Gson to Kotlinx.Serialization.
Once these steps are complete, network calls in KMP are fully migrated.
Pure kotlin code migration
So now we have migrated local and network storage code that is dependent on 3rd party libraries.
If your project follows a repository pattern and there are UseCase-s too then you have to migrate these components as well.
Since repositories are pure Kotlin classes without dependencies on third-party libraries, they can be directly copied into a shared KMP module.
UseCase-s also should be pure Kotlin classes, but if some of them have dependency on 3rd party code, such dependencies should be wrapped into abstractions.
Workability on iOS
Let’s check the workability on iOS. The shared module is a regular Gradle module, so its use in an Android application does not require a detailed explanation. Therefore, we will focus on how to use migrated entities from the data layer in an iOS application.
Let’s make DI available on iOS:
shared/src/iosMain/kotlin/DiHelper.kt
class DiComponent : KoinComponent {
fun authRepository() = getKoin().get<AuthRepository>()
}
fun setupDi() {
startKoin {
modules(sharedModule)
}
}
The above code declares the DiComponent class, which is a Koin component. This allows calling the getKoin function inside it to obtain dependencies. A separate function, setupDi, initializes the Koin dependency graph.
Since this is a public Kotlin code in the iosMain folder, it can be called from the Swift code of your iOS app.
Here’s what the KMP code call looks like in SwiftUI:
iosApp/iosApp/ContentView.swift
struct ContentView: View {
let component: DiComponent
init() {
DiHelperKt.setupDi()
component = DiComponent()
}
var body: some View {
VStack(spacing: 20) {
Button (action: {
Task {
let result = try? await component.authRepository()
.authenticateStep1(email: "MAIL", password: "PASS")
print("message: " + (result?.message ?? "nil"))
}
}) {
Text("Start Async Action")
}
The init function initializes Koin and creates DiComponent. In the action callback, when the button is pressed, the AuthRepository is obtained and authenticateStep1 is called. Since authenticateStep1 is a suspend function in Kotlin, Swift needs to call it asynchronously. For this, you can use async/await in Swift.
Launch the iOS app, press the button, and see the network request logs. The logic from the data layer was successfully migrated.
Build Types, Flavors, and Build Config in KMP
Applications typically use separate environments for debugging, releasing, and testing. It is not customary to keep environment configurations in Kotlin code so that they cannot be obtained by reverse engineering methods. Android relies on buildConfig for environment configurations, but KMP doesn’t offer this capability out of the box.
Workaround: You can get something similar to buildConfig by using a third-party plugin called BuildKonfig. This plugin allows you to set different constant values for individual configurations in the KMP shared module.
KMP migration can be challenging for applications that require different resources or code for various build types. In this case, you will have to split specific code or resources into separate modules and connect them depending on the configuration. You can read a discussion about this issue.
Example: Setting Up BuildKonfig
shared/build.gradle.kts
buildkonfig {
packageName = "com.some.package"
val DASHBOARD_URL_SANDBOX = "your_sandbox_url"
val DASHBOARD_URL_PROD = "your_prod_url"
defaultConfigs {
buildConfigField(STRING, "DASHBOARD_URL", DASHBOARD_URL_SANDBOX)
}
defaultConfigs("debug") {
buildConfigField(STRING, "SHARED_TYPE", "debug")
}
defaultConfigs("qa") {
buildConfigField(STRING, "SHARED_TYPE", "qa")
}
defaultConfigs("release") {
buildConfigField(STRING, "SHARED_TYPE", "release")
buildConfigField(STRING, "DASHBOARD_URL", DASHBOARD_URL_PROD)
}
}
Migrating ViewModel to KMP
After migrating the data layer to KMP, the next step is to migrate the business and presentation logic. This project does not have a separate layer for business logic such as Use Cases or Interactors. Business logic entities should be written in pure Kotlin classes without dependencies on external libraries. Therefore, their migration involves copying from one module to another.
The presentation logic in ViewModel is a bit different. The base class androidx.lifecycle.ViewModel is a third-party dependency, but luckily, the androidx.lifecycle package supports KMP.
1. Declare the dependency in the shared module:
sourceSets {
commonMain.dependencies {
// ...
implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.4")
}
androidMain.dependencies {
implementation("io.insert-koin:koin-android:3.4.3")
}
}
2. You can safely copy the ViewModel into a shared module. Ensure that its dependencies (such as repositories or Use Cases) are already migrated.
3. ViewModel platform-specific instantiation implementations.
The logic for creating ViewModel objects varies across different platforms. Since Android has a lifecycle that ViewModel must adhere to, we define an expected function getNativeModule() in commonMain to allow platform-specific implementations.
- Common getNativeModule definition.
shared/src/commonMain/kotlin/di/Modules.kt
internal expect fun getNativeModule(): Module
val sharedModule = module {
includes(getNativeModule())
}
- Android getNativeModule implementation.
Use the Koin-Android ViewModel provider function:
shared/src/androidMain/kotlin/di/Modules.android.kt
internal actual fun getNativeModule(): Module = module {
viewModel { LoginViewModel(get(), get()) }
}
- iOS getNativeModule implementation.
For iOS, use the standard factory object creation:
shared/src/iosMain/kotlin/di/Modules.kt
internal actual fun getNativeModule(): Module = module {
factory { LoginViewModel(get(), get()) }
}
Note: You need to implement ViewModel scoping and cleanup manually for iOS, since iOS lacks a lifecycle to bind a ViewModel to. Here’s an example of how this can be done using Koin scopes. This may not be suitable for production, which requires a more robust solution.
4. Use Flow to get results from the ViewModel.
class LoginViewModel(
private val authRepository: AuthRepository,
private val exceptionHandler: ExceptionHandler,
) : ViewModel(), ExceptionProvider by exceptionHandler {
private val _onResult: MutableSharedFlow<Result> = MutableSharedFlow()
val onResult: SharedFlow<Result> = _onResult
When accessing onResult in Swift, its type does not retain the generic Result type. Swift code:
let result: Kotlinx_coroutines_coreSharedFlow = loginViewModel.onResult
This issue arises because Kotlin (KMP) code is converted to Objective-C for iOS use. Most modern iOS apps are written in Swift, so you need to call the converted Objective-C code from your Swift code. Converting Kotlin to Objective-C isn’t great because Kotlin has more advanced features than Objective-C. Another issue is the lack of generic types. Read the article on how to write Kotlin code for better compatibility with iOS.
The JetBrains team recognizes these challenges and plans to introduce direct Kotlin-to-Swift conversion in 2025, greatly improving interoperability. Until then, third-party solutions such as the Skie plugin can enhance the experience of using Kotlin code in Swift.
Integrating Skie:
- Add the following to your shared/build.gradle.kts:
plugins {
// …
id("co.touchlab.skie") version "0.8.4"
}
2. Clear the caches and rebuild the project. And now the onResult type in the Swift code is generic. Swift code:
let result: SkieSwiftSharedFlow<LoginViewModel.Result> = loginViewModel.onResult
3. With Skie, you can subscribe to Flow using Swift’s await for construct:
for await state in loginViewModel.onResult {
isLoading = state is LoginViewModel.ResultLoading
if let resultAuth = state as? LoginViewModel.ResultAuth {
// process resultAuth
}
}
Key Takeaways from the migration of ViewModel to the shared module:
- Migrating ViewModel code is fast thanks to the compatibility of Jetpack libraries with KMP.
- For iOS, manual cleanup of ViewModel is necessary due to the lack of a lifecycle familiar to Android developers. Consulting iOS developers for best practices is recommended.
- Compatibility issues between Kotlin with Swift can cause unpleasant surprises.
Error Handling
If your KMP code throws an unhandled error in your iOS app, it can be quite difficult to understand where and why it occurred, especially if there are no logs in the code.
Example of an error:
Important:
Even if you put try/catch around KMP function calls in Swift code, it may not save you from a crash. Therefore, you need to pay special attention to error handling in KMP code. That may lead to possible refactoring.
Conclusions
I have found the following advantages of migrating data and logic from an Android application to KMP:
1. KMP flexibility.
Unlike the most popular cross-platform frameworks like Flutter and React Native, KMP offers flexibility. You can choose the areas of your codebase to share across platforms:
- You can make a native GUI and keep all the application logic in KMP.
- You can also migrate only part of the logic if there is no time for refactoring.
- You can migrate to KMP iteratively: layer by layer, feature by feature.
2. Jetpack library compatibility and community support.
KMP has become stable relatively recently, but you can already see the development of the tooling and ecosystem. This already allows you to quickly find solutions to many problems.
3. Simplicity.
There’s no need to learn a new programming language or a new framework like RN or Flutter. That means KMP should be more attractive for business and management as there is no need to hire additional team members with specific skills in additional languages and frameworks.
Potential drawbacks:
1. Library compatibility.
Not all libraries support KMP. While some, like Ktorfit, are built to facilitate migration, you may find others that aren’t.
2. Different IDEs.
Since Fleet is not yet stable enough, I had to frequently jump between 3 IDEs: Android Studio, XCode, and Fleet, which can be distracting.
3. Common risks.
There are several challenges, including:
- Lack of support for separating logic for flavors and build types.
- Compatibility issues between Kotlin and Swift.
- Potential error handling problems.
These are all problems that have solutions, but it takes time. Therefore, it is almost impossible to make a forecast estimate of how long the migration to KMP will take. I came across articles where teams took 3 times longer than planned.
This is risky for projects with tight deadlines.
The impact of KMP on Android development is obvious
In my opinion, you can make Android applications and already try to use libraries and architecture compatible with KMP. If necessary, you would be able to reuse parts of the application across different platforms, minimizing additional efforts. For Android applications, this does not create significant overhead. On the contrary, KMP allows you to improve the quality of the architecture due to a clearer separation of logic into layers.
Recommendations for New Projects
To ease migration, consider the following steps at the start of a new project:
- Use Ktor (Ktorfit) instead of OkHttp (Retrofit).
- Use Koin or other KMP DI libraries.
- Use KMP-compatible libraries (Jetpack, for data storage, for logging, for JSON parsing, and so on).
- Create a multi-module architecture. Do NOT create modules with logic or data handling as an Android library. Instead, create them as Java modules or KMP right away, even if it is not needed yet.
- Use kts scripts with Gradle instead of Groovie
- Do not separate code or resources by flavors or build types. Instead, create separate modules and connect them according to configuration in properties or environment variables.
If you are working on an existing Android project without KMP support, these steps might be too radical. It would be better to stick to the already-established best practices.
Check out the repository for an example of Android to KMP migration.