For months I worked with KMP the “obvious” way: I shared the data and domain layers, and kept ViewModels on each platform. It worked. But I was maintaining double the presentation logic, and every change required touching Android and iOS separately.
Until I decided to move ViewModels to the shared module. What seemed like a small refactor became the most significant change in the entire migration.
The problem I wanted to solve
Imagine having a product detail ViewModel. On Android you had your ProductDetailViewModel with StateFlow, coroutines, and all the UI state logic. On iOS you had exactly the same thing, rewritten in Swift with Combine or a manual ObservableObject. Same behavior, two implementations.
Any change — a new loading state, a new error to handle — required two PRs, two code reviews, twice the risk of implementations silently diverging.
The key question: Why am I writing the same logic twice when I have Kotlin Multiplatform precisely to avoid that?
The implementation
The biggest obstacle isn’t moving the ViewModel itself — it’s exposing StateFlow to Swift in a usable way. Swift doesn’t understand Kotlin flows natively.
class ProductDetailViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProductDetailState())
val state: StateFlow<ProductDetailState> = _state
fun loadProduct(id: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
repository.getProduct(id)
.onSuccess { product ->
_state.update { it.copy(product = product, isLoading = false) }
}
.onFailure { error ->
_state.update { it.copy(error = error.message, isLoading = false) }
}
}
}
}
On Android this works directly — it’s a Jetpack ViewModel with full integration. The problem is Swift.
Exposing state to Swift
I used SKIE (Swift Kotlin Interface Enhancer) to make flows naturally observable from SwiftUI. Without SKIE, you have to wrap each flow in callbacks manually, which is tedious and prone to memory leaks.
struct ProductDetailView: View {
@StateObject private var viewModel = ProductDetailViewModel(
repository: Dependencies.productRepository
)
var body: some View {
// SKIE makes StateFlow directly observable
let state = viewModel.state.value
if state.isLoading {
ProgressView()
} else if let product = state.product {
ProductContent(product: product)
}
}
}
What I sacrificed
Not everything was perfect. I lost automatic lifecycle integration on iOS — on Android the ViewModel survives screen rotations natively thanks to androidx.lifecycle. On iOS I had to manage this manually in each view.
Also debugging is somewhat more complex — Kotlin stack traces on iOS can be cryptic. You get used to it over time, but at first it’s disorienting.
What I gained
A single source of truth for all presentation logic. A bug fixed in the shared ViewModel gets fixed on both platforms simultaneously. ViewModel unit tests cover behavior on both platforms with a single test suite.
The ratio of code to maintain was roughly halved in the presentation layer. For a solo personal project, that makes the difference between a living project and an abandoned one.
Would I recommend it? Yes, especially if you work alone or in a small team where the cost of maintaining two implementations is real. If you have separate Android and iOS teams with their own conventions, it might create more friction than it solves.
In my case it was the right decision. And it’s what convinced me that KMP is the future of cross-platform mobile development.