7.
Building Features — Search
Written by Ricardo Costeira
In the previous chapters, you developed the Animals near you feature. You built it one layer at a time, with the small exception of the use cases. For the Search feature, you’ll follow a more dynamic approach, adding code to the layers as you need it.
In this chapter, you’ll learn about:
- Handling user-triggered events.
- Reacting to different events and reducing them to the same view state.
- Handling pending requests.
- Testing, and the advantages that this architecture brings to it.
There’s a lot of fun ahead!
Building a search feature
Your goal now is to create a search function to help potential owners find their perfect pet. Here’s a breakdown of how the feature works:
- The user types the animal’s name in the search query.
- The user can filter the queries by the age and type of animal.
- The app searches the cache for matching animals.
- If no animals exist locally, the app sends a request to the PetFinder API.
- The app stores the API result, if it finds one, and shows the search results to the user.
Now, it’s time to jump in and start finding pets!
Getting started
To start, go to the fragment_search
layout and look around. The whole UI is ready to go: You have a search widget and View
s to display the remote search and no results cases.
In the code, notice how every View
has an ID. As a best practice, you should have IDs for all your View
s. View
s can be stateful and the Android system needs those IDs to restore their state when necessary. For instance, if a ScrollView
doesn’t have an ID, the system won’t restore its scroll position after a configuration change.
Another thing to keep in mind is that you should strive for unique IDs whenever possible. This applies not only to the layout you’re working on, but throughout the whole app. This helps the system search for the correct View
in the hierarchy tree. Having the same IDs can lead to subtle bugs in cases where you include different layouts under the same View
hierarchy.
Searching locally
According to your plan, the app should search for pet names locally before calling on the remote API.
The classes you need to do this already exist. Open SearchFragment.kt in the search.presentation package. You’ll notice that it has a similar basic UI code as AnimalsNearYouFragment.kt, in the animalsnearyou.presentation package, does.
The app only has these two Fragments
, so it’s not a big deal. With more Fragments
, it might make sense to extract the common code into a common class or set of functions. Just don’t create a BaseFragment
class. Over time, base classes get polluted with code that only specific child classes use. This creates an implicit coupling between that code and classes that don’t use it. It also turns the base class into a spaghetti mess, making maintenance and refactoring harder.
Ideally, you’d delegate the intended behavior through well-defined, single-responsibility classes that Fragments
can then use through composition.
You need to set up a few things before the user can start interacting with the UI:
-
Search field: The search field is a
SearchView
. You need to set it up with anOnQueryTextListener
to react to text changes. -
Filters: Both filters are
AutoCompleteTextView
instances. You need to add anOnItemClickListener
to both, so you can retrieve the selected option.
Every interaction will trigger an event, and each event is sent to the ViewModel
. You can find the pre-created events in SearchEvent.kt:
sealed class SearchEvent {
object PrepareForSearch : SearchEvent()
data class QueryInput(val input: String): SearchEvent()
data class AgeValueSelected(val age: String): SearchEvent()
data class TypeValueSelected(val type: String): SearchEvent()
}
Recognizing text in the search field
Your first step is to change the search field so it recognizes when the user types a query. Start by adding the following method in the SearchFragment.kt:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
private fun setupSearchViewListener() {
val searchView = binding.searchWidget.search
searchView.setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(
query: String?
): Boolean {
viewModel.onEvent(
SearchEvent.QueryInput(query.orEmpty()) // 1
)
searchView.clearFocus()
return true
}
override fun onQueryTextChange(
newText: String?
): Boolean {
viewModel.onEvent(
SearchEvent.QueryInput(newText.orEmpty()) // 2
)
return true
}
}
)
}
// ...
}
This method creates and sets OnQueryTextListener
on SearchView
. It sends a SearchEvent.QueryInput
event:
- With the text you receive as a parameter of the
onQueryTextSubmit()
callback that’s invoked when you submit the text in input. - With the
String
you get every time the text in input changes andonQueryTextChange()
is invoked.
Both of the overrides trigger events on the ViewModel
, updating the search query. The difference between them is that onQueryTextSubmit
also calls clearFocus
on the SearchView
. This hides the soft keyboard when the user taps its Search button.
Handling the search filters
Next, you need to add the functionality that lets the user filter their results by age and type of animal. To handle the filters, add these methods to the same SearchFragment.kt:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
// 1
private fun setupFilterListeners() {
with (binding.searchWidget) {
setupFilterListenerFor(age) { item ->
viewModel
.onEvent(SearchEvent.AgeValueSelected(item)) // 2
}
setupFilterListenerFor(type) { item ->
viewModel
.onEvent(SearchEvent.TypeValueSelected(item)) // 3
}
}
}
// 4
private fun setupFilterListenerFor(
filter: AutoCompleteTextView,
block: (item: String) -> Unit
) {
filter.onItemClickListener =
AdapterView.OnItemClickListener { parent, _, position, _ ->
parent?.let {
block(it.adapter.getItem(position) as String)
}
}
}
// ...
}
This code defines:
-
setupFilterListeners()
as a utility method that allows you to set up the filter logic for the age and type of animal, passing in a lambda that triggers theViewModel
event that updates each filter. - The event to trigger when the user selects a new age.
- The event to trigger when the user selects a new type.
-
setupFilterListenerFor
as a method that sets the listener on the filters. The listener gets the filter at a given position and passes it into the lambda. The behavior is the same for both filters, so you reuse it.
To call all these methods, update SearchFragment
like this:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
private fun prepareForSearch() { // 1
setupFilterListeners()
setupSearchViewListener()
viewModel.onEvent(SearchEvent.PrepareForSearch) // 2
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUI()
prepareForSearch() // 3
}
// ...
}
In this code you:
- Add
prepareForSearch()
as a convenience method that invokes bothsetupFilterListeners()
andsetupSearchViewListener()
. - Send the
SearchEvent.PrepareForSearch
event to theViewModel
, so it knows when the UI is ready to start searching. - Call
prepareForSearch()
when you initialize the UI forSearchFragment
.
Build and run to make sure you didn’t break anything. You haven’t handled the events on the ViewModel
side yet, so you’ll see the same incomplete UI as Figure 7.2:
Dealing with a more complex state
Before going to the ViewModel
, open SearchViewState.kt. It might not look like it, but this view state is a lot more complex than the other one:
data class SearchViewState(
val noSearchQuery: Boolean = true,
val searchResults: List<UIAnimal> = emptyList(),
val ageFilterValues: Event<List<String>> = Event(emptyList()),
val typeFilterValues: Event<List<String>> = Event(emptyList()),
val searchingRemotely: Boolean = false,
val noRemoteResults: Boolean = false,
val failure: Event<Throwable>? = null
)
The filters are modeled as Event
’s for performance reasons.
AutoCompleteTextView
uses an Adapter
— not the same Adapter
you used with RecyclerView
— to display items. The simplest way to update that Adapter
is to create a new one with the updated data. Once you set the filters, the data they display doesn’t change. However, creating a new Adapter
on each state update is a waste of resources. Using the Event
wrapper class, you ensure you only create one Adapter
for each filter.
This feature has a lot of different states. It would get too complex to manage them without losing track of what they mean. That’s why SearchViewState
has a few methods to manage that for you. Each method copies the original state into a new one associated with the method’s name.
You could also use sealed classes here, keeping a class for each state. Sealed classes have no copy
method, though. So in that case, you’d either have to handle the state update itself or apply a State pattern.
The methods help give you an idea of the current state, but you can still have impossible state updates like going to a no remote results state immediately after a no search query state. At this point, the code is still simple enough to catch bugs like this quickly, but you might have to update to sealed classes if the state gets more complicated.
Populating the filters
Open SearchFragmentViewModel.kt. You can see that onEvent()
already reacts to events, but the methods it calls don’t do anything yet. You’ll change that now adding loadFilterValues()
like this:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
) : ViewModel() {
// ...
private fun loadFilterValues() {
// 1
val exceptionHandler =
createExceptionHandler(
message = "Failed to get filter values!"
)
viewModelScope.launch(exceptionHandler) {
val (ages, types) = withContext(dispatchersProvider.io()) {
getSearchFilters() // 2
}
updateStateWithFilterValues(ages, types) // 3
}
}
}
This code:
- Creates
CoroutineExceptionHandler
throughcreateExceptionHandler()
, which you defined in theViewModel
. - Launches a coroutine in
viewModelScope
. The coroutine callsgetSearchFilters()
in the background. The return value is destructured intoages
andtypes
.getSearchFilters()
is a use case. - Calls
updateStateWithFilterValues()
and passes in the filter values.
Before creating the use case, create updateStateWithFilterValues()
like this:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
) : ViewModel() {
// ...
private fun updateStateWithFilterValues(
ages: List<String>,
types: List<String>
) {
_state.value = state.value!!.updateToReadyToSearch(
ages,
types
)
}
}
Now that it has the filter data, the UI is ready for the user, so you update the state to ready to search.
Updating the domain with search
You need to create getSearchFilters()
, the use case that gets the data to populate the filters.
First, think about what the use case should return. Strings? Animals? According to the use case’s name, it should return search filters. But the domain layer doesn’t know what a search filter is, nor does it have any knowledge about the search.
To change this, go to the search.domain.model package and create the SearchFilters.kt file with the following code:
data class SearchFilters(
val ages: List<String>,
val types: List<String>
)
Now, create GetSearchFilters.kt in the search.domain.usecases package and write the use case class, like this:
class GetSearchFilters @Inject constructor(
private val animalRepository: AnimalRepository
) {
companion object {
const val NO_FILTER_SELECTED = "Any"
}
suspend operator fun invoke(): SearchFilters {
}
}
The companion object
property is the default value for both filters. The use case will get both ages and types from the repository. The methods for this already exist. Don’t worry, you’ll create the whole method chain for the next use case. :]
Getting data from the repository
Now, you’re going to add the functionality to get the search results from the repository.
Complete invoke()
with the following code:
suspend operator fun invoke(): SearchFilters {
val unknown = Age.UNKNOWN.name
//1
val types =
listOf(NO_FILTER_SELECTED) + animalRepository.getAnimalTypes()
// 2
val ages = animalRepository.getAnimalAges()
.map {
if (it.name == unknown) {
NO_FILTER_SELECTED
} else {
it.name.toLowerCase(Locale.ROOT).capitalize(Locale.ROOT)
}
}
return SearchFilters(ages, types)
}
Here, you:
- Request the animal types from the repository and add a default value,
Any
, to the head of the type list. - Get the ages from the repository, then map the
Enum
s to their names, replacingUNKNOWN
withAny
and capitalizing the words. After that, you returnSearchFilters
with the ages and types.
The default value will be at the head of the ages list as well. This is due to the order you set the Enum
values.
Note: It’s not advisable to rely on
Enum
’s value order, which can change over time. By doing so, you create a tight coupling between this code and theEnum
’s implementation.
Head to SearchFragmentViewModel
. Inject the use case in the constructor:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val getSearchFilters: GetSearchFilters, // HERE
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel()
Finally, in the empty prepareForSearch()
method of the VIewModel
, call loadFilterValues()
:
private fun prepareForSearch() {
loadFilterValues()
}
Build and run to make sure everything works. You won’t see any differences yet because the Fragment
isn’t observing these changes.
Updating the UI
To update the UI with the filter information, you need to implement two methods. Go to SearchFragment
and add:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
// 1
private fun setupFilterValues(
filter: AutoCompleteTextView,
filterValues: List<String>?
) {
if (filterValues == null || filterValues.isEmpty()) return
filter.setAdapter(createFilterAdapter(filterValues))
filter.setText(GetSearchFilters.NO_FILTER_SELECTED, false)
}
// 2
private fun createFilterAdapter(
adapterValues: List<String>
): ArrayAdapter<String> {
return ArrayAdapter(
requireContext(),
R.layout.dropdown_menu_popup_item,
adapterValues
)
}
// ...
}
Here’s what’s going on in this code:
- You’ll use this method for both filters. It returns early if the list is either null or empty — for instance, on the initial state or when the filter content was already handled. It creates the adapter for a given filter and sets the filter to show the default value from the use case. Both filters will have the default value as the first one on the list. However, to avoid relying on the age
Enum
’s value order, it’s best to use the default value from the use case instead. Having theFragment
access the use case isn’t great either, but it’s better. A workaround here would be to have theViewModel
declare a property for the default value, which it would get from the use case, and have theFragment
access that instead. - Creates an
ArrayAdapter
that displays aTextView
for each element, as per thedropdown_menu_popup_item
layout.
Finally, locate updateScreenState()
, the method responsible for rendering the state. Update it to call setupFilterValues()
for both filters, like this:
private fun updateScreenState(
newState: SearchViewState,
searchAdapter: AnimalsAdapter
) {
val (
inInitialState,
searchResults,
ageFilterValues,
typeFilterValues,
searchingRemotely,
noResultsState,
failure
) = newState
updateInitialStateViews(inInitialState)
with (binding.searchWidget) {
setupFilterValues(
age,
ageFilterValues.getContentIfNotHandled()
)
setupFilterValues(
type,
typeFilterValues.getContentIfNotHandled()
)
}
handleFailures(failure)
}
The view state observer already calls updateScreenState()
, so view state updates will already trigger it.
Build and run. The app now displays the filters with data!
Cool. Now you can use this data, along with a search query, to search for animals.
Triggering the search
Earlier, you triggered the search parameters’ change events, but the code doesn’t react to them yet. You’ll change that next.
Open SearchFragmentViewModel.kt. At the top of the class is one BehaviorSubject
for the search query and two others for the age and type filters. You’ll use all three of them, merge them into one single Flowable
and operate on it so it searches the cache. This same Flowable
will then update the view state.
Locate onSearchParametersUpdate()
in SearchFragmentViewModel
and update it to:
private fun onSearchParametersUpdate(event: SearchEvent) {
when (event) {
is SearchEvent.QueryInput -> updateQuery(event.input)
is SearchEvent.AgeValueSelected -> updateAgeValue(event.age)
is SearchEvent.TypeValueSelected -> updateTypeValue(event.type)
}
}
This method is already called in onEvent()
and, in turn, calls a different method for each event.
You’re probably getting a yellow squiggly line under the when
. You could solve this by having these cases join the PrepareForSearch
case, instead of having them in a separate method. However, bear with it for now — this separation will make sense later.
None of the methods exist, so add them in ´SearchFragmentViewModel`:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private fun updateQuery(input: String) {
resetPagination() // 1
querySubject.onNext(input) // 2
// 3
if (input.isEmpty()) {
setNoSearchQueryState()
} else {
setSearchingState()
}
}
// 4
private fun updateAgeValue(age: String) {
ageSubject.onNext(age)
}
private fun updateTypeValue(type: String) {
typeSubject.onNext(type)
}
// ...
}
In this code, you:
- Reset the pagination with each query. The search screen needs infinite scrolling for the cases where the remote results return more than one page. For simplicity, though, some parts of that code were omitted.
- Send the input to the input’s
BehaviorSubject
. - Want to show different things on the screen, depending on whether the input is empty or not. The no search query state is visually identical to the ready to search state. For instance, if you write something on the
SearchView
and then delete it, you want to revert to no search query. - Send the selected filter values to the corresponding
BehaviorSubject
s.
Build and run. Now, when you type something into the search, the background cat will disappear as you update to the searching state.
Tying everything together
You now have all the ingredients for the local search. You just need to tie everything together to make the search work.
You’ll now create a SearchAnimals
use case. Just like before, this use case returns a specific domain model: SearchResults
.
Start by creating the model. In the search.domain.model package, create SearchResults.kt with the code:
data class SearchResults(
val animals: List<Animal>,
val searchParameters: SearchParameters
)
It’s composed of a list of animals and SearchParameters
, a value object that models the search parameters. You’ll use it to search the cache and to propagate the search parameters to a remote search, in case nothing in the cache matches.
Create SearchParameters.kt in the same package, with the value object code:
data class SearchParameters(
val name: String,
val age: String,
val type: String
)
Finally, in the search.domain.usecases package, create SearchAnimals.kt. In it, add the code:
class SearchAnimals @Inject constructor(
private val animalRepository: AnimalRepository
) {
operator fun invoke(
querySubject: BehaviorSubject<String>,
ageSubject: BehaviorSubject<String>,
typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
}
}
This use case takes in all the BehaviorSubject
s and outputs a Flowable
of SearchResults
. This Flowable
emits new values every time one of the BehaviorSubject
s emits something new.
You need to do some work on the streams before you’re able to use them. You’ll start with the query stream first.
Handling search queries
There are a few steps to follow to handle the search queries properly. Update the invoke
operator method of the use case:
operator fun invoke(
querySubject: BehaviorSubject<String>,
ageSubject: BehaviorSubject<String>,
typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
val query = querySubject
.debounce(500L, TimeUnit.MILLISECONDS) // 1
.map { it.trim() } // 2
.filter { it.length >= 2 } // 3
}
Here’s what’s going on above:
-
debounce
is important because it helps you avoid reacting to every little change in the query. There’s no need to react instantly to what a user types when waiting half a second longer might allow you to provide more information. The user won’t notice, you’ll provide a better service and you’ll lighten the load on the device, performance-wise. - The user might add unnecessary spaces before or after the query, and the app considers these to be characters. It’s best to remove them.
- This avoids events with a single character or less. Hopefully, there are no animals called Z or something. :]
Removing the Any value
For the filters, you need to replace the Any
value with an empty string because you don’t want ages or types that match Any
. The reason for this will become clearer when you implement the cache search method. For now, add these two lines to invoke
:
operator fun invoke(
querySubject: BehaviorSubject<String>,
ageSubject: BehaviorSubject<String>,
typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
val query = querySubject
.debounce(500L, TimeUnit.MILLISECONDS)
.map { it.trim() }
.filter { it.length >= 2 }
val age = ageSubject.replaceUIEmptyValue() // This
val type = typeSubject.replaceUIEmptyValue() // And this
}
And create the replaceUIEmptyValue()
private extension function in the use case’s scope:
class SearchAnimals @Inject constructor(
private val animalRepository: AnimalRepository
) {
// ...
private fun BehaviorSubject<String>.replaceUIEmptyValue() = map {
if (it == GetSearchFilters.NO_FILTER_SELECTED) "" else it
}
// ...
}
This extension function handles the required string replacement. You can now merge the BehaviorSubject
s and use their joint result to output a Flowable
.
To do so, you need to add the following property, called combiningFunction
, to the class. You’ll see why in a second:
class SearchAnimals @Inject constructor(
private val animalRepository: AnimalRepository
) {
// ...
private val combiningFunction: Function3<String, String, String, SearchParameters>
get() = Function3 { query, age, type ->
SearchParameters(query, age, type)
}
//...
}
To avoid trouble with Function3
, add this import at the top:
import io.reactivex.functions.Function3
Make the final update to invoke
by adding the return statement:
operator fun invoke(
querySubject: BehaviorSubject<String>,
ageSubject: BehaviorSubject<String>,
typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
val query = querySubject
.debounce(500L, TimeUnit.MILLISECONDS)
.map { it.trim() }
.filter { it.length >= 2 }
val age = ageSubject.replaceUIEmptyValue()
val type = typeSubject.replaceUIEmptyValue()
return Observable.combineLatest(query, age, type, combiningFunction) // 1
.toFlowable(BackpressureStrategy.LATEST) // 2
.switchMap { parameters: SearchParameters -> // 3
animalRepository.searchCachedAnimalsBy(parameters)
}
}
Here’s what this does:
-
combineLatest
joins the latest results of each stream, using the combining function. In this case, your combining function is the property you just created. It outputs aSearchParameters
instance with the values from all the streams. Every time a stream emits something new,combineLatest
creates an updatedSearchParameters
instance. - The
toFlowable
operator transforms the stream into aFlowable
. You need to do this to wire the stream up to theFlowable
you’ll get from the repository. When you create aFlowable
with this operator, you need to specify a backpressure strategy. Only the most recently emitted event matters. As such, you create theFlowable
withBackpressureStrategy.LATEST
, which discards any previous event it’s holding in favor of the new one. -
switchMap
discards any old events in favor of new ones. This is exactly what you want for a search. Also, usingswitchMap
makes the backpressure definition above unnecessary. Regardless, since you have to specify one anyway, you might as well use the one that fits better. InsideswitchMap
, you call the repository’ssearchCachedAnimalsBy()
, passing in the search parameters.
The repository method doesn’t exist yet. In fact, none of the needed methods exist, so buckle up: You need to go through the layers and create all the necessary methods.
Adding search to the Repository
Since you’re already calling the repository’s method in the use case, it makes sense to start from there. Go to AnimalRepository
and add the method declaration:
interface AnimalRepository {
// ...
fun searchCachedAnimalsBy(searchParameters: SearchParameters): Flowable<SearchResults>
// ...
}
Then, implement it in PetFinderAnimalRepository
:
class PetFinderAnimalRepository @Inject constructor(
private val api: PetFinderApi,
private val cache: Cache,
private val apiAnimalMapper: ApiAnimalMapper,
private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
// ...
override fun searchCachedAnimalsBy(
searchParameters: SearchParameters
): Flowable<SearchResults> {
val (name, age, type) = searchParameters
return cache.searchAnimalsBy(name, age, type)
.distinctUntilChanged()
.map { animalList ->
animalList.map {
it.animal.toAnimalDomain(
it.photos,
it.videos,
it.tags
)
}
}
.map { SearchResults(it, searchParameters) }
}
// ...
}
This is similar to getAnimals
, which also returns a Flowable
. The difference is that there’s an extra map
at the end.
Of course, searchAnimalsBy()
also doesn’t exist yet. Add it to the Cache
interface:
interface Cache {
// ...
fun searchAnimalsBy(
name: String,
age: String,
type: String
): Flowable<List<CachedAnimalAggregate>>
// ...
}
And implement it in RoomCache
:
class RoomCache @Inject constructor(
private val animalsDao: AnimalsDao,
private val organizationsDao: OrganizationsDao
) : Cache {
// ...
override fun searchAnimalsBy(
name: String,
age: String,
type: String
): Flowable<List<CachedAnimalAggregate>> {
return animalsDao.searchAnimalsBy(name, age, type)
}
// ...
}
Finally, add the most interesting method of them all, in AnimalsDao
:
@Dao
abstract class AnimalsDao {
// ...
@Transaction
@Query("""
SELECT * FROM animals
WHERE name LIKE '%' || :name || '%' AND
AGE LIKE '%' || :age || '%'
AND type LIKE '%' || :type || '%'
""")
abstract fun searchAnimalsBy(
name: String,
age: String,
type: String
): Flowable<List<CachedAnimalAggregate>>
// ...
}
This query uses the search parameters to filter the table elements. Using """
lets you write multiline statements. SQLite’s LIKE
operator is case-insensitive, so you don’t need to worry about capitalization. '%' ||
and || '%'
search for the parameters, even if they’re prefixed or suffixed with other characters. So for instance, searching by rce will return an animal named “Marcel”.
Here, you can see why you replaced Any
with empty strings. Using LIKE
with an empty string matches every item, so it works as if you’re not using any filter at all.
Whew! That’s the price you pay for organized layers. The only thing missing now to call the search use case and observe its Flowable
to see the search results.
Adding search to the ViewModel
Head back to SearchFragmentViewModel.kt and inject the SearchAnimals
use case in the constructor:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimals: SearchAnimals, // HERE
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel()
Then, update prepareForSearch()
:
private fun prepareForSearch() {
loadFilterValues()
setupSearchSubscription() // WITH THIS
}
This method is where you’ll call the use case. Add it to SearchFragmentViewModel
:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private fun setupSearchSubscription() {
searchAnimals(querySubject, ageSubject, typeSubject)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ onSearchResults(it) },
{ onFailure(it) }
)
.addTo(compositeDisposable)
}
// ...
}
Nothing new here, but you still need to create onSearchResults()
. Add it to SearchFragmentViewModel
as well:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private fun onSearchResults(searchResults: SearchResults) {
val (animals, searchParameters) = searchResults
if (animals.isEmpty()) {
// search remotely
} else {
onAnimalList(animals)
}
}
// ...
}
This is where you’ll decide whether you need to search remotely. You’ll do that later. onAnimalList()
already updates the state with the search results. You now have to update updateScreenState()
in SearchFragment
to react to those changes.
To do this, go to SearchFragment.kt. Add this line in updateScreenState()
:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
private fun updateScreenState(
newState: SearchViewState,
searchAdapter: AnimalsAdapter
) {
val (
inInitialState,
searchResults,
ageFilterValues,
typeFilterValues,
searchingRemotely,
noResultsState,
failure
) = newState
updateInitialStateViews(inInitialState)
searchAdapter.submitList(searchResults) // HERE
// ...
}
// ...
}
Build, run, and try out the search! You’ll see some results now. Note that there’s no filter on the left, while the results on the right are filtered.
If there aren’t any results, the screen will just stay empty. Also, the state doesn’t update properly when you display results, change the search parameters and don’t get any results for that change.
To fix that, you’ll implement remote searching next.
Searching remotely
Go back to onSearchResults()
in SearchFragmentViewModel
. There’s an empty if
inside, reserved to act upon an empty animal list. That’s where the remote search will start.
Delete the comment inside the if
(if any) and add this line in its place:
private fun onSearchResults(searchResults: SearchResults) {
val (animals, searchParameters) = searchResults
if (animals.isEmpty()) {
onEmptyCacheResults(searchParameters) // THIS ONE
} else {
onAnimalList(animals)
}
}
Then create the method:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private fun onEmptyCacheResults(searchParameters: SearchParameters) {
_state.value = state.value!!.updateToSearchingRemotely()
searchRemotely(searchParameters)
}
// ...
}
This method updates the state to searching remotely, which shows a ProgressBar
and a warning message. You still have to update the Fragment
to see these changes, but you’ll leave that for later.
Most of what you need to do now just copies what you’ve done so far. To reduce repetition, most of the code already exists, you just have to uncomment it.
Locate SearchAnimalsRemotely.kt in the search.domain.usecases package and uncomment invoke
.
Then, go to AnimalRepository
and uncomment the searchAnimalsRemotely()
declaration.
Finally, go to PetFinderAnimalRepository
and uncomment the implementation.
The API method already exists, so you don’t need to worry about it. It’s similar to the animals near you method, but with added fields for the search.
Build and run to make sure everything works.
Triggering the search API call
Go back to SearchFragmentViewModel
. Just like before, inject a SearchAnimalsRemotely
instance in the constructor:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimalsRemotely: SearchAnimalsRemotely, // HERE
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel()
Next, create searchRemotely()
:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private fun searchRemotely(searchParameters: SearchParameters) {
val exceptionHandler = createExceptionHandler(message = "Failed to search remotely.")
viewModelScope.launch(exceptionHandler) {
val pagination = withContext(dispatchersProvider.io()) {
Logger.d("Searching remotely...")
searchAnimalsRemotely(++currentPage, searchParameters)
}
onPaginationInfoObtained(pagination)
}
}
// ...
}
This is a one-shot operation, as any network operation should be. You have the search results Flowable
up and running. This operation will store any results in the database, triggering the Flowable
to display them.
Finally, go to SearchFragment
and update updateScreenState()
:
private fun updateScreenState(
newState: SearchViewState,
searchAdapter: AnimalsAdapter
) {
val (
inInitialState,
searchResults,
ageFilterValues,
typeFilterValues,
searchingRemotely,
noResultsState,
failure
) = newState
// ...
updateRemoteSearchViews(searchingRemotely) // WITH THIS LINE
handleFailures(failure)
}
You also have to create the method in SearchFragment
:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
private fun updateRemoteSearchViews(searchingRemotely: Boolean) {
binding.searchRemotelyProgressBar.isVisible = searchingRemotely
binding.searchRemotelyText.isVisible = searchingRemotely
}
// ...
}
Build and run, then try searching for random names. Your remote search now works. :]
There’s something important to consider regarding remote search: What happens when the user starts a new remote search before the old one is complete? The previous one keeps going!
You won’t see this in the UI. Even if you store items that come from an old request, they probably won’t pass the search parameters’ filtering. However, behind the curtain, you can have the bad luck of a previous request taking longer to finish than a new one. This can mess up the pagination data, for instance. For safety and good hygiene, you should cancel old requests.
Canceling old search requests
When you call launch
on a CoroutineScope
, you create a coroutine. launch
returns a Job
that represents that coroutine. You’ll use this Job
to control the remote requests.
In SearchFragmentViewModel
, add:
@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
private val uiAnimalMapper: UiAnimalMapper,
private val searchAnimalsRemotely: SearchAnimalsRemotely,
private val searchAnimals: SearchAnimals,
private val getSearchFilters: GetSearchFilters,
private val dispatchersProvider: DispatchersProvider,
private val compositeDisposable: CompositeDisposable
): ViewModel() {
// ...
private var remoteSearchJob: Job = Job()
// ...
}
You’ll set this property to any new job you create for remote search. That said, go to searchRemotely()
and update the launch
call to:
private fun searchRemotely(searchParameters: SearchParameters) {
// ...
remoteSearchJob = viewModelScope.launch(exceptionHandler) { // THIS
// ...
}
}
Here, you’re getting the job for each coroutine and storing it. But when should you cancel it?
When you change any of the search parameters, you search for a different parameter set. Therefore, onSearchParametersUpdate()
seems like the best place to cancel the old coroutine.
To implement this, update the method, like so`:
private fun onSearchParametersUpdate(event: SearchEvent) {
remoteSearchJob.cancel( // cancels the job
CancellationException("New search parameters incoming!")
)
when (event) {
is SearchEvent.QueryInput -> updateQuery(event.input)
is SearchEvent.AgeValueSelected -> updateAgeValue(event.age)
is SearchEvent.TypeValueSelected -> updateTypeValue(event.type)
}
}
That’s why you need the SearchEvent
checks in this method.
Build and run. Everything works as before, but how do you know you’re canceling the coroutine? An easy way to verify that is to check when the job completes, and why.
Checking that the coroutine canceled
Back in searchRemotely()
, at the bottom of the method and outside launch
’s scope, add:
private fun searchRemotely(searchParameters: SearchParameters) {
// ...
remoteSearchJob = viewModelScope.launch(exceptionHandler) {
// ...
}
remoteSearchJob.invokeOnCompletion { it?.printStackTrace() } // THIS LINE
}
Now, build and run. Try changing search parameters while a remote search is running. You’ll now see the CancellationException
above printed in Logcat with that same message!
Another way of knowing if the coroutine was canceled is by checking Logcat for interceptor logs. Retrofit supports coroutine cancellation, so the request gets canceled and logged. This also cancels the coroutine.
If SearchAnimalsRemotely
wasn’t using Retrofit, nothing in it would check for coroutine cancellation. In this case, since coroutine cancellation is cooperative, you’d have to do the check yourself.
For instance, imagine that Retrofit didn’t care about coroutine cancellation. In that case, right after a Retrofit call, you’d need something like:
if (!coroutineContext.isActive) {
throw CancellationException(
"Cancelled — New data was requested"
)
}
Fortunately, Retrofit is a great library and it handles all of this for you!
Finishing touches
You’re almost done. Your search is just missing a state update in the Fragment
.
SearchAnimalsRemotely
throws a NoMoreAnimalsException
when the search has no results. onFailure()
in SearchFragmentViewModel
already handles this, updating the state to no results.
So go to SearchFragment
and update it by adding this line to updateScreenState()
:
private fun updateScreenState(
newState: SearchViewState,
searchAdapter: AnimalsAdapter
) {
val (
inInitialState,
searchResults,
ageFilterValues,
typeFilterValues,
searchingRemotely,
noResultsState,
failure
) = newState
// ...
updateNoResultsViews(noResultsState)
}
Then, create this method:
@AndroidEntryPoint
class SearchFragment : Fragment() {
// ...
private fun updateNoResultsViews(noResultsState: Boolean) {
binding.noSearchResultsImageView.isVisible = noResultsState
binding.noSearchResultsText.isVisible = noResultsState
}
// ...
}
Build, run, and search for qwe. Hopefully, no one has terrible taste in pet naming. The remote search won’t have any results, and you’ll see a sad little pug in the background.
You’re done! To sum up the chapter so far:
- You implemented two new features with cache and network data sources.
- You separated your logic into well-defined and easily testable layers.
- You did all that while following a unidirectional data flow approach.
Of course, even though you did a lot, there are still things missing: Cache invalidation, better error handling, request retries, possibly one or two bugs to solve… Regardless, these changes only require their essential complexity, as the overall architecture of the app makes it easier to apply changes and extend behavior.
Now, I don’t want to be that guy, but you know that there’s still one thing to do before proceeding to the next chapter. Trust me, as you do it more and more in this kind of architecture, you actually start to enjoy it. :]
Testing
To test the presentation layer, you’ll use two different kinds of tests. You’ll test:
- The
ViewModel
- The UI
You won’t test the use cases directly because there’s nothing new to learn from that — that would be a simple unit test of a class. You will test how the use cases integrate with the ViewModel
, however.
ViewModel tests
Thanks to this architecture, testing the ViewModel
is only a matter of sending events in and getting view states out. It’s so clean and straightforward that it’s actually enjoyable. Also, since ViewModel
doesn’t require a device to run, you can run the unit tests on the JVM.
You’ll find SearchFragmentViewModelTest.kt in the test package, in a directory matching the original ViewModel
. It has an empty class for now. Before writing any tests, there’s something you need to do.
Setting up your tests
In the debug package, locate common/data/FakeRepository.kt. Open it and uncomment everything. As the name suggests, it’s a fake AnimalRepository
implementation to use with tests.
Go back to SearchFragmentViewModelTest
. Start by adding these rules to the class:
class SearchFragmentViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule() // 1
@get:Rule
val testCoroutineRule = TestCoroutineRule() // 2
@get:Rule
val rxImmediateSchedulerRule = RxImmediateSchedulerRule() // 3
}
This code overrides:
- The main looper, which you need to test code with
LiveData
. - Coroutine dispatchers, replacing the main dispatcher with a test dispatcher. It’s also why the class has the
@ExperimentalCoroutinesApi
annotation. - RxJava schedulers, setting them all to execute immediately.
InstantTaskExecutorRule
comes from the androidx.arch.core:core-testing
library. TestCoroutineRule
and RxImmediateSchedulerRule
are custom rules. Both are defined in the debug package, which means tests in both test and androidTest can use them.
Below the rules, inside SearchFragmentViewModelTest
, add these properties:
private lateinit var viewModel: SearchFragmentViewModel
private lateinit var repository: FakeRepository
private lateinit var getSearchFilters: GetSearchFilters
private val uiAnimalsMapper = UiAnimalMapper()
And below them, setup()
:
@Before
fun setup() {
// 1
val dispatchersProvider = object : DispatchersProvider {
override fun io() = Dispatchers.Main
}
// 2
repository = FakeRepository()
getSearchFilters = GetSearchFilters(repository)
viewModel = SearchFragmentViewModel(
SearchAnimalsRemotely(repository),
SearchAnimals(repository),
getSearchFilters,
uiAnimalsMapper,
dispatchersProvider,
CompositeDisposable()
)
}
Here’s what’s happening above:
- This anonymous class implements
DispatchersProvider
by replacing theIO
dispatcher withMain
. - You instantiate the
lateinit
properties.
Now, to the actual test.
Building your test
You’ll start by testing the case where you do a remote search and get results. Add the method signature:
@Test
fun `SearchFragmentViewModel remote search with success`() = testCoroutineRule.runBlockingTest {
// Given
// When
// Then
}
testCoroutineRule.runBlockingTest
is like runBlocking
, but optimized for tests. It gives you more control over coroutines, like pausing the dispatcher or advancing the test clock.
Note that you don’t need anything like that for this test. In fact, you could use the regular runBlocking
here. The important thing is the declaration of TestCoroutineRule
at the top of the class. Without it, the test will run on the default main dispatcher and fail.
Adding the initial conditions
Below // Given
, add the initial conditions:
// 1
val (name, age, type) = repository.remotelySearchableAnimal
val (ages, types) = getSearchFilters()
val expectedRemoteAnimals = repository.remoteAnimals.map {
uiAnimalsMapper.mapToView(it)
}
// 2
viewModel.state.observeForever { }
// 3
val expectedViewState = SearchViewState(
noSearchQuery = false,
searchResults = expectedRemoteAnimals,
ageFilterValues = Event(ages),
typeFilterValues = Event(types),
searchingRemotely = false,
noRemoteResults = false
)
- The fake repository has a few helper properties for testing. Here, you get the name, age and type to use for searching, along with the list of remote animals you expect.
-
LiveData
only emits events if something’s observing it. Here, you observeViewModel
’s state so it emits items. - At the end of the test, you expect a certain state. Since you’re testing for the remote search case, you expect that the view state corresponds to that case.
Triggering the events to test
Now, for the // When
:
viewModel.onEvent(SearchEvent.PrepareForSearch)
viewModel.onEvent(SearchEvent.TypeValueSelected(type))
viewModel.onEvent(SearchEvent.AgeValueSelected(age))
viewModel.onEvent(SearchEvent.QueryInput(name))
The view state can only reach the remote search state after a specific sequence of view state updates. As such, you need to trigger the events on the ViewModel
that lead to that state.
Checking the results
Finally, the // Then
:
val viewState = viewModel.state.value!!
assertThat(viewState).isEqualTo(expectedViewState)
So simple, yet so effective. You get the state and compare it to what you expect it to be. You’re effectively testing the whole state of your screen by doing so.
On a side note, you had to convert Event
to a data class so it implements equals()
.
Tests like this make you think about what each state should represent. They’ll fail if you mess those states up. Build and run the test to make sure it works.
That’s it for the ViewModel
tests, as every test will follow this same recipe. It’s time to test the UI.
UI tests
Animations affect UI tests, so you need to disable them before testing. Go to your device’s developer options. If you don’t have developer options, go to the About section of the settings and click Build number until you unlock them.
Change the animation settings so:
- Window animation scale is off.
- Transition animation scale is off.
- Animation duration scale is off.
See the image below:
To create a UI test, the androidTest code needs to implement the full DI graph. Most of that is ready, including updates of the previous tests you made to handle the full graph. To wrap it up, go to common/di/TestActivityRetainedModule.kt in androidTest and uncomment bindAnimalRepository()
. This injects the FakeRepository
instead of the real one.
With that out of the way, go to search/presentation/SearchFragment.kt. You’ll test a case that’s similar to the one before, but from the UI perspective. It’ll test the integration of the Fragment
, ViewModel
and use cases. It stops testing real code at the FakeRepository
, but you could easily make it an end to end test by uninstalling TestActivityRetainedModule.kt instead of ActivityRetainedModule, and setting up a fake server with mockWebServer.
Building your test
Locate searchFragment_testSearch_success()
. Below // Given
, add:
val nameToSearch =
FakeRepository().remotelySearchableAnimal.name
launchFragmentInHiltContainer<SearchFragment>()
As in the ViewModel
unit test, you get the name of the animal to search. The second line is a lot more interesting though.
When you run tests on Fragment
s, you’d typically use a FragmentScenario, which lets you launch your Fragment
and control its lifecycle state. However, Hilt doesn’t support that, at least for now.
Instead, you’ll do what the Hilt team recommends and use launchFragmentInHiltContainer()
.
You declare this function in the debug package. Along with it, you declare a HiltTestActivity
annotated with @AndroidEntryPoint
. The function creates an Intent
to launch the HiltTestActivity
, then creates an ActivityScenario with it and uses it to host your Fragment
. Just like any Activity
, you’ll find the HiltTestActivity
definition in the (debug) manifest.
Triggering what to test
At this point, your Fragment
is running. You want to test the search, so you need to write nameToSearch
in the Fragment
’s SearchView
. Below // When
, add:
with (onView(withId(R.id.search))) {
perform(click())
perform(typeSearchViewText(nameToSearch))
}
Using Espresso, you access the SearchView
through its ID. You click it for focus, then run typeSearchViewText()
. Typing in SearchView
programmatically is a little more complex than typing in a simple TextView
. Therefore, using Espresso’s typeText()
won’t work.
You can see typeSearchViewText()
below the test method. It creates an anonymous ViewAction
class, where the two main methods are:
// 1
override fun getConstraints(): Matcher<View> {
return allOf(
isDisplayed(),
isAssignableFrom(SearchView::class.java)
)
}
// 2
override fun perform(uiController: UiController?, view: View?) {
(view as SearchView).setQuery(text, false)
}
The code above defines:
- Every
View
theViewAction
can operate on. - The action you want to perform.
Checking the results
Go back to the test and add the final code below // Then
:
with (onView(withId(R.id.searchRecyclerView))) {
check(matches(childCountIs(1)))
check(matches(hasDescendant(withText(nameToSearch))))
}
This code checks if RecyclerView
has only one item and if the text in that item matches what you expect. childCountIs()
is also custom. It’s defined below typeSearchViewText()
, and it compares the value you pass to Adapter
’s item count.
Build and run the test. Look at your device while the test runs and you’ll see the changes in the UI.
There you have it. By adding these two tests, you are now testing every layer of your app. Well done!
This concludes your work on the Search feature. In the next chapter, you’ll work on a new feature while learning how to create a multi-module app.
Key points
- Avoid using base classes for Android components.
- View state management can get complex when you use data classes. Consider using functions to transition states, a state pattern or sealed classes.
- Avoid relying on
Enum
’s value order. - A great way to handle user input is to treat it as a reactive stream, especially when input from one source can influence another.
- I can’t stress this enough: Network requests are one-shot operations. So don’t handle them like their event streams!
- Always consider the network requests you make. If you have requests that don’t matter anymore, find a way to cancel them. Coroutines allow you to do this organically, thanks to structured concurrency and cooperative cancellation.
- Following a unidirectional data flow makes unit testing the
ViewModel
a breeze. - Hilt makes it easy to include test dependencies, but it has some limitations.