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

Advanced Model-View-Intent: The Missing Guide

Hannes Dorfmann
June 26, 2018
1.1k

Advanced Model-View-Intent: The Missing Guide

Presented at Droidcon Berlin 2018

Hannes Dorfmann

June 26, 2018
Tweet

Transcript

  1. 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) }) } }
  2. 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) }) } }
  3. 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 ) }) }
  4. 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 ) }) }
  5. 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));
  6. 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);
  7. .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(); }
  8. sealed class State { object Loading : State() data class

    Content(val persons : List<Person>) : State() data class Error(val error : Throwable) : State() }
  9. sealed class State { object Loading : State() data class

    Content(val persons : List<Person>) : State() data class Error(val error : Throwable) : State() }
  10. 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)
  11. 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)
  12. 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)
  13. Simple State Machines val sm1: Observable<State> = … val sm2:

    Observable<State> = ... val combined: Observable<State> = Observable.zip(sm1, sm2){ s1,s2 → ... })
  14. 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 ... })
  15. 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 ... })
  16. 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 ... })
  17. 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)
  18. 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() } }
  19. class CalculatorStateMachine { sealed class Input { ... } sealed

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

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

    class State { ... } private val inputRelay = PublishRelay.create<Input>() val input : Consumer<Input> = inputRelay }
  22. 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 { ... } }
  23. 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 { ... } }
  24. Fancy Redux Based State Machines - Store → Observable for

    State - Actions as input - Reducer: (State , Action ) → State - Isolate Side Effects
  25. View ViewModel / Presenter Redux State Machine Intent Action State

    View State Action triggered by side effect
  26. val sideEffects = listOf< SideEffect<State, Action> >( ... nextPageSideEffect, pullToRefreshSideEffect,

    ... ) intents .reduxStore(initialState, sideEffects, reducer) .subscribe { state → view.render(state) } https://github.com/freeletics/RxRedux
  27. 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
  28. class MyActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) {

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

    ViewBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // inject binding ... } fun render(state: State) { binding.render() } }
  30. 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() } }
  31. 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() } }
  32. 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() } }
  33. 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() } }
  34. 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/
  35. stateMachine .share() .subscribe { state -> when (state) { is

    StateA -> tracker.trackStateA() ... } }
  36. 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() }
  37. 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() } ... } }
  38. Navigator stateMachine .share() .subscribe { state -> when (state) {

    ... -> navigator.navigateTo(Destination.OptionalQuestions) } }
  39. 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()) } } }
  40. 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() } ) }
  41. 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) }
  42. 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() }
  43. Animation data class Progress( val textResId: Int, val currentProgressPercent: Int,

    val totalProgressDuration: Long ) :InternalAnimationState()
  44. 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(...) } } })
  45. 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(...) }) } } } } }
  46. 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(...) }) } } } } }
  47. enum class Variant { Variant1, Variant2 } data class Step1State(

    val selected: Variant ) data class Step2State( val option1Checked: Boolean, val option2Checked: Boolean )
  48. 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 )
  49. 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
  50. 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