Pavan Rangani

HomeBlogKotlin Multiplatform Shared ViewModel Architecture for Android and iOS

Kotlin Multiplatform Shared ViewModel Architecture for Android and iOS

By Pavan Rangani · April 7, 2026 · Mobile Development

Kotlin Multiplatform Shared ViewModel Architecture for Android and iOS

KMP Shared ViewModel: Unifying Android and iOS Business Logic

KMP shared ViewModel architecture enables teams to write business logic once and share it between Android (Jetpack Compose) and iOS (SwiftUI) applications. By moving ViewModels into the shared Kotlin Multiplatform module, you eliminate duplicate state management while keeping platform-native UI layers. Therefore, bug fixes and feature updates apply to both platforms simultaneously, instead of being implemented twice and drifting apart over time.

The key challenge is bridging Kotlin coroutines with Swift’s async/await and adapting Kotlin’s StateFlow to SwiftUI’s observable patterns. Moreover, dependency injection must work across the shared/platform boundary. Consequently, a well-designed KMP ViewModel architecture requires careful abstraction of platform differences without sacrificing native developer experience on either side. Get the boundary right and each team keeps the idioms they know; get it wrong and you trade duplicated logic for a leaky, frustrating bridge.

KMP Shared ViewModel Architecture: Core Pattern

Define ViewModels in the shared module using Kotlin coroutines and StateFlow. The shared ViewModel owns state and business logic, while platform-specific wrappers adapt the API for each UI framework. Furthermore, expect/actual declarations handle platform-specific implementations such as analytics, secure storage, and logging, so the common code can call a clean interface without knowing which platform fulfills it.

// shared/src/commonMain/kotlin/viewmodel/ProductListViewModel.kt
class ProductListViewModel(
    private val productRepository: ProductRepository,
    private val analyticsTracker: AnalyticsTracker,
) {
    private val _state = MutableStateFlow(ProductListState())
    val state: StateFlow = _state.asStateFlow()

    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

    fun loadProducts(category: String? = null) {
        scope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            try {
                val products = productRepository.getProducts(category)
                _state.update { it.copy(
                    products = products,
                    isLoading = false
                )}
                analyticsTracker.trackEvent("products_loaded",
                    mapOf("count" to products.size.toString()))
            } catch (e: Exception) {
                _state.update { it.copy(
                    isLoading = false,
                    error = e.message ?: "Failed to load products"
                )}
            }
        }
    }

    fun toggleFavorite(productId: String) {
        scope.launch {
            productRepository.toggleFavorite(productId)
            _state.update { state ->
                state.copy(products = state.products.map { product ->
                    if (product.id == productId)
                        product.copy(isFavorite = !product.isFavorite)
                    else product
                })
            }
        }
    }

    fun onCleared() { scope.cancel() }
}

data class ProductListState(
    val products: List = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
)

Notice that the entire screen is described by a single immutable ProductListState. This unidirectional flow — events in, one state object out — is what makes the ViewModel portable. Because state is a plain data class with no platform types, both Android and iOS can render it directly, and tests can assert against it without any UI at all.

KMP shared ViewModel development for mobile
Shared ViewModels contain business logic once, consumed by both Android and iOS UIs

Android Integration with Jetpack Compose

On Android, the shared ViewModel slots in almost as if it were native. You collect the StateFlow with collectAsStateWithLifecycle, which respects the Compose lifecycle and stops collecting when the screen is not visible. Additionally, Koin or Kodein provides dependency injection that spans the shared and Android modules, so the same repository wiring serves both. Many teams wrap the shared instance in an AndroidX ViewModel to inherit configuration-change survival and the standard viewModelScope.

// androidApp/src/main/kotlin/ui/ProductListScreen.kt
@Composable
fun ProductListScreen(
    viewModel: ProductListViewModel = koinInject(),
    onProductClick: (String) -> Unit,
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    DisposableEffect(Unit) {
        viewModel.loadProducts()
        onDispose { viewModel.onCleared() }
    }

    when {
        state.isLoading -> LoadingIndicator()
        state.error != null -> ErrorMessage(
            message = state.error!!,
            onRetry = { viewModel.loadProducts() }
        )
        else -> LazyColumn {
            items(state.products) { product ->
                ProductCard(
                    product = product,
                    onFavoriteClick = { viewModel.toggleFavorite(product.id) },
                    onClick = { onProductClick(product.id) },
                )
            }
        }
    }
}

iOS Integration with SwiftUI

On iOS, the work is to turn a Kotlin StateFlow into something SwiftUI can observe. You create an ObservableObject wrapper that subscribes to the flow and republishes each new state through @Published. Libraries like SKIE or KMP-NativeCoroutines automate the Flow-to-AsyncSequence bridge, which is the single most important ergonomic decision on the iOS side. Without one of them, Kotlin’s suspend functions surface as completion-handler callbacks and Flows are awkward to consume; with one, they read like idiomatic Swift concurrency.

// iosApp/Sources/ViewModels/ProductListObservable.swift
import shared
import KMPNativeCoroutinesAsync

@MainActor
class ProductListObservable: ObservableObject {
    @Published var state = ProductListState(
        products: [], isLoading: false, error: nil)

