· 12 min read Posted by Gustavo Fão Valvassori

Is AndroidX ViewModel the best choice for KMP projects?

Sharing ViewModels between platforms affects the iOS developer experience. In this post we will discuss this topic and some possible solutions in KMP projects that respect the native feel of iOS and Android.
Kaleidico - https://unsplash.com/photos/two-people-drawing-on-whiteboard-26MJGnCM0Wc
Credit: Kaleidico - https://unsplash.com/photos/two-people-drawing-on-whiteboard-26MJGnCM0Wc

Model-View-ViewModel is, undoubtedly, the preferred pattern of the mobile developer community. You can easily find usages of it on Android, iOS, Flutter, and React Native. However, each variation of it addresses concerns that are specific for the platform it is working on.

The Pattern

The MVVM pattern was built to address one key pain the developers had: separation of concerns. Using it, you can split your application into three distinct groups:

  • The Model: That represents your state and all the machinery to get it;
  • The View: The UI that will display the content to the final user; and
  • The ViewModel: The “glue” that fetches the content from the Model, shapes it, holds all view-specific logic and states, and then “binds” it to the view to display its content.

When researching about it, you will often see it with the following diagram:

flowchart LR
    View <-- Binds to the state --> ViewModel
    ViewModel -- Sends data --> Model
    Model -- Receive data --> ViewModel

And that can be translated into code as follows:

// Model
data class Dog(val id: String, val name: String, val imageUrl: String)

interface DogsRepository {
    suspend fun fetchDogs(): List<Dog>
}

// View
@Composable
fun MyDogList(viewModel: DogsViewModel) {
    val content: List<Dog> by viewModel.dogList.collectAsState()

    LazyColumn(
        modifier = Modifier.onFirstVisible {
            viewModel.fetchDogs()
        }
    ) {
        items(content) { dog ->
            DogItem(dog)
        }
    }
}

// ViewModel
class DogsViewModel(
    private val dogsRepository: DogsRepository
) : ViewModel() {
    private val _dogList = MutableStateFlow<List<Dog>>(emptyList())
    val dogList: StateFlow<List<Dog>> = _dogList.asStateFlow()

    fun fetchDogs() {
        viewModelScope.launch {
            _dogList.value = dogsRepository.fetchDogs()
        }
    }
}

It’s important to mention that the Model layer is not only the “entity” that will be presented (the ‘Dog’ type). As mentioned before, the Model is about the whole set of classes you will need to use to build the content.

It can be as simple as an HTTP Client, or as complex as a full Clean Architecture configuration with use cases, repositories, data sources, etc.

The Binder

If you want to go deep down the rabbit hole, you will also see that MVVM posts mention a “Binder”. The key role of the Binder is to update the UI whenever the state changes. Here, in our example, the StateFlow type acts as an observable entry that can be used by the binder (.collectAsState()).

The StateFlow works amazingly well with Compose (both for Android or multiplatform). For iOS, it can be used together with SKIE, making it a good option for cross-platform.

The Android View Model

Before we dive into the Android ViewModel library, we need to understand the story behind it. Historically, Android development struggled with a major separation of concerns issue. Developers were putting a lot of logic in their Activities and Fragments, creating bloated classes that were hard to maintain.

As described above, the ViewModel pattern is great for solving this. It allows you to extract that logic into a separate component. But Android went a step further because it also had a unique lifecycle problem.

Configuration changes, like screen rotations, mean Activities are frequently destroyed and recreated. To solve this, Google introduced ViewModelStores to manage ViewModel instances independently of Activities. This ensures that data survives configuration changes but is still cleaned up when the screen is truly finished. So now, the concept of ViewModels on Android is deeply connected with lifecycle management.

When Coroutines started to getting adopted, the ViewModel also embraced the idea of providing a coroutine scope. With that, the addition of the viewModelScope and another concept was added to the ViewModel.

Years after, Compose came. And the usage of the ViewModel was well-established. Even if you can build other solutions using the Compose machinery, the community saw the Android ViewModel as a good starting point.

To work with Compose, Google had to make minor changes to the library, like hooking the ViewModelStore to the composable lifecycle. But in the end, as Compose was built for Android, they had many similarities with the Android framework, making it easier to work with.

Nowadays, most Android developers are exploring alternatives for multiplatform projects, and Kotlin Multiplatform is the most common choice (especially for Android developers that use Kotlin on their day-to-day projects). However, different platforms have different requirement constraints. And sometimes, the same solution might not work as well as you expect on other platforms.

