Getting Started with Jetpack ViewModels and DataStore in Kotlin Multiplatform

Using Jetpack Compose, SwiftUI, Koin and SKIE

Introduction

With Google adding KMP support to the AndroidX Jetpack ViewModels and DataStore, I was excited to try this on a Kotlin Multiplatform app using SwiftUI and Jetpack Compose on iOS and Android.

Philipp Lackner created a video focusing on Compose Multiplatform: How to Share ViewModels in Compose Multiplatform (with Dependency Injection), which I used as the inspiration for this demo app.

Arnaud Giuliani forked Philipp’s example and updated it to use koinViewModel, which is available from Koin 3.6.0-Beta4; see https://x.com/arnogiu/status/1791511039810638287.

I created a new project using the Kotlin Multiplatform Wizard and initially used the MainViewModel from Philipp’s example. Later, this became the DetailViewModel as I later wanted to explore how to hook up the lifecycle and needed to be able to navigate away from the View to implement the lifecycle on both platforms.

I used the Jetpack DataStore to implement a TimerRepository to save the timer value, which is as simple as an integer value; Room also has KMP support for more complex data scenarios.

Where's the example?

The example app source code accompanying this article is available in the KMPJetpackDemo repo. A video of the app showing the Home and Detail views is below.

The example app has two views, a Home and a Detail. The DetailViewModel runs a Timer which fires every second and is shown on the view.

Key Points

The example app has a Home View with a Button to navigate to a Detail View, displaying a timer value incremented every second.

The DetailViewModel saves the timer value to a TimerRepository, which uses DataStore to persist the value.

To investigate the lifecycle support for both platforms, I have set up the app so that when the DetailView disappears, both the view and ViewModel are disposed of.

By setting a breakpoint in the DetailViewModel’s init() and onCleared() functions, we can verify they are called when we navigate to and from the DetailView on both platforms. We utilize the lifecycle capabilities to implement Structured Concurrency.

Previously, we needed to create custom expected/actual ViewModel, such as those in Touchlab’s KaMPKit, or use a third-party library, such as Rick Clephas’s KMP-ObservableviewModel. Now, we can use the Jetpack ViewModel from version 2.8.0-alpha03. I have used a viewModelScope constructor parameter to aid testing, available from version 2.8.0.

I used the SupervisorJob() + Dispatchers.Main rather than the default Dispatchers.Main.immediate after reading this conversation between Ian Lake and Stylianos Gakis.

John O’Reilly recently updated the FantasyPremierLeague sample app to use the ViewModelStoreOwner in SwiftUI (Tweet), which I used as the basis for the SwiftUI integration with the Jetpack ViewModels and the DataStore initialisation.

To access the ViewModelStoreOwner from SwiftUI, we need to export the lifecycle viewmodel dependency. See the documentation ‘Export dependencies to binaries’. I needed to comment out the ‘isStatic = true’ flag that the KMP wizard generated.

On iOS, I used a navigationId to force the DetailView and, hence, the DetailViewModel to be destroyed and recreated.

I used Manuel Vivo’s Gist Scope ViewModels to Composables to refine the scoping of the ViewModel so that it is disposed of with the DetailView.

Configuration of DI for the shared ViewModels can be done in the shared module using the viewModelOf.

I’m using the Wrapper strategy in the SwiftUI code. See the Koin KMP Cheat Sheet

Shared Code Walkthrough

On initialisation, the DetailViewModel reads the current timer value from the TimerRepository and starts a timer. Each time the timer fires, we update the persisted value via the TimerRepository. In the onCleared() function, we cancel the timer. I’ve updated this based on some feedback from Stojan Anastasov – thank you!

class DetailViewModel(
    private val viewModelScope: CoroutineScope,
    helloWorldRepository: HelloWorldRepository,
    private val timerRepository: TimerRepository,
) : ViewModel(viewModelScope) {
    private val _timer = MutableStateFlow(0)
    val timer = _timer.asStateFlow()

    private var timerJob: Job? = null

    init {
        viewModelScope.launch {
            try {
                val initialTimerValue = timerRepository.getTimerValue()
                _timer.value = initialTimerValue
                println("Initial timer value from timerRepository: $initialTimerValue")
            } catch (e: Exception) {
                println("Error fetching initial timer value")
            }
        }
        startTimer()
        println(helloWorldRepository.getHelloWorldMessage())
    }

    private fun startTimer() {
        timerJob =
            viewModelScope.launch {
                while (isActive) {
                    delay(1000)
                    _timer.value++
                    try {
                        timerRepository.saveTimerValue(_timer.value)
                        println("Timer fired, value from timerRepository: ${_timer.value}")
                    } catch (e: Exception) {
                        println("Error saving timer value")
                    }
                }
            }
    }

    override fun onCleared() {
        super.onCleared()
        timerJob?.cancel()
    }
}

