In this article, we’ll take an existing Android MVVM app and gradually convert it into a Kotlin Multiplatform (KMP) app — without rewriting the business logic.
The goal is simple:
Share business logic (repository, networking, database) across Android and iOS, while keeping UI native.
Out of scope for this article
To keep things simple and focused, we will not cover:
Common UI (Compose Multiplatform)
Common ViewModel
Publishing shared module remotely (KMMBridge / SPM)
We want clarity first, not complexity.
Outline of our article
Android only MVVM app (Starting point)
- Feature of sample app
- Basic outline of android project
- How data flows (mental model)
- Clone and run the android appSetup the KMP world
- What’s important from the official guide (summary)Beginning the KMP magic
- Step 1: Move data folder to the shared module
- Step 2: Replace Hilt with Koin (Multiplatform DI)
- Step 3: Replace Retrofit with Ktor
- Step 4: Replace Room with Room KMP
- Step 5: Create UI and ViewModel alternative for IOSFinal words
Android only MVVM app (Starting point)
We begin with an Android-only MVVM application that follows a clean and well-structured architecture.
Source code (master branch) — Link
Features of the sample app
Movies app using OMDb free API
Offline-first architecture (Database acts as a single source of truth, refer MovieRepository.kt file for implementation)
Two screens: Movie list screen (Uses MovieViewModel and shows a list of movies), Movie detail screen (simple UI, no ViewModel)
Uses: Retrofit → Networking; Room → Local caching; Hilt → Dependency Injection; Jetpack Compose → UI
Basic outline of the android project
└── com.khush.moviesapp
├── data
│ ├── database
│ │ ├── DatabaseService.kt
│ │ ├── MovieDao.kt
│ │ ├── MovieDatabase.kt
│ │ └── MovieDatabaseService.kt
│ ├── model
│ │ └── MovieData.kt
│ ├── network
│ │ └── ApiInterface.kt
│ ├── repository
│ │ └── MovieRepository.kt
│ └── utils
│ └── Const.kt
├── di
│ └── module
│ └── ApplicationModule.kt
├── ui
│ ├── viewmodel
│ │ ├── MovieViewModel.kt
│ │ └── UIState.kt
│ ├── DetailScreen.kt
│ ├── MovieActivity.kt
│ └── MovieScreen.kt
└── MovieApplication.kt
How data flows (mental model)
UI observes ViewModel
ViewModel talks to Repository
Repository decides: fetch from DB or fetch from API
UI reacts to changes in UIState
This mental model is important — we’ll preserve it even after KMP.
Clone and run the Android app
Before touching KMP, always verify the base app works.
Clone the repo (master branch)
Generate an API key from https://www.omdbapi.com/
Add the API key inside Const.kt
Run the Android app
We should see the movie list screen working correctly.
Setup up the KMP world
Before converting our Android MVVM app to a KMP MVVM app, we must ensure the KMP environment is correctly set up.
We will follow the official Kotlin guide for integrating KMP into an existing Android app — https://kotlinlang.org/docs/multiplatform/multiplatform-integrate-in-existing-app.html
You can follow this guide end-to-end. At this stage:
- Nothing meaningful will be shown on iOS yet
- Our goal is only to ensure the project builds and runs on both platforms
What’s important from the official guide (summary)
- Create a shared module for cross-platform code — Link - Once created, we’ll see three important folders:
- commonMain - common kotlin code, accessible by both android and ios
- androidMain - android specific implementations, can use android sdk
- iosMain - ios specific implementations, can use ios platform apis
- Add code to the shared module — Link - This allows Android code to access shared Kotlin code. We can see the use of expect/actual in the example, let’s understand the basics
KMP allows us to declare what we need in common code, and define how it works per platform.
- expect class — written in commonMain — method declaration without implementation
- actual class — written in androidMain or iosMain with platform specific implementationWhenever common code needs different behavior on Android and iOS, we use expect/actual.
Add a dependency on the shared module to your Android application — Link
Now we will run our cross-platform application on Android
Make your cross-platform application work on iOS — Link
- Follow all four steps and finally verify that shared module can be imported in Swift and iOS app builds and runs successfully
Optional (but recommended): Install Kotlin Multiplatform plugin
Android Studio Settings → Plugins → Marketplace → Kotlin Multiplatform
This helps with: Environment checks, Project configuration, Boilerplate generation (We can do KMP without the plugin, but it smoothens the experience)
Beginning the KMP magic ✨
At this point:
• Android app works
• iOS app builds
• Shared module is ready
• KMP pipeline is validated
Now we are finally ready to convert our Android MVVM app into a KMP MVVM app.
At any point, you can refer to the full conversion Pull Request for comparison.
Highlights of what we will be doing
Moving our data folder from app module to shared module
Replace Android only libraries to KMP compatible libraries
- Hilt → Koin (Dependency Injection)
- Retrofit → Ktor (Networking) (Additionally Gson → Kotlin Serialization)
- Room → Room KMP (Database)Create native UI and ViewModel in swift for IOS
Step 1: Move data folder to the shared module
The data layer is the most natural candidate for sharing.
What we move
Move the entire data package from:
app/src/main/java
to:
shared/src/commonMain/kotlin
This includes: database, network, repository, model, utils
At this point, the project will break — this is expected.
We’ll fix things step by step.
Clean up Android-only dependencies
Since data logic is now shared, we must remove Android-only dependencies from app.
Remove from app/build.gradle.kts: Hilt, Retrofit, Room, kapt (we’ll use KSPinstead)
Step 2: Replace Hilt with Koin (Multiplatform DI)
Hilt is Android-only, so we replace it with Koin, which works on both Android and iOS.
Remove all Hilt annotations
@HiltViewModel, @AndroidEntryPoint, @HiltAndroidApp, @Inject constructor
Also delete: ApplicationModule.kt (Hilt module)
Create Android-specific Koin module in App module folder, and Shared module in commonMain folder.
#AppModule.kt
val appModule = module {
viewModel {
MovieViewModel(
movieRepository = get()
)
}
}
###################################
#SharedModule.kt
val sharedModule = module {
single<MovieRepository> {
MovieRepository(
apiInterface = get(),
databaseService = get()
)
}
}
Now we will add koin related dependencies
#libs.version.toml
koin-bom = "4.1.1"
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { module = "io.insert-koin:koin-android" }
androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" }
###################################
#App Module build.gradle.kts
dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.android)
}
###################################
#Shared Module build.gradle.kts
commonMain {
dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
}
}
Sync Gradle and then update MovieApplication.kt and MovieActivity.kt files
#MovieApplication.kt
class MovieApplication: Application() {
override fun onCreate() {
super.onCreate()
// Initialize Koin on Android
startKoin {
androidContext(this@MovieApplication)
modules(listOf(appModule, sharedModule))
}
}
}
###################################
#MovieActivity.kt
class MovieActivity : ComponentActivity() {
// Inject ViewModel
private val movieViewModel: MovieViewModel by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen(movieViewModel = movieViewModel, ...)
...
}
}
Step 3: Replace Retrofit with Ktor
Retrofit is Android-only.
We replace it with Ktorfit (built upon Ktor), which gives us a Retrofit-like API but works on KMP.
To do it we will add Ktor, Ktorfit and Kotlin Serialization dependency (We will replace with Gson used in android project). We will also need KSP for this.
#libs.version.toml
ktor = "3.3.3"
ktorfit = "2.7.2"
ksp = "2.3.4"
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktorfit-lib = {module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
###################################
#Project level build.gradle.kts
alias(libs.plugins.ksp) apply false
alias(libs.plugins.ktorfit) apply false
alias(libs.plugins.kotlinxSerialization) apply false
###################################
#Shared module build.gradle.kts
alias(libs.plugins.ksp)
alias(libs.plugins.ktorfit)
alias(libs.plugins.kotlinxSerialization)
commonMain {
dependencies {
// Ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization)
// Ktorfit
implementation(libs.ktorfit.lib)
}
We will use the same ApiInterface.kt class just replace the imports
#ApiInterface.kt
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Query
interface ApiInterface {
@GET(".")
suspend fun getMoviesData(
@Query("s") s: String = "army",
@Query("apikey") apikey: String = Const.API_KEY
): ApiResponse
}
We will add the dependency in shared module class for Ktorfit
#SharedModule.kt
val sharedModule = module {
single<ApiInterface> {
val ktorfit = Ktorfit
.Builder()
.baseUrl(Const.BASE_URL)
.httpClient(
HttpClient {
install(ContentNegotiation) {
json(
Json {
isLenient = true
ignoreUnknownKeys = true
prettyPrint = true
}
)
}
}
)
.build()
ktorfit.createApiInterface()
}
}
To use Kotlin Serialization, inside MovieData.kt replace @Serializable with @SerialName, add @Serializable over data classes
Step 4: Replace Room with Room KMP
We will now replace room with KMP supported Room version.
We will add the dependency for room and all necessary configurations.
#libs.version.toml
room = "2.8.4"
sqlite = "2.6.2"
koin-bom = "4.1.1"
startupRuntime = "1.2.0"
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" }
[plugins]
androidx-room = { id = "androidx.room", version.ref = "room" }
###################################
#Project level build.gradle.kts
alias(libs.plugins.androidx.room) apply false
###################################
#Shared Module build.gradle.kts
alias(libs.plugins.androidx.room)
commonMain {
dependencies {
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
}
androidMain {
dependencies {
implementation(libs.androidx.startup.runtime)
}
}
room {
schemaDirectory("$projectDir/schemas")
}
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosSimulatorArm64", libs.androidx.room.compiler)
add("kspIosX64", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
}
###################################
#proguard-rules.pro
-keep class * extends androidx.room.RoomDatabase { <init>(); }
We need to modify MovieDatabase.kt class definition
#MovieDatabase.kt
@Database(entities = [MovieData::class], version = 1, exportSchema = false)
@ConstructedBy(MovieDatabaseConstructor::class)
abstract class MovieDatabase : RoomDatabase() {
abstract fun getMovieDao(): MovieDao
}
// The Room compiler generates the `actual` implementations.
@Suppress("KotlinNoActualForExpect")
expect object MovieDatabaseConstructor : RoomDatabaseConstructor<MovieDatabase> {
override fun initialize(): MovieDatabase
}
Now we will create the instance inside SharedModule.kt
val sharedModule = module {
single<DatabaseService> {
MovieDatabaseService(
getDatabaseBuilder()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
)
}
}
You can see getDatabaseBuilder() which is needed for Room to create Database instance, we need to create the RoomDatabase.Builder instance separately for platform specific modules (android and ios).
Before creating expect actual method for database builder, we need app context for creating it for android platform module, we will create it using app startup library that we have added in dependency in *androidMain*module
#androidMain folder AndroidManifest.xml file
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.khush.shared.data.database.AppContextInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>
###################################
#AppContextInitializer.kt
class AppContextInitializer: Initializer<Context> {
companion object {
lateinit var applicationContext: Context
}
override fun create(context: Context): Context {
applicationContext = context.applicationContext
return context
}
override fun dependencies(): List<Class<out Initializer<*>?>?> {
return emptyList()
}
}
Now let’s create Database builder instance
#MovieDatabase.kt
expect fun getDatabaseBuilder(): RoomDatabase.Builder<MovieDatabase>
###################################
#MovieDatabase.android.kt
actual fun getDatabaseBuilder(): RoomDatabase.Builder<MovieDatabase> {
val appContext = AppContextInitializer.applicationContext
val dbFile = appContext.getDatabasePath("main_db.db")
return Room.databaseBuilder<MovieDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}
###################################
#MovieDatabase.ios.kt
actual fun getDatabaseBuilder(): RoomDatabase.Builder<MovieDatabase> {
val dbFilePath = documentDirectory() + "/main_db.db"
return Room.databaseBuilder<MovieDatabase>(
name = dbFilePath,
)
}
@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
return requireNotNull(documentDirectory?.path)
}
That’s it, rest room structure remains the same, no changes in DAO or Entity class.
Run Android app: Congrats, Android app now runs fully using KMP shared module
Let’s run it on IOS now.
Step 5: Create UI and ViewModel alternative for IOS
We will first add Skie dependency. Skie library helps between Kotlin and Swift interoperability.
Skie allows Kotlin Flow → Swift async/await.
#libs.version.toml
skie = "0.10.9"
[plugins]
skie = { id = "co.touchlab.skie", version.ref = "skie" }
###################################
#Project level build.gradle.kts
alias(libs.plugins.skie) apply false
###################################
#Shared Module build.gradle.kts
alias(libs.plugins.skie)
We need to start koin and access movie repository from ios swift files, we will create a mediator class in iosMain folder inside shared module that will do the work, we can directly access it from Swift file
#SharedIosInterop.kt
object SharedIosInterop: KoinComponent {
val movieRepository: MovieRepository by inject()
fun initKoin() {
startKoin {
modules(sharedModule)
}
}
}
Let’s create the UI and ViewModel for IOS in iosApp folder. Delete existing Swift file and create the below files.
#iosApp.swift
import SwiftUI
import sharedKit
@main
struct iosAppApp: App {
private let shared = SharedIosInterop()
// Initialize Koin on app start
init() {
shared.doInitKoin()
}
var body: some Scene {
WindowGroup {
MovieScreen(
vm: MovieViewModel(repo: shared.movieRepository)
)
}
}
}
###################################
#MovieScreen.swift
struct MovieScreen: View {
@StateObject var vm: MovieViewModel
var body: some View {
NavigationStack {
content
}
}
@ViewBuilder
private var content: some View {
switch vm.state {
case .loading:
ProgressView()
case .failure(let msg):
VStack(spacing: 12) {
Text(msg).multilineTextAlignment(.center)
Button("Retry") { vm.start() }
}
.padding()
case .success(let movies):
List {
ForEach(movies, id: \.title) { movie in
NavigationLink {
DetailScreen(title: movie.title, poster: movie.poster)
} label: {
MovieRow(movie: movie)
}
}
}
}
}
}
struct MovieRow: View {
let movie: MovieData
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: URL(string: movie.poster)) { phase in
switch phase {
case .empty:
Rectangle().opacity(0.2)
case .success(let image):
image.resizable().scaledToFill()
default:
Rectangle().opacity(0.2)
}
}
.frame(width: 80, height: 80)
.clipped()
Text(movie.title)
.font(.headline)
Spacer()
}
.padding(.vertical, 6)
}
}
###################################
#MovieViewModel.swift
import Foundation
import Combine
import sharedKit
@MainActor
final class MovieViewModel: ObservableObject {
enum ScreenState {
case loading
case success([MovieData])
case failure(String)
}
@Published private(set) var state: ScreenState = .loading
private let repo: MovieRepository
private var task: Task<Void, Never>?
init(repo: MovieRepository) {
self.repo = repo
start()
}
func start() {
task?.cancel()
state = .loading
task = Task {
do {
for try await value in repo.getMoviesData() {
let movies = (value as? [MovieData]) ?? []
if movies.isEmpty {
state = .failure("Empty Data")
} else {
state = .success(movies)
}
}
} catch {
state = .failure(error.localizedDescription)
}
}
}
deinit {
task?.cancel()
}
}
###################################
#DetailScreen.swift
import SwiftUI
struct DetailScreen: View {
let title: String
let poster: String
var body: some View {
VStack(spacing: 16) {
Text(title).font(.title2)
AsyncImage(url: URL(string: poster)) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image.resizable().scaledToFit()
default:
Text("Image load failed")
}
}
}
.padding()
}
}
We will add few files on .gitignore.
#.gitignore
shared/build
xcuserdata
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
Great, now run the IOS project it should run the MVVM app in IOS using shared module
Finally after all, your structure should look something like this
├── app
│ ├── src
│ │ ├── main
│ │ │ ├── java
│ │ │ │ └── com.khush.moviesapp
│ │ │ │ ├── di.module
│ │ │ │ │ └── AppModule.kt
│ │ │ │ ├── ui
│ │ │ │ │ ├── viewmodel
│ │ │ │ │ │ ├── MovieViewModel.kt
│ │ │ │ │ │ └── UIState.kt
│ │ │ │ │ ├── DetailScreen.kt
│ │ │ │ │ ├── MovieActivity.kt
│ │ │ │ │ └── MovieScreen.kt
│ │ │ │ └── MovieApplication.kt
│ │ │ └── AndroidManifest.xml
│ ├── build.gradle.kts
│ └── proguard-rules.pro
├── gradle
│ └── libs.versions.toml
├── iosApp
│ ├── iosApp
│ │ ├── DetailScreen.swift
│ │ ├── IosApp.swift
│ │ ├── MovieScreen.swift
│ │ └── MovieViewModel.swift
│ └── iosApp.xcodeproj
├── shared
│ ├── src
│ │ ├── androidMain
│ │ │ ├── kotlin
│ │ │ │ └── com.khush.shared
│ │ │ │ └── data
│ │ │ │ └── database
│ │ │ │ ├── AppContextInitializer.kt
│ │ │ │ └── MovieDatabase.android.kt
│ │ │ └── AndroidManifest.xml
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ └── com.khush.shared
│ │ │ ├── data
│ │ │ │ ├── database
│ │ │ │ │ ├── DatabaseService.kt
│ │ │ │ │ ├── MovieDao.kt
│ │ │ │ │ ├── MovieDatabase.kt
│ │ │ │ │ └── MovieDatabaseService.kt
│ │ │ │ ├── model
│ │ │ │ │ └── MovieData.kt
│ │ │ │ ├── network
│ │ │ │ │ └── ApiInterface.kt
│ │ │ │ ├── repository
│ │ │ │ │ └── MovieRepository.kt
│ │ │ │ └── utils
│ │ │ │ └── Const.kt
│ │ │ └── di
│ │ │ └── SharedModule.kt
│ │ └── iosMain
│ │ └── kotlin
│ │ └── com.khush.shared
│ │ ├── data
│ │ │ └── database
│ │ │ └── MovieDatabase.ios.kt
│ │ └── interop
│ │ └── SharedIosInterop.kt
│ └── build.gradle.kts
├── build.gradle.kts
└── settings.gradle.kts
Final words
Congratulations! Now we are able to run same MVVM app on IOS and it is just the beginning, this base understanding will help you integrate more complex project into IOS later.
Source code (master-multiplatform branch) — Link
Check out the pull request for the conversion for better understanding — Link
Happy coding ✌️

Top comments (0)