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.
- Cleanup code
- Re-think project structure, especially separation of
state
andmvvm
modules. - Create a better "Real life example app"
- Re-thing distribution, maybe a library*
- 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.
Investigate the example below. Some basic requirements that were taken into account regarding the ViewModel approach.
ExampleViewModel
is shared both on Android and iOS.- Coroutines launched within this ViewModel's scope will be cancelled automatically both on Android and iOS.
- Interface of this ViewModel does not contain suspend functions. It can be easily called from Android and iOS.
- 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".
- Current state which is
ExampleViewModelState
data class is represented viastate
field. Both platforms reflect changes that are made tostate
value. - Explicit synchronization is needed when state is updated from threads other than main.
- 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.
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).
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.
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.
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)
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
)
}
}
}