What it means for Multiplatform

Google started embracing KMP in 2023, migrating some edge libraries to it. After that, some core libraries started to be migrated as well. One important milestone was the release of the Android ViewModel library with KMP support in 2024.

With time, that library also became one of the favorites for KMP projects. Many developers started to use and recommend it, and it started to be a “default” solution for KMP projects. However, it’s important to use it carefully. As mentioned earlier, this library was created to solve Android problems. When moving to the multiplatform realm, the same issues might not be true for other platforms such as iOS, Web, or Desktop.

The Google team has worked together with JetBrains to ensure the library works in the best possible ways on all platforms. But some pains will still exist as platforms are built differently.

For Compose Multiplatform projects, this is one of the best options available as it works out of the box with Compose. The lifecycles are linked to the Composables lifecycle and the usage is simple for most cases.

On the other hand, if you decide to build native UIs and share the View Model, you will need to “Androidify” your Swift code. If you are building a new app from scratch, that may not be a real problem. But when you are integrating in large apps with a consolidated iOS team, you may get some pushback.

It’s important to raise that some changes will happen with the adoption of KMP. However, making the iOS code look more like Android may not be well-received by iOS devs (The same may be true for other platforms you may be integrating).

The other side of the coin

As mentioned earlier, using the Android library for View Models may not be well-received when you present the idea to iOS Devs. This usually has two different reasons:

The Concept difference

When you chat with an iOS dev, they usually think of the term “View Model” as a block of data that shapes the View. Like a “Snapshot” of your view state. In other words, the lifecycle management is not part of the View Model responsibility.

In code, that can be translated into the following:

// MARK: - Model
struct Dog: Identifiable, Codable {
    let id: String
    let name: String
    let imageUrl: String
}

protocol DogsRepository {
    func fetchDogs() async throws -> [Dog]
}

// MARK: - ViewModel
@Observable
class DogsViewModel {
    var dogList: [Dog] = []
    private let repository: DogsRepository

    init(repository: DogsRepository) {
        self.repository = repository
    }

    func fetchDogs() async {
        do {
            // Updating the UI (dogList) automatically happens on the MainActor 
            // if the property is observed by a View.
            self.dogList = try await repository.fetchDogs()
        } catch {
            print("Error fetching dogs: \(error)")
        }
    }
}

// MARK: - View
struct MyDogList: View {
    var viewModel: DogsViewModel

    var body: some View {
        List(viewModel.dogList) { dog in
            DogItem(dog: dog)
        }
        .task { await viewModel.fetchDogs() }
    }
}

Note that no lifecycle management is needed in the ViewModel, and the ViewModel is just a “snapshot” of the state that shapes the view. The same happens for the asynchronous tasks. The View is responsible for the lifecycle of the tasks in .task {} block.

On the other hand, the Jetpack ViewModel library provides you with an “empty canvas” that they call ViewModel. This class works as a building block to one of the parts needed for the MVVM arch, but does not enforce it. Its main goal is to provide a strong bound to the lifecycle (more specifically, the Android lifecycle), which is not a real “principle” for the MVVM pattern.

This concept brings many advantages for the Android community, but it also has some downsides. The way it connects to the Android lifecycle is also a pro and con for the lib. At the same time it offers the convenience of automatically disposing references when they are not needed anymore, it brings the need for boilerplate code that only works well on Android. Enforcing the other platforms to shape into the same idea can be not well received.

Other concepts from the View Model architecture are also lost. Nested View Models are, in this library, not supported. Even though you can see samples and discussions in the community about its usage when applying the pattern.

The Android face of it

As we have already stated many times, the main goal of the library is to solve Android problems. Even though you can work around those problems and implement something that works, it will not feel “native” for the iOS devs.

The best example is the ViewModelStoreOwner interface that is used to access the ViewModelStore and control its lifecycle. This interface has a sample implementation in the default documentations for swift.

class IosViewModelStoreOwner: ObservableObject, ViewModelStoreOwner {
    let viewModelStore = ViewModelStore()
    func viewModel<T: ViewModel>(
        key: String? = nil,
        factory: ViewModelProviderFactory,
        extras: CreationExtras? = nil
    ) -> T {
        do {
            return try viewModelStore.resolveViewModel(
                modelClass: T.self,
                factory: factory,
                key: key,
                extras: extras
            ) as! T
        } catch {
            fatalError("Failed to create ViewModel of type \(T.self)")
        }
    }

