Shared BaseViewModel in Kotlin Multiplatform (KMP)

Codefy Labs's photo
Jun 6, 2024·

4 min read

In this blog post, we'll explore how to create and use a shared BaseViewModel in a Kotlin Multiplatform (KMP) project. This approach allows us to write shared logic for both Android and iOS platforms, making our codebase more efficient and maintainable.

Common Shared Code

The common shared code consists of abstract and interface definitions that are expected to be implemented differently on each platform. Let's break down each component step by step.

1. Abstract ViewModel Class

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

This abstract class defines a ViewModel with a CoroutineScope and an onCleared method. The expect keyword indicates that platform-specific implementations will be provided.

2. Event and State Interfaces

interface Event {}

interface State {}

These interfaces are used to define events and states that the ViewModel will handle.

3. StateViewModel Class

abstract class StateViewModel<E : Event, S : State>(initialState: S) : ViewModel() {
    private val _event = Channel<E>()
    val event = _event.receiveAsFlow().shareIn(CoroutineScope(Dispatchers.Main), SharingStarted.Lazily).toCommonFlow()

    private val _state = MutableStateFlow(initialState)
    val state = _state.asStateFlow().toCommonStateFlow()

    protected suspend fun sendEvent(event: E) = _event.send(event)

    @OptIn(DelicateCoroutinesApi::class)
    protected fun sendEventSync(event: E) = GlobalScope.launch { _event.send(event) }

    protected fun updateState(newState: S) {
        _state.value = newState
    }

    override fun onCleared() {
        _event.close()
        super.onCleared()
    }
}

The StateViewModel class extends ViewModel and is parameterized by Event and State. It uses Channel and StateFlow to handle events and state updates.

  • sendEvent and sendEventSync are used to send events asynchronously and synchronously, respectively.

  • updateState is used to update the current state.

  • onCleared is overridden to close the event channel.

4. CommonStateFlow, CommonFlow, and CommonMutableStateFlow Classes

These classes wrap Kotlin StateFlow, Flow, and MutableStateFlow to provide platform-specific implementations.

expect class CommonStateFlow<T>(flow: StateFlow<T>): StateFlow<T>

fun <T> StateFlow<T>.toCommonStateFlow() = CommonStateFlow(this)

expect class CommonFlow<T>(flow: Flow<T>): Flow<T>

fun <T> Flow<T>.toCommonFlow() = CommonFlow(this)

expect class CommonMutableStateFlow<T>(flow: MutableStateFlow<T>): MutableStateFlow<T>

fun <T> MutableStateFlow<T>.toCommonMutableStateFlow() = CommonMutableStateFlow(this)

iOS Module

The iOS-specific implementations of the common shared code are as follows:

actual open class CommonFlow<T> actual constructor(private val flow: Flow<T>): Flow<T> by flow {
    fun subscribe(coroutineScope: CoroutineScope, dispatcher: CoroutineDispatcher, onCollect: (T) -> Unit): DisposableHandle {
        val job = coroutineScope.launch(dispatcher) {
            flow.collect(onCollect)
        }
        return DisposableHandle { job.cancel() }
    }

    fun subscribe(onCollect: (T) -> Unit): DisposableHandle {
        return subscribe(coroutineScope = GlobalScope, dispatcher = Dispatchers.Main, onCollect = onCollect)
    }
}

actual open class CommonMutableStateFlow<T> actual constructor(private val flow: MutableStateFlow<T>)
    : CommonStateFlow<T>(flow), MutableStateFlow<T> {
    override var value: T
        get() = super.value
        set(value) { flow.value = value }

    override val subscriptionCount: StateFlow<Int>
        get() = flow.subscriptionCount

    override fun compareAndSet(expect: T, update: T): Boolean = flow.compareAndSet(expect, update)

    @ExperimentalCoroutinesApi
    override fun resetReplayCache() { flow.resetReplayCache() }

    override fun tryEmit(value: T): Boolean = flow.tryEmit(value)

    override suspend fun emit(value: T) { flow.emit(value) }
}

actual open class CommonStateFlow<T> actual constructor(private val flow: StateFlow<T>)
    : CommonFlow<T>(flow), StateFlow<T> {
    override val replayCache: List<T>
        get() = flow.replayCache

    override val value: T
        get() = flow.value

    override suspend fun collect(collector: FlowCollector<T>): Nothing {
        flow.collect(collector)
    }
}

fun interface DisposableHandle: kotlinx.coroutines.DisposableHandle

These classes provide implementations for subscribing to Flow and handling StateFlow and MutableStateFlow on iOS.

Android Module

The Android-specific implementations of the common shared code are as follows:

actual abstract class ViewModel actual constructor() : AndroidXViewModel() {
    actual override fun onCleared() {
        super.onCleared()
    }

    actual val coroutine = viewModelScope
}

actual class CommonFlow<T> actual constructor(private val flow: Flow<T>) : Flow<T> by flow

actual class CommonMutableStateFlow<T> actual constructor(private val flow: MutableStateFlow<T>)
    : MutableStateFlow<T> by flow

actual class CommonStateFlow<T> actual constructor(private val flow: StateFlow<T>)
    : StateFlow<T> by flow

These implementations utilize Android's viewModelScope and extend AndroidXViewModel.

How to use this SharedViewModel while managing state and event for android and iOS both.

Checkout this next part of this blog : https://engineering.codefylabs.com/event-and-state-management-in-kmp

Conclusion

Integrating a shared BaseViewModel in a KMP project allows you to share business logic between Android and iOS, reducing code duplication and maintenance efforts. By defining expected classes and interfaces in the common module and providing platform-specific implementations, you can effectively manage state and events in a multiplatform environment.

By following the structure and code provided in this blog, you should be able to create a robust and scalable architecture for your KMP project. Happy coding!