KotlinConf 2017: View State Machine For Network Calls on Android

B929614ff0dc615d1a7c4cf4b5535459?s=47 Amanda Hill
November 02, 2017

KotlinConf 2017: View State Machine For Network Calls on Android

B929614ff0dc615d1a7c4cf4b5535459?s=128

Amanda Hill

November 02, 2017
Tweet

Transcript

  1. VIEW STATE MACHINE FOR NETWORK CALLS ON ANDROID @MANDYBESS

  2. THOUGHTBOT

  3. WHAT IS A VIEW STATE MACHINE?

  4. !

  5. BUILD AN APP TOGETHER ! "

  6. GAME OF CONES !

  7. MODELING !"!

  8. ICE CREAM - title - icon

  9. data class IceCream(val title: String, val iconUrl: String)

  10. ! ➡ "

  11. MVP MODEL VIEW PRESENTER

  12. VIEW INTERFACE interface MainView { fun showTitle(title: String) fun showIcon(url:

    String) }
  13. PRESENTER class MainPresenter(val view: MainView) { fun onCreate() { val

    iceCream = ?? view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) } }
  14. DATA STORE interface DataStore { fun fetchCone(): IceCream }

  15. PRESENTER + DATA STORE + class MainPresenter(val view: MainView, val

    dataStore: DataStore) { fun onCreate() { + val iceCream = dataStore.fetchCone() view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) } }
  16. TESTING !

  17. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  18. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  19. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  20. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  21. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  22. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  23. PRESENTER TEST class MainPresenterTest { val view = mock<MainView>() val

    dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com") @Test fun test_onCreate() { //stub response whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  24. NETWORK CALLS !"

  25. DATA STORE + RX interface DataStore { + fun fetchCone():

    Observable<IceCream> }
  26. PRESENTER + RX class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> // on success view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> // on error } ) } }
  27. PRESENTER + RX class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> // on success view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> // on error } ) } }
  28. PRESENTER + RX class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> // on success view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> // on error } ) } }
  29. PRESENTER + RX class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> // on success view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> // on error } ) } }
  30. PRESENTER + RX class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { // loading?? dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> // on success view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> // on error } ) } }
  31. VIEW INTERFACE + NETWORKING interface MainView { fun showTitle(title: String)

    fun showIcon(url: String) + fun showLoading() + fun hideLoading() + fun showError(errorMessage: String) }
  32. PRESENTER + NETWORKING class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { + view.showLoading() dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> + view.hideLoading() view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) }, { error -> + view.hideLoading() + view.showError(error.message) } ) } }
  33. PRESENTER TEST + RX + NETWORKING ... @Test fun test_onCreate_success()

    { // stub response - whenever(dataStore.fetchCone()).thenReturn(fakeIceCream) + whenever(dataStore.fetchCone()).thenReturn(Observable.just(fakeIceCream)) val presenter = MainPresenter(view, dataStore) presenter.onCreate() + verify(view).showLoading() + verify(view).hideLoading() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") verifyNoMoreInteractions(view) }
  34. PRESENTER TEST + RX + NETWORKING ... @Test fun test_onCreate_error()

    { // stub response val errorMessage = "There was an error" whenever(dataStore.fetchCone()).thenReturn(Observable.error(Throwable(errorMessage))) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showLoading() verify(view).hideLoading() verify(view).showError(errorMessage) verifyNoMoreInteractions(view) }
  35. WE DID IT !

  36. None
  37. None
  38. !

  39. UPDATES 1. Update our model 2. Update our view interface

    3. Update our presenter 4. Update presenter tests
  40. MODEL UPDATE data class IceCream(val title: String, val iconUrl: String,

    val calorieCount: Int)
  41. VIEW INTERFACE + CALORIES interface MainView { fun showTitle(title: String)

    fun showIcon(url: String) fun showLoading() fun hideLoading() fun showError(errorMessage: String) + fun showCalorieCount(calorieCount: String) }
  42. PRESENTER + CALORIES class MainPresenter(val view: MainView, val dataStore: DataStore)

    { fun onCreate() { view.showLoading() dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> view.hideLoading() view.showTitle(iceCream.title) view.showIcon(iceCream.iconUrl) + view.showCalorieCount(context.getString(R.string.formatted_calorie_count, iceCream.calorieCount)) }, { error -> view.hideLding() view.showError(error.message) } ) } }
  43. PRESENTER TEST + CALORIES class MainPresenterTest { val view =

    mock<MainView>() val dataStore = mock<DataStore>() val fakeIceCream = IceCream("Vanilla", "www.icecream.com", 120) @Test fun test_onCreate_success() { // stub response whenever(dataStore.fetchCone()).thenReturn(Observable.just(fakeIceCream)) val presenter = MainPresenter(view, dataStore) presenter.onCreate() verify(view).showLoading() verify(view).hideLoading() verify(view).showTitle("Vanilla") verify(view).showIcon("www.icecream.com") + verify(view).showCalories("120 Calories") verifyNoMoreInteractions(view) } ...
  44. PROGRESS REPORT !

  45. - used THE MVP PATTERN - added NETWORKING - updated

    THE UI
  46. Pros Cons UI updates are caught in tests Updates to

    the Presenter violate Open-Closed principle Networking view functions take away from the bigger picture of what's happening
  47. Pros Cons UI updates are caught in tests Updates to

    the Presenter violate Open-Closed principle Networking view functions take away from the bigger picture of what's happening
  48. HELLO ! NetworkingViewState

  49. NetworkingViewState sealed class NetworkingViewState { class Init() : NetworkingViewState() class

    Loading() : NetworkingViewState() class Success<out T>(val item: T) : NetworkingViewState() class Error(val errorMessage: String?) : NetworkingViewState() }
  50. MainView interface MainView { - fun showTitle(title: String) - fun

    showIcon(url: String) - fun showLoading() - fun hideLoading() - fun showError(errorMessage: String) - fun showCalorieCount(calorieCount: String) + var networkingViewState: NetworkingViewState }
  51. MainPresenter class MainPresenter(val view: MainView, val dataStore: DataStore) { fun

    onCreate() { - view.showLoading() + view.networkingViewState = NetworkingViewState.Loading() dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> - view.hideLoading() - view.showTitle(iceCream.title) - view.showIcon(iceCream.iconUrl) - view.showCalorieCount(R.string.formatted_calorie_count, iceCream.calorieCount) + view.networkingViewState = NetworkingViewState.Success<IceCream>(iceCream) }, { error -> - view.hideLoading() - view.showError(error.message) + view.networkingViewState = NetworkingViewState.Error(error.message) } ) } }
  52. MainPresenterTest ... @Test fun test_onCreate_success() { // stub response whenever(dataStore.fetchCone()).thenReturn(Observable.just(fakeIceCream))

    val presenter = MainPresenter(view, dataStore) presenter.onCreate() - verify(view).showLoading() - verify(view).hideLoading() - verify(view).showTitle("Vanilla") - verify(view).showIcon("www.icecream.com") - verify(view).showCalorieCount(120 Calories) + verify(view).networkingViewState = isA<NetworkingViewState.Loading>() + verify(view).networkingViewState = isA<NetworkingViewState.Success<IceCream>>() verifyNoMoreInteractions(view) } }
  53. MainPresenterTest ... @Test fun test_onCreate_error() { // stub response val

    errorMessage = "There was an error" whenever(dataStore.fetchCone()).thenReturn(Observable.error(Throwable(errorMessage))) val presenter = MainPresenter(view, dataStore) presenter.onCreate() - verify(view).showLoading() - verify(view).hideLoading() - verify(view).showError() + verify(view).networkingViewState = isA<NetworkingViewState.Loading>() + verify(view).networkingViewState = isA<NetworkingViewState.Error(errorMessage) verifyNoMoreInteractions(view) } }
  54. OUR VIEW TESTS ARE HOMELESS !"

  55. None
  56. NetworkingViewState sealed class NetworkingViewState { class Init() : NetworkingViewState() class

    Loading() : NetworkingViewState() class Success<out T>(val item: T) : NetworkingViewState() class Error(val errorMessage: String?) : NetworkingViewState() }
  57. PRESENTER + VIEW MODEL class MainPresenter(val view: MainView, val dataStore:

    DataStore) { fun onCreate() { view.networkingViewState = NetworkingViewState.Loading() dataStore.fetchCone() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { iceCream -> - view.networkingViewState = NetworkingViewState.Success<IceCream>(iceCream) + view.networkingViewState = NetworkingViewState.Success<IceCreamViewModel>(IceCreamViewModel(iceCream, context)) }, { error -> view.networkingViewState = NetworkingViewState.Error(error.message) } ) } }
  58. VIEW MODEL class IceCreamViewModel(val iceCream: IceCream, val context: Context) {

    fun title(): String { return iceCream.title } fun iconUrl(): String { return iceCream.iconUrl } fun calorieCount(): String { return context.getString(R.string.formatted_calorie_count, iceCream.calorieCount) } }
  59. VIEW MODEL TEST class IceCreamViewModelTest { val fakeIceCream = IceCream("Vanilla",

    "www.icecream.com", 120) val context: Context @Test fun testTitle() { val viewModel = IceCreamViewModel(context, fakeIceCream) val expected = "Vanilla" val actual = viewModel.title() assertEquals(expected, actual) } }
  60. !

  61. !

  62. MAIN ACTIVITY class MainActivity : AppCompatActivity(), MainView { override fun

    onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } override var networkingViewState: NetworkingViewState get() = TODO() set(value) {} }
  63. MAIN ACTIVITY class MainActivity : AppCompatActivity(), MainView { override fun

    onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } override var networkingViewState: NetworkingViewState get() = TODO() set(value) {} }
  64. HELLO ! PROPERTY DELEGATES

  65. Delegates.observable

  66. ObservableProperty public abstract class ObservableProperty<T>(initialValue: T) : ReadWriteProperty<Any?, T> {

    private var value = initialValue protected open fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = true protected open fun afterChange (property: KProperty<*>, oldValue: T, newValue: T): Unit {} public override fun getValue(thisRef: Any?, property: KProperty<*>): T { return value } public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { val oldValue = this.value if (!beforeChange(property, oldValue, value)) { return } this.value = value afterChange(property, oldValue, value) } }
  67. ObservableProperty public abstract class ObservableProperty<T>(initialValue: T) : ReadWriteProperty<Any?, T> {

    private var value = initialValue protected open fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = true protected open fun afterChange (property: KProperty<*>, oldValue: T, newValue: T): Unit {} public override fun getValue(thisRef: Any?, property: KProperty<*>): T { return value } public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { val oldValue = this.value if (!beforeChange(property, oldValue, value)) { return } this.value = value afterChange(property, oldValue, value) } }
  68. ObservableProperty public abstract class ObservableProperty<T>(initialValue: T) : ReadWriteProperty<Any?, T> {

    private var value = initialValue protected open fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = true protected open fun afterChange (property: KProperty<*>, oldValue: T, newValue: T): Unit {} public override fun getValue(thisRef: Any?, property: KProperty<*>): T { return value } public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { val oldValue = this.value if (!beforeChange(property, oldValue, value)) { return } this.value = value afterChange(property, oldValue, value) } }
  69. MAIN ACTIVITY + PROPERTY DELEGATE class MainActivity : AppCompatActivity(), MainView

    { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } override var networkingViewState: NetworkingViewState by Delegates.observable<NetworkingViewState>( Init(), { property, oldValue, newValue -> }) }
  70. None
  71. ! LINKS ! @mandybess " www.mandybess.com # https://robots.thoughtbot.com/android-networking-view-state $ https://speakerdeck.com/mandybess