Four years into the adoption of the Model-View-Intent (MVI) architecture for their Android app, Yelp engineer Paul Martin says it allowed them to have performant screens and improve unit testing.
Before adopting MVI, Yelp was using the Model-View-Presenter (MVP) architecture, which had the main shortcomings of producing larger and more complex files as the app pages grew in complexity.
Our presenters grew to have far too many lines of code and became unwieldy and awkward to maintain as we needed to add more state-management and create more complex presenter logic for MVP pages
Yelp engineers also evaluated the possibility of switching to the Model-View-ViewModel (MVVM) architecture, which is more suitable for event-driven, reactive UIs. While MVVM mitigated some of the shortcomings of MVP, it could still lead to larger data classes with many properties as the view complexity grows. Additionally, Yelp engineers found that MVVM mixed poorly with their own Bento framework they based their app user interface on.
At the foundation of the Model-View-Intent (MVI) architecture is the notion of intent, which represents the user intention behind a given event received by the UI. The user intent is converted into an action which is responsible to update the view state, which the view then renders on screen. In MVI, the flow of data related to events and states can be represented through a reactive stream that both the model and the view subscribe to for changes.
One weak point in MVI is the mapping between events and actions, which is usually accomplished in big switch
statement. This leads to obvious limitations on scalability. Yelp engineers circumvented this issue by annotating methods implementing actions with their corresponding events, e.g.:
@Event(HeaderClicked::class)
fun onHeaderClick() {
// do something
}
@Event(BodyClicked::class)
fun onBodyClick() {
// do something
}
@Event(FooterClicked::class)
fun onFooterClick() {
// make network request etc
}
At runtime, when the view and the model are created, annotations are processed to create a mapping between events and actions.
In addition to this, Yelp engineers also addressed the issue of growing code complexity, as a result of views growing more complex, with the idea of sub-presenters, which can be though of as sub-models. In short, instead of defining all actions in a single model, a sub-presenter enables modularization them. For example, we can modify the previous example so the header and footer click action handlers belong to a different sub-presenter:
@Event(BodyClicked::class)
fun onBodyClick() {
// do something
}
// The rest of click events are handled in here
@SubPresenter private val subPresenter = MyFeatureSubPresenter(eventBus)
Since events and states are transmitted in a stream — called eventBus
in the example above — any interested party can observe, there are no added dependencies introduced by this approach. An additional benefit it brought was simplifying unit testing by enabling recording events and states transmitted across the bus using presenter rules and using asserts to guarantee the expected outcome is produced:
fun whenButtonClicked_loadingProgressShown() {
presenterRule.sendEvent(ButtonClicked)
presenterRule.assertEquals { listOf(ShowLoadingProgress) }
}
Using MVI, Yelp engineers could move many actions to background threads, which improved the app performance. In particular, they could reduce by over 50% the average frame render time, and by almost 4% the number of frozen frames, which in turn translated into better results for their onboarding and sign-up use cases.