The TimerRepository uses the DataStore to store the current value of the timer:

fun createDataStore(producePath: () -> String): DataStore =
    PreferenceDataStoreFactory.createWithPath(
        corruptionHandler = null,
        migrations = emptyList(),
        produceFile = { producePath().toPath() },
    )

interface TimerRepository {
    val timerValue: Flow

    suspend fun getTimerValue(): Int

    suspend fun saveTimerValue(value: Int)
}

class TimerRepositoryImpl(private val dataStore: DataStore) : TimerRepository {
    private val timerKey = intPreferencesKey("timer_value")

    override suspend fun getTimerValue(): Int {
        return dataStore.data.first()[timerKey] ?: 0
    }

    override suspend fun saveTimerValue(value: Int) {
        dataStore.updateData { preferences ->
            preferences.toMutablePreferences().apply {
                this[timerKey] = value
            }
        }
    }

    override val timerValue: Flow =
        dataStore.data.map { preferences ->
            preferences[timerKey] ?: 0
        }
}

The  Koin commonModule registers the ViewModels, Repositories and the CoroutineScope:

val commonModule =
    module {
        single { TimerRepositoryImpl(get()) }
        viewModel { DetailViewModel(get(), get(), get()) }
        viewModel { HomeViewModel() }
        single { DefaultHelloWorldRepository() }
        factory { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
    }

There is an expect class definition for the KoinInitializer:

expect class KoinInitializer {
    fun init(platformSpecific: Module = module { }): KoinApplication
}

There are tests for the DetailViewModel:

class DetailViewModelFactory(
    private val scope: CoroutineScope,
    private val timerRepository: TimerRepository,
    private val helloWorldRepository: HelloWorldRepository,
) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun  create(
        modelClass: KClass,
        extras: CreationExtras,
    ): T {
        return DetailViewModel(
            viewModelScope = scope,
            helloWorldRepository = helloWorldRepository,
            timerRepository = timerRepository,
        ) as T
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
class DetailViewModelTest {
    private val testDispatcher = StandardTestDispatcher()

    @BeforeTest
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }

    @AfterTest
    fun tearDown() {
        try {
            // Other teardown code
        } finally {
            Dispatchers.resetMain()
        }
    }

    @Test
    fun shouldInitializeTimerWithValueFromTimerRepository() =
        runTest {
            val initialValue = 10
            val timerRepository = FakeTimerRepository(initialValue)
            val helloWorldRepository = FakeHelloWorldRepository()

            val detailViewModel =
                DetailViewModel(
                    viewModelScope = this,
                    helloWorldRepository = helloWorldRepository,
                    timerRepository = timerRepository,
                )

            advanceTimeBy(100)

            assertEquals(initialValue, detailViewModel.timer.value)

            this.coroutineContext.cancelChildren()
        }

    @Test
    fun shouldIncrementTimerValueEverySecond() =
        runTest {
            val timerRepository = FakeTimerRepository(0)
            val helloWorldRepository = FakeHelloWorldRepository()

            val detailViewModel =
                DetailViewModel(
                    viewModelScope = this,
                    helloWorldRepository = helloWorldRepository,
                    timerRepository = timerRepository,
                )

            val values = mutableListOf()
            val job =
                launch {
                    detailViewModel.timer.collect { values.add(it) }
                }

            advanceTimeBy(3500)

            assertEquals(listOf(0, 1, 2, 3), values)
            job.cancel() // Clean up the collecting coroutine

            this.coroutineContext.cancelChildren()
        }

    @Test
    fun shouldSaveIncrementedTimerValueToTimerRepository() =
        runTest {
            val timerRepository =
                object : TimerRepository {
                    private val _timerValue = flow { emit(0) }
                    override val timerValue: Flow get() = _timerValue

                    override suspend fun getTimerValue(): Int {
                        return _timerValue.first()
                    }

                    var saveCalls = 0

                    override suspend fun saveTimerValue(value: Int) {
                        saveCalls++
                    }
                }
            val helloWorldRepository = FakeHelloWorldRepository()

            DetailViewModel(
                viewModelScope = this,
                helloWorldRepository = helloWorldRepository,
                timerRepository = timerRepository,
            )

            advanceTimeBy(2500)

            assertEquals(2, timerRepository.saveCalls)

            this.coroutineContext.cancelChildren()
        }

    @Test
    fun shouldCancelTimerWhenViewModelIsCleared() =
        runTest {
            val timerRepository = FakeTimerRepository(0)
            val helloWorldRepository = FakeHelloWorldRepository()

            val factory = DetailViewModelFactory(this, timerRepository, helloWorldRepository)
            val viewModelStore = ViewModelStore()
            val provider = ViewModelProvider.create(viewModelStore, factory)

            val detailViewModel = provider[DetailViewModel::class]

            val values = mutableListOf()
            val job =
                launch {
                    detailViewModel.timer.collect { values.add(it) }
                }

            advanceTimeBy(2500)

            this.coroutineContext.cancelChildren()
            advanceTimeBy(1000)

            assertEquals(listOf(0, 1, 2), values) // The timer should stop at 2 after being cleared
            job.cancel()
        }
}

class FakeTimerRepository(initialValue: Int) : TimerRepository {
    private val _timerValue = MutableStateFlow(initialValue)
    override val timerValue: Flow get() = _timerValue.asStateFlow()

    override suspend fun getTimerValue(): Int {
        return _timerValue.first()
    }

    override suspend fun saveTimerValue(value: Int) {
        _timerValue.value = value
    }
}

class FakeHelloWorldRepository : HelloWorldRepository {
    override fun getHelloWorldMessage(): String = "Hello, World!"
}

Android Code Walkthrough

The Android DetailView uses the koinViewModel() and displays the timer value.

@OptIn(KoinExperimentalAPI::class)
@Composable
fun DetailView() {
    val detailViewModel = koinViewModel()
    val timer by detailViewModel.timer.collectAsState()

    MaterialTheme {
        Box(
            Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = timer.toString(),
            )
        }
    }
}

