Skip to content
Eli Hart edited this page Feb 12, 2021 · 82 revisions

MvRx 1.x: Android on Autopilot

NOTE: The docs in this wiki are for the old 1.x version of MvRx. The documentation for Mavericks 2.x are at airbnb.io/mavericks.

MvRx (pronounced mavericks) is the Android framework from Airbnb that we use for nearly all product development at Airbnb. MvRx provides a framework that makes Android screens, from the simplest to the most complex, easier to write than before. However, it is built on top of existing components such as Fragments and architecture components so it doesn't constrain you and is easy to incrementally adopt.

When we began creating MvRx, our goal was not to create yet another architecture pattern for Airbnb, it was to make building products easier, faster, and more fun. All of our decisions have built on that. We believe that for MvRx to be successful, it must be effective for building everything from the simplest of screens to the most complex in our app.

MvRx is Kotlin first and Kotlin only. By being Kotlin only, we could leverage several powerful language features for a cleaner API. If you are not familiar with Kotlin, in particular, data classes, and receiver types, please run through Kotlin Koans or other Kotlin tutorials before continuing with MvRx.

MvRx is built on top of the following existing technologies and concepts:

  • Kotlin
  • Android Architecture Components
  • RxJava
  • React (conceptually)
  • Epoxy (optional)

Simple Example

data class MyState(val listing: Async<Listing> = Uninitialized) : MvRxState
class MyViewModel(override val initialState: MyState) : MvRxViewModel<MyState>() {
    init {
        fetchListing()
    }

    private fun fetchListing() {
        ListingRequest.forId(1234).execute { copy(listing = it) }
    }
}

class MyFragment : MvRxFragment() {
    private val viewModel: MyViewModel by fragmentViewModel()

    override fun invalidate() = withState(viewModel) { state ->
        loadingView.isVisible = state.listing is Loading
        titleView.text = listing()?.title
    }
}

This simple example fires a network request, handles loading states, displays the results, and handles rotation and other configuration changes. The listing response could also be shared with other screens in a flow by replacing fragmentViewModel with activityViewModel.

Core Concepts (tl;dr version)

State

MvRxState is an immutable Kotlin data class that contains the properties necessary to render your screen.

ViewModel

MvRxViewModels handle the business logic and anything other than just rendering views. ViewModels own state and their state can be observed.

View

MvRxView is a LifecycleOwner that has an invalidate() function that gets called any time any of its ViewModels state changes.

Async

Async is a Kotlin sealed class with 4 types: Uninitialized, Loading, Success, and Fail. MvRx comes with an easy extension to map Observable<T> to an Async<T> property on your state to make executing network requests and other actions trivially easy with a single line of code.

Core Concepts

State

MvRxState is an interface that you should extend with an immutable Kotlin data class. Your ViewModel will be generic on this state class. State can only be modified inside of a MvRxViewModel but you can observe it anywhere.

Immutability

MvRxState is forced to be immutable. If debug mode is on in your ViewModel, your state properties will be explicitly checked for immutability and will throw an exception if they are not. State should be mutated with Kotlin data class copy. For more information on how to deal with immutable lists and maps, view the section in Advanced Concepts.

State Creation

MvRx will create the initial state for your ViewModel automatically. This will use the default constructor if possible. However, if you need to pass some default state such as an id, you can create a secondary constructor that takes a parcelable args class. MvRx will automatically look for the key MvRx.KEY_ARG in the arguments in the Fragment that created the ViewModel and call the secondary constructor of the state class like this:

data class MyState(val foo: Int) : MvRxState {
    constructor(args: MyArgs) : this(args.foo)
}

You can have multiple secondary constructors if you need to and MvRx will use the one that matches your args type. If you need to access a context for dependency injection, you can also create initial state with a factory.

ViewModel<S>

A MvRxViewModel extends Google's own ViewModel. ViewModels make Android development dramatically simpler because they are tied to the "logical lifecycle" of your screen rather than the "view lifecycle". While Fragments, Views, and Activities get recreated on configuration changes, ViewModels do not. MvRx uses Kotlin delegates to either create a new ViewModel or return the existing instance that was already created. This lifecycle diagram clearly illustrates how ViewModels simplify lifecycles on Android:

ViewModel lifecycle

The main difference between Google ViewModels and MvRx ViewModels is that a MvRxViewModel is generic on a single immutable MvRxState data class rather than using LiveData. Views can only call functions on it and observe its state.

Initial State

All MvRxViewModels must be created with an initialState. In many cases, you can provide default values for all of its properties. In that case, MvRx will just create a new default instance. If not, refer to the MvRxState section to see how you can create initial state from Fragment args.

Airbnb engineers, refer to the Airbnb section on Fragment args for an even more streamlined way to pass args.

View Model Creation

A ViewModel can be created in two ways:

  1. If your ViewModel requires no external dependencies, create a single argument constructor with just state:
class MyViewModel(initialState: MyState) : BaseMvRxViewModel(initialState, debugMode = true)

Whenever your ViewModel is requested, it will automatically be created with the initial state.

  1. If your ViewModel requires external dependencies, or any constructor arguments besides initial state, have the ViewModel's companion object implement MvRxViewModelFactory:
class MyViewModel(initialState: MyState, dataStore: DataStore) : BaseMvRxViewModel(initialState, debugMode = true) {
    companion object : MvRxViewModelFactory<MyViewModel, MyState> {

        override fun create(viewModelContext: ViewModelContext, state: MyState): MyViewModel {
            val dataStore = if (viewModelContext is FragmentViewModelContext) {
              // If the ViewModel has a fragment scope it will be a FragmentViewModelContext, and you can access the fragment.
              viewModelContext.fragment.inject()
            } else {
              // The activity owner will be available for both fragment and activity view models.
              viewModelContext.activity.inject()
            }
            return MyViewModel(state, dataStore)
        }

    } 
}

This companion will be invoked anytime your ViewModel needs to be created. In tests, feel free to create the ViewModel directly via its constructor.

State factory

An alternative way of creating initial state is to override initialState in your ViewModelFactory.

class MyViewModel(initialState: MyState, dataStore: DataStore) : BaseMvRxViewModel(initialState, debugMode = true) {
    companion object : MvRxViewModelFactory<MyViewModel, MyState> {

        override fun initialState(viewModelContext: ViewModelContext): MyState? {
            // Args are accessible from the context.
            // val foo = vieWModelContext.args<MyArgs>.foo

            // The owner is available too, if your state needs a value stored in a DI component, for example.
            val foo = viewModelContext.activity.inject()
            return MyState(foo)
        }

    } 
}

If the initialState function returns a non-null state, it will be used over the state's default, or secondary arg constructor.

Accessing State

State can be accessed with the withState block like:

withState { state -> }

Unlike in a MvRxView/Fragment, this block is not run synchronously. All pending setState updates are run first so that your state is guaranteed to be up to date. MvRx will always run all pending setState updates before every single withState block so you could have code like this:

fun fetchSomething() = withState { state ->
    if (state.foo is Loading) return@withState
    MyRequest().execute { copy(foo = it) }
}

If you were to call fetchSomething() twice in succession, it would execute the first withState then process the state update from execute before running the withState call from the second fetchSomething(). Without this behavior, the second withState call would run before Loading() has been emitted from execute so the is Loading check would return false and you would end up executing a second request.

Updating State

ViewModels are the only object that can modify state. To do so, they can call setState. The setState block is called a reducer and is called with the current state as the receiver type of the block and returns the new state like this:

setState { copy(checked = true) }

For clarity, a verbose version of the API would look like:

setState { currentState ->
    return currentState.copy(checked = true)
}

The MvRx syntax leverages Kotlin receiver types and copy from Kotlin data classes.

Conceptually, this is extremely similar to React's setState function including the fact that it isn't run synchronously (see the section on threading).

Subscribing to State

View the Advanced Concepts section for more info. This is normally done from init.

View

A MvRxView is what the users sees and interacts with. It is free of business logic like network requests. MvRxView is a simple interface. It's main function is invalidate() which signals that state has changed and the view should update. At Airbnb, we implement MvRxView in a Fragment. We strongly recommend going this route because MvRx simplifies many of the frustrations around Fragments and Google is starting to invest in and build more libraries around them such as Architecture Components.