    private let viewModel: ProductListViewModel

    init(viewModel: ProductListViewModel) {
        self.viewModel = viewModel
        Task { await observeState() }
    }

    private func observeState() async {
        do {
            let sequence = asyncSequence(for: viewModel.stateFlow)
            for try await newState in sequence {
                self.state = newState
            }
        } catch { print("State observation error: \(error)") }
    }

    func loadProducts() { viewModel.loadProducts(category: nil) }
    func toggleFavorite(id: String) { viewModel.toggleFavorite(productId: id) }
    deinit { viewModel.onCleared() }
}

// SwiftUI View
struct ProductListView: View {
    @StateObject private var observable: ProductListObservable

    init() {
        _observable = StateObject(wrappedValue:
            ProductListObservable(viewModel: KoinHelper().productListViewModel))
    }

    var body: some View {
        Group {
            if observable.state.isLoading {
                ProgressView()
            } else {
                List(observable.state.products, id: \.id) { product in
                    ProductRow(product: product) {
                        observable.toggleFavorite(id: product.id)
                    }
                }
            }
        }.onAppear { observable.loadProducts() }
    }
}
Cross-platform mobile app development
SwiftUI observes shared ViewModel state through an ObservableObject wrapper

Threading, Memory, and the Sharp Edges

The pattern is clean on paper, yet a few platform realities deserve respect. First, threading: the shared ViewModel above pins its scope to Dispatchers.Main, which keeps state updates on the UI thread on both platforms. On iOS that matters because SwiftUI requires updates on the main actor, which is why the observable wrapper is annotated @MainActor. Mismatched dispatchers are a common source of crashes that only appear on one platform.

Second, lifecycle. Kotlin/Native uses its own memory model, so an ObservableObject that subscribes in init must cancel in deinit; otherwise the Flow collection leaks and the ViewModel never releases. Calling onCleared() from deinit, as shown above, ties the shared scope to the Swift object’s lifetime. Configuration changes on Android add a wrinkle too: if you do not host the shared instance inside an AndroidX ViewModel, a screen rotation tears down and recreates it, discarding in-flight loads. Hosting it correctly means the coroutine scope survives rotation, which is usually what users expect.

Third, value mapping. Enums and sealed classes from Kotlin can surface awkwardly in Swift, where they lose exhaustive when checks and arrive as opaque types, so it often pays to expose simple state shapes rather than deep generic hierarchies across the boundary. Similarly, default arguments do not cross the bridge — Swift sees every parameter as required — which is why the wrapper above passes category: nil explicitly. Keeping the shared API small and concrete, rather than clever, is the single most reliable way to keep both platform teams productive.

Testing Shared ViewModels

Because the logic lives in one place, a single suite in commonTest covers behavior for both platforms at once. Kotlin’s runTest and a test dispatcher let you drive coroutines deterministically, asserting that each event produces the expected sequence of states. This is the quiet payoff of the architecture: you test the hard part — loading, error handling, optimistic updates — once, and trust the thin UI wrappers to merely render.

// shared/src/commonTest/kotlin/ProductListViewModelTest.kt
@Test
fun loadProducts_emitsLoadingThenContent() = runTest {
    val repo = FakeProductRepository(listOf(sampleProduct))
    val vm = ProductListViewModel(repo, NoopAnalytics())

    val emissions = mutableListOf()
    val job = launch { vm.state.toList(emissions) }

    vm.loadProducts()
    advanceUntilIdle()

    assertTrue(emissions.first().isLoading.not())   // initial idle
    assertTrue(emissions.any { it.isLoading })       // loading flips true
    assertEquals(1, emissions.last().products.size)  // content arrives
    assertNull(emissions.last().error)
    job.cancel()
}

See the Kotlin Multiplatform documentation for advanced testing patterns, including turbine-style flow assertions and shared fakes.

When NOT to Share the ViewModel

Sharing is powerful, but it is not free, so be deliberate. If your two apps genuinely differ in product behavior — different flows, different navigation, different feature sets — forcing a shared ViewModel creates a tangle of platform conditionals that is harder to maintain than two clean native implementations. Likewise, screens that are mostly platform UI with little logic (a settings toggle list, say) gain little from sharing and still pay the bridging tax. The sweet spot is logic-heavy, behavior-identical screens: data loading, validation, pagination, and state machines.

There is also a team dimension. Adopting KMP means your iOS engineers must read Kotlin and your Android engineers must reason about Kotlin/Native quirks, and the build setup is more involved than a single-platform project. Therefore, the honest recommendation is to start small. Share exactly one ViewModel, prove the bridging and CI pipeline work end to end, and only then expand. For more on structuring large systems, our guide on cell based architecture covers complementary isolation principles.

Mobile testing and development
Shared ViewModel tests cover business logic for both Android and iOS simultaneously

In conclusion, KMP shared ViewModel architecture dramatically reduces code duplication between Android and iOS apps. By centralizing state management and business logic in shared Kotlin code, teams ship features faster and with fewer platform-specific bugs, while still preserving native UI on each side. Start by sharing one ViewModel, validate the threading and lifecycle behavior, then gradually move more logic into the shared module as your confidence grows.

← Back to all articles