@Preview
@Composable
fun DetailViewPreview() {
    DetailView()
}

Initially I used a DisposableEffect to clear the ViewModelStore and dispose the ViewModel, but I refactored to use the code below:

// From this Gist
// https://gist.github.com/manuelvicnt/a2e4c4812243ac1b218b24d0ac8d22bb

internal class CompositionScopedViewModelStoreOwner : ViewModelStoreOwner, RememberObserver {
    override val viewModelStore = ViewModelStore()

    override fun onAbandoned() {
        viewModelStore.clear()
    }

    override fun onForgotten() {
        viewModelStore.clear()
    }

    override fun onRemembered() {
        // Nothing to do here
    }
}

@Composable
fun ProvidesViewModelStoreOwner(content: @Composable () -> Unit) {
    val viewModelStoreOwner = remember { CompositionScopedViewModelStoreOwner() }
    CompositionLocalProvider(LocalViewModelStoreOwner provides viewModelStoreOwner) {
        content()
    }
}

The MainActivity is shown below:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                KoinContext {
                    val navController = rememberNavController()
                    NavHost(navController = navController, startDestination = "home") {
                        composable("home") { HomeView(navController) }
                        composable("detail") {
                            ProvidesViewModelStoreOwner {
                                DetailView()
                            }
                        }
                    }
                }
            }
        }
    }
}

The MainApplication calls the KoinInitializer passing the ApplicationContext:

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        KoinInitializer(applicationContext).init(androidModule)
    }
}

The Android implementation of the KoinInitializer:

actual class KoinInitializer(
    private val context: Context
) {
    actual fun init(platformSpecific: Module): KoinApplication = startKoin {
        androidContext(context)
        androidLogger()
        modules(appModule() + platformSpecific)
    }
}

iOS Code Walkthrough

The HomeView sets up some simple navigation using a navigation Id, so the DetailView is re-created and disposed each time we navigate to it:

struct HomeView: View {
    
    @ObservedObject
    var viewModel: SwiftHomeViewModel = SwiftHomeViewModel()
    
    @State private var navigationID = UUID().uuidString
    
    var body: some View {
        NavigationView {
            VStack {
                Text(viewModel.message)
                    .font(.largeTitle)
                    .padding()
                    
                
                NavigationLink(destination: DetailView(navigationID: $navigationID)) {
                    Text("Show DetailView")
                        .font(.largeTitle)
                        .padding()
                }
            }
            .onAppear {
                Task {
                    await viewModel.activate()
                    }
                }
            .navigationBarTitle("Home", displayMode: .inline)
        }
        
    }
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        HomeView()
    }
}

The DetailView uses a wapper SwiftDetailViewModel and calls the activate() function in onAppear and deactiveate() function in onDisappear:

struct DetailView: View {
    @Binding var navigationID: String

    @StateObject
    var viewModel = SwiftDetailViewModel()
    
    
    init(navigationID: Binding) {
        self._navigationID = navigationID
    }
    
    @State private var showContent = false
    var body: some View {
        VStack {
            if let timerInterval = viewModel.timerInterval {
                            Text("\(timerInterval)")
                                .font(.largeTitle)
                                .padding()
                        } else {
                            Text("Loading...")
                                .font(.largeTitle)
                                .padding()
                        }
            
         
            }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .padding()
        .onAppear {
            Task {
                await viewModel.activate()
                }
            }
        .onDisappear {
            viewModel.deactivate()
            // When the view disappears, update the navigation ID to ensure recreation next time
            self.navigationID = UUID().uuidString
            }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(navigationID: .constant(UUID().uuidString))
    }
}

