Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Reactive StateManagement with Model-View-Intent

Reactive StateManagement with Model-View-Intent

mobiconf 2017, Krakow

Managing application state is not a simple topic especially on Android with a synchronous and asynchronous source of data, components having different lifecycles, back stack navigation and process death.

Model-View-Intent (MVI) is an architectural design pattern to separate the View from the Model. In this talk, we will discuss the idea behind MVI and how this pattern compares to other architectural patterns like Flux, Redux, Model-View-Presenter or Model-View-ViewModel. Furthermore, we will discuss what a Model actually is and how Model is related to State.

Once we have understood the role of Model and State we will focus on state management by building an unidirectional data flow.

Finally, we will connect the dots with RxJava to build apps with deterministic state management in a reactive way that makes maintaining and testing such apps easy.

Hannes Dorfmann

October 05, 2017
Tweet

More Decks by Hannes Dorfmann

Other Decks in Programming

Transcript

  1. 1

  2. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 Rainer Weiss Kip Thorne

    Barry Barish Nobel laureates in Physics 2017 Gravitational waves
  3. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 2 black holes melded

    1.3 Billion years ago Gravitational waves detected
  4. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons() { view?.showLoading(true) backend.loadPersons({

    persons : List<Person> -> view?.showPersons(persons) }, { error: Throwable -> view?.showError(error) }) } }
  5. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons() { view?.showLoading(true) backend.loadPersons({

    persons : List<Person> -> view?.showPersons(persons) }, { error: Throwable -> view?.showError(error) }) } }
  6. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.showLoading(true) backend.loadPersons({ persons

    : List<Person> -> view?.showPersons(persons) }, { error: Throwable -> view?.showError(error) }) } }
  7. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.showLoading(true) backend.loadPersons({ persons

    : List<Person> -> view?.showPersons(persons) }, { error: Throwable -> view?.showError(error) }) } }
  8. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.showLoading(true) backend.loadPersons({ persons

    : List<Person> -> view?.showPersons(persons) }, { error: Throwable -> view?.showError(error) }) } }
  9. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.showLoading(true) // Displays

    a ProgressBar backend.loadPersons({ persons : List<Person> -> view?.showPersons(persons) // Displays a ProgressBar }, { error: Throwable -> view?.showError(error) // Displays a error message }) } }
  10. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  11. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  12. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  13. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  14. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  15. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  16. class PersonsViewModel : ViewModel { val loadingLiveData : MutableLiveData<Boolean> …

    fun loadPersons(){ loadingLiveData.setValue((true) backend.loadPersons({ persons : List<Person> -> loadingLiveData.setValue( false ) personsLiveData.setValue( persons ) }, { error: Throwable -> loadingLiveData.setValue( false ) errorLiveData.setValue( error ) }) } }
  17. data class PersonsModel ( val loading : boolean, val persons

    : List<Person>?, val error : Throwable? )
  18. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.render(PersonsModel ( true,

    null, null) ) backend.loadPersons({ persons : List<Person> -> view?.render(PersonsModel ( false, persons, null) ) }, { error: Throwable -> view?.render(PersonsModel ( false, null, error) ) }) } }
  19. class SearchFragment : Fragment , SearchView { ... override fun

    searchIntent() : Observable<String> { return RxSearchView.queryTextChanges(searchView) // Thanks Jake Wharton } }
  20. class SearchFragment : Fragment , SearchView { ... override fun

    render( viewState : SearchViewState) { if (viewState.loading) renderLoading() else if (viewState.result != null) renderResult(viewState.result) else if (viewState.error != null) renderError() else throw IllegalStateException("Unknown ViewState") } }
  21. class SearchPresenter : MviBasePresenter<SearchView> { val SearchInteractor searchInteractor; override fun

    bindIntents() { val searchState : Observable<SearchViewState> = intent(SearchView::searchIntent) .filter { term -> term.size >= 3 } .switchMap(searchInteractor::search) subscribeViewState(searchState, SearchView::render) } }
  22. class SearchInteractor { val searchEngine : SearchEngine // Makes http

    calls fun search(searchString : String) : Observable<SearchViewState>{ return searchEngine.searchFor(searchString) // Observable<List<Product>> .map(products -> SearchViewState(false, products, null)) .startWith(SearchViewState(true, null, null)) .onErrorReturn(error -> SearchViewState(false, null, error)); } }
  23. class SearchInteractor { val searchEngine : SearchEngine // Makes http

    calls fun search(searchString : String) : Observable<SearchViewState>{ return searchEngine.searchFor(searchString) // Observable<List<Product>> .map(products -> new SearchViewState(false, products, null)) .startWith(new SearchViewState(true, null, null)) .onErrorReturn(error -> new SearchViewState(false, null, error)); } }
  24. class SearchInteractor { val searchEngine : SearchEngine // Makes http

    calls fun search(searchString : String) : Observable<SearchViewState>{ return searchEngine.searchFor(searchString) // Observable<List<Product>> .map(products -> new SearchViewState(false, products, null)) .startWith(new SearchViewState(true, null, null)) .onErrorReturn(error -> new SearchViewState(false, null, error)); } }
  25. class SearchInteractor { val searchEngine : SearchEngine // Makes http

    calls fun search(searchString : String) : Observable<SearchViewState>{ return searchEngine.searchFor(searchString) // Observable<List<Product>> .map(products -> new SearchViewState(false, products, null)) .startWith(new SearchViewState(true, null, null)) .onErrorReturn(error -> new SearchViewState(false, null, error)); } }
  26. class SearchInteractor { val searchEngine : SearchEngine // Makes http

    calls fun search(searchString : String) : Observable<SearchViewState>{ return searchEngine.searchFor(searchString) // Observable<List<Product>> .map(products -> new SearchViewState(false, products, null)) .startWith(new SearchViewState(true, null, null)) .onErrorReturn(error -> new SearchViewState(false, null, error)); } }
  27. Cam searchIntent filter() flatMap() startWith( new Loading() ) L map(

    new SR() ) SR view.render( SearchViewState.SearchResult ) Cam C Ca Cam Cam
  28. Cam searchIntent filter() flatMap() startWith( new Loading() ) L map(

    new SR() ) SR view.render( SearchViewState.SearchResult ) Cam C Ca Cam Cam
  29. Cam searchIntent filter() flatMap() startWith( new Loading() ) L map(

    new SR() ) SR view.render( SearchViewState.SearchResult ) Cam C Ca Cam Cam
  30. interface HomeView { fun loadFirstPageIntent() : Observable<Unit> fun loadNextPageIntent() :

    Observable<Unit> fun pullToRefreshIntent() : Observable<Unit> fun loadAllProductsFromCategoryIntent() : Observable<String> fun render(viewState : HomeViewState) }
  31. class HomePresenter : MviBasePresenter<HomeView> { val HomeFeedLoader feedLoader; override fun

    bindIntents() { val firstPage : Observable<FeedLoaderState> = ... val pullToRefresh : Observable<FeedLoaderState> = ... val nextPage : Observable<FeedLoaderState> = ... val loadMoreFromCategory : Observable<FeedLoaderState> = ... val allIntents : Observable<FeedLoaderState> = Observable.merge(firstPage, pullToRefresh, nextPage, loadMoreFromCategory) stateObservable : Observable<HomeViewState> = allIntents.scan(this::reduceState) subscribeViewState(stateObservable, HomeView::render) }
  32. class HomePresenter : MviBasePresenter<HomeView> { val HomeFeedLoader feedLoader; override fun

    bindIntents() { val firstPage : Observable<FeedLoaderState> = ... val pullToRefresh : Observable<FeedLoaderState> = ... val nextPage : Observable<FeedLoaderState> = ... val loadMoreFromCategory : Observable<FeedLoaderState> = ... val allIntents : Observable<FeedLoaderState> = Observable.merge(firstPage, pullToRefresh, nextPage, loadMoreFromCategory) stateObservable : Observable<HomeViewState> = allIntents.scan(this::reduceState) subscribeViewState(stateObservable, HomeView::render) }
  33. class HomePresenter : MviBasePresenter<HomeView> { val HomeFeedLoader feedLoader; override fun

    bindIntents() { val firstPage : Observable<FeedLoaderState> = ... val pullToRefresh : Observable<FeedLoaderState> = ... val nextPage : Observable<FeedLoaderState> = ... val loadMoreFromCategory : Observable<FeedLoaderState> = ... val allIntents : Observable<FeedLoaderState> = Observable.merge(firstPage, pullToRefresh, nextPage, loadMoreFromCategory) stateObservable : Observable<HomeViewState> = allIntents.scan(this::reduceState) subscribeViewState(stateObservable, HomeView::render) }
  34. class HomePresenter : MviBasePresenter<HomeView> { val HomeFeedLoader feedLoader; override fun

    bindIntents() { val firstPage : Observable<FeedLoaderState> = ... val pullToRefresh : Observable<FeedLoaderState> = ... val nextPage : Observable<FeedLoaderState> = ... val loadMoreFromCategory : Observable<FeedLoaderState> = ... val allIntents : Observable<FeedLoaderState> = Observable.merge(firstPage, pullToRefresh, nextPage, loadMoreFromCategory) stateObservable : Observable<HomeViewState> = allIntents.scan(this::reduceState) subscribeViewState(stateObservable, HomeView::render) }
  35. fun reduceState ( previous : HomeViewState, feedLoaderState : FeedLoaderState )

    : HomeViewState { val newState : HomeViewState = … // compute the new State // by taking previous state and foo into account return newState; }
  36. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 Why Unit Tests -

    Test code in isolation - Reliable (not flaky) - Run fast - Testing UI as part of an Integration Test is senseless, since View is dump
  37. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 What if we architect

    and write Test Code the way we write production code?
  38. class SearchFragment : Fragment , SearchView { ... override fun

    searchIntent() : Observable<String> { return RxSearchView.queryTextChanges(searchView) } }
  39. class SearchFragment : Fragment , SearchView { ... override fun

    render( viewState : SearchViewState) { if (viewState.loading) renderLoading() else if (viewState.result != null) renderResult(viewState.result) else if (viewState.error != null) renderError() else throw IllegalStateException("Unknown ViewState") } }
  40. class SearchFragment : Fragment , SearchView { @Inject var viewBinding

    : SearchViewBinding override fun searchIntent() : Observable<String> { return viewBinding.searchIntent() } override fun render( viewState : SearchViewState) { viewBinding.render(viewState) } }
  41. class SearchViewBinding : SearchView { val rootView : ViewGroup override

    fun searchIntent() : Observable<String> { return RxSearchView.queryTextChanges(searchView) } override fun render( viewState : SearchViewState) { if (viewState.loading) … } }
  42. class TestSearchViewBinding : SearchViewBinding { var statesList : List<SearchViewState> val

    renderedStates : BehaviorSubject<List<SearchViewState>> override fun render( viewState : SearchViewState ) { super.render(viewState) statesList = statesList.copyAndAdd(viewState) renderedStates.onNext(statesList) } }
  43. class TestSearchViewBinding : SearchViewBinding { var statesList : List<SearchViewState> val

    renderedStates : BehaviorSubject<List<SearchViewState>> override fun render( viewState : SearchViewState ) { super.render(viewState) statesList = statesList.copyAndAdd(viewState) renderedStates.onNext(statesList) Screenshot.snap(rootView).record() } } http://facebook.github.io/screenshot-tests-for-android
  44. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { onView(withId(…).perform(typeText(“Camera“) val states = viewBinding.renderedStates.blockingFirst() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } }
  45. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { onView(withId(…).perform(typeText(“Camera“) val states = viewBinding.renderedStates.blockingFirst() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } }
  46. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { onView(withId(…).perform(typeText(“Camera“) val states = viewBinding.renderedStates.blockingFirst() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } }
  47. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { onView(withId(…).perform(typeText(“Camera“) val states = viewBinding.renderedStates.blockingFirst() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } }
  48. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { onView(withId(…).perform(typeText(“Camera“) val states = viewBinding.renderedStates.blockingFirst() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } } INPUT OUTPUT ASSERT.
  49. class Inputs (val viewBinding : TestSearchViewBinding) { fun writeIntoSearchBox( term

    : String) { onView(withId(…).perform(typeText(term) } }
  50. class Output (val viewBinding : TestSearchViewBinding) { fun renderedStates() :

    List<SearchViewState> = viewBinding.renderedStates.blockingFirst() }
  51. class Assertions (val inputs : Inputs, val output : Output)

    { fun loadThenShowResults() = Completable.fromCallable { inputs.writeIntoSearchBox(“Camera“) val states = output.renderedStates() val loading = SearchViewState(true, null, null) val results = SearchViewState(false, someProducts, null) val expectedStates = listOf(loading, results) assertEquals(expectedStates, states) } }
  52. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { val assertions = Assertions ( Inputs(viewBinding) , Output(viewBinding) ) assertions.loadThenShowResults().awaitBlocking() } }
  53. class FooAndBarTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun fooAndBar() { val fooAssertions = FooAssertions (…) val barAssertions = BarAssertions (…) fooAssertions.something() .concatWith(barAssertions) .blockingAwait() } }
  54. class SearchFragmentTest { @Rule val rule = ActivityTestRule()
 val viewBinding

    : TestSearchViewBinding @Test fun onTyping3CharsShowLoadingThenResults() { val assertions = Assertions ( Inputs(viewBinding) , Output(viewBinding) ) assertions.loadThenShowResults().awaitBlocking() } }
  55. class JvmSearchViewTest { @Test fun onTyping3CharsShowLoadingThenResults() { val assertions =

    Assertions ( JvmInputs(realPresenter) , JvmOutput(realPresenter) ) assertions.loadThenShowResults().awaitBlocking() } }
  56. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 Why Unit Tests -

    Test code in isolation - Reliable (not flaky) - Run fast - Testing UI as part of an Integration Test is senseless, since View is dump
  57. MOBICONF.ORG | 5-6 OCTOBER 2017 #mobiconf2017 Model-View-Intent - takes state

    management serious —> Solves & avoids a lot of problems - Unidirectional dataflow - State driven by business logic - Composable - Easy to test