A View State Machine for Network Calls on Android

Amanda Hill

As Ben Franklin once said, “In this world nothing can be said to be certain, except death and taxes [and that software design specs always change]”.

The reality of ever-changing design specs can, at times, be frustrating and miserable, but rather than focus on the negatives I like to think that it can also be a fun challenge in how we approach development. So today I am going to share one improvement I made to how I handle network requests in Android development to protect against UI changes.

MVP

MVP (Model View Presenter) is a popular architectural style for Android development. And while there are many positives - easy to test, good separation between views and model - there are also some downsides. One particular frustration I have faced is having to update the view interface every time you want to update another UI element. For example, if you were building a profile page you might have the following view interface:

interface ProfileView {
  fun showFirstName(name: String)
  fun showLastName(name: String)
}

But if you wanted to update the UI to also show a user’s age and astrological sign you would also have to update the view interface.

interface ProfileView {
  fun showFirstName(name: String)
  fun showLastName(name: String)
  fun showAge(age: Int)
  fun showAstrologicalSign(sign: String)
}

This update to the view interface is both frustrating and a violation of the Open-Closed Principle.

Note: Before I continue talking about how annoying updating view interfaces can be, I want to admit that, just like everything else in life, there are pros and cons, and sometimes this sort of update is necessary and can even be good.

But enough mature pragmatism, back to the bad… Another reason I find these sorts of updates frustrating is that they can take away from the bigger picture of what is happening in a given view and focus too much on the minute details of visual implementation. Now I know what you’re thinking, “but it’s a VIEW INTERFACE! It’s supposed to focus on the visuals!”. And you would be correct, but before you get a bee 🐝 in your bonnet πŸ‘’ consider that we do not yet have the full picture. So far in our example we have only been focusing on the methods that handle displaying a User. Let’s look at the presenter, to see how it fetches the User to better understand the rest of what is happening with this view.

class ProfilePresenter(val view: ProfileView, val api: ProfileDataStore) {

  fun fetchUser() {
    api.fetchUser()
        .subscribe({ user ->
          // network call successful
          view.showFirstName(user.firstName)
          view.showLastName(user.lastName)
          view.showAge(user.age)
          view.showAstrologicalSign(user.sign)
        }, { error ->
          // network call failed
        })
  }
}

Ah good ol’ network calls. Because our User object is coming from a network call, we need to update our view interface to include methods for showing the various states surrounding making a request.

interface ProfileView {
  fun showFirstName(name: String)
  fun showLastName(name: String)
  fun showAge(age: Int)
  fun showAstrologicalSign(sign: String)
+ fun showError(errorMessage:String)
+ fun showLoading()
+ fun hideLoading()
}

Our view interface is starting to look a bit different. It’s not all about the User details any more, its also got some networking view state stuff in there. As you can imagine all view interfaces that work with a presenter that makes networking calls would also have to add these networking related view methods. That means a lot of duplication across all our view interfaces and no one wants that πŸ™….

Networking View State

Say hello to NetworkingViewState πŸ‘‹. A sealed class that abstracts all the various states a view can be in, as a result of a networking call, into a single type.

sealed class NetworkingViewState {
  class Loading() : NetworkingViewState()
  class Success<T>(val item: T) : NetworkingViewState()
  class Error(val errorMessage: String?) : NetworkingViewState()
}

Now let’s update our view interface with this new type.

interface ProfileView  {
  var networkingViewState: NetworkingViewState
}

And our presenter…

class ProfilePresenter(val view: ProfileView, val api: ProfileDataStore) {

  fun fetchUser() {
    view.networkingViewState = NetworkingViewState.Loading()
    api.fetchUser()
        .subscribe({ user ->
          // network call successful
          view.networkingViewState = NetworkingViewState.Success<User>(user)
        }, { error ->
          // network call failed
          view.networkingViewState = NetworkingViewState.Error(error.message)
        })
  }
}

Now if the UI changes, our view interface, our presenter and our tests do not have to change, because the bigger picture behavior remains the same πŸ’ͺ

But were not done yet… While we have made our code more SOLID (get it!?) we have also lost some test coverage. Before we introduced NetworkingViewState our presenter was in charge of mapping the User object we got in the response from the API into the appropriate types to be shown by the view interface. And because those formatted values were passed through the view interface we could assert they were the correct values in our tests.

Like this:

class ProfilePresenterTest() {

  val view = mock<ProfileView>()
  val dataStore = mock<ProfileDataStore>()
  val mockUser = User("Amanda", "Hill", 100, "Aquarius")

  @Test
  fun testFetchUser_success() {
    //stub network response
    whenever(dataStore.fetchUser()).thenReturn(Observable.just(mockUser))

    val presenter = ProfilePresenter(view, dataStore)
    presenter.fetchUser()

    verify(view).showLoading()
    verify(view).showFirstName(mockUser.firstName)
    verify(view).showLastName(mockUser.lastName)
    verify(view).showAge(mockUser.age)
    verify(view).showAstrologicalSign(mockUser.sign)
    verify(view).hideLoading()
  }
}

But with our updated presenter our tests now look like this:

class ProfilePresenterTest() {

  val view = mock<ProfileView>()
  val dataStore = mock<ProfileDataStore>()
  val mockUser = User("Amanda", "Hill", 100, "Aquarius")

  @Test
  fun testFetchUser_success() {
    //stub network response
    whenever(dataStore.fetchUser()).thenReturn(Observable.just(mockUser))

    val presenter = ProfilePresenter(view, dataStore)
    presenter.fetchUser()

    verify(view).networkingViewState = isA<NetworkingViewState.Loading>()
    verify(view).networkingViewState = isA<NetworkingViewState.Success<User>>()
    verifyNoMoreInteractions(view)
  }
}

There is no validation around the User object being passed on the success case. So how can we gain those formatting tests back?

view model meme

That’s right, view models! When we set the NetworkingViewState to Success in our presenter, rather than pass the User object we can pass a UserViewModel instead. And we can add tests for our UserViewModel in a separate class to ensure all values are formatted properly.

Something like this:

class UserViewModelTest {

  val mockUser = User("Amanda", "Hill", 100, "Aquarius")

  @Test
  fun testName() {
    val viewModel = UserViewModel(mockUser)
    val expected = "Amanda Hill"
    val actual = viewModel.fullName()

    assertEquals(expected, actual)
  }
}

Huzzah πŸ’ͺ ! Test coverage regained πŸ’ƒ ! And blog post complete πŸ€“ !