This module adds a lifecycle aware viewModel solution with support for navigation and observables. Kaluga favours an MVVM based architecture due to its clean delegation between UI and view state.
Installing
The library is available on Maven Central. You can import Kaluga Architecture as follows:
repositories {
// ...
mavenCentral()
}
// ...
dependencies {
// ...
implementation("com.splendo.kaluga:architecture:$kalugaVersion")
}
ViewModels
Check out the full documentation
ViewModels can be created by subclassing the BaseLifecycleViewModel class. The viewModel runs within its own coroutine scope that exists for the entire lifetime of the viewModel.
In addition viewModels have their own lifecycle, allowing them to the paused and resumed by the view. When the viewModel is resumed a CoroutineScope with a lifecycle limited to the resumed state is provided.
Launch any coroutines within this scope to automatically cancel when the viewModel is paused.
class SomeViewModel : BaseLifecycleViewModel() {
init {
coroutineScope.launch {
// Do stuff that happens even when viewModel paused
}
}
override fun onResume(scope: CoroutineScope) {
super.onResume(scope)
scope.launch {
// Do stuff that happens only when viewmodel is active
}
}
}
Android
On Android the AndroidX ViewModel serves as a base. The viewModel lifecycle matches onResume/onPause of the Activity or Fragment that created it.
To achieve this, the View should be bound to KalugaViewModelLifecycleObserver' using the 'bind() method.
class SomeActivity : Activity {
private val viewModel: SomeViewModel = SomeViewModel() // create ViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.bind(this)
}
}
For convenience default implementations for Activity, Fragment, and DialogFragment exist (KalugaViewModelActivity, KalugaViewModelFragment, and KalugaViewModelDialogFragment respectively).
class SomeActivity : KalugaViewModelActivity<SomeViewModel> {
override val viewModel: SomeViewModel = SomeViewModel // create ViewModel
}
The KalugaViewModelLifecycleObserver will automatically update the ActivityLifecycleSubscribable.LifecycleManager context of all ActivityLifecycleSubscribable added to BaseLifecycleViewModel.activeLifecycleSubscribables of the viewModel.
Implement this interface if a viewModel has properties that should be lifecycle or context aware.
It can be delegated using LifecycleManagerObserver.
The LifecycleManagerObserver is a default implementation of ActivityLifecycleSubscribable that provides updates to the context as a Flow.
iOS
On iOS the viewModel lifecycle should match ‘onDidAppear’/viewDidDisappear.
ViewControllers should also make sure they call clear on the ViewModel whenever they are done using the ViewModel, to ensure that the ViewModel coroutinescope is cleared.
class SomeViewController : UIViewController {
let viewModel: SomeViewModel = // create ViewModel
deinit {
viewModel.clear()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.didResume()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
viewModel.didPause()
}
}
Automatic setup can be achieved by binding a UIViewController to the viewModel using addLifecycleManager.
When bound the viewModel lifecycle is automatically matched with the viewControllers viewDidAppear and viewDidDisappear methods.
The resulting LifecycleManager should be unbound using unbind when no longer required. Unbinding will also clear the bound viewModel.
Automatic binding is achieved by adding an invisible child UIViewController to the bound viewController.
When the Lifecycle Manager is used you should therefore not remove all child UIViewController from the parent.
class SomeViewController : UIViewController {
let viewModel: SomeViewModel = // create ViewModel
var lifecycleManager: LifecycleManager
deinit {
lifecycleManager.unbind()
}
override func viewDidLoad() {
super.viewDidLoad()
lifecycleManager = viewModel.addLifecycleManager(parent: self, onLifecycle: { [weak self] in return [] }
}
}
Testing ViewModels
See the test-utils module for base test classes that help setting up the ViewModel in the main thread, while still allowing Dispatchers.Main to function.
Observables (and Subjects)
Kaluga supports data binding using Observables (one way binding) and Subjects (two way binding).
An observable can be created through a ReadOnlyProperty (making it immutable on both sides), a Flow (allowing the flow to modify the observeable), or a SharedFlow or StateFlow (allowing both the Flow and the owner of BaseFlowable to modify the observer.
Subjects can be created using either a ReadWritePropery or MutableSharedFlow or MutableStateFlow.
It’s also possible to use the observableOf and subjectOf methods for any value.
Observables/Subjects are intended to be used from the Main thread.
Goals and design
Kaluga observables are not meant as a replacement for fully reactive frameworks. Instead the focus is on using other such frameworks in a cross-platform manner, with a main focus on use in ViewModels.
In order to smoothly interact with observables from viewmodels, the following methods of observation are supported as much as possible:
- Delegation through the
byoperator. This lets you use your observables as normal variables. - Observing value changes through
StateFlow(which can also be used as aLiveData), this is useful especially for Android, e.g. to observe in Compose, or bind it using data binding. - Simple disposable listeners, for example for observing directly in iOS or integrating with another framework there (such as Combine).
- Asynchronously
posting value changes (also from background threads) to subjects. - A suspended
setmethod for subjects (which will suspend until the value has reached the underlying object).
Using observables in viewmodels also has some common problems:
- Distinguishing between a
nullvalue and a value not available yet. - Setting initial values for if no value is available yet.
- using default values instead of
null. - repeated updates with equal values.
Kaluga observables deal with this by only allowing specific subtypes:
UnintializedObservable, these hold anObservableOptionalvalue, which is either anObservableOptional.ValueorObservableOptional.Nothing. If the generic type an optional?, this means a Value can holdnull, which is distinguishable fromNothing.IntializedObservable, these can only hold anObservableOptional.Value.DefaultObservable, a subtype of anIntializedObservablewhere allnullvalues of the observedObservableOptional.Valueare replaced with a default value.
Equivalent subjects also exist.
For all of these updates of the same value are conflated and not pushes to observers.
Usage
The easiest way of creating an observable by calling the function observableOf or subjectOf:
val observable = observableOf(1)
This will result in an InitializedObservable<Int>. Now it’s possible to observe this value in several ways:
// property
assertEquals(1, observable.current)
// observation
observable.observe {
assertEquals(1, it)
}
// stateFlow
observable.stateFlow.collect {
assertEquals(1, it)
}
// delegation
val oo:ObservableOptional.Value by observable
assertEquals(1, oo.value)
// delegation of the value is also possible
val i:Int by observable.valueDelegate
assertEquals(1, i)
However since there is nothing backing the observable (other than an initial value), the value will never change. It makes more sense to make a subject this way:
val subject = subjectOf(1)
subject.post(2)
assertEquals(2, observable.current)
// or use delegation
var i:Int by subject.valueDelegate
assertEquals(2, i)
i = 3
assertEquals(3, observable.current)
Another method is using the extension methods provided on many types of classes, to*Observable() or to*Subject().
For example on a StateFlow:
val stateFlow = MutableStateFlow<String>(null)
val subject = stateFlow.toDefaultSubject()
Other than (Mutable)StateFlow there are extension methods for Flow, (Mutable)SharedFlow, ReadOnlyProperty, ReadWriteProperty.
Be aware that properties can not be observed, only when a value is read from the observable is the backing Property checked for changes (which will then propagate to observers).
Android
On Android both observable and subject can easily be converted into LiveData objects using the extension property liveData, allowing lifecycle-aware one way binding.
Subjects also have a liveDataObserver property that can be added to a liveData to automatically set values on the subject.
There are also asState() extension methods for use with Compose in the architecture-compose module.
Two way bindings can be done on the MutableStateFlow of the stateFlow field of any subject. Starting with Android Studio ArcticFox databinding also support binding directly to MutableStateFlow.
This required calling the bind method to provide a coroutine scope in which the binding takes place. *Flow based observables do this automatically.
iOS
On iOS value changes can be observed using observe(onNext: ...)).
Calling this returns a Disposable object. The caller is responsible for disposing the disposable after the object should no longer be observed using dispose().
A convenience DisposeBag is available to dispose multiple disposables at one. Use either DisposeBag.add() or Disposable.addTo() to add a Disposable to a DisposeBag.
DisposeBags can be emptied using dispose(). To post new data to the Subject the post() method can be called.
SwiftUI and Combine
Since Kotlin Native does not have access to pure Swift libraries, no out of the box solution for SwiftUI/Combine is provided.
Use the Kaluga SwiftUI scripts to add support to your project.
Usage from ViewController
When bound to a viewController, the LifecycleManager calls its onLifeCycleChanged callback automatically at the start of each cycle (viewDidAppear).
Use this callback to start observing data that should be bound to the lifecycle and return the resulting list of Disposable.
At the end of a lifecycle (viewDidDisappear) the Observables are automatically disposed.
class SomeViewController : UIViewController {
let viewModel: SomeViewModel = // create ViewModel
override func viewDidLoad() {
super.viewDidLoad()
lifecycleManager = viewModel.addLifecycleManager(
parent: self,
onLifecycle: { [weak self] in
guard let observable = self.viewModel.observable else {
return []
}
return [
observable.observe(onNext: { (value) in
// Handle value
}
]
}
)
}
Navigation
Navigation is available through a specialized NavigatingViewModel.
This viewModel takes a Navigator interface that responds to a given NavigationAction.
While a custom Navigator can be implemented, the default ActivityNavigator (Android) and ViewControllerNavigator (iOS) provide easy access to most common navigation patterns.
The Navigation action specifies the action(s) that can navigate.
sealed class SomeNavigationAction : NavigationAction<Nothing>(null) {
object ActionA : SomeNavigationAction()
object ActionB : SomeNavigationAction()
}
class SomeNavigatingViewModel(navigator: Navigator<SomeNavigationAction>): NavigatingViewModel<SomeNavigationAction>(navigator) {
fun performActionA() {
navigator.navigate(ActionA)
}
fun performActionB() {
navigator.navigate(ActionB)
}
}
On the platform side the created navigator should specify the NavigationSpec to be used for navigating.
Multiple specs exist per platform, including all common navigation patterns within the app (both new screens and nested elements) as well as navigating to common OS screens (e.g opening the mail app).
// Android
val viewModel = SomeNavigatingViewModel(
ActivityNavigator { action ->
when (action) {
is ActionA -> NavigationSpec.Activity(SomeActivity::class.java)
is ActionB -> NavigationSpec.Fragment(R.id.some_fragment_container, createFragment = { SomeFragment() })
}
})
// iOS
val viewModel = SomeNavigatingViewModel(
ViewControllerNavigator(viewController) { action ->
when (action) {
is ActionA -> NavigationSpec.Present(present = { someViewController() })
is ActionB -> NavigationSpec.Nested(containerView = containerView, nested = { someNestedViewController() })
}
}
)
To share data between navigating objects a NavigationBundle class can be passed via the NavigationAction.
This bundle supports all common data types as well as optionals, nested bundles and serializable objects.
Values can be extracted from the bundle given a known NavigationSpecRow.
Note that it is possible to request a navigationSpecRow not supported by the bundle, which will result in a NavigationBundleGetError.
sealed class SomeSpecRow<V>(associatedType: NavigationBundleSpecType<V>) : NavigationBundleSpecRow<V>(associatedType) {
object BooleanSpecRow : SomeSpecRow<Boolean>(NavigationBundleSpecType.BooleanType)
object SerializableSpecRow : SomeSpecRow<MockSerializable>(NavigationBundleSpecType.SerializedType(SomeSerializable.serializer()))
object OptionalString : SomeSpecRow<String?>(NavigationBundleSpecType.OptionalType(NavigationBundleSpecType.StringType))
object OptionalFloat : SomeSpecRow<Float?>(NavigationBundleSpecType.OptionalType(NavigationBundleSpecType.FloatType))
}
class SomeBundleSpec : NavigationBundleSpec<SomeSpecRow<*>>(setOf(SomeSpecRow.BooleanSpecRow, SomeSpecRow.SerializableSpecRow, SomeSpecRow.OptionalString, SomeSpecRow.OptionalFloat))
sealed class SomeBundleNavigationAction<B: NavigationBundleSpecRow<*>>(bundle: NavigationBundle<B>) : NavigationAction<B>(bundle) {
class SomeAction(bundle: NavigationBundle<SomeSpecRow<*>>) : SomeNavigationAction<NavigationBundle<SomeSpecRow<*>>>(bundle)
}
class SomeBundleNavigatingViewModel(navigator: Navigator<SomeBundleNavigationAction<*>>): NavigatingViewModel<SomeNavigationAction>(navigator) {
fun performAction() {
navigator.navigate(SomeBundleNavigationAction.SomeAction(SomeBundleSpec().toBundle { row ->
when(row) {
is BooleanSpecRow -> row.convertValue(true)
is SerializableSpecRow -> row.convertValue(someSerializable)
is OptionalString -> row.convertValue("")
is OptionalFloat -> row.convertValue(null)
}
}))
}
}
class BundleProcessor(bundle: NavigationBundle<SomeSpecRow<*>>) {
val booleanResult = bundle.get(SomeSpecRow.BooleanSpecRow)
val serializableResult = bundle.get(SomeSpecRow.SerializableSpecRow)
val optionalStringResult = bundle.get(SomeSpecRow.OptionalString)
val optionalFloatResult = bundle.get(SomeSpecRow.OptionalFloat)
}
Often for navigation it is desirable to pass only a single object.
Use the SingleValueNavigationAction to reduce boilerplate code in these cases
sealed class Navigation<T>(value: T, type: NavigationBundleSpecType<T>) : SingleValueNavigationAction<T>(value, type) {
class A(string: String) : TestNavigation<String>(string, NavigationBundleSpecType.StringType)
object B : TestNavigation<Unit>(Unit, NavigationBundleSpecType.UnitType)
}
A SingleValueNavigationAction has a SingleValueNavigationSpec that consists of a single SingleValueNavigationSpec.Row.
Convenience getters are available for the bundle. Simply pass the NavigationBundleSpecType of the action to the bundle.a
val type = NavigationBundleSpecType.SerializedType(SomeClass.serializer())
val action = SingleValueNavigationAction(SomeClass(), type)
action.bundle?.get(type) // returns SomeClass
On Android a Bundle can be directly converted to the associated property using toTypedProperty
val type = NavigationBundleSpecType.SerializedType(SomeClass.serializer())
val bundle = SingleValueNavigationAction(SomeClass(), type)?.bundle ?: return
val androidBundle = bundle.toBundle()
androidBundle.toTypedProperty(type) // returns SomeClass
Testing
Use the test-utils-archictecture module to test ViewModels.