Encapsulating View State
The AndroidX ViewModel has become a central component in many modern Android apps. They offer a relatively simple abstraction to encapsulate business logic and decision making code which separates it from the platform frameworks, which are often difficult to test.
Since ViewModels are easier to test than Framework classes like Activity and Fragment due to their simple lifecycles and lack of external dependencies, it makes sense to try to put as much logic, the complex decision-making code that should be tested, in ViewModels as possible. Ideally we’ll be left with a view layer, either Activities, Fragments, or something else, which is so simple that it’s not worth testing.
Let’s take a look at a simple approach to communication between the ViewModel and the view layer that allows us to have dead simple views and easily test the valuable code in our app.
À La Carte Data Sources⌗
The official documentation recommends an app architecture that includes a ViewModel
which exposes individual LiveData objects. This allows views to observe specific data from the view model à la carte.
For example, the UserViewModel that’s included in the GithubBrowserSample exposes both repositories
and user
LiveData, which the view observes to display different portions of the user interface.
class UserViewModel {
val repositories: LiveData<Resource<List<Repo>>>
val user: LiveData<Resource<User>>
}
In this example, since each property is loaded separately from the network, the LiveData actually returns a Resource, which is a custom data class that may contain the data, along with it’s loading state and an error message, if present.
This might seem like a simple solution since the view can easily observe different data for different portions of the screen, but in practice this pushes logic and decisions down into the view, making them more complex than necessary, and making them more important to test.
class UserActivity {
val viewModel: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
viewModel.user.observe(this) { userResource ->
if (userResource.data != null) {
bindUser(userResource.data)
} else if (userResource.status == Status.LOADING) {
showUserLoading()
}
if (userResource.message != null) {
showError(userResource.message)
}
}
viewModel.repositories.observe(this) { repoResource ->
if (repoResource.data != null) {
bindRepo(repoResource.data)
} else if (repoResource.status == Status.LOADING) {
showRepoLoading()
}
if (repoResource.message != null) {
showError(repoResource.message)
}
}
}
}
This is not the actual view layer from the example since that doesn’t handle this complexity by ignoring error and loading states for the repositories.
In this hypothetical UserActivity
we bind to the individual components of the ViewModel, but it turns out there are a lot of states to handle. For both the user
and the repositories
we:
- always show data if it exists
- always show an error message from the data source if it exists
- show the loading indicator only if the status is
LOADING
and we don’t have data.
While this seems simple, it can hide subtle bugs. For instance, since there are multiple sources for error messages, we can have a situtation in which one message is overwritten by another, and it’s unclear which that will be. Alternatively, if we display errors in a Snackbar or Toast, we could end up with multiple overlapping error messages.
Additionally, it’s unlikely that we want multiple loading indicators on the screen at the same time, so the view has to decide how to handle two different loading states and combine them into a single UI.
This type of complexity means that, ideally, this view should be tested, and it only has two sources of data to observe. A more complex view will almost certainly require tests to ensure that it’s handling the interconnected states properly.
Expose the View, Not the Data⌗
Instead of exposing data that the ViewModel
has loaded, complete with loading state and error messages, we can simplify our view layer, and our test requirements, by updating our ViewModel
s to expose only the data that the views require, encapsulated in a ViewState
.
We can start by looking not at our data and data sources, but at the view itself. When defining a view layout in XML or the layout editor we don’t think “I need to display a user here”, instead we think “I need a TextView to display a username string here”. This is because we are thinking about what the view shows, not what it represents.
Using that same logic we can identify the specific data that the view requires and encapsulate that in a ViewState
object. The ViewState
should include all of the data that’s required to construct the view, without the view needing to know intricate details about our model data.
Here, for example, is how we might represent a UserViewState
for the previous example. Notice that it doesn’t expose model data like Users
and Repositories
, instead it exposes simple elements like images and text.
data class UserViewState(
val isLoading: Boolean = false,
val error: Error = UserViewState.Error.None,
val username: String? = null,
val avatarUrl: String? = null,
val repos: List<UserViewState.Repo> = emptyList()
) {
// A fixed set of error objects means the view
// can pick localized strings.
sealed class Error {
object NoPermission : Error()
object UserNotFound : Error()
object None : Error()
}
// The list items are converted to their own
// ViewState objects.
data class Repo {
val id: Int, // Not displayed in the view, but used
// to identify the item on click
val name: String,
val stars: Int
}
}
This data class contains all of the data that the view needs to display itself, but isn’t tied to things like API models and network status. The logic to translate the data from the repositories to the view state representation lives where it belongs: in the ViewModel.
Combine Data in the ViewModel⌗
Where previously the combination of data sources, users
and repositories
, was happening in the view, we can now move that logic up into the ViewModel
and only expose a single viewState
property.
class UserViewModel {
// These become private
private val repositories: LiveData<Resource<List<Repo>>>
private val user: LiveData<Resource<User>>
// The only public LiveData is the ViewState
val viewState: LiveData<UserViewState> = MediatorLiveData<UserViewState>().apply {
// Set a default loading state
value = UserViewState(isLoading = true)
// Store the API resources
var userResource: Resource<User> = Resource.loading(null)
var repoResource: Resource<List<Repo>> = Resource.loading(null)
// Combine the API resources into a single UserViewState
fun updateState() {
value = UserViewState(
isLoading = userResource.status == Status.LOADING || repoResource.status == Status.LOADING,
error = getError(userResource, repoResource),
username = userResource.data?.login,
avatarUrl = userResource.data?.avatarUrl,
repos = repoResource.data?.mapToVSRepos() // <- custom extension function
)
}
// Extract a single error from the data sources
fun getError(user: Resource<User>, repos: Resource<List<Repo>>): Error {
// Prefer the user error message
return if (user.status == Status.ERROR) {
Error.UserNotFound
} else if (repos.status == Status.ERROR) {
Error.NoPermission
} else {
Error.None
}
}
// Add our private data sources
addSource(repositories) { resource ->
repoResource = resource
updateState()
}
addSource(user) { resource ->
userResource = resource
updateState()
}
}
}
As you can see, our UserViewModel
has become quite a bit more complex, but that’s exactly where our complexity should live!
Since the view model can contain a custom constructor (omitted for brevity), and doesn’t have system dependencies, we can verify this complex logic using fast unit tests, instead of needing to rely on on-device testing.
Additionally, since the view model now only exposes a single data point both binding the view and testing become simpler.
Binding the View⌗
Now that our UserViewModel
has been updated to expose a single UserViewState
, which is an code based representation of our view, binding the view to that data becomes trivial.
class UserActivity {
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private val viewModel: UserViewModel by viewModels(viewModelFactory)
override fun onCreate(savedInstanceState: Bundle?) {
viewModel.viewState.observe(this) { state ->
bindLoading(state.isLoading)
bindError(state.error)
usernameView.text = state.username
avatarView.imageUrl = state.avatarUrl
repoAdapter.update(state.repos)
}
}
private fun bindLoading(isLoading: Boolean) {
loadingIndicator.visibility = if (isLoading) View.VISIBLE else View.GONE
}
private fun bindError(error: UserViewState.Error) {
when (error) {
Error.UserNotFound -> showErrorSnackbar(R.string.error_user_not_found)
Error.NoPermission -> showErrorSnackbar(R.string.error_no_permission)
Error.None -> removeErrorSnackbarIfPresent()
}
}
}
Now that the UserActivity
is simply setting it’s view properties based on the state it receives, there isn’t much need to test it. It is, however, important to test the UserViewModel
, as we’ve moved all of the logic into that component.
A Note About Sealed Classes⌗
A quick aside about why I don’t use a sealed class to represent the view state, having separate Error
, Loading
and Success
states. The reason some might suggest this is that with separate sealed classes you can guarantee that you only have valid states, for instance, an Error
or Loading
state without data.
While this can make sense for some views, the limitation is that sealed classes only work when your view states are mutually exclusive. For most apps that come to mind, like email clients, social media apps and countless others, it’s perfectly valid to show cached data when the network is unavailable, or when new data is loading.
A common example of this is pull to refresh. While the view is loading after the user triggers a refresh, it should still show data, but also show the loading indicator.
In the example above, you’ll notice that the loading or error properties of the UserViewState
being set to true doesn’t mean that there isn’t data to display. Its very possible to have a network error, have the user attempt a refresh (starting a new load), and also show cached data, all at the same time.
For that reason I almost always use data classes to represent view state. In certain situtations in which you have a select number of mutually exclusive states sealed classes might work, but I’ve almost never found that to be the case.
Testing the ViewModel⌗
While it’s more important to test the ViewModel since it contains even more logic, this now becomes easier since we have a code based representation of the UI that we can verify. All we need to do is ensure that the view state object we get from the ViewModel is what we expect, which data classes make easy.
class UserViewModelTest {
private lateinit var userRepo: FakeUserRepository
private lateinit var repoRepo: FakeRepoRepository
private lateinit var viewModel: UserViewModel
@Test fun `emits loading while user repo loads`() {
userRepo.emit(Resource<User>.loading(null))
assertThat(viewModel.viewState.get()).isEqualTo(UserViewState(isLoading = true))
}
@Test fun `emits loading while repo repo loads`() {
repoRepo.emit(Resource<Repo>.loading(null))
assertThat(viewModel.viewState.get()).isEqualTo(UserViewState(isLoading = true))
}
}
It’s easy to thoroughly test the ViewModel when verification only involves checking that a single property has been set appropriately.
Testing the View⌗
If you do choose to test the view, it also becomes easier since you can provide an implementation of your UserViewModel
that returns known states that are easily written in code.
class UserActivityTest {
@get:Rule val instantTaskExecutor = InstantTaskExecutorRule()
private lateinit var viewState: MutableLiveData<UserViewState>
private lateinit var viewModel: UserViewModel
private lateinit var activityScenario: InjectableActivityScenario<UserActivity>
@Before fun setup() {
viewState = MutableLiveData()
viewModel = mock {
on { viewState } doReturn viewState
}
activityScenario = injectableActivityScenario<UserActivity> {
injectActivity {
viewModelFactory = viewModelFactoryFor(viewModel)
}
}.launch()
}
@Test fun `shows loading state indicator`() {
viewState.value = UserViewState(isLoading = true)
onView(withId(R.id.loading)).check(matches(isDisplayed()))
}
}
This test uses my InjectableActivityScenario to provide the mock
UserViewModel
.
Using a view state object that maps directly to the view means that the view tests require little more than passing in an object representation of the view and making sure the result matches.
Where This Doesn’t Work⌗
It’s important to note that this only works for view models that map directly to a view. It might make sense, for instance, to track the currently selected item in a multi-pane layout using a shared view model, so that both your list and detail views can communicate. In these cases, since the observable data from those shared view models doesn’t represent the view, this approach probably won’t help much.
Often times these case only require a single piece of data that your view can pass directly to it’s view model, allowing the view model to decide how to handle that shared state.
Conclusion⌗
While it can often seem trivial to display our data model directly in our view, it can often lead to a more complex view, which is hard to test, and spreads logic around our app. Using a ViewState
object that acts like an object based representation of our UI allows us to keep our views dead simple, reducing the need for tests, and simplify interaction with our view models.
While there are cases that the ViewState
doesn’t handle, like displaying transient data that shouldn’t be restored into the view, there are other solutions for that that we’ll touch on later.
What do you think? Have you used a similar model to simplify the exposure points of ViewModels, or is it something you’d like to try out? Let me know your thoughts, or questions or comments, by tweeting @rharter.
Thanks to Florina Muntenescu, Jeroen Mols and Daniele Bonaldo for providing feedback on a draft of this post.