DEV Community

Cover image for Run Your MVVM Android App on iOS — KMP Magic ✨
Khush Panchal
Khush Panchal

Posted on • Originally published at Medium

Run Your MVVM Android App on iOS — KMP Magic ✨

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 app

  • Setup 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 IOS

  • Final 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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Clone the repo (master branch)

  2. Generate an API key from https://www.omdbapi.com/

  3. Add the API key inside Const.kt

  4. 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
Enter fullscreen mode Exit fullscreen mode
  • 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 implementation

Whenever 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.

PR — Android → KMP conversion

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
Enter fullscreen mode Exit fullscreen mode

to:

shared/src/commonMain/kotlin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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, ...)
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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>(); }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Now we will create the instance inside SharedModule.kt

val sharedModule = module {
  single<DatabaseService> {
        MovieDatabaseService(
            getDatabaseBuilder()
                .setDriver(BundledSQLiteDriver())
                .setQueryCoroutineContext(Dispatchers.IO)
                .build()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }

}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Contact Me: LinkedIn, Twitter

Happy coding ✌️

Top comments (0)