    deinit {
        viewModelStore.clear()
    }
}


struct ContentView: View {
    @StateObject private var viewModelStoreOwner = IosViewModelStoreOwner()

    var body: some View {
        let mainViewModel: MainViewModel = viewModelStoreOwner.viewModel(
            factory: MainViewModelKt.mainViewModelFactory
        )
        // .. the rest of the SwiftUI code
    }
}

Even though it is a basic snippet, the concept feels odd for iOS developers that don’t have to worry about lifecycle management in the same way as Android devs do.

To have a successful usage of KMP in your project, one of the key points you will need to understand and apply is that platforms are different. Sometimes, the same solution will not work as well on both platforms, and you will need to build around it.

Maybe for Android you can keep the library while on iOS you will need to implement something different. The same issues may arise in other parts of the project, so keeping this in mind will always help you succeed.

If you still need to get buy-in from your iOS coworkers, that may become a problem. Improving the developer experience for iOS devs is the best way to get their support, and making their code look like Android but on Swift might be a bad choice.

The DIY Approach

If you choose not to use the Jetpack ViewModel but still want to share your view models between platforms you will need to handle lifecycle yourself. An example you can use as inspiration is an old version of KampKit ViewModel. The whole idea revolves around using the standard library for Android, and providing a simplified implementation for iOS.

// CommonMain
import kotlinx.coroutines.CoroutineScope

expect abstract class ViewModel() {
    val viewModelScope: CoroutineScope
    protected open fun onCleared()
}

// AndroidMain
import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.ViewModel as AndroidXViewModel
import androidx.lifecycle.viewModelScope as androidXViewModelScope

actual abstract class ViewModel actual constructor() : AndroidXViewModel() {
    actual val viewModelScope: CoroutineScope = androidXViewModelScope

    actual override fun onCleared() {
        super.onCleared()
    }
}

// iOSMain
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel

actual abstract class ViewModel {

    actual val viewModelScope = MainScope()

    protected actual open fun onCleared() {
    }

    fun clear() {
        onCleared()
        viewModelScope.cancel()
    }
}

For the binding, you can rely on SKIE flows for SwiftUI. It provides a nice way to consume your StateFlows.

class SharedViewModel : ViewModel() {
    private val _counter = MutableStateFlow(0)
    val counter = _counter.asStateFlow()

    fun increment() {
        viewModelScope.launch {
            _counter.value += 1
        }
    }

    fun decrement() {
        viewModelScope.launch {
            _counter.value -= 1
        }
    }
}

The count state can then be collected into a state in your view:

struct ViewModelHolder <T : ViewModel> : ObservableObject  {
    let viewModel: T

    init(_ viewModel: T) {
        self.viewModel = viewModel
    }

    deinit {
        viewModel.clear()
    }
}

struct ExampleView: View {
    @StateObject let holder = ViewModelHolder(SharedViewModel())
    
    @State
    var counter: KotlinInt = 0

    var body: some View {
        Text("Bound counter using Binding: \(counter)")
            .collect(flow: holder.viewModel.counter, into: $counter)
    }
}

This solution also adds code to make the shared view model possible on iOS. But in this case, we are using concepts that are “native” to the iOS devs.

This is a simple sample to show an alternative for the Android ViewModel library. However, if you want to use something developed by the community, you may be able to find alternatives in the KLibs website. The community has already seen this problem in the past and has been working on it for a while now.

Conclusion

After reading all of this, you are probably thinking: Well, in that case, should I use the Jetpack ViewModel library for my KMP project?. And the answer is the same as always: It depends!

For most modern KMP projects, especially those starting from scratch or using Compose Multiplatform, the answer is a strong yes. Compose Multiplatform has effectively standardized many Android lifecycle concepts across platforms. In this context, the ViewModel does exactly what you expect: it manages state and scope efficiently, letting you share more code with less friction.

The story changes if you are targeting native iOS UI or integrating into a team with strong iOS architectural opinions. In these scenarios, the Android-centric nature of the ViewModel can feel alien and restrictive. If your iOS developers feel like they are fighting the architecture rather than benefiting from it, it might be time to look for a more platform-agnostic solution or a custom abstraction that respects the “native feel” of both worlds.


Big shout out to Tadeas Kriz that helped me to see the MVVM concept from the iOS point of view and shape the ideas discussed in this article.