/kmm-mvvm-state

State management pattern for Compose and SwiftUI KMM apps

Primary LanguageKotlinMIT LicenseMIT

Overview!

Project kmm-mvvm-state aims to share yet another way to manage state in Kotlin Multiplatform applications. Pattern tries to provide similar API both for Android and iOS application and as an example wires state management with MVVM architectural pattern to show more "real-life" use cases. Please the into account that this solution works fine for apps using Jetpack Compose and SwiftUI to draw the UI on platforms, also we are relaying here on new KMM memory management to utilise multiplatform Kotlin coroutines.

Roadmap

  1. Cleanup code
  2. Re-think project structure, especially separation of state and mvvm modules.
  3. Create a better "Real life example app"
  4. Re-thing distribution, maybe a library*
  5. Cover SaveableState example regarding Android in README.md

*The issue I faced here is when after creating Swift Package based on framework generated from state module, then adding this package (KMM lib distributed via XCFramework wrapped as SwiftPackage) as dependency to another Swift Package with helpers (ObservableStateHolder etc.), and then adding this package (with helpers) finally to the real project which was using some shared Common module and using state sources in Kotlin, the base StateHolder protocol from binary XCFramework from Swift Package "was no the same StateHolder" that files from shared Common module were implementing ;/.

For now shared code in Kotlin can be distributed but I do not have any idea how to distribute Swift helpers automatically rather than manually copying them into project.

Example

Investigate the example below. Some basic requirements that were taken into account regarding the ViewModel approach.

  1. ExampleViewModel is shared both on Android and iOS.
  2. Coroutines launched within this ViewModel's scope will be cancelled automatically both on Android and iOS.
  3. Interface of this ViewModel does not contain suspend functions. It can be easily called from Android and iOS.
  4. Asynchronous behaviour can be easily tested in shared module tests, because functions launching coroutines are exposing it's Job objects.

Let's focus on a way the state is "distributed".

  1. Current state which is ExampleViewModelState data class is represented via state field. Both platforms reflect changes that are made to state value.
  2. Explicit synchronization is needed when state is updated from threads other than main.
  3. Value of state is saved and retained in case of process death on Android platform (which is another useful functionality).
@Parcelize
data class ExampleViewModelState(
    val isLoading: Boolean = false,
    val counter: Int = 0,
    val title: String = "Title",
    val items: List<Int> = emptyList(),
    @IgnoreOnParcel
    val otherCounter: Int = 0
) : Parcelable

class ExampleViewModel(config: Config) : ViewModel(config) {

    var state: ExampleViewModelState by state(ExampleViewModelState())
        private set

    private val mutex = Mutex()

    fun updateState() = viewModelScope.launch(Dispatchers.Default) {
        coroutineScope {
            while (isActive) {
                delay(300)
                repeat(30) {
                    launch {
                        mutex.withLock {
                            state = state.copy(counter = state.counter + 1)
                        }
                    }
                }
            }
        }
    }
}

ViewModel's parameter config is just a way to provide some platform dependent information's like savedStateHandle, some key-value params etc. Let's consider an implementation detail for now.

ExampleViewModel has the following interface on iOS platform.

struct ExampleView: View {
    
    @ObservedViewModel var viewModel = ExampleViewModel(config: MvvmConfig())
    
	var body: some View {
        Text("Counter = \(viewModel.state.counter)")
            .onTapGesture {
                viewModel.updateState()
            }
	}
}

The key here is the ObservedViewModel property wrapper which wires-up our common code with platform "reactivity" and provides other enhancements to make usage of common shared code easier. As presented, we can access the properties of state value directly via getter and trigger it's updates in asynchronous manner via non suspending API of ViewModel expecting updates to be reflected within the UI. The details of ObservedViewModel and other wrappers will be discussed later in this document. I highly encourage to check the source code of ios app within the project rather than reading this document all the way down.

On Android, ExampleViewModel's interface is as follows.

setContent {

    val viewModel: ExampleViewModel = viewModel {
        ExampleViewModel(config = Config(createSavedStateHandle()))
    }

    MyApplicationTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colors.background
        ) {
            Column(modifier = Modifier.fillMaxSize()) {
                Greeting(
                    modifier = Modifier
                        .clickable { viewModel.updateState() }
                        .align(Alignment.CenterHorizontally),
                    text = viewModel.state.counter.toString()
                )
            }
        }
    }
}

On Android API of ViewModel is the same as on iOS, the ViewModel is created via utility functions just to properly attach it to lifecycle of chosen components. Platform's Config implementation is expecting SavedStateHandle, because that's the way the state is persisted on process death, but that's also an implementation detail.

Implementing the state management

The purpose of this project is to demonstrate another way to wire state management tricks on KMM apps, so let's dig first into this and then other components. I highly encourage to read the source code of state module, because that's where the "core" of the approach is (more "vm oriented" elements are in mvvm module).

