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 logic while keeping platform-native UI layers. Therefore, bug fixes and feature updates apply to both platforms simultaneously.

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.

KMP Shared ViewModel Architecture: Core Pattern

Define ViewModels in the shared module using Kotlin coroutines and StateFlow. The shared ViewModel manages state and business logic, while platform-specific wrappers adapt the API for each UI framework. Furthermore, expect/actual declarations handle platform-specific implementations like analytics and storage.

// 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,
)
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, wrap the shared ViewModel in an AndroidX ViewModel for lifecycle management. Collect the StateFlow directly in Compose using collectAsStateWithLifecycle. Additionally, use Koin or Kodein for dependency injection that works across shared and Android modules.

// 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, create an ObservableObject wrapper that collects the Kotlin StateFlow and publishes changes to SwiftUI views. The SKIE library or KMP-NativeCoroutines can automate Flow-to-AsyncSequence bridging, making the integration seamless.

// 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

Testing Shared ViewModels

Test shared ViewModels in commonTest with Kotlin’s test coroutine utilities. Since the business logic is in one place, tests cover both platforms simultaneously. See the Kotlin Multiplatform documentation for advanced testing patterns.

Key Takeaways

  • Start with a solid foundation and build incrementally based on your requirements
  • Test thoroughly in staging before deploying to production environments
  • Monitor performance metrics and iterate based on real-world data
  • Follow security best practices and keep dependencies up to date
  • Document architectural decisions for future team members
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 with fewer platform-specific bugs. Start by sharing one ViewModel, validate the pattern, then gradually move more business logic to the shared module.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top