The SwiftDetailViewModel calls SharedViewModelStoreOwner to clear the ViewModel in the deactivate() function. We use SKIE to consume the StateFlow in the DetailViewModel:

class SwiftDetailViewModel: ObservableObject {
    
    var viewModelStoreOwner = SharedViewModelStoreOwner()
    var viewModel: DetailViewModel
    
    @Published
    private(set) var timerInterval: Int? = nil
    
    init() {
        let viewModel = viewModelStoreOwner.instance
        self.viewModel = viewModel
    }

    @MainActor
    func activate() async {
        for await interval in viewModel.timer {
        
            self.timerInterval = Int(truncating: interval)
        }
    }

    func deactivate() {
        viewModelStoreOwner.clearViewModel()
    }
}

The SharedViewModelStoreOwner is adapted slightly from the FantasyPreimerLeague version to get the ViewModel from Koin and to provide the clearViewModel() function:

class SharedViewModelStoreOwner: ObservableObject, ViewModelStoreOwner {
    var viewModelStore: ViewModelStore = ViewModelStore()
    
    private let key: String = String(describing: VM.self)
    
    init() {
        let viewModel: VM = KotlinDependencies.shared.getKoin().get(objCClass: VM.self) as! VM
            viewModelStore.put(key: key, viewModel: viewModel)
        }
        
    
    var instance: VM {
        get {
            return viewModelStore.get(key: key) as! VM
        }
    }
    
    deinit {
        viewModelStore.clear()
    }
    
    func clearViewModel() {
        viewModelStore.clear()
    }
}

The AppDelegate calls Koin.start():

class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Perform any setup required before the application launches.
        // For example, initialize any SDKs, services, etc.

        Koin.start()

        return true
    }

    // MARK: UISceneSession Lifecycle
    
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}

The Koin class provides the start() function:

final class Koin {
    
    private var core: Koin_coreKoin?

    static let instance = Koin()

    static func start() {
        if instance.core == nil {

                let koinApp = KoinDarwinKt.doInitKoin()

                let koin = koinApp.koin
            
                instance.core = koin
        }
        if instance.core == nil {
            fatalError("Can't initialize Koin.")
        }
    }

    private init() {
    }
}

The KoinDarwin utility written in Kotlin:

@BetaInteropApi
fun Koin.get(objCClass: ObjCClass): Any {
    val kClazz = getOriginalKotlinClass(objCClass)!!
    return get(kClazz, null, null)
}

@BetaInteropApi
fun Koin.get(
    objCClass: ObjCClass,
    qualifier: Qualifier?,
    parameter: Any,
): Any {
    val kClazz = getOriginalKotlinClass(objCClass)!!
    return get(kClazz, qualifier) { parametersOf(parameter) }
}

inline fun  Module.singleInstance(instance: T) {
    single(createdAtStart = true) { instance } bind T::class
}

inline fun  Module.factoryInstance(instance: T) {
    factory { instance } bind T::class
}

fun initKoin(): KoinApplication {
    return KoinInitializer().init(darwinModule)
}

@Suppress("unused") // Called from Swift
object KotlinDependencies : KoinComponent {
    fun getMainViewModel() = getKoin().get() // TODO remove as no longer needed
}

The KoinInitializer for iOS written in Kotlin:

actual class KoinInitializer {
    actual fun init(platformSpecific: Module): KoinApplication = startKoin {
        modules(appModule() + platformSpecific)
    }
}

Summary

I hope sharing this experiment was helpful. It’s an exciting time for Kotlin Multiplatform development, with Google adding KMP support to the AndroidX Jetpack libraries.

For further reading around this, please take a look at these articles:

Android Support for Kotlin Multiplatform (KMP) to Share Business Logic Across Mobile, web, Server, and Desktop

Google I/O 2024: Kotlin Multiplatform at Google Scale!

Google @KotlinConf 2024: A Look Inside Multiplatform Development with KMP and more

If you are interested in discussing how Kotlin Multiplatform could help, you can reach me, Richard Woollcott, at [email protected]

Credits

As ever, we continue to build on the shoulders of giants – many thanks to these people for their work and conversations on socials, which help us achieve so much more!

John O’Reilly

Touchlab

Philipp Lackner

Arnaud Giuliani

Ian Lake

Manuel Vivo

And, of course, thank you to the JetBrains and Google teams for their work in creating and supporting the fantastic Kotlin Multiplatform technology!