trbnb / MvvmBase

Apache License 2.0
15 stars 7 forks source link
android databinding kotlin mvvm

What is MvvmBase?

MvvmBase is an MVVM framework to use with Jetpack Compose (and Data Binding for compatibility reasons). It is written in Kotlin and best used with it.

Setup

MvvmBase is available via Maven Central. To use it put this in your build.gradle:

dependencies {
    def mvvmbaseVersion = "3.0.4"

    [...]
    implementation de.trbnb:mvvmbase-core:$mvvmbaseVersion"

    // Data Binding compatibility extensions
    implementation de.trbnb:mvvmbase-databinding:$mvvmbaseVersion"

    // RxJava 2 extensions for Data Binding
    implementation de.trbnb:mvvmbase-rxjava2:$mvvmbaseVersion"

    // RxJava 3 extensions for Data Binding
    implementation "de.trbnb:mvvmbase-rxjava3:$mvvmbaseVersion"

    // Coroutines extensions for Data Binding
    implementation "de.trbnb:mvvmbase-coroutines:$mvvmbaseVersion"

    // Conductor support for Data Binding
    implementation "de.trbnb:mvvmbase-conductor:$mvvmbaseVersion"
}

Usage

ViewModels can simply be declared like this:

class MyViewModel : BaseViewModel()

The BaseViewModel implements the ViewModel interface which has these major features:

notifyPropertyChanged(String)/observeAsState()

To notify observers that the values of properties have changed they can be notified by calling notifyPropertyChanged with the propertys name or alternatively a property reference.

To observe a specific property as a Compose state observeAsState can be called:

viewModel::isLoading.observeAsState()
viewModel::textInput.observeAsMutableState()

Observable properties

To write observable properties without having to call notifyPropertyChanged all the time one can use observable() as delegate property. Alternatively getter-only properties can be annotated with DependsOn to specify when it may have changed depending on other properties.

class MyViewModel : BaseViewModel() {
    var isLoading by observable(false)

    @DependsOn("isLoading")
    val showItems: Boolean
        get() = !isLoading
}

ObservableProperty extensions

The ObservableProperty also can be customized to react to value changes, etc. These extensions can be used in a builder pattern way like this:

var foo by observable("")
    .distinct()
    .beforeSet { old, new ->
        Log.d(TAG, "Value is about to be changed from $old to $new")
    }
    .validate { old, new ->
        if (new.length > old.length) new else old
    }
    .afterSet { old, new ->
        Log.d(TAG, "Value changed: $new")
    }

This is what those extensions do:

Saving state

Jetpack libraries provide a way to save state of ViewModels via a SavedStateHandle. An implementation for that is provided as previously mentioned with BaseStateSavingViewModel. It contains a getter property for that handle that will have to be passed as constructor parameter.

ObservableProperty also support the handle. By default the library tries to automatically derive a key from the property name for supported types. A key can also be defined manually or saving state can be disabled.

Example:

// defaults to StateSaveOption.Automatic for supported types (see below), otherwise StateSaveOption.None
var text1 by observable("")

// key is derived automatically
var text2 by observable("", stateSaveOption = StateSaveOption.Automatic)

// key is specified manually
var text3 by observable("", stateSaveOption = StateSaveOption.Manual("progress"))

// no state saving for this property
var text4 by observable("", stateSaveOption = StateSaveOption.None)

Supported types

As mentioned above StateSaveOption.Automatic is applied implicitly to most types. This includes all primitive types (unsigned ones too) and the following:

Note that this excludes ArrayList and SparseArray which are supported by SavedStateHandle but that is dependent on the type they are containing. Type erasure makes it dificult to check for this so the default for those types is StateSaveOption.None (StateSaveOption.Automatic can still be applied manually for the right types).

Event channel

Every ViewModel has an EventChannel. This can be used to transfer information to the view that are not state (e.g. showing a temporary error as toast).

class MyViewModel : BaseViewModel() {
    fun somethingHappened() {
        eventChannel(ToastEvent("Error message!"))
    }
}

class ToastEvent(val message: String) : Event

Because of lifecycles it can happen that events are called when no listener is registered. By default these events are kept in memory until a listener is registered. That listener will then be called with all those events in the same order in which they were raised.
This behavior can be turned off in the BaseViewModel by overriding memorizeNotReceivedEvents to let it return false.

Commands

Commands have two major functions.

  1. They can be invoked.
  2. They can be enabled. If it is not enabled but still invoked it will throw an Exception.

Commands can also take a parameter and return a value. A parameter-less command should use Unit as parameter type.

Commands are mainly used when a user has to interact with the UI and the ViewModel has to react to that. For that a Command is bound to a certain type of event of a UI element.

Examples

The first step is to create a Command in your ViewModel. Since Command is only an interface you have to choose which implementation you want to use. This library includes two from the start (you can of course also write your own implementation):

Both commands let you pass a function in the constructor and come with a helper function to create a parameter-less instance without specifying Unit as parameter type. This function will then be invoked when the Command is invoked.

class MyViewModel : BaseViewModel() {
    val isLoading by observable(false)

    val fetchCommand = ruleCommand(
      enabledRule = { !isLoading },
      action = {/*do some fetching */},
      dependencyProperties = listOf(::isLoading)
    )

    val someSimpleCommand = simpleCommand(isEnabled = false) {
      // do some stuff
    }

    fun function() {
      someSimpleCommand.isEnabled = true
    }
}

The enabled state of a Command can be observed as a Compose state like this:

viewModel.command::isEnabled.observeAsState()

Other ViewModel features

onDestroy

Will be called when the instance is about to be destroyed. This should be used to clear references to avoid memory leaks.

Lifecycle

The ViewModel also implements LifecycleOwner which allows LiveData, rx.Observable, etc. to cancel listeners, subscriptions and others automatically.

Its state is:

Nested ViewModels

Sometimes it can be useful to have multiple ViewModels inside another ViewModel (ViewModels for list items e.g.). In this case the "child" ViewModels must be destroyed when the "parent" is destroyed. For this purpose the function autoDestroy() can be used.

It can also be quite handy to send events from a "child" ViewModel and to have the "parent" emit them as well. This can be achieved via bindEvents().

Example:

class MainViewModel : BaseViewModel() {
    var items by bindable<List<ViewModel>>(emptyList())
        .beforeSet { old, new ->
            old.destroyAll()
            new.autoDestroy().bindEvents()
        }

    fun fetch() {
        items = loadData()
            .map { /* map data to ItemViewModel */ }
    }
}

Because this can be common and tedious .asChildren() can be used instead:

class MainViewModel : BaseViewModel() {
    var items by observable<List<ViewModel>>(emptyList())
        .asChildren()

    fun fetch() {
        items = loadData()
            .map { /* map data to ItemViewModel */ }
    }
}

Note however that this does override beforeSet so asChildren takes an action for beforeSet.

DataBinding compatibility

Previous versions of the library were meant to be used with DataBinding. The documentation for the compatibility modules is here.