StateHolder

The basic element of state update hack is StateHolder interface. Which should be implemented by "base state holder", typically an abstract ViewModel, some kind of abstract Store, or a abstract domain object if you are this MV person.

The expect common implementation of StateHolder goes as follows

expect interface StateHolder

On Android:

actual interface StateHolder

And finally on iOS some things change.

actual interface StateHolder {

    var objectWillChange: () -> Unit
}

The objectWillChange variable is a callback that is needed to be set to receive updates. I wanted to somehow make it non mutable etc. but encountered some issues regarding initialization in Swift, so for now it is a var and the "binding" of proper update is wired up automatically via helper Swift classes.

State delegate

Other important class is the sate property wrapper which in fact wraps our state in common code.

expect class State<T : Any> {

   operator fun getValue(thisRef: Any?, property: KProperty<*>): T

   operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T)
}

The implementation on Android goes as follows:

actual open class State<T : Any>(
    initialValue: T,
) {

    protected var value by mutableStateOf(initialValue)

    actual open operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value

    actual open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        this.value = value
    }
}

Here the value of initial state is kept withing MutableState of Compose Runtime, which makes it easily observable in Compose world. The saveValue value probably should not be here, but I was experimenting recently. Though it is triggered with every write to the value so some "external saver" (... SavedStateHandle) can persist the value.

On iOS the implementation goes as follows:

actual open class State<T : Any>(
    private var initialValue: T,
    private val objectWillChange: () -> Unit
) {

    actual open operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return initialValue
    }

    actual open operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        initialValue = value
        objectWillChange()
    }
}

After every successful set, the objectWillChange lambda is triggered, to notify platform that the new state value is available. The call to objectWillChange can probably be limited scenarios when the new value differs from previous one.

Wiring extensions in shared module

To wire everything there is an extension function defined in common module

expect fun <T : Any> StateHolder.state(
    initialValue: T
): State<T>

With following implementation on iOS

actual fun <T : Any> StateHolder.state(initialValue: T): State<T> =
    State(initialValue) { objectWillChange() }

And Android

actual fun <T : Any> StateHolder.state(initialValue: T): State<T> =
    State(initialValue)

Wiring on iOS platform

To make things observable in iOS world we normally make classes implement ObservableObject protocol and from something that conforms to ObservableObject protocol with special property wrappers like @Published updated can be scheduled on write operation to those properties. Other way is to manually call objectWillChange from ObservableObject protocol. That's the approach that was chosen to StatHolder implementations reflect changes made to state properties.

import Foundation
import common

class ObservableStateHolder<StateHolder>: ObservableObject where StateHolder: common.StateStateHolder {

    var stateHolder: StateHolder

    init(_ stateHolder: StateHolder) {
        self.stateHolder = stateHolder
        self.stateHolder.objectWillChange = { [weak self] in
            DispatchQueue.main.async {
                self?.objectWillChange.send()
            }
        }
    }
}

The implementation above takes care of dispatching the changes to main thread on iOS side, while also being not blocking the ability to be collected from memory by ARC.

Now the stake holders reflect the changes, but their API is a bit different than on Android. To "flatten" the API a @propertyWrapper is needed. The wrapper is mixed with StateObject and ObservedObjects to glue everything with SwiftUI's patterns and also expose clean API to use the StateHolder.

Example ObservedStateHolder implementation is presented below

import Foundation
import SwiftUI
import common

@propertyWrapper
struct ObservedStateHolder<StateHolder>: DynamicProperty where StateHolder: common.StateStateHolder {

    @ObservedObject private var stateHolderObservable: ObservableStateHolder<StateHolder>

    init(wrappedValue: StateHolder) {
        self.stateHolderObservable = ObservableStateHolder(wrappedValue)
    }

    var wrappedValue: StateHolder {
        get { return stateHolderObservable.stateHolder }
        set { stateHolderObservable.stateHolder = newValue }
    }

    var projectedValue: ObservedObject<ObservableStateHolder<StateHolder>>.Wrapper {
        self.$stateHolderObservable
    }
}

With help of property wrapper the final API of the observable StateHolder that reflects changes on iOS side looks like that:

import SwiftUI
import common

struct ExampleView: View {
    
    @ObservedStateHolder var domainObject = DomainObject()
    
	var body: some View {
        Text("Name = \(domainObject.name)")
            .onTapGesture {
                domainObject.updateName()
            }
	}
}

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ExampleView()
	}
}

Where DomainObject is just a example of something which conforms to StateHolder interface with stateful name field.

On Android the usage looks as follows

            val domainObject = remember { DomainObject() }

            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    Column(modifier = Modifier.fillMaxSize()) {
                        Greeting(
                            modifier = Modifier
                                .clickable { domainObject.updateName() }
                                .align(Alignment.CenterHorizontally),
                            text = domainObject.name
                        )
                    }
                }
            }