You have an existing Android app. It works. Users like it. Now the team wants an iOS version. Rewriting from scratch in Swift takes months. Kotlin Multiplatform lets you share the business logic and add iOS on top, without rewriting everything.
In this final tutorial, we cover how to migrate an existing Android app to KMP. This is not a “rewrite from scratch” approach. It is a gradual migration — move one layer at a time, keep the Android app working at every step, and add iOS when the shared module is ready.
Before You Start: Assessment
Not every piece of code should be shared. Before you touch any code, make a list of what can move to commonMain and what should stay in Android.
What to Share
These are good candidates for the shared module:
- Domain models — data classes, enums, sealed classes
- Business logic — validation, calculations, formatting
- Networking — API clients (Retrofit to Ktor)
- Database — local storage (Room to SQLDelight)
- Preferences — settings and flags (SharedPreferences to DataStore KMP)
- ViewModels — state management and use cases
- Utilities — date formatting, string helpers, converters
What to Keep Native
These should stay in platform-specific code:
- UI — Compose stays on Android, SwiftUI on iOS
- Platform SDKs — Camera, GPS, Bluetooth, biometrics, NFC
- Third-party Android SDKs — Firebase, Google Play Services, Crashlytics
- Android-specific APIs — WorkManager, NotificationManager, ContentProvider
- Deep linking — each platform has its own deep link handling
The Decision Rule
Ask yourself: “Does this code depend on Android APIs?”
- No — move it to
commonMain - Yes, but I can abstract it — use
expect/actual - Yes, and it is deeply tied to Android — keep it in
androidMain
Step 1: Create the Shared Module
Add a shared module to your existing project. This is where all the shared code will live.
Update settings.gradle.kts
// settings.gradle.kts
include(":app") // Your existing Android app
include(":shared") // New shared module
Create shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
// Platform-specific dependencies
}
iosMain.dependencies {
// Platform-specific dependencies
}
}
}
android {
namespace = "com.yourapp.shared"
compileSdk = 36
defaultConfig { minSdk = 24 }
}
Add the Shared Module as a Dependency
In your existing Android app’s build.gradle.kts:
dependencies {
implementation(project(":shared"))
// ... existing dependencies
}
Now your Android app can use code from the shared module. The shared module is empty at this point. We will move code into it one piece at a time.
Step 2: Move Domain Models
Start with the simplest, lowest-risk migration: domain models. These are usually plain data classes with no Android dependencies.
Before (Android-only)
// app/src/main/java/com/yourapp/model/User.kt
data class User(
val id: Long,
val name: String,
val email: String,
val createdAt: Long
)
After (Shared)
// shared/src/commonMain/kotlin/com/yourapp/model/User.kt
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: Long,
val name: String,
val email: String,
val createdAt: Long
)
Move the file from app/ to shared/src/commonMain/. Update the package if needed. Add @Serializable if you plan to use the model for networking or database later.
Your Android app still compiles because it depends on shared. The import path stays the same (or you update it). This is a safe change — nothing breaks.
How Many Models to Move
Move all domain models at once. They usually have no dependencies on Android APIs, so this is a fast, low-risk step. If a model uses an Android-specific type (like android.os.Parcelable), leave it in the Android module for now and create a shared version without the Android annotation.
Step 3: Move Networking to Ktor
Most Android apps use Retrofit for networking. KMP does not support Retrofit because it depends on OkHttp (JVM-only). The replacement is Ktor Client.
Retrofit vs Ktor
| Feature | Retrofit | Ktor Client |
|---|---|---|
| Platform | JVM only | Android, iOS, Desktop, Web |
| Interface-based | Yes | No (function-based) |
| JSON parsing | Gson/Moshi | kotlinx-serialization |
| Interceptors | OkHttp interceptors | Ktor plugins |
| Coroutines | With adapter | Built-in |
Before (Retrofit)
// app/.../api/UserApi.kt
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Long): UserDto
@GET("users")
suspend fun getUsers(): List<UserDto>
}
After (Ktor)
// shared/src/commonMain/.../api/UserApiClient.kt
class UserApiClient(private val httpClient: HttpClient) {
private val baseUrl = "https://api.example.com"
suspend fun getUser(id: Long): UserDto =
httpClient.get("$baseUrl/users/$id").body()
suspend fun getUsers(): List<UserDto> =
httpClient.get("$baseUrl/users").body()
}
Migration Strategy
- Create the Ktor client in the shared module
- Add the same endpoints as your Retrofit interface
- Update the Android app to use the new Ktor client
- Remove the Retrofit dependency
- Run all tests to make sure nothing breaks
Do not try to migrate all endpoints at once. Start with one or two endpoints, verify they work, then migrate the rest.
HttpClient Configuration
Set up the HttpClient in a Koin module:
// shared/src/commonMain/.../di/NetworkModule.kt
val networkModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
}
}
}
singleOf(::UserApiClient)
}
Platform-specific engines are added in androidMain and iosMain:
// androidMain — uses OkHttp under the hood
// iosMain — uses URLSession under the hood
// No explicit engine config needed — Ktor picks the right one
Step 4: Move Database to SQLDelight
If your app uses Room, you have two options: (1) use Room’s KMP support (added in 2024, now stable for Android + iOS), or (2) migrate to SQLDelight which has been KMP-native from the start. SQLDelight is the more established choice for KMP projects.
Room vs SQLDelight
| Feature | Room | SQLDelight |
|---|---|---|
| Platform | Android only | Android, iOS, Desktop, Web |
| Approach | Annotated Kotlin classes | SQL-first (.sq files) |
| Queries | DAO annotations | Written in SQL |
| Migrations | Java/Kotlin migration classes | .sqm migration files |
| Flow support | Yes | Yes (with coroutines extension) |
Before (Room)
// Room Entity
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val email: String,
@ColumnInfo(name = "created_at") val createdAt: Long
)
// Room DAO
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<UserEntity>>
@Insert
suspend fun insert(user: UserEntity)
}
After (SQLDelight)
-- shared/src/commonMain/sqldelight/.../User.sq
CREATE TABLE UserEntity (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL
);
getAllUsers:
SELECT * FROM UserEntity;
insertUser:
INSERT INTO UserEntity (name, email, created_at) VALUES (?, ?, ?);
SQLDelight generates type-safe Kotlin code from these SQL files. The generated code works on all platforms.
Data Migration
If your app has existing users with data in Room, you need to handle the data migration:
- On the first launch after the update, read all data from Room
- Write it to the new SQLDelight database
- Delete the old Room database
// androidMain — one-time migration
class DatabaseMigrator(
private val context: Context,
private val sqlDelightDb: AppDatabase
) {
fun migrateFromRoom() {
val roomDb = Room.databaseBuilder(context, OldRoomDb::class.java, "old-db").build()
val oldUsers = roomDb.userDao().getAllUsersSync()
oldUsers.forEach { user ->
sqlDelightDb.userQueries.insertUser(user.name, user.email, user.createdAt)
}
context.deleteDatabase("old-db")
}
}
This migration code lives in androidMain because it uses Room (Android-only). iOS does not need it since there is no existing Room database on iOS.
Step 5: Move Business Logic
Business logic is the code between the UI and the data layer. It includes:
- Use cases / interactors
- Validation rules
- Data transformations
- State management (ViewModels)
Before (Android ViewModel)
// app/.../viewmodel/UserViewModel.kt
class UserViewModel(
private val userRepository: UserRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val users = userRepository.getAllUsers()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun createUser(name: String, email: String) {
viewModelScope.launch {
userRepository.createUser(name, email)
}
}
}
After (Shared ViewModel)
// shared/src/commonMain/.../domain/UserViewModel.kt
class UserViewModel(
private val userRepository: UserRepository
) : ViewModel() {
val users = userRepository.getAllUsers()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun createUser(name: String, email: String) {
viewModelScope.launch {
userRepository.createUser(name, email)
}
}
}
The main change: remove SavedStateHandle and other Android-specific dependencies. If your ViewModel uses SavedStateHandle, extract the saved state logic into the platform layer.
KMP supports ViewModel and viewModelScope in commonMain (via the Jetpack Lifecycle KMP artifact). This is a recent addition that makes migration much easier.
Step 6: Create the iOS App
Once the shared module has your models, networking, database, and business logic, create the iOS app.
Set Up the Xcode Project
In the KMP project, make sure the iOS framework builds:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Create a new Xcode project (SwiftUI App)
Add the shared framework:
- Drag
Shared.frameworkinto the project - Or use a Gradle task to copy it automatically
- Drag
Consume the Shared Module in SwiftUI
import Shared
struct ContentView: View {
@StateObject private var viewModel = UserViewModelWrapper()
var body: some View {
List(viewModel.users, id: \.id) { user in
Text(user.name)
}
}
}
The iOS app uses the same domain models, the same business logic, and the same data layer. Only the UI is new.
How Much iOS Code Do You Need?
For a typical app:
- SwiftUI views — rebuild the UI in SwiftUI (or use Compose Multiplatform for iOS)
- ViewModel wrappers — thin wrappers that bridge Kotlin Flow to SwiftUI’s ObservableObject
- Platform module — Koin module providing iOS-specific implementations (database driver, Ktor engine)
- App entry point — the SwiftUI
@mainstruct
The iOS app is smaller than the Android app because all the business logic is shared. You are only building the UI and the platform glue.
Common Pitfalls
1. Trying to Migrate Everything at Once
The biggest mistake is trying to move all code to the shared module in one step. This leads to a long, unstable branch and a lot of broken builds.
Instead: migrate one layer at a time. Domain models first, then networking, then database, then ViewModels. Keep the Android app working after each step.
2. Android-Specific Types Leaking Into Shared Code
Watch out for these Android-only types that cannot go into commonMain:
android.content.Contextandroid.os.Parcelableandroid.net.Urijava.io.Filejava.util.Date(usekotlin.time.Instantinstead)android.graphics.Bitmap
If your shared code needs these, create interfaces in commonMain and implement them with expect/actual.
3. Third-Party SDK Compatibility
Not all Android libraries have KMP equivalents:
| Android Library | KMP Replacement |
|---|---|
| Retrofit | Ktor Client |
| Room | SQLDelight |
| SharedPreferences | DataStore KMP |
| Hilt/Dagger | Koin |
| Gson/Moshi | kotlinx-serialization |
| Timber | Kermit |
| Glide/Coil | Coil (KMP version available) |
| Firebase | No direct equivalent (keep in androidMain, use wrapper) |
| Play Services | No equivalent (keep in androidMain) |
For libraries without KMP equivalents, keep them in androidMain and create expect/actual abstractions if iOS needs similar functionality.
4. Coroutine Scope Management
On Android, viewModelScope is tied to the Android lifecycle. In KMP, viewModelScope works in commonMain (via the Lifecycle KMP artifact), but you need to make sure coroutines are cancelled properly on iOS.
The FlowCollector helper we built in Part 15 handles this:
// commonMain
class FlowCollector<T>(
private val flow: Flow<T>,
private val onEach: (T) -> Unit
) : Closeable {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val job = scope.launch { flow.collect { onEach(it) } }
override fun close() { job.cancel() }
}
On iOS, you call close() in deinit to cancel the collection:
deinit {
collector?.close()
}
5. Build Time Increase
Kotlin/Native (iOS) compilation is slower than Kotlin/JVM (Android). Your CI build will take longer. To manage this:
- Cache Gradle and Konan directories in CI
- Use
linkDebugFrameworkIosSimulatorArm64for development (faster than release) - Consider building iOS only on push to main (not on every commit)
Migration Timeline
Here is a realistic timeline for a medium-sized Android app (50-100 screens):
| Week | What to Do |
|---|---|
| 1 | Create shared module, move domain models |
| 2-3 | Migrate networking from Retrofit to Ktor |
| 4-5 | Migrate database from Room to SQLDelight |
| 6-7 | Move business logic and ViewModels |
| 8 | Set up Koin DI in shared module |
| 9-10 | Build iOS app skeleton, connect to shared module |
| 11-14 | Build iOS UI screens in SwiftUI |
| 15-16 | Testing, bug fixes, polish |
For a smaller app (10-20 screens), you can cut this in half. The key is to keep shipping the Android app while migrating. Every step should be a merge-able PR that does not break the Android build.
Gradual Adoption Checklist
- Create shared module with
commonMain,androidMain,iosMain - Move domain models to
commonMain - Move or recreate networking in Ktor (
commonMain) - Move or recreate database in SQLDelight (
commonMain) - Move business logic / ViewModels to
commonMain - Set up Koin DI in shared module
- Handle data migration for existing Android users
- Build iOS shared framework
- Create iOS project in Xcode
- Build iOS UI with SwiftUI
- Set up CI/CD for both platforms
- Test on real devices (both platforms)
Source Code
Full source code for this tutorial: GitHub — tutorial-20-migration
Series Complete
This is the last tutorial in the KMP series. Over 20 tutorials, we went from “What is Kotlin Multiplatform?” to building a real cross-platform app with shared networking, database, business logic, and platform-native UI.
What You Learned
- Parts 1-5: KMP foundations — setup, project structure, Compose Multiplatform, framework comparison
- Parts 6-10: Core libraries — Ktor, SQLDelight, DataStore, Koin, shared ViewModel
- Parts 11-14: Architecture — clean architecture, navigation, testing, error handling
- Parts 15-18: Real app — planning, data layer, UI layer, publishing
- Parts 19-20: Advanced — desktop/web targets, migration from Android
Where to Go From Here
- Add more features to the notes app (tags, reminders, images)
- Try Compose Multiplatform for iOS (shared UI instead of SwiftUI)
- Add a desktop target and build a macOS version
- Explore Kotlin/Wasm for a web companion
- Migrate one of your existing Android apps using the strategy from this tutorial
The KMP ecosystem is growing fast. New libraries add multiplatform support regularly. The skills you built in this series will serve you well as the ecosystem matures.
- Previous: KMP Tutorial #19: KMP for Desktop and Web
- Series: KMP from Zero to Production