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