6.
Building Features — Animals Near You
Written by Ricardo Costeira
Until now, you focused your efforts on building a solid foundation for PetSave. You created the main entities and value objects in your domain layer, and you have a data layer ready to handle and shape data. Now that you’ve laid that foundation, you’ll start building features that people can use. You’ll look at the presentation layer and set up the app’s user interface. You’ll also visit the domain layer again to create use cases.
In this chapter, you’ll learn:
- What a presentation layer is.
- How to create a deterministic data flow.
- How to leverage UI Android framework components like
ViewModel
,Fragment
andViewBinding
. - What defines the state of your app and how to manage state.
- How to build use cases.
By the end of the chapter, you’ll have your first feature!
What Is a Presentation Layer?
To create this feature, you’ll start by adding a presentation layer. But what, exactly, is that and why do you need it?
The presentation layer encapsulates all the code related to the UI, holding all the UI-related components. In other words, this layer deals with framework code.
App UI and UX are typically more prone to change than business logic. That means you’ll find yourself changing UI code more often than any other code.
At the same time, UI toolkits are well known for being hard to test. In fact, the whole Android framework makes it hard to write tests. That’s why you should avoid using it in your business logic code as much as possible.
You can test Android UI with instrumented tests and Espresso. These tests need to run on a device, which makes them slow compared to unit tests. Plus, they’re also flakier, because the code changes more often. In some cases, the framework actually prevents you from being able to test at all!
For those reasons, it’s a good idea to make the UI as dumb as possible. You should strive to keep any logic unrelated to the UI decoupled from it. It’s a good thing you have a domain layer. :]
By keeping all the UI code in the same place, you protect both your business logic and yourself. It lets you test your logic, regardless of the UI. You’ll have less of a headache when you try to introduce that new shiny Android UI library in your codebase, because you can do so without messing with the logic — and knowing Android, that’s bound to happen!
Working with the presentation layer offers some challenges, however. You’ll learn about some of them next.
Lifecycles of UI Components
The presentation layer is both the easiest layer to understand and the hardest to work with.
Android UI components have their own individual lifecycles. Picture an Activity
hosting a Fragment
. The system can destroy and recreate that Fragment
multiple times throughout the Activity
’s lifetime. At the same time, that Fragment
’s View
can be destroyed and recreated multiple times while the Fragment
lives on.
Juggling different lifecycles can be daunting. For instance, imagine that you have a Fragment
that calls postDelayed
on a local Handler
, and you forget to remove the callbacks from the Handler
in the Fragment
’s onDestroy()
. This might cause a memory leak, as the garbage collector can’t clean up the Fragment
because something still references it.
In this case, the problem is simple to solve. Other cases, however, can become so complex that it’s difficult to even understand what’s going on.
State Management
There’s also the problem of state management, which is the information your app is holding at any given time. Your UI has a given state, which mutates according to user input and other events.
Different parts of that state may or may not affect one other. Depending on your implementation and needs, changes can even happen concurrently, meaning that even simple states can be hard to implement correctly. With time, bad state management leads to maintenance nightmares. Bugs start creeping in from one part of the code when you change something in another part.
Your app’s state not only includes the data regarding the business logic but also the framework component state. This includes things like the color of a Button
or the visibility of a TextView
. These types of intrinsic properties also represent the state.
Making Your Life Easier With Architecture
App development can be challenging. You have the typical software problems to deal with, like state management and increasing complexity. On top of that, you have to worry about the framework. As a developer, you must do everything you can to make development easier and, more importantly, fun! Choosing the right architecture is a great start.
Android has seen a few different architectural patterns throughout the years. The most common patterns are MVP (Model-View-Presenter) and MVVM (Model-View-ViewModel). The reason for their popularity is simple: They do an excellent job of decoupling the UI from the business logic. Keep the UI dumb, remember?
These patterns help keep your concerns separated and well defined. Still, things can get messy when you start considering state. For instance, presenters in MVP are usually stateless. Even if you make them stateful, the way the architecture works can make it hard to sync it with the View
’s state.
MVVM Architecture
MVVM makes this a lot easier, as state management is built into the architecture. The View
communicates with the ViewModel
by subscribing to changes on its state. This lets you use the ViewModel
to represent the View’s state.
Even so, it can get tricky if the View
subscribes to a lot of different state properties — especially if those properties depend on each other. It’s not hard to imagine an Android ViewModel
with a few different StateFlow
instances emitting tightly coupled properties. For instance:
class MyViewModel() {
val isLoading: StateFlow<Boolean>
val items: StateFlow<List<Item>>
}
Handling the properties incorrectly can lead to impossible states, like showing a loading ProgressBar
when you have already have the item list. Plus, as the number of properties increases, so does the complexity.
Note: Keep in mind that using the
ViewModel
Android component doesn’t necessarily mean that you’re following the MVVM pattern. You can use aViewModel
in many other ways. It’s not the best component name. :]
MVI Architecture
So, what should you use? You might have heard about the new kid on the block: MVI (Model-View-Intent). This pattern enforces a few interesting rules:
- Immutable state: You create updated copies of the state, rather than mutating it. This avoids bugs stemming from mutability.
- One single view state per screen: A view state can be a data class with all the state’s properties, or even a set of sealed classes representing the different possible states. Using sealed classes solves the problem of impossible states.
- Unidirectional data flow: Makes your state deterministic — which makes testing actually enjoyable!
You won’t exactly follow an MVI pattern in this chapter, as you don’t need to create reducers and/or intents. Instead, you’ll do something simpler, somewhere between MVVM and MVI. The Android community likes to call it a unidirectional data flow architecture. In fact, even Google’s architecture guide reccomends it now. Here’s a high-level view, where the black arrows represent data flow and the open arrow represents inheritance:
Now, it’s time to start coding your new feature!
Building Animals Near You
Make sure you’re running the starter project, then build and run the app. The bottom navigation bar is there to let you navigate between the screens of the two features.
The bottom navigation bar uses the Navigation component, from Android Jetpack Components. Clicking on the bar’s icons lets you navigate between screens, although they don’t show anything but an infinite spinner at this point.
Note: If you’re interested in using the Navigation component with the bottom navigation bar, you can check out Navigation Component for Android Part 3: Transition and Navigation here: https://www.raywenderlich.com/8279305-navigation-component-for-android-part-3-transition-and-navigation.
Open AnimalsNearYouFragment.kt in the animalsnearyou.presentation package. The app uses view binding to access view elements. If you don’t need two-way data binding or layout variables, view binding is the best choice. It provides the null and type safety that findViewById
doesn’t. It’s also easier to use than data binding and compiles faster.
As with data binding, there’s one thing to remember when using view binding in a Fragment
: Fragment
s can outlive their View
s. So you need to clear up the binding in the Fragment
’s onDestroyView
:
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
This is why there are two different binding variables. The nullable one sets and destroys the binding, and the non-nullable binding accesses the view elements without the safe call operator, ?.. Accessing the latter from outside the View
’s lifecycle will crash the app. If that happens, you’re doing something wrong. :]
To set up the UI, you need:
- A
RecyclerView
for the list. - An
Adapter
for theRecyclerView
. - View state and events for state management.
- Use cases.
- A
ViewModel
to handle events and update the view state. - To observe the view state.
You’ll start with the UI components. The XML layouts are ready, so you’ll just work on the code.
Creating the UI Components
For your first step, you’ll create the UI for your feature in the existing AnimalsNearYouFragment
.
In AnimalsNearYouFragment.kt, add the following code:
// 1
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
setupUI()
}
// 2
private fun setupUI() {
val adapter = createAdapter() // 3
setupRecyclerView(adapter)
}
private fun createAdapter(): AnimalsAdapter {
return AnimalsAdapter()
}
// 4
private fun setupRecyclerView(animalsNearYouAdapter: AnimalsAdapter) {
binding.animalsRecyclerView.apply {
adapter = animalsNearYouAdapter
layoutManager = GridLayoutManager(requireContext(), ITEMS_PER_ROW)
setHasFixedSize(true)
}
}
Here’s what’s happening above:
-
onViewCreated()
executes immediately afteronCreateView()
. The framework makes sure that you’ve correctly initialized allViews
at this point, so you should do all yourView
setup here. For instance, if you useLiveData
s, observing them here ensures they’re unsubscribed inonDestroyView()
. By creating the view inonCreateView()
, then initializing it inonViewCreated()
, you maintain a good separation of concerns (SoC). Plus, you don’t need to worry about null pointer exceptions (NPEs) onView
access. -
Create a method to glue together all the UI setup code. Inside it, you delegate each component’s setup to other methods.
-
Create an
adapter
value and initialize it. -
Run some standard
RecyclerView
code. You set theAdapter
, aGridLayoutManager
with two items per row, and tell theRecyclerView
that all elements have the same size. This way, theRecyclerView
can skip some measuring steps and perform some optimizations.
There’s a good reason why the adapter value only exists in setupUI()
’s scope. Having an Adapter
as a property of a Fragment
is a known way of leaking the RecyclerView
.
That’s because, when the View
is destroyed, the RecyclerView
is destroyed along with it. But if the Fragment
references the Adapter
, the garbage collector won’t be able to collect the RecyclerView
instance because Adapter
s and RecyclerView
s have a circular dependency. In other words, they reference each other.
Making an Adapter a Property of a Fragment
If you need the Adapter
as a property of a Fragment
, don’t forget to either:
- Null out the
Adapter
property inonDestroyView
. - Null out the
Adapter
reference in theRecyclerView
itself, before doing the same for the binding.
So, if you needed the Adapter
as a property of the Fragment
in this code, you’d have something like:
override fun onDestroyView() {
super.onDestroyView()
// either
adapter = null
// or
binding.recyclerView.adapter = null
_binding = null
}
By the way, AnimalsAdapter
already exists. It’s in the common.presentation package because the search screen also uses it.
Open AnimalsAdapter.kt and have a look at the code inside. You’ll notice that this file contains the definition of a simple adapter, with a single view type. Since the code is so simple, ViewHolder
is also here as an inner class. If you had more view types and more complex ViewHolder
s, it would be best to decouple them from the adapter for proper SoC.
Defining the UI Model
As you know, Adapter
is the abstraction Android uses for the object responsible for providing the View
for each item in a RecyclerView
. Each View
is then encapsulated in a ViewHolder
that’s responsible for:
- Creation of the
View
to recycle for other items of the same type. - Binding the data of each item you want to display to the
View
created for that specific time.
To see how this works, examine AnimalsViewHolder
’s bind
:
fun bind(item: UIAnimal) {
binding.name.text = item.name
binding.photo.setImage(item.photo)
}
setImage
is an ImageView
extension function that internally calls Glide to load the picture.
This binding requires a name and a photo, which come from a UIAnimal
. Like the domain and data layers, the presentation layer also has its own model. UIAnimal
is a simple data class:
data class UIAnimal(
val id: Long,
val name: String,
val photo: String
)
Remember that you want to keep the UI dumb. Ideally, a UI model will consist of simple primitive types like this one. The model should have only the minimum necessary information to do its job — in other words, only enough to satisfy the UI’s needs.
Using DiffUtil for AnimalsAdapter
Items in the model can change over time, and so can the RecyclerView
that displays them. To minimize the work and make the transition smooth, Android provides a special Adapter
: the ListAdapter
.
AnimalsAdapter
extends ListAdapter
, which requires a DiffUtil.ItemCallback
. There’s an ITEM_COMPARATOR
property at the bottom of the file with an anonymous class extending DiffUtil.ItemCallback
. It already overrides the areItemsTheSame
and areContentsTheSame
abstract methods.
For your next step, complete them by replacing their contents with:
private val ITEM_COMPARATOR = object : DiffUtil.ItemCallback<UIAnimal>() {
override fun areItemsTheSame(oldItem: UIAnimal, newItem: UIAnimal): Boolean {
return oldItem.id == newItem.id // 1
}
override fun areContentsTheSame(oldItem: UIAnimal, newItem: UIAnimal): Boolean {
return oldItem == newItem // 2
}
}
- This method has the job of checking if
oldItem
andnewItem
are the same. That means you have to compare their identities and nothing else. If the contents of an item change and you compare them here, the method will returnfalse
instead oftrue
— which will cause the item to flicker in theRecyclerView
! - This method is called only if
areItemsTheSame
returnstrue
. Here’s where you should compare the contents. SinceUIAnimal
is a data class, using==
will compare all of its properties.
Build and run. You’ll see the same screen as before because there’s no view state yet.
Creating the View State
Now, you need to create a class that stores the current state of your View
.
To do this, open AnimalsNearYouViewState.kt in the animalsnearyou.presentation package and add the following code:
data class AnimalsNearYouViewState(
val loading: Boolean = true, // 1
val animals: List<UIAnimal> = emptyList(), // 2
val noMoreAnimalsNearby: Boolean = false, // 3
val failure: Event<Throwable>? = null // 4
)
This state is as simple as it gets. It contains:
- A Boolean representing the loading state.
- A list of items to display.
- A Boolean representing the no more animals nearby state.
- A value for possible errors. It defaults to
null
, representing the absence of errors.
The default values represent the initial state. When you launch the app for the first time, you won’t have any items and will need to show a loading screen while you get them.
You want your UI to always have the latest view state. To do so, you use either something like LiveData
or a reactive stream like StateFlow
to emit the state for an observer in the UI.
You want this object to survive configuration changes, so you put it in the ViewModel
. This way, if the configuration changes, you can display the state immediately. Even if you need to update it, at least you’re showing something already.
However, there’s something to note here: If you’re modeling errors as part of the state, you’ll display those errors as well! Imagine showing a Snackbar
at the bottom saying Something went wrong every time you flip the phone. I’ve uninstalled apps for less!
Using Event
prevents your app from handling the error more than once. You might’ve seen examples using SingleLiveEvent
to work around this issue, but it doesn’t solve the problem.
Errors are results, or effects, that are consequences of specific actions. Therefore, some developers — like me! — prefer to treat them differently from the rest of the state. They can happen for a variety of reasons; they might not even relate to the state at all.
Just as you have a stream for your state, you can have a separate stream for your effects. That stream should hold things like errors, navigation, dialogs… anything you want to consume once.
A nice way to model this is with a hot reactive stream, like a PublishSubject
or a SharedFlow
. You emit on it, properly react to it and don’t look back.
Now that you understand the theory, you’re ready to work on your view state. You need to wire everything up so that the view can observe it.
Creating the Data Flow
You need to make some changes so that your view state works properly. Here’s how it should work when you’re done:
- The UI sends events to the
ViewModel
. -
ViewModel
reacts to those events by triggering the use cases. - The use cases return state information.
-
ViewModel
updates the view state, which the UI observes.
You’ll work through these, step-by-step.
Handling Events
Events are actions that the UI triggers. What does the UI need when you open the app? A list of animals! That’s what you’ll work on next.
In animalsnearyou.presentation, create a new AnimalsNearYouEvent.kt and write a sealed class to represent both the UI events and the event that requests the animals list:
sealed class AnimalsNearYouEvent {
object RequestInitialAnimalsList: AnimalsNearYouEvent()
}
Now, create AnimalsNearYouFragmentViewModel.kt in the same package. Start by defining the class:
class AnimalsNearYouFragmentViewModel constructor(
private val uiAnimalMapper: UiAnimalMapper, // 1
private val compositeDisposable: CompositeDisposable // 2
): ViewModel() {
override fun onCleared() {
super.onCleared()
compositeDisposable.clear() // 3
}
}
In this code, you have:
- A mapper that translates the domain model to the UI model.
- A
CompositeDisposable
for RxJava. You don’t need to inject schedulers because RxJava provides a way to override them all while testing. - Something to clear the disposable, which you never want to forget. You don’t need to worry about coroutines here; instead, you’ll use
viewModelScope
.ViewModel
will clear them internally.
Exposing the State
Every time you get a new state, you need to update the UI. StateFlow
is a good choice to do this.
In the same AnimalsNearYouFragmentViewModel.kt, add the following code:
private val _state = MutableStateFlow(AnimalsNearYouViewState()) // 1
private var currentPage = 0 // 2
val state: StateFlow<AnimalsNearYouViewState> = _state.asStateFlow() // 3
// 4
fun onEvent(event: AnimalsNearYouEvent) {
when(event) {
is AnimalsNearYouEvent.RequestInitialAnimalsList -> loadAnimals()
}
}
Here’s what’s going on:
- You create a private
MutableStateFlow
with the initial state ofAnimalsNearYouViewState
. You’ll use this property to update the state, which will be exposed toAnimalsNearYouFragment
through an immutableStateFlow
. UnlikeLiveData
,StateFlow
doesn’t benefit from lifecycle-aware behavior, so you’ll have to take some extra steps when subscribing to the stream. - You need to track the page you’re on to request the right data. Knowing the exact page isn’t relevant for the UI state — unless it’s the last one, but that’s why you have
noMoreAnimalsNearby
. This lets you keep this property out of the exposed state. - You set
_state
to the immutableStateFlow
that actually exposes the state. - You create the only public method in the
ViewModel
.AnimalsNearYouFragment
calls this method whenever it has an event to trigger.
Triggering the Initial API Request
Next, you’ll use loadAnimals
to trigger the initial API request for animals. To do this, add this code below onEvent()
:
private fun loadAnimals() {
if (state.value.animals.isEmpty()) { // 1
loadNextAnimalPage()
}
}
private fun loadNextAnimalPage() {
val errorMessage = "Failed to fetch nearby animals"
val exceptionHandler = viewModelScope.createExceptionHandler(errorMessage) { onFailure(it) } // 2
viewModelScope.launch(exceptionHandler) { // 3
// request more animals!
}
}
Here’s what you’re doing:
- The
if
condition checks if the state already has animals.Fragment
will send theRequestInitialAnimalsList
event every time it’s created. Without this condition, you’d make a request every time the configuration changes. This way, you avoid making unnecessary API requests. If there are no animals, though, you callloadNextAnimalPage()
. - You create a
CoroutineExceptionHandler
through a customcreateExceptionHandler
extension function onviewModelScope
. It takes in a lambda, which in turn takes aThrowable
. You callonFailure()
in the lambda, then pass it that sameThrowable
. - You launch a coroutine on
viewModelScope
, passing in theCoroutineExceptionHandler
to thelaunch
extension function.
CoroutineExceptionHandler
is a global solution for exception handling that will catch exceptions even from child coroutines. It only works if you set it on the parent coroutine. It’ll ignore exceptions if you set it on a child coroutine.
You only call CoroutineExceptionHandler
when the parent coroutine has already finished. As such, there’s no coroutine to recover from the exception it catches. If you need the coroutine to recover or you need more control over exceptions, go with try-catch
, which also works with child coroutines.
You can call CoroutineExceptionHandler
from any thread. If you need to access the UI thread in the lambda that you pass to a CoroutineExceptionHandler
, you have to force it. That’s why createExceptionHandler
is an extension function on viewModelScope
. This scope runs on the UI thread, so calling launch
inside the function will run on the UI thread as well.
Handling Errors
Getting back to the code, create onFailure
below the method above:
private fun onFailure(failure: Throwable) {
when (failure) {
is NetworkException, // 1
is NetworkUnavailableException -> {
_state.update { oldState -> // 2
oldState.copy(
loading = false,
failure = Event(failure) // 3
)
}
}
}
}
Here’s what’s happening:
- For now, you’re only handling
NetworkException
andNetworkUnavailableException
. The former is a new exception that avoids having Retrofit code in the presentation layer. CheckrequestMoreAnimals
inPetFinderAnimalRepository
and you’ll see that it throws aNetworkException
— a domain exception — when Retrofit’sHttpException
occurs. - You update the state by calling
update
on theMutableStateFlow
. This method is thread-safe, meaning that it’s OK if multiple events come in at the same time and try to change the state. Theupdate
method has a lambda as a parameter, which in turn reveives an object of typeAnimalsNearYouViewState
as a parameter, only to return another instance of the same type. You can see where this is going, right? The lambda receives the old state as a parameter, and whatever it returns will become the new state. Notice that you’re not mutating the old state, but rather replacing it with an updated copy of itself. Data classes implement thiscopy
method, which really comes in handy here. - Again, you use
Event
to wrapThrowable
so the UI reacts to it only once.
You’ll add more code here later. But first, you need to implement the logic to fetch the animals.
Your First Use Case
Use cases keep your app’s logic well-separated and testable. Each use case will be a class. The use case you’re about to create belongs in the app’s domain, but only animals near you uses it. For that reason, create RequestNextPageOfAnimals.kt in animalsnearyou.domain.usecases and add the following code:
// 1
class RequestNextPageOfAnimals @Inject constructor(
private val animalRepository: AnimalRepository, // 2
private val dispatchersProvider: DispatchersProvider // 3
) {
}
Here’s what’s happening:
- Use case names should be specific, but at the domain level. You can’t tell where the data comes from by the name, for instance.
- You inject an
AnimalRepository
, allowing the use case to access the data sources. - You inject a coroutine dispatchers provider. Rule of thumb: Always inject coroutine dispatchers. They help with testing! Injecting it in the use case also helps keep the
ViewModel
simple since it doesn’t have to worry about which dispatcher it should run the use case on.
A use case has a purpose, so it makes sense for the class to have only one method. However, using it as requestNextPageOfAnimals.run()
when your use case already has a good name is just adding noise. It would be a lot cooler to do requestNextPageOfAnimals()
.
You can do that by overloading the invoke
operator of the class. Add the following operator to the class:
suspend operator fun invoke( // 1
pageToLoad: Int,
pageSize: Int = Pagination.DEFAULT_PAGE_SIZE
): Pagination {
// 2
return withContext(dispatchersProvider.io()) {
// 3
val (animals, pagination) =
animalRepository.requestMoreAnimals(pageToLoad, pageSize)
// 4
if (animals.isEmpty()) {
throw NoMoreAnimalsException("No animals nearby :(")
}
animalRepository.storeAnimals(animals) // 5
return@withContext pagination // 6
}
}
This code implements pagination. Note that:
- It’s a
suspend
function. Neat! - It uses calls
withContext
, which shifts code execution to a background thread — in this case, a thread on the IO dispatcher pool. Note that you don’t need this for Room or Retrofit. Room calls an IO dispatcher internally, and Retrofit’ssuspend
functions already delegate to a background executor. Still, the code performs some operations before reaching Room and Retrofit, and coroutine context switching is cheap, so you might as well use an IO dispatcher, anyway. Apart from this,withContext
returns the scope’s result, so by callingreturn
here you have to make the scope return something of typePagination
. - You’re calling
requestMoreAnimals
on the repository and destructuring its result. - If there are no animals, you throw the
NoMoreAnimalsException
exception, which you’ll handle inonFailure
. - You call
storeAnimals
to store the animals you got from the API in the database. - You return the pagination information that handles paging on the view.
That’s it. Plain and simple!
Now that you’ve created the use case, it’s time to use it.
Connecting the Layers
Go back to AnimalsNearYouFragmentViewModel
. You have to inject the use case before you can use it.
Start by updating the constructor by adding this line above uiAnimalMapper
:
private val requestNextPageOfAnimals: RequestNextPageOfAnimals,
Now, you can update loadNextAnimalPage()
. In launch
’s scope, add:
Logger.d("Requesting more animals.")
val pagination = requestNextPageOfAnimals(++currentPage) // 1
onPaginationInfoObtained(pagination) // 2
In this code, you:
- Call the use case, passing in the current page after incrementing the value.
- Pass the
pagination
result toonPaginationInfoObtained
.
That last method doesn’t exist yet, so create it below loadNextAnimalPage
:
private fun onPaginationInfoObtained(pagination: Pagination) {
currentPage = pagination.currentPage
}
Although the page should be the same one you asked for, you still update it for good hygiene. Also, don’t forget to update onFailure
by adding this to the when
:
is NoMoreAnimalsException -> {
_state.update { oldState ->
oldState.copy(
noMoreAnimalsNearby = true,
failure = Event(failure)
)
}
}
This updates the state to the no more animals nearby state.
Triggering the Event
You now need to trigger the event in AnimalsNearYouFragment
. In onViewCreated
, below setupUI
, add:
requestInitialAnimalsList()
Create the method below setupRecyclerView
, to keep the code organized:
private fun requestInitialAnimalsList() {
viewModel.onEvent(AnimalsNearYouEvent.RequestInitialAnimalsList)
}
You don’t have a viewModel
property yet, so add it at the top of Fragment
, above binding
:
private val viewModel: AnimalsNearYouFragmentViewModel by viewModels()
The viewModels()
delegate will create the ViewModel
for you.
Build and run. It crashes! Check Logcat and you’ll find an error stating that AnimalsNearYouFragmentViewModel
doesn’t have a zero-argument constructor.
If you were to do things manually, you’d have to create a ViewModelFactory
which, in turn, would create your ViewModel
. You’d then pass it as a lambda to the viewModels
property delegate.
But you don’t have to do this manually — instead, you’ll use Hilt, which you’ll implement next.
Hilt on Android Components
Although you’ve already done some work with Hilt, you can’t inject dependencies yet.
With vanilla Dagger, you’d include the main modules in a main Component
, then use that Component
to create the dependency graph.
With Hilt, it’s simpler. A lot simpler. At the root of the project, locate and open PetSaveApplication.kt. Annotate the class:
@HiltAndroidApp
class PetSaveApplication: Application()
Done. :]
No main Dagger Component
, no AndroidInjector
, AndroidInjectionModule
, @ContributesAndroidInjector
, nothing. Just a single annotation!
Build and run — and you’ll still get the same runtime exception regarding AnimalsNearYouFragmentViewModel
. To fix it, go to the class and annotate the constructor:
@HiltViewModel
class AnimalsNearYouFragmentViewModel @Inject constructor
This @HiltViewModel
is a Hilt annotation specific to ViewModel
injection. Using it together with the already known @Inject
ensures your ViewModel
instances get injected — you don’t need anything else. Yes, no more multibinding for ViewModel
instances!
Binding the Repository
Build the app again. You’ll get a compile-time Hilt error stating that it doesn’t know how to inject AnimalRepository
— which makes sense, since you didn’t @Bind
the interface yet.
Open ActivityRetainedModule.kt in common.di and replace the comment with:
@Binds
@ActivityRetainedScoped
abstract fun bindAnimalRepository(repository: PetFinderAnimalRepository): AnimalRepository
The app follows a single Activity, multiple Fragments architecture. You want to retain the repository when you swap Fragment
s. You also want it to survive configuration changes. To enable this, you add the @ActivityRetainedScoped
annotation to the binding method. It makes PetFinderAnimalRepository
live as long as the Activity
and also survive configuration changes. You could also add this annotation to the class itself — the effect would be the same.
I bet you’re starting to get the same feeling I had when I first used Hilt: “This seems too easy. When will it blow up in my face?”
Well… It kinda will as soon as you build and run the app. You’ll get the ViewModel
runtime exception again!
While you marked the ViewModel
for injection, Hilt can’t reach it yet. As far as Hilt knows, you’re not injecting it into any other component. That’s because it doesn’t know that AnimalsNearYouFragment
is a target for injection.
As you might already expect, you can solve the problem with a simple annotation:
@AndroidEntryPoint
class AnimalsNearYouFragment : Fragment()
This annotation marks Android components for injection. Comparing this to what you had to do with vanilla Dagger, it’s pretty cool that you only need a simple annotation now.
Build and run. Yes, it crashes again. But this time, the error is different: Hilt Fragments must be attached to an @AndroidEntryPoint Activity.
Easy. Open MainActivity.kt in the common package and annotate it:
@AndroidEntryPoint
class MainActivity : AppCompatActivity()
Build and run. No crashes! Check Logcat and you’ll see that network requests are happening.
Your next step is to connect to your single source of truth — the database — and display its contents.
Displaying Cute Animals
Before you can make the view observe the data updates, you have to get the stream of data itself. For that purpose, create GetAnimals.kt in animalsnearyou.domain.usecases. In it, create the following use case:
class GetAnimals @Inject constructor(
private val animalRepository: AnimalRepository
) {
operator fun invoke() = animalRepository.getAnimals()
.filter { it.isNotEmpty() }
}
You might wonder whether it’s worthwhile to have a use case this small. Why not just call the repository in ViewModel
? Well, while it seems like unneeded complexity, you can look at it as a case of “avoiding broken windows” — that is, inviting more bad behavior.
Say you add the repository to ViewModel
. It’s just a matter of time until other developers use it for other things, instead of creating use cases for whatever they need. With this little bit of overhead, you gain a lot in terms of consistency and code management. In the end, as always, it’s a matter of coming to an agreement with your team about how to handle these cases.
Injecting the Use Case
Head back to AnimalsNearYouFragmentViewModel
and inject the use case in the constructor, just above the other one:
private val getAnimals: GetAnimals,
You’ll use it in a new method, subscribeToAnimalUpdates
. Create it just below onEvent()
:
private fun subscribeToAnimalUpdates() {
getAnimals()
.map { animals -> animals.map { uiAnimalMapper.mapToView(it) } } // 1
.subscribeOn(Schedulers.io()) // 2
.observeOn(AndroidSchedulers.mainThread()) // 3
.subscribe(
{ onNewAnimalList(it) }, // 4
{ onFailure(it) }
)
.addTo(compositeDisposable) // 5
}
Here’s what you’ve done above:
- You go through the animal list and map each element to its UI counterpart.
- Room handles the
Flowable
in a background thread for you, but you still have some code (like mapping above and in the repository) between this code and Room. As such, you callsubscribeOn(Schedulers.computation())
to move that work off the main thread. - Calling
observeOn(AndroidSchedulers.mainThread())
ensures you access the items on the UI thread. You need to do that to update the UI. - You pass each new list to
onNewAnimalList
, which you’ll create in a minute. If an error occurs, you passThrowable
to the already familiaronFailure
. - Never, ever forget to add the subscription to
CompositeDisposable
. Otherwise, you might leak it.
onNewAnimalList
will finally update the view state with the list of animals. Create it below subscribeToAnimalUpdates
:
private fun onNewAnimalList(animals: List<UIAnimal>) {
Logger.d("Got more animals!")
// 1
val updatedAnimalSet = (state.value.animals + animals).toSet()
// 2
_state.update { oldState ->
oldState.copy(
loading = false,
animals = updatedAnimalSet.toList()
)
}
}
Step by step:
- The API returns unordered pages. The item with ID 79 can appear on page 12, while the item with ID 1000 can show up on the first page. Room returns the elements ordered by their IDs. This means that on each update, you can have new elements appearing amid old ones. This will cause some weird UI animations, with items appearing out of nowhere. To work around it, you concatenate the new list to the end of the current one, and convert the whole thing to a
Set
. By definition, sets can’t have repeated elements. This way, you’ll get a nice animation where new items appear below the old ones. Another possible fix is to locally add something like anupdatedAt
field for each item, and use it to order the list. - Update the state with the new item list.
To invoke subscribeToAnimalUpdates()
, create an init
block in AnimalsNearYouFragmentViewModel.kt, like this:
@HiltViewModel
class AnimalsNearYouFragmentViewModel @Inject constructor(
private val getAnimals: GetAnimals,
private val requestNextPageOfAnimals: RequestNextPageOfAnimals,
private val uiAnimalMapper: UiAnimalMapper,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
init { // HERE
subscribeToAnimalUpdates()
}
// ...
}
Build and run to make sure everything’s OK.
Observing the State
The last step is to observe the state in the Fragment
. In AnimalsNearYouFragment
, add this method below setupRecyclerView()
:
private fun subscribeToViewStateUpdates(adapter: AnimalsAdapter) {
viewLifecycleOwner.lifecycleScope.launch { // 1
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { // 2
viewModel.state.collect { // 3
updateScreenState(it, adapter)
}
}
}
}
Going step by step:
- You only care about observing the state while the
Fragment
’sView
is alive. As such, you calllaunch
onviewLifecycleOwner
so the coroutine gets scoped to theView
. - Since Kotlin Flows don’t come with native support to Android component lifecycles, you have to be explicit about how you want to handle it. By using
repeatOnLifecycle
, you can force the coroutine to run when theView
’s lifecycle is at least at the state you pass in as parameter (in this case,STARTED
), and to get cancelled when the opposite lifecycle event happens (in this case,ON_STOP
). Not only that, but it’ll restart again whenever theON_START
event occurs. On a different note, you’re telling the coroutine to run when the lifecycle reaches theSTARTED
state because at that point, while theView
is already laid out and ready, it’s still not visible to the user — which makes this a great moment for an update. - You call
collect
on theStateFlow
to receive any state events that it might emit.
Rendering the State
updateScreenState()
is responsible for rendering the view state. Add it below, along with the related methods:
private fun updateScreenState(
state: AnimalsNearYouViewState,
adapter: AnimalsAdapter
) {
// 1
binding.progressBar.isVisible = state.loading
adapter.submitList(state.animals)
handleNoMoreAnimalsNearby(state.noMoreAnimalsNearby)
handleFailures(state.failure)
}
// 2
private fun handleNoMoreAnimalsNearby(noMoreAnimalsNearby: Boolean) {
}
// 3
private fun handleFailures(failure: Event<Throwable>?) {
val unhandledFailure = failure?.getContentIfNotHandled() ?: return
val fallbackMessage = getString(R.string.an_error_occurred)
val snackbarMessage = if (unhandledFailure.message.isNullOrEmpty()) {
fallbackMessage
}
else {
unhandledFailure.message!! // 4
}
if (snackbarMessage.isNotEmpty()) {
Snackbar.make(requireView(), snackbarMessage, Snackbar.LENGTH_SHORT).show()
}
}
In the code above:
- You update every property of the state. If you don’t need to update something, it has no place in the view state.
- This is a placeholder method. It’ll prompt the user to try a different distance or postal code if there aren’t any more animals nearby. For the purposes of this chapter, this method isn’t worth implementing. Plus, you haven’t created the code for distance and postal code selection yet.
- Handling failures can be complex, involving things like retrying requests or screen navigation. In this case, you’re handling every failure the same way: by using a
Snackbar
to display a simple message on the screen. You can also see howEvent
lets you handle each error just once, through itsgetContentIfNotHandled()
. - Yes, those double bangs are on purpose. Don’t be afraid of using them when you want to make sure that nullable values exist. The sooner your app crashes, the sooner you can fix the problem. Of course, don’t use them without weighing the consequences. If, for some reason, you can’t use tests or don’t have a QA team testing the app, be more careful. My late uncle always said: “With great power, comes great responsibility.” Just kidding, I never really liked web development. :]
Calling the Subscriber
Last, but not least, you need to call subscribeToViewStateUpdates()
. Add it at the end of setupUI()
, below the setupRecyclerView()
call, and pass in the adapter:
observeViewStateUpdates(adapter)
Build and run. You did it! Look at all those cute animals! Hopefully, you got lucky with the request and got real images instead of the placeholder Glide’s using. :]
Great job! You can finally visualize the work you did over the last few chapters. You built the basis for a scalable and maintainable app — you should be proud of yourself.
There’s a lot still missing here — and it will continue to be missing, because there isn’t enough time to fix everything. For instance, if an animal disappears from the API, you don’t have a way of syncing the cache. There also is no refresh mechanism.
You will add one last thing for this feature, though, because it exposes some interesting topics. In the app, scroll to the bottom of the list and you’ll notice it doesn’t add any more items. You’ll fix that next.
Allowing an Infinite Scroll
Paging is a hard problem to solve, but you won’t use the Paging library here. It adds a lot of complexity, and it’s not that compatible with this architecture because you’d need it in every layer. What matters here is the state management aspect of paging, not what you use to implement it.
Instead, you’ll use a simple infinite scrolling class, which you’ll attach to the RecyclerView
. You’ve probably seen InfiniteScrollListener.kt in animalsnearyou.presentation already. Take a peek if you want; it just checks if RecyclerView
scrolled close to the last item. Until now, you requested only the first page of animals. With infinite scrolling, you’ll start requesting more pages.
Start by adding a new event in AnimalsNearYouEvent
, right below RequestInitialAnimalsList
:
object RequestMoreAnimals: AnimalsNearYouEvent()
Now, switch to AnimalsNearYouFragment
. Add this method just below setupRecyclerView()
:
private fun createInfiniteScrollListener(
layoutManager: GridLayoutManager
): RecyclerView.OnScrollListener {
return object : InfiniteScrollListener(
layoutManager,
AnimalsNearYouFragmentViewModel.UI_PAGE_SIZE
) {
override fun loadMoreItems() { requestMoreAnimals() }
override fun isLoading(): Boolean = viewModel.isLoadingMoreAnimals
override fun isLastPage(): Boolean = viewModel.isLastPage
}
}
isLoading()
and isLastPage()
both use properties that come from ViewModel
. These properties don’t exist yet. The loadMoreItems
override calls requestMoreAnimals()
. This method also doesn’t exist yet, but it should be obvious what it does. Add it below:
private fun requestMoreAnimals() {
viewModel.onEvent(AnimalsNearYouEvent.RequestMoreAnimals)
}
Then, call createInfiniteScrollListener()
in setupRecyclerView()
, below setHasFixedSize(true)
, like so:
addOnScrollListener(createInfiniteScrollListener(layoutManager as GridLayoutManager))
Modifying ViewModel
Now, continue to ViewModel
. First, you’ll create all the properties to get rid of the errors. At the beginning of the class, add:
companion object {
const val UI_PAGE_SIZE = Pagination.DEFAULT_PAGE_SIZE
}
This gets the page size limit defined in the domain. Then, just below state
, add:
val isLastPage: Boolean
get() = state.value.noMoreAnimalsNearby
var isLoadingMoreAnimals: Boolean = false
private set
Finally, react to the event in onEvent()
by adding this line to when
:
is AnimalsNearYouEvent.RequestMoreAnimals -> loadNextAnimalPage()
You can build, and even run, the app now. In fact, the scrolling already works, although a few details are still missing.
Before dealing with those missing details, however, it’s important to talk about why although isLastPage
derives its value from the domain, the same is not true for isLoadingMoreAnimals
. In fact, this one has nothing to do with the view state.
That’s because isLoadingMoreAnimals
is an implementation detail, since the view doesn’t need to know that the loading is ongoing — at least for now. Things would be different if the UI had something like a Loading more view type.
Another relevant point here is the use of isLastPage
, instead of using viewModel.state.value.noMoreAnimalsNearby
directly. There are two reasons for this:
- You want the
Fragment
to accessviewState
only when collecting it. Try to avoid accessing it in other places or the code might get harder to maintain. - Notice the different meaning that each name conveys. While
noMoreAnimalsNearby
alludes to the domain of the app,isLastPage
is actually an implementation detail of the infinite scroll. It just happens to have the view state as its source.
In the end, it’s a trade-off: What you lose by exposing properties other than the view state, you win in code simplicity, intent expression and SoC.
Using the Properties
You don’t need to do anything else for isLastPage
— its custom getter will make sure you always get the most up-to-date value from the view state. However, for isLoadingMoreAnimals
, you want it to be true
when you’re waiting for the API request to finish and false
when you have its result. You’ll make this happen in loadNextAnimalPage()
.
Right on top of the method, above errorMessage
, add:
isLoadingMoreAnimals = true
And at the end, right after the onPaginationInfoObtained()
call and still inside the coroutine’s scope, add:
isLoadingMoreAnimals = false
This avoids triggering more requests while another request is running. The infinite scrolling methods run on the UI thread, so there’s no risk of concurrency here.
Build and run. Look at the logs and you’ll see that the infinite scroll works one request at a time. If you’re patient enough, you’ll see that it stops loading more items when it reaches the end.
And that’s it — you’re done with the Animals near you feature. Great work! By implementing this feature, you learned the basics of state management.
You won’t add any tests in this chapter. In fact, if you try to run the tests in the androidTest package now, Hilt will complain about dependencies. That’s expected due to the current configuration, as you’re not providing every dependency needed. This will be fixed in the next chapter, where you’ll also take the development up a notch by implementing a constantly changing state.
Key Points
- Keep the UI as dumb as possible.
- View states represent the state that the user sees.
- UI models should contain the minimum information necessary for display, in the simplest format possible.
- You can handle exception handling in coroutines with a
CoroutineExceptionHandler
or, for more control, withtry-catch
blocks. - Encapsulate your logic in use cases.
- Inject your dependencies with Hilt, using the Android-specific features it provides.