Views should be considered ephemeral. invalidate() will be called every time state changes. As a result, you should use something like epoxy or optimize your view's setters to no-op if the same data is set again.

Accessing State

State can be accessed with a withState block such as:

withState(viewModel) { state ->
  ...
}

If you have multiple viewModels, there are overloaded versions like:

withState(viewModel1, viewModel2) { state1, state2 -> }

In a MvRxView, this block is run synchronously with a snapshot of the state from your ViewModels. It also returns the last expression in the block so it can be used like this:

fun isInteractive() = withState(viewModel) { state ->
    state.foo is Success && state.bar is Success
}

ViewModel Scope

ViewModels can either be scoped to a Fragment or an Activity. A Fragment and Activity each contain a map of String keys to ViewModel instances. By default, the key is derived from the class name but you can override it. In general, if your state is only applicable to the current screen, scope it to the Fragment. However, if data is shared across multiple screens, scope it to the Activity.

Creating and Subscribing to a ViewModel

MvRx ships with three Kotlin delegates for creating and accessing a ViewModel:

  • fragmentViewModel: Create a new or fetch an existing ViewModel scoped to this Fragment.
  • activityViewModel: Create a new or fetch an existing ViewModel scoped to this Activity. This is useful for sharing data across multiple screens.
  • existingViewModel: Fetch an existing ViewModel from the current Activity scope. This is useful for views in the middle of a flow that should never need to create their own ViewModel and depend on one being created by an earlier screen.

Subscribing to State

View the Advanced Concepts section for more info. This should be done from onCreate, not onViewCreated. You might expect this to be in onViewCreated because you are updating views in your callback but MvRx handles the lifecycles and resubscriptions so they will only be invoked when there is a view.

Async<T>

MvRx makes dealing with async requests like fetching from a network, database, or anything that can be mapped to an observable incredibly easy. MvRx includes Async<T>. Async is a sealed class with four subclasses:

  • Uninitialized
  • Loading
  • Success
  • Fail
    • Fail has an error property to retrieve the failure.

You can directly call an Async object and it will return either the value if it is Success or null otherwise thanks to Kotlin's invoke operator. For example:

var foo = Loading()
println(foo()) // null
foo = Success<Int>(5)
println(foo()) // 5
foo = Fail(IllegalStateException("bar"))
println(foo()) // null

Observable.execute { ... }

MvRxViewModel ships with the following type extension: Observable<T>.execute(reducer: S.(Async<V>) -> S). This looks simple but its implications are powerful. When you call execute on an observable, it will subscribe to it, immediately emit Loading, and then will emit Success or Fail for onNext and onError events on the observable stream.

MvRx will automatically dispose of the subscription in onCleared of the ViewModel so you never have to manage the lifecycle or unsubscribing.

For each event it emits, it will call the reducer which takes the current state and returns an updated state. At Airbnb, our network requests are observables so we can execute them directly without having to worry about separate success and failure handlers as demonstrated in the example below.

Executing a network request looks like:

MyRequest.create(123).execute { copy(response = it) }

In this case:

  • MyRequest.create(123) returns Observable<MyResponse>
  • execute subscribes to the stream and immediately emits Loading<MyResponse>. It will then emit either Success<MyResponse> or Fail<MyResponse> depending on the result.
  • For each emission, it will call the reducer. This is simple to setState in the Updating State section. Inside the block:
    • it is the Async value emitted.
    • copy is the copy function from Kotlin data classes.
    • The block implicitly returns the result of the copy function which is the updated state.

Testing

Every MvRx release ships with a mvrx-testing artifact as well. The artifact ships with a MvRxTestRule which will run all MvRx reducers synchronously. This enables you to call ViewModel actions that update state and make assertions on the state right after. A simple test that increments a counter would look like this:

class CounterViewModelTest {

    @Test
    fun testIncrementCount() {
        // Create your ViewModel in your test. The rule won't apply to a field-instantiated ViewModel.
        val viewModel = CounterViewModel(CounterState())
        viewModel.incrementCount()
        withState(viewModel) { state ->
            assertEquals(1, state.count)
        }
    }

    companion object {
        @JvmField
        @ClassRule
        val mvrxTestRule = MvRxTestRule()
    }
}

Threading in MvRx

One challenging aspect of Android is that everything happens on the main thread by default. Interacting with views must be there but many other things such as business logic are only there because it is too much work to do so otherwise. One of the core tenants of MvRx is that it is thread-safe. Everything non-view related in MvRx can and does run on background threads. MvRx abstracts away most of the challenges of multi-threading. However, it is important to be aware of this when using MvRx. Some of its implications are:

Accessing state

State is accessed with a withState block:

withState(viewModel) { state ->
  ...
}

This guarantees that the value of state within the lambda is immutable for all of the code executed within the lambda. Any other calls to withState may receive a different state object if it has changed.

setState

When you call setState, your reducer is passed on a background queue. It is not accessed synchronously and there may be other state updates in the queue that will get executed. As a result, it is extremely important that you use the current state (this inside of your reducer)` to create your new state rather than using a copy saved from outside your reducer. React has exactly the same concept.

In other words, your reducer should be a pure function. For example: DO

fun toggleChecked() {
    setState { copy(toggled = !toggled) }
}

DO NOT

fun toggleChecked() {
    val (state) = withState()
    setState { copy(toggled = !state.toggled) }
}

In debug, MvRx will run your reducer twice to ensure that it returns the same state both times. For more information, read the prerequisite concepts for Redux reducers. The same concepts apply.

MvRx for React Engineers

Conceptually, MvRx will feel very familiar for those who are used to React.

React MvRx
ReactComponent#render() MvRxView#invalidate()
ReactComponent#state MvRxState
ReactComponent#setState() MvRxViewModel#setState()
React reconciliation Epoxy diffing
Components Epoxy

In MvRx things should have as few side effects as possible and views should be a "pure function" of the state of its ViewModels.

MvRx & Epoxy

MvRx integrates very well with Epoxy since it provides a reactive and efficient way to describe a layout - in fact, almost every page in the Airbnb app is a RecyclerView combined with Epoxy. Epoxy integration does not ship with the core MvRx library, but the sample app provides detailed code examples for how Epoxy can be used. For more information, see the page on MvRx & Epoxy.

Persistence

In Android, there are two major cases in which persistence must be considered:

Configuration Changes / Back Stack

Traditionally, you would use savedInstanceState to save important information. However, with MvRx, you will get the exact same instance of your ViewModel back so you have everything you need to re-render the view. Generally, screens won't need to implement savedInstanceState with MvRx.

Restoration in a New Process

Sometimes, Android will kill your process to save memory and restore your app in a new process with just savedInstanceState. In this case, Google recommends saving just enough data such as ids so that you can re-fetch what you need. When you return to your app, MvRx will attempt to recreate all ViewModels that existed in the original process. In most cases, the initial state created from default state or fragment arguments (see above) is enough. However, if you would like to persist individual pieces in state, you may annotate properties in your state class with @PersistState. Note that these properties must be Parcelable or Serializable and may not be Async objects because persisting and restoring loading states would lead to a broken user experience.

Debug Checks

MvRx has a debug mode that enables some new checks. When you integrate MvRx into your app, you will need to create your own MvRxViewModel that extends BaseMvRxViewModel. One of the required things to override is protected abstract val debugMode: Boolean. You could set it to something like BuildConfig.DEBUG. The checks include:

  • Running all state reducers twice and ensuring that the resulting state is the same. This ensures that reducers are pure. To understand this better, read the Redux prerequisite concepts.
  • All state properties are immutable vals not vars.
  • State types are not one of: ArrayList, SparseArray, LongSparseArray, SparseArrayCompat, ArrayMap, and HashMap.
  • State class visibility is public so it can be created and restored
  • Each instance of your State is not mutated once it is set on the viewmodel

What about...?

Lifecycles???

None of our sample code needs to override any lifecycles and this is no coincidence. MvRx is completely lifecycle-aware under the hood and all of its functions from execute to subscribe to invalidate(). MvRx will never deliver an event outside of the STARTED state and it will also clean up all execute subscriptions in onCleared of the ViewModel. You don't need to pair MvRx with AutoDispose or write any manual code to make MvRx work.

LiveData

Although MvRx uses RxJava under the hood, the MvRx APIs don't expose the RxJava subscriptions directly because you fundamentally just react to state changes. It does, however, have built-in functions for converting an RxJava Data stream to an Async stream. The code for that is here. You could write a similar extension function for LiveData if you wanted.

Custom views instead of Fragments?

At Airbnb, we use Fragments for our screens. MvRx eliminates most of the lifecycle issues that have caused problems in the past. MvRx doesn't have out of the box support for using custom views instead of Fragments but we would accept a contribution from the community.

Handle clicks and UI events

UI event listeners should exist in your Fragment/MvRxView. If it triggers a navigation event, it can happen right there. If it should trigger a state change, it should call a function on the relevant ViewModel which will trigger a state change that the view will then observer and re-render with.

Proguard/Dexguard/R8

The MvRx lib itself has a consumer Proguard file that configures Proguard. As the lib uses Kotlin, Kotlin reflection and RxJava you might have to add rules to handle those to the Proguard rules of your app itself. (If you did not add them for other purposes already). These are not part of the consumer Proguard file, as they are not part of the lib specific Proguard configuration. This is what that part of the configuration looks like for our sample app:

# These classes are used via kotlin reflection and the keep might not be required anymore once Proguard supports
# Kotlin reflection directly.
-keep class kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoaderImpl
-keep class kotlin.reflect.jvm.internal.impl.serialization.deserialization.builtins.BuiltInsLoaderImpl
-keep class kotlin.reflect.jvm.internal.impl.load.java.FieldOverridabilityCondition
-keep class kotlin.reflect.jvm.internal.impl.load.java.ErasedOverridabilityCondition
-keep class kotlin.reflect.jvm.internal.impl.load.java.JavaIncompatibilityRulesOverridabilityCondition

# If Companion objects are instantiated via Kotlin reflection and they extend/implement a class that Proguard
# would have removed or inlined we run into trouble as the inheritance is still in the Metadata annotation 
# read by Kotlin reflection.
# FIXME Remove if Kotlin reflection is supported by Pro/Dexguard
-if class **$Companion extends **
-keep class <2>
-if class **$Companion implements **
-keep class <2>

# https://medium.com/@AthorNZ/kotlin-metadata-jackson-and-proguard-f64f51e5ed32
-keep class kotlin.Metadata { *; }

# https://stackoverflow.com/questions/33547643/how-to-use-kotlin-with-proguard
-dontwarn kotlin.**

# RxJava
-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* {
   long producerIndex;
   long consumerIndex;
}

Kotlin reflection with Proguard/Dexguard/R8

The configuration provided for the lib assumes that your shrinker can correctly handle Kotlin @Metadata annotations (e.g. Dexguard 8.6++) if it cannot (e.g. Proguard and current versions of R8) you need to add these rules to the proguard configuration of your app:

# BaseMvRxViewModels loads the Companion class via reflection and thus we need to make sure we keep
# the name of the Companion object.
-keepclassmembers class ** extends com.airbnb.mvrx.BaseMvRxViewModel {
    ** Companion;
}

# Members of the Kotlin data classes used as the state in MvRx are read via Kotlin reflection which cause trouble
# with Proguard if they are not kept.
# During reflection cache warming also the types are accessed via reflection. Need to keep them too.
-keepclassmembers,includedescriptorclasses,allowobfuscation class ** implements com.airbnb.mvrx.MvRxState {
   *;
}

# The MvRxState object and the names classes that implement the MvRxState interfrace need to be
# kept as they are accessed via reflection.
-keepnames class com.airbnb.mvrx.MvRxState
-keepnames class * implements com.airbnb.mvrx.MvRxState

# MvRxViewModelFactory is referenced via reflection using the Companion class name.
-keepnames class * implements com.airbnb.mvrx.MvRxViewModelFactory