When building NCHETA, a Kotlin Multiplatform (KMP) app, the shared business logic layer felt almost effortless. Dependency injection was handled with Koin, networking with Ktor, and local caching with SQLDelight. Everything inside commonMain felt cohesive, testable, and predictable.
Eventually, though, you have to leave the shared world and render pixels on a screen.
On Android, using Jetpack Compose, this transition is straightforward.
On iOS, using SwiftUI, it requires a bridge — and architectural decisions suddenly matter.
In this article, I’ll walk through the “Pure Kotlin ViewModel” pattern I used to keep shared ViewModels platform-agnostic, how I bridged them to SwiftUI using explicit wrappers, and how this approach compares to newer, library-driven alternatives in the evolving KMP ecosystem.
The Strategy: Pure Kotlin ViewModels
Many KMP tutorials suggest making shared ViewModels inherit from a base class — often androidx.lifecycle.ViewModel, now available in multiplatform form.
I deliberately chose a different route: composition over inheritance.
In this pattern, shared ViewModels are plain Kotlin classes. They know nothing about Android lifecycles, viewModelScope, or iOS memory management. Their responsibility is limited to holding state and executing business logic.
// shared/commonMain
class InputViewModel(
private val coroutineScope: CoroutineScope, // platform-owned scope
private val generationService: ContentGenerationService,
private val repository: NchetaRepository,
// ... other dependencies
) {
val inputText = MutableStateFlow("")
val uiState = MutableStateFlow<InputUiState>(InputUiState.Idle)
fun onSummarizeClicked() {
coroutineScope.launch {
// Business logic
}
}
fun clear() {
coroutineScope.cancel()
}
}
The key decision here is explicit ownership of the CoroutineScope.
Each platform provides — and ultimately cancels — the scope. The shared ViewModel never assumes where or how it runs.
I use the term “ViewModel” here in the architectural sense: a state holder for UI logic — not in the AndroidX inheritance sense.
Android: Lifecycle Ownership via a Wrapper ViewModel
Android has a well-defined lifecycle model, and ignoring it leads to memory leaks. To respect this, I wrap the shared ViewModel inside a native Android ViewModel.
This wrapper owns the lifecycle and provides a lifecycle-aware CoroutineScope.
// androidApp/src/.../AndroidInputViewModel.kt
class AndroidInputViewModel(
generationService: ContentGenerationService,
// ... dependencies injected by Koin
) : ViewModel() {
val inputViewModel = InputViewModel(
coroutineScope = viewModelScope,
generationService = generationService,
// ...
)
override fun onCleared() {
super.onCleared()
// Critical: explicitly clean up the shared VM
inputViewModel.clear()
}
}
This approach keeps commonMain completely free of Android dependencies (androidx.*) while still respecting Android’s lifecycle constraints.
The result is a clear separation of concerns:
- Android owns lifecycle
- Kotlin owns business logic
- Cleanup is explicit, not implicit
iOS: Bridging StateFlow to SwiftUI with an Observer Wrapper
SwiftUI relies on ObservableObject and @Published properties to drive UI updates. Kotlin, on the other hand, uses StateFlow. These two systems don’t communicate natively.
To bridge this gap, I implemented an Observer Wrapper on the iOS side.
The Swift wrapper’s responsibilities are simple and explicit:
- Inject the shared Kotlin ViewModel
- Observe Kotlin StateFlows
- Publish updates to SwiftUI
- Manage lifecycle and cleanup
The Swift Wrapper
@MainActor
class ObservableInputViewModel: ObservableObject {
private let sharedVm: InputViewModel
@Published var inputText: String = ""
@Published var uiState: InputUiState
private var inputTextWatcherTask: Task<Void, Error>?
private var uiStateWatcherTask: Task<Void, Never>?
init() {
// Inject via Koin helper
self.sharedVm = ViewModels().inputViewModel
// Initialize state immediately
self.uiState = sharedVm.uiState.value
// Bridge StateFlow -> @Published using async sequences (via SKIE)
self.inputTextWatcherTask = Task {
for await nsStringValue in sharedVm.inputText {
if let swiftString = nsStringValue as String? {
self.inputText = swiftString
}
}
}
self.uiStateWatcherTask = Task {
for await newState in sharedVm.uiState {
self.uiState = newState
}
}
}
// Proxy methods
func onSummarizeClicked() {
sharedVm.onSummarizeClicked()
}
deinit {
// Critical: cancel observers and clean up Kotlin scope
inputTextWatcherTask?.cancel()
uiStateWatcherTask?.cancel()
sharedVm.clear()
}
}
The wrapper is annotated with @MainActor to guarantee that all @Published updates occur on the main thread — exactly what SwiftUI expects.
This example uses SKIE to bridge StateFlow to Swift async sequences. The same pattern can be implemented manually or using other bridging techniques if preferred.
Why I Like This Approach
Total Control
I explicitly define how data flows from Kotlin to Swift. Nothing is hidden behind a framework abstraction.
No Magic
There is no invisible lifecycle management. Cleanup happens in onCleared() on Android and deinit on iOS — exactly where you expect it.
Separation of Concerns
Kotlin handles business logic and state. Swift handles UI observation and rendering. Each platform plays to its strengths.
The cost is some boilerplate — but in return, the architecture remains predictable and debuggable.
The Alternative: Lifecycle Multiplatform ViewModels
Google now officially recommends using Lifecycle-aware Multiplatform ViewModels in Kotlin Multiplatform projects.
In this approach, shared ViewModels inherit directly from androidx.lifecycle.ViewModel, which is available in commonMain. This allows shared code to use viewModelScope without injecting a coroutine scope manually.
// shared/commonMain
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
class SharedViewModel : ViewModel() {
fun doSomething() {
viewModelScope.launch {
// Shared coroutine scope available natively
}
}
}
On Android, this works exactly as expected. ViewModels integrate naturally with Jetpack Compose, lifecycle cancellation is automatic, and the mental model is familiar.
On iOS, additional setup is required. SwiftUI does not have an equivalent to Android’s ViewModelStore, so a bridging layer is needed to adapt SwiftUI lifecycle events to the AndroidX ViewModel system.
There is also a limitation around generics. Android ViewModel creation relies on Kotlin class references and reflection. Objective-C generics do not preserve enough type information to retrieve a generic ViewModel<T> from Swift. Because of this, the recommended approach requires a helper that:
- Accepts an ObjCClass instead of a generic type
- Uses getOriginalKotlinClass() to recover the Kotlin class
- Delegates creation back to ViewModelProvider
This works, but it introduces reflection-based infrastructure before SwiftUI can even observe state. The shared ViewModel implicitly assumes Android lifecycle semantics, and iOS must adapt to those assumptions.
Conclusion
Building NCHETA was a lesson in navigating the evolving landscape of Kotlin Multiplatform architecture.
By avoiding Android dependencies in commonMain, I enforced a strict separation of concerns that made debugging iOS interop significantly easier. While the manual wrapper pattern adds some upfront work, it removes hidden behavior and makes lifecycle ownership explicit on every platform.
There’s no single “correct” approach — only tradeoffs.
Have you adopted the new Lifecycle Multiplatform libraries?
Or do you prefer keeping shared ViewModels pure and platform-owned?
I’d love to hear how others are approaching this in real-world KMP projects.
KMP Architecture: The Case for Pure Kotlin ViewModels was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments