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

Advanced Model-View-Intent: The Missing Guide

Hannes Dorfmann
June 26, 2018
880

Advanced Model-View-Intent: The Missing Guide

Presented at Droidcon Berlin 2018

Hannes Dorfmann

June 26, 2018
Tweet

Transcript

  1. Advanced Model-View-Intent The Missing Guide

  2. Kostiantyn Tarasenko Hannes Dorfmann

  3. None
  4. class PersonsPresenter : Presenter<PersonsView> { fun loadPersons(){ view?.showLoading(true) backend.loadPersons({ persons

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

    : List<Person> → view?.showLoading(false) view?.showPersons(persons) }, { error: Throwable → view?.showLoading(false) view?.showError(error) }) } }
  6. class PersonsViewModel : ViewModel { val loading : LiveData<Boolean> val

    persons : LiveData<List<Person>> val error : LiveData<Throwable> fun loadPersons(){ loading.setValue( true ) backend.loadPersons({ p : List<Person> → loading.setValue( false ) persons.setValue( p ) }, { e : Throwable → loading.setValue( false ) error.setValue( e ) }) }
  7. class PersonsViewModel : ViewModel { val loading : LiveData<Boolean> val

    persons : LiveData<List<Person>> val error : LiveData<Throwable> fun loadPersons(){ loading.setValue( true ) backend.loadPersons({ p : List<Person> → loading.setValue( false ) persons.setValue( p ) }, { e : Throwable → loading.setValue( false ) error.setValue( e ) }) }
  8. public TrainingSpotsPresenter(TrainingSpotsMvp.View view, TrainingSpotsMvp.Model model, NetworkManager networkManager, FreeleticsTracking tracking, ScreenTrackingDelegate

    screenTrackingDelegate, EventBuildConfigInfo eventBuildConfigInfo) { this.model = model; this.view = view; this.networkManager = networkManager; this.tracking = tracking; this.screenTrackingDelegate = screenTrackingDelegate; this.eventBuildConfigInfo = eventBuildConfigInfo; subscriptions = new CompositeDisposable(); } @Override public void setTrackingScreenName() { screenTrackingDelegate.setScreenName(tracking, TrainingSpotsEvents.TRAINING_SPOT_LIST_PAGE_ID); } @Override public void loadTrainingSpots() { if (!networkManager.isOnline()) { view.showNoInternetConnection(); return; } view.showProgress(true); if (model.hasNoGpsPermissions()) { loadDefaultTrainingSpots(); } else { subscriptions.add(model.checkForHighAccuracy() .subscribe(status -> loadDefaultTrainingSpots(), throwable -> loadDefaultTrainingSpots(), () -> subscriptions.add( model.getNextTrainingSpots() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(trainingSpots -> { view.showProgress(false); view.showLocalTrainingSpots(trainingSpots, model.getNearbyTrainingSpotThreshold()); view.showShareNearbyTrainingSpotBanner( model.shouldShowBanner(trainingSpots)); tracking.trackEvent( TrainingSpotsEvents .pageImpressionTrainingSpotsList( eventBuildConfigInfo, true, trainingSpots));
  9. model.shouldShowBanner(trainingSpots)); tracking.trackEvent( TrainingSpotsEvents .pageImpressionTrainingSpotsList( eventBuildConfigInfo, true, trainingSpots)); view.enablePaging(true); }, throwable

    -> { view.showProgress(false); if (throwable instanceof TimeoutException) { view.showTimeoutMessage(); } else { view.showConnectionError(); } })))); } } private void loadDefaultTrainingSpots() { subscriptions.add(model.getDefaultTrainingSpots() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(trainingSpots -> { view.showProgress(false); view.showDefaultTrainingSpots(trainingSpots); tracking.trackEvent(TrainingSpotsEvents.pageImpressionTrainingSpotsList( eventBuildConfigInfo, false, trainingSpots)); view.enablePaging(false); }, throwable -> { view.showProgress(false); view.showConnectionError(); })); } @Override public void loadMoreTrainingSpots() { if (!networkManager.isOnline()) { view.showNoInternetConnectionMessage(); view.enablePaging(false); return; } view.showLoadingMoreTrainingSpotsProgress(true); Disposable disposable = model.getNextTrainingSpots() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(trainingSpots -> { view.showLoadingMoreTrainingSpotsProgress(false);
  10. .subscribe(trainingSpots -> { view.showLoadingMoreTrainingSpotsProgress(false); view.showMoreLocalTrainingSpots(trainingSpots, model.getNearbyTrainingSpotThreshold()); view.showShareNearbyTrainingSpotBanner(model.shouldShowBanner(trainingSpots)); }, throwable ->

    { view.showLoadingMoreTrainingSpotsProgress(false); view.showConnectionErrorMessage(); view.enablePaging(false); }); subscriptions.add(disposable); } @Override public void handleTrainingSpotSelection(TrainingSpot trainingSpot) { tracking.trackEvent(TrainingSpotsEvents.trainingSpotDetails(eventBuildConfigInfo, trainingSpot.id())); view.showTrainingSpotDetails(model.getLastKnownUserLocation(), trainingSpot); } @Override public void handleShareNearbyTrainingSpotAction() { view.openShareNearbySpotForm(model.getFormUrl()); } @Override public void handleFooterLocationAction() { if (model.hasNoGpsPermissions()) { view.showGpsPermissionDialog(GPS_PERMISSIONS_REQUEST_CODE); } else { checkForHighAccuracy(); } } @Override public void handleDisclaimerAction() { view.showDisclaimerPopUp(); } @Override public void handleChangeLocationSettingsResult(boolean success) { if (success) { if (model.hasNoGpsPermissions()) { view.showGpsPermissionDialog(GPS_PERMISSIONS_REQUEST_CODE); } else { loadTrainingSpots(); } } else { view.showEnableHighAccuracyModeErrorDialog(); }
  11. None
  12. None
  13. None
  14. None
  15. intent

  16. intent state render (state)

  17. intent state render (state)

  18. sealed class State { object Loading : State() data class

    Content(val persons : List<Person>) : State() data class Error(val error : Throwable) : State() }
  19. https://youtu.be/YGmxbwI3a-4

  20. intent state render (state)

  21. State Management

  22. View ViewModel / Presenter State Machine

  23. View ViewModel / Presenter State Machine Intent

  24. View ViewModel / Presenter State Machine Intent Input

  25. View ViewModel / Presenter State Machine Intent Input State

  26. View ViewModel / Presenter State Machine Intent Input State View

    State
  27. Wednesday June 27 2:50 PM - Room Cupcake

  28. Simple State Machines val httpRequest : Observable<List<Person>> = ... val

    state : Observable<State> = ???
  29. sealed class State { object Loading : State() data class

    Content(val persons : List<Person>) : State() data class Error(val error : Throwable) : State() }
  30. Simple State Machines val httpRequest : Observable<List<Person>> = ... val

    state : Observable<State> = httpRequest .map { persons → State.Content(persons) } .onErrorReturn { State.Error(it) } .startWith(State.Loading)
  31. Simple State Machines val httpRequest : Observable<List<Person>> = ... val

    state : Observable<State> = httpRequest .map { persons → State.Content(persons) } .onErrorReturn { State.Error(it) } .startWith(State.Loading)
  32. Simple State Machines val httpRequest : Observable<List<Person>> = ... val

    state : Observable<State> = httpRequest .map { persons → State.Content(persons) } .onErrorReturn { State.Error(it) } .startWith(State.Loading)
  33. Simple State Machines val sm1: Observable<State> = … val sm2:

    Observable<State> = ... val combined: Observable<State> = Observable.zip(sm1, sm2){ s1,s2 → ... })
  34. Simple State Machines val sm1: Observable<State> = … val sm2:

    Observable<State> = ... val combined: Observable<State> = Observable.zip(sm1, sm2){ s1,s2 → if (s1 is State.Error) s1 ... })
  35. Simple State Machines val sm1: Observable<State> = … val sm2:

    Observable<State> = ... val combined: Observable<State> = Observable.zip(sm1, sm2){ s1,s2 → if (s1 is State.Error) s1 if (s2 is State.Error) s2 ... })
  36. Simple State Machines val sm1: Observable<State> = … val sm2:

    Observable<State> = ... val combined: Observable<State> = Observable.zip(sm1, sm2){ s1,s2 → if (s1 is State.Error) s1 if (s2 is State.Error) s2 if (s1 == State.Loading || s2 == State.Loading) State.Loading ... })
  37. Simple State Machines val http1: Observable<List<Person>> = … val http2:

    Observable<List<Person>> = ... val combined: Observable<State> = Observable.zip(http1, http2 ){ p1,p2 → p1 + p2 }) .map { persons → State.Content(persons) } .onErrorReturn { State.Error(it) } .startWith(State.Loading)
  38. Advanced State Machines

  39. class CalculatorStateMachine { }

  40. class CalculatorStateMachine { sealed class Input { data class Add(val

    value: Int) : Input() data class Sub(val value: Int) : Input() data class Mul(val value: Int) : Input() data class Div(val value: Int) : Input() } }
  41. class CalculatorStateMachine { sealed class Input { ... } sealed

    class State { data class Result(val value: Int) : State() data class Error(val error: Throwable) : State() } }
  42. class CalculatorStateMachine { sealed class Input { ... } sealed

    class State { ... } private val inputRelay = PublishRelay.create<Input>() }
  43. class CalculatorStateMachine { sealed class Input { ... } sealed

    class State { ... } private val inputRelay = PublishRelay.create<Input>() val input : Consumer<Input> = inputRelay }
  44. class CalculatorStateMachine { sealed class Input { ... } sealed

    class State { ... } private val inputRelay = PublishRelay.create<Input>() val input : Consumer<Input> = inputRelay val state: Observable<State> = inputRelay.map { ... } }
  45. class CalculatorStateMachine { sealed class Input { ... } sealed

    class State { ... } private val inputRelay = PublishRelay.create<Input>() val input : Consumer<Input> = inputRelay val state: Observable<State> = inputRelay.map { ... } }
  46. State Reducer .scan(State.Loading) { currentState , input → // compute

    new state … }
  47. Fancy Redux Based State Machines - Store → Observable for

    State - Actions as input - Reducer: (State , Action ) → State - Isolate Side Effects
  48. Fancy Redux Based State Machines

  49. Tuesday June 26 11:05 AM - Room Lollipop

  50. Tuesday June 26 11:05 AM - Room Oreo

  51. Intent? Action?

  52. None
  53. View ViewModel / Presenter State Machine Intent Intent State View

    State
  54. View ViewModel / Presenter State Machine Intent Intent State View

    State .scan( )
  55. View ViewModel / Presenter Redux State Machine Intent Action State

    View State Action triggered by side effect
  56. None
  57. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State
  58. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State Next page
  59. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State Next page Next page
  60. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State Next page Next page
  61. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State Next page
  62. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State LoadingPage
  63. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State LoadingPage
  64. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State LoadingPage
  65. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State
  66. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State
  67. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State ResultNextPage
  68. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State ResultNextPage
  69. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State ResultNextPage
  70. View Redux State Machine reducer(state, action) : State nextPage(nextPageAction) :

    Action State
  71. View Redux State Machine reducer(state, action) : State Side Effect

    1 State Side Effect 2 Side Effect n ...
  72. val sideEffects = listOf< SideEffect<State, Action> >( ... nextPageSideEffect, pullToRefreshSideEffect,

    ... ) intents .reduxStore(initialState, sideEffects, reducer) .subscribe { state → view.render(state) } https://github.com/freeletics/RxRedux
  73. Testing

  74. Testing is easy - assert(expectedState, actualState) - Just trigger intent,

    wait for state change - No need for idling resource needed - Prefer integration testing over unit testing
  75. class MyActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) {

    ... val button = findViewById(R.id.button) ... } fun render(state: State) { ... } }
  76. class MyActivity : Activity() { @Inject lateinit var binding :

    ViewBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // inject binding ... } fun render(state: State) { binding.render() } }
  77. class ViewBinding(val rootView : View) { val button = rootView.findViewById(R.id.button)

    fun render(state: State) { ... } }
  78. class TestingViewBinding(rootView : View) : ViewBinding { val renderdStates :

    Observable<State> = replay private val replay = ReplayRelay.create<State>() override fun render(state: State) { super.render(state) replay.onNext(state) Screenshot.snap(rootView).record() } }
  79. class TestingViewBinding(rootView : View) : ViewBinding { val renderdStates :

    Observable<State> = replay private val replay = ReplayRelay.create<State>() override fun render(state: State) { super.render(state) replay.onNext(state) Screenshot.snap(rootView).record() } }
  80. class TestingViewBinding(rootView : View) : ViewBinding { val renderdStates :

    Observable<State> = replay private val replay = ReplayRelay.create<State>() override fun render(state: State) { super.render(state) replay.onNext(state) Screenshot.snap(rootView).record() } }
  81. class TestingViewBinding(rootView : View) : ViewBinding { val renderdStates :

    Observable<State> = replay private val replay = ReplayRelay.create<State>() override fun render(state: State) { super.render(state) replay.onNext(state) Screenshot.snap(rootView).record() } }
  82. class TestingViewBinding(rootView : View) : ViewBinding { val renderdStates :

    Observable<State> = replay private val replay = ReplayRelay.create<State>() override fun render(state: State) { super.render(state) replay.onNext(state) Screenshot.snap(rootView).record() } } https://facebook.github.io/screenshot-tests-for-android/
  83. Tracking

  84. Multicasting

  85. RxJava By Example - Vol 3. the Multicast Edition

  86. intents .share() .subscribe { intent -> when (intent) { NextButtonClick

    -> tracker.trackNextButtonClick() ... } }
  87. stateMachine .share() .subscribe { state -> when (state) { is

    StateA -> tracker.trackStateA() ... } }
  88. None
  89. Navigation

  90. sealed class State { data class UserData( val login: String,

    val password: String ) : State() data class SingingIn( val login: String, val password: String ) : State() data class Error( val login: String, val password: String, val errorMessage: String, ) : State() object SignedIn : State() }
  91. sealed class State { data class UserData( val login: String,

    val password: String ) : State() data class SingingIn( val login: String, val password: String ) : State() data class Error( val login: String, val password: String, val errorMessage: String, ) : State() object SignedIn : State( } fun render(state: State) { when (state) { State.SignedIn -> { startActivity(nextActivityIntent()) finish() } ... } }
  92. None
  93. Navigator stateMachine .share() .subscribe { state -> when (state) {

    ... -> navigator.navigateTo(Destination.OptionalQuestions) } }
  94. Navigator stateMachine .share() .subscribe { state -> when (state) {

    ... -> navigator.navigateTo(Destination.OptionalQuestions) } } class WizardNavigator( private val activity: Activity, private val abTestProvider: AbTestProvider ) : Navigator { override fun navigateTo(dest: Destination) { when (dest) { Destination.Form -> showFormFragment() Destination.OptionalQuestions -> showOptionalQuestionsFragment() Destination.VariantSelector -> showVariantSelectorFragment() Destination.FromSaved -> activity.startActivity(abTestProvider.nextScreen()) } } }
  95. Navigator implementation Deep inside, the Navigator implementation could be as

    simple as override fun navigateTo(dest: Destination) { when (dest) { Destination.Next -> showNextActivity() ... } } fun showNextActivity() { activity.startActivity( if (FeatureFlag.A) { activityOneIntent() } else { activityTwoIntent() } ) }
  96. Navigator implementation Or be little bit modern override fun navigateTo(dest:

    Destination) { when (dest) { Destination.Next -> showNextActivity() ... } } fun showNextActivity() { view.findNavController().navigate(R.id.wizard_next_activity) }
  97. Animation

  98. Animation private sealed class InternalAnimationState { object InitialState : InternalAnimationState()

    object ProgressTransition : InternalAnimationState() data class Progress( val textResId: Int, val currentProgressPercent: Int, val totalProgressDuration: Long ) : InternalAnimationState() object GenerationFinished : InternalAnimationState() }
  99. Animation object InitialState :InternalAnimationState()

  100. Animation object ProgressTransition :InternalAnimationState()

  101. Animation data class Progress( val textResId: Int, val currentProgressPercent: Int,

    val totalProgressDuration: Long ) :InternalAnimationState()
  102. Animation object GenerationFinished :InternalAnimationState()

  103. Animation private fun createAnimationStateObservable(): Observable<InternalAnimationState> = Observable.merge( intents.filter { it

    == GenerationClicked } .map { ProgressTransition }, intents.filter { it == GenerationStarted } .flatMap { Observable.intervalRange( 0, steps, 0, duration / steps, TimeUnit.MILLISECONDS ).map { it.toInt() } .map { if (step == size) { GenerationFinished } else { Progress(...) } } })
  104. Animation private fun createAnimationState(): Observable<InternalAnimationState> = rxObservable { intents.consumeEach {

    intent -> when (intent) { GenerationClicked -> send(ProgressTransition) GenerationStarted -> { repeat(size) { step -> delay(duration / size) send(if (step == texts.size) { GenerationFinished } else { Progress(...) }) } } } } }
  105. Animation private fun createAnimationState(): Observable<InternalAnimationState> = rxObservable { intentStream.consumeEach {

    intent -> when (intent) { GenerationClicked -> send(ProgressTransition) GenerationStarted -> { repeat(size) { step -> delay(duration / size) send(if (step == texts.size) { GenerationFinished } else { Progress(...) }) } } } } }
  106. Restoring state

  107. None
  108. enum class Variant { Variant1, Variant2 } data class Step1State(

    val selected: Variant )
  109. enum class Variant { Variant1, Variant2 } data class Step1State(

    val selected: Variant ) data class Step2State( val option1Checked: Boolean, val option2Checked: Boolean )
  110. enum class Variant { Variant1, Variant2 } data class Step1State(

    val selected: Variant ) data class Step2State( val option1Checked: Boolean, val option2Checked: Boolean ) data class Step3State( val name : String, val description : String )
  111. data class WizardState( val currentScreenIndex: Int, val step1State: Step1State, val

    step2State: Step2State, val step3State: Step3State )
  112. Tips and tricks

  113. How to handle onActivityResult?

  114. None
  115. onRequestPermissionResult()?

  116. None
  117. What about Lifecycle callback?

  118. None
  119. Rendering state too fast?

  120. None
  121. None
  122. None
  123. How to refactor existing code to MVI?

  124. Refactoring 1. Define States 2. MVP: replace all view.showX() →

    view.render(state) MVVM: replace all LiveData<X> → LiveData<State> 3. Define a state machine with a clear API Inputs and Outputs 4. Define Intents that trigger inputs on state machine 5. Trigger Intents from view layer
  125. Questions?

  126. Links - The State of Representing State by Christina Lee

    - RxJava By Example - Vol 3. the Multicast Edition by Kaushik Gopal - Screenshot testing: https://facebook.github.io/screenshot-tests-for-android/ - https://github.com/freeletics/RxRedux