-
Notifications
You must be signed in to change notification settings - Fork 499
Home
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)
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
.
MvRxState is an immutable Kotlin data class that contains the properties necessary to render your screen.
MvRxViewModels handle the business logic and anything other than just rendering views. ViewModels own state and their state can be observed.
MvRxView is a LifecycleOwner
that has an invalidate()
function that gets called any time any of its ViewModels state changes.
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.
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.
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.
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.
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:
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.
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.
A ViewModel can be created in two ways:
- 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.
- 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.
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.
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.
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).
View the Advanced Concepts section for more info. This is normally done from init
.
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.
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
}
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
.
MvRx ships with three Kotlin delegates for creating and accessing a ViewModel:
-
fragmentViewModel
: Create a new or fetch an existing ViewModel scoped to thisFragment
. -
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 currentActivity
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.
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.
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.
- Fail has an
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
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)
returnsObservable<MyResponse>
-
execute
subscribes to the stream and immediately emitsLoading<MyResponse>
. It will then emit eitherSuccess<MyResponse>
orFail<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.
-
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()
}
}
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:
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.
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.
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 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.
In Android, there are two major cases in which persistence must be considered:
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.
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.
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
notvars
. - 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
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.
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.
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.
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.
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;
}
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