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

Architecting Your App with MVP and ViewModels

Architecting Your App with MVP and ViewModels

An exploration of how you can move your application architecture from MVP to something more closely resembling a uni-directional data flow.

Code samples here: https://github.com/sddamico/MvmvpDemo

Stephen D'Amico

August 27, 2018
Tweet

More Decks by Stephen D'Amico

Other Decks in Programming

Transcript

  1. Or, as I like to call it, MVMVP Stephen D’Amico

    - @sddamico - DroidconNYC - Aug 2018
  2. Today we're going to be building a simple app that

    has a button that lets us increment the number shown on screen Code samples here: https://github.com/sddamico/MvmvpDemo
  3. Revolves around a View and Presenter contract View has calls

    for displaying data/updating UI state Presenter has calls for responding to UI events and acting upon them interface MvpView interface MvpPresenter<View: MvpView> { fun attach(view : View) fun detach() } abstract class MvpPresenterBase<View: MvpView> : MvpPresenter<View> { var view: View? = null override fun attach(view: View) { this.view = view } override fun detach() { this.view = null } }
  4. First create your Contract, this is a place to define

    the View and the Presenter and the "contract" between them. This can also include things like constants that are shared between Presenter and View. We're building a simple "increment the counter" app today, so we don't have much, just a few small items. interface IncrementActivityContract { interface IncrementActivityView : MvpView { fun setCountView(countString : String) } interface IncrementActivityPresenter : MvpPresenter<IncrementActivityView> { fun onIncrementClicked() } }
  5. Next we implement our Presenter. We have one field for

    storing our state, the count of the incrementer. In onIncrementClicked callback, we increment count and update the View accordingly You'll note that view is nullable in onIncrementClicked, if there's a click without a view, we'll update our state but need to update the View later In our attach callback, we restore the view state accordingly, alternatively we could queue events class IncrementActivityPresenterImpl : MvpPresenterBase<View>(), Presenter { private var count = 0 override fun attach(view: View) { super.attach(view) view.setCountView(getCountString()) } override fun onIncrementClicked() { count++ view?.setCountView(getCountString()) } private fun getCountString() = count.toString() }
  6. Finally, we create our Activity and have it implement our

    View interface. In our onCreate, we create our Presenter and initialize our view In onStart and onStop, we attach and detach our View to and from our Presenter Finally, we implement the View call for setting the count information on the view class IncrementActivityMvp : AppCompatActivity(), View { private lateinit var presenter: Presenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initPresenter() setContentView(R.layout.activity_increment) increment.setOnClickListener { presenter.onIncrementClicked() } } override fun onStart() { super.onStart() presenter.attach(this) } override fun onStop() { presenter.detach() super.onStop() } override fun setCountView(countString: String) { counter.text = countString } }
  7. Of course, all this extra effort would be somewhat wasted

    if we did not then go back and verify our logic Testing our Presenter,, we create a new instance, we attach a mocked View and then verify that the state changes correctly with each Presenter call class MvpTest { @Test fun `test increment`() { val presenterImpl = IncrementActivityPresenterImpl() val view = mock<View> {} presenterImpl.attach(view) presenterImpl.onIncrementClicked() verify(view).setCountView("0") // initial state verify(view).setCountView("1") // updated state } }
  8. So, now we want to do some complex background tasks

    when our button is clicked. Let's update our MVP example to use RxJava and see where that lands us Initially, not much has changed here with our Contract, still the same interactions interface IncrementActivityRxContract { interface IncrementActivityRxView : MvpView { fun setCountView(countString : String) } interface IncrementActivityRxPresenter : MvpPresenter<IncrementActivityRxView> { fun onIncrementClicked() } }
  9. In our Activity, similarly, not many changes, we still have

    roughly the same callbacks as before We've utilized RxBinding for some nicety observing click events from the view instead of adding a listener, though neither were particularly burdensome to begin with class IncrementActivityRxMvp : AppCompatActivity(), ViewRx { private lateinit var presenter: PresenterRx override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initPresenter() setContentView(R.layout.activity_increment) increment.clicks() .subscribe { presenter.onIncrementClicked() } } override fun onStart() { super.onStart() presenter.attach(this) } override fun onStop() { presenter.detach() super.onStop() } override fun setCountView(countString: String) { counter.text = countString } }
  10. So now, implementing our Presenter is where things get interesting

    In this case, we'll now use the BehaviorRelay reactive type to store the state of our counter This allows us to observe changes in its value and update the UI accordingly, as we're doing in attach() Our onIncrementClick now just needs to update the value stored in the field and listening parties will be notified! class IncrementActivityRxPresenterImpl : MvpPresenterBaseRx<ViewRx>(), PresenterRx { private var count = BehaviorRelay.createDefault(0) override fun attach(view: ViewRx) { super.attach(view) getCountString() .autoDispose() .subscribe { view.setCountView(it) } } override fun onIncrementClicked() { count.take(1) .observeOn(Schedulers.io()) .map { it.plus(1) } .observeOn(AndroidSchedulers.mainThread()) .subscribe(count) } private fun getCountString() = count.map { it.toString() } }
  11. The world of RxJava testing can be a bit of

    an interesting place, but for our purposes this will suffice We replace our default Schedulers with some that we can control then proceed as we did previously The only real addition is the triggerActions() call to tell Rx to propagate events that are queued class MvpRxTest { val mainThreadScheduler = TestScheduler() val ioThreadScheduler = TestScheduler() init { RxAndroidPlugins.setInitMainThreadSchedulerHandler { mainThreadScheduler } RxJavaPlugins.setInitIoSchedulerHandler { ioThreadScheduler } } @Test fun `test increment`() { val presenterImpl = IncrementActivityRxPresenterImpl() val view = mock<ViewRx> {} presenterImpl.attach(view) presenterImpl.onIncrementClicked() ioThreadScheduler.triggerActions() mainThreadScheduler.triggerActions() verify(view).setCountView("0") verify(view).setCountView("1") } }
  12. • State ownership can be ambiguous ◦ View needs to

    know how to update from disjointed calls ◦ Presenter owning state means complex view restoration • Handling situations with View availability ◦ Presenter is cluttered with handling for view not being available ▪ Restoring view in onAttach ▪ Managing Rx subscriptions ◦ Can recreate presenters for each Activity ▪ Makes tasks that run through configuration change more difficult ▪ Likely need to use persistence more often • Presenter becomes overly view-aware
  13. ViewModel • ViewModel is a state container that survives configuration

    changes • "Scoped" object that better aligns with the lifecycle of a "task" • Out of the box support in AppCompatActivity and support Fragments • A place to put complex data that is too ephemeral for sqlite but too big for onSaveInstanceState • Does NOT survive empty process state The ViewModel class is designed to store and manage UI-related data in a lifecycle conscious way.
  14. ViewModels are fortunately fairly easy to create Simply extend the

    ViewModel class and then ask for an instance from your scoped provider Providers are tied to a LifecycleOwner Scoped, usually, to Fragments or Activitys Can create own provider for purposes of dependency injection or other factory functions ViewModel data class Data(val someString: String?) class MyViewModel : ViewModel() { val data: BehaviorRelay<Data> = BehaviorRelay.createDefault(Data(null)) } ViewModelProviders .of(this) // this == Activity or Fragment .get(MyViewModel::class.java) // provider creates instance
  15. So, just like with MVP, we have a few base

    classes to create first Abstract Presenter, you'll note the type params. This time we have a State object and a ViewModel that will act as a container for it To that end, there's an abstract ViewModel to use Finally, we have our base presenter which also implements our "reducer" queue, more on that in a bit interface MvmvpPresenter<State, out VM: MvmvpViewModel<State>> { val viewModel: VM } abstract class MvmvpViewModel<State>(initialState: State) : ViewModel() { val state = BehaviorRelay.createDefault(initialState)!! } abstract class MvmvpPresenterBase<State, out VM: MvmvpViewModel<State>>(override val viewModel: VM) : MvmvpPresenter<State, VM> { @SuppressLint("CheckResult") fun sendToViewModel(reducer: (State) -> State) { Observable.just(reducer) .observeOn(AndroidSchedulers.mainThread()) // ensures // mutations happen serially on main thread .zipWith(viewModel.state) .map { (reducer, state) -> reducer.invoke(state) } .subscribe(viewModel.state) } }
  16. Same as before, we define a contract However, this time

    we don't care about the View! We create a contract for the Presenter and then the State that the Presenter will be mutating Finally, we create our ViewModel that will be the container for our State, we also pass in a default state interface IncrementActivityMvmvpContract { interface PresenterMvmvp : MvmvpPresenter<IncrementActivityMvmvpState, IncrementActivityStateViewModel> { fun onIncrementClicked() } data class IncrementActivityMvmvpState( var count: String = 0.toString() ) class IncrementActivityStateViewModel : MvmvpViewModel<IncrementActivityMvmvpState> (IncrementActivityMvmvpState()) }
  17. Now, all that work completed, our Presenter becomes nearly trivial

    to implement We accept the ViewModel that we'll be using for storing state as a parameter and then implement our Presenter interface Implementing onIncrementClicked becomes as easy as sending a mutation to the queue Since mutations can happen from multiple sources, important to process them serially class PresenterMvmvpImpl(override val viewModel: ViewModelMvmvp) : MvmvpPresenterBase<StateMvmvp, ViewModelMvmvp>(viewModel), PresenterMvmvp { override fun onIncrementClicked() { sendToViewModel { it.copy( count = it.count.toInt().plus(1).toString() )} } }
  18. Our Activity now gains a little bit of responsibility, but

    it's still straightforward Instead of implementing a View interface, we now listen for changes coming from the ViewModel class IncrementActivityRxMvmvp : RxAppCompatActivity(), ScopeProvider { private val viewModel by lazy { ViewModelProviders.of(this) .get(ViewModelMvmvp::class.java) } private lateinit var presenter: IncrementActivityMvmvpContract.PresenterMvmvp override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initPresenter() setContentView(R.layout.activity_increment) observeActions() observeState() } // … }
  19. Here's what the code looks like for observing the state

    and observing the view interactions to pass them to the Presenter We now have a render() function that takes the updated State object and updates the Android Views accordingly private fun observeState() { viewModel.state .`as`(autoDisposable(this)) .subscribe { render(it) } } private fun render(state: StateMvmvp) { counter.text = state.count } private fun observeActions() { increment.clicks() .`as`(autoDisposable(this)) .subscribe { presenter.onIncrementClicked() } }
  20. Tests now work a lot like the RxJava ones Except

    now, we are asserting that the State ViewModel updates the way we expect it to In this example, I used Hamkrest, a port of Hamcrest for Kotlin to make assertions @Test fun `test increment`() { val viewModel = ViewModelMvmvp() val presenterImpl = PresenterMvmvpImpl(viewModel) assert.that(viewModel.state.value.count, equalTo("0")) presenterImpl.onIncrementClicked() ioThreadScheduler.triggerActions() mainThreadScheduler.triggerActions() assert.that(viewModel.state.value.count, equalTo("1")) }
  21. • Data binding is a code generation tool for mapping

    your data model to what is displayed in your view • Uses an enhanced XML layout • Eliminates the complexity of checking to see if data has changed before rendering an updated view • Simplifies the render() step of our process when using a defined State The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically
  22. In the data binding enhanced layout, you can declare variables

    in the data field that will be generated as fields that you can assign and update later In our case, we want two fields, one for our State object that will be updated when it changes and other for our presenter so that we can trivially send events to our presenter <?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" > <data> <variable name="model" type="com...IncrementActivityMvmvpState"/> <variable name="presenter" type="com...PresenterMvmvp"/> </data> <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent" > <!-- layout here --> </androidx.coordinatorlayout.widget.CoordinatorLayout> </layout>
  23. Now in our layout, we can reference the variables that

    we previously defined You'll note the syntax for entering a "data binding expression" in the xml Once there, we can perform basic logic and mapping values to the view Since we're using a data model, there's just mapping of data to view Finally, since we can also access our Presenter, we can set our onClick listener right nere <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:orientation="vertical"> <TextView android:id="@+id/counter" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="@{model.count}" /> <Button android:id="@+id/increment" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="Increment!" android:onClick="@{view -> presenter.onIncrementClicked()}" /> </LinearLayout>
  24. Finally, back in our Activity, we use the DatabindingUtil to

    reference our generated "binding" and we assign it a LifecycleOwner and a reference to our Presenter Our observeState call now becomes exceedingly trivial as we just assign our State object to the model field in the data binding and we let the generated code handle updating our views accordingly override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initPresenter() binding = DataBindingUtil .setContentView(this, R.layout.activity_increment_binding) binding.setLifecycleOwner(this) binding.presenter = presenter observeState() } private fun observeState() { viewModel.state .`as`(autoDisposable(this)) .subscribe { binding.model = it } }
  25. • This is somewhere between MVVM, MVI and Redux, I

    recommend exploring all of them! • Some may choose to use a formal implementation of these patterns • Building your own can give more flexibility ◦ Can tailor solutions to your needs ◦ Can help solve app-specific requirements you might have • One size never quite fits all, use what works best for your application! ◦ But also, don't reinvent the wheel if you don't need to