Repository Pattern with Jetpack Compose
In this tutorial, you’ll learn how to combine Jetpack Compose and the repository pattern, making your Android code easier to read and more maintainable. By Pablo Gonzalez Alonso.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Repository Pattern with Jetpack Compose
40 mins
- Getting Started
- The Repository Pattern
- Understanding Datasources
- Using a Repository
- Creating a UI for Words
- Creating the Main ViewModel
- Building the WordRepository
- Working With State in Compose
- Upgrading State to Flow
- Using StateFlow to Deliver Results to the UI
- Showing a Loading State
- Storing Words With Room
- Adding Pagination
- Searching the Dictionary
- Reacting to Searches
- Showing an Empty Search Result
- Where to Go From Here?
Adding Pagination
To avoid loading all the possible words that exist in a dictionary into memory instead of just the ones currently being viewed, you'll add pagination to your app.
The Jetpack Paging 3 library has a companion library for Compose made for this purpose. There are a few important concepts in this library for you to understand before moving forward:
-
PagingSource: uses
LoadParams
to getLoadResult
instances usingload
. -
LoadParams
: tells thePagingSource
how many items to load and also includes a key. This key is usually the page number but could be anything. -
LoadResult
: a sealed class that tells you if there is a page or if an error happened while loading it. -
Pager
: a convenience utility that helps you convert aPagingSource
to a Flow ofPagingData
. -
PagingData
: the final representation of a page that you're going to use in the UI.
Luckily, Room works well with Jetpack Paging 3 and has built in functionality for it. So, you can edit queryAll
in WordDao.kt to enable pagination:
@Query("select * from word order by value") fun queryAll(): PagingSource<Int, LocalWord>
Open WordStore.kt and you'll see that the compiler isn't happy with the syntax in all
. You'll fix this next.
Add the following code to the bottom of WordStore.kt:
private fun pagingWord( block: () -> PagingSource<Int, LocalWord>, ): Flow<PagingData<Word>> = Pager(PagingConfig(pageSize = 20)) { block() }.flow .map { page -> page.map { localWord -> localWord.fromLocal() } }
Here, you're using Pager
to convert a PagingSource
to a Flow
of PagingData
. A nested map converts each PagingData
's LocalWords
to regular Word
instances.
With pagination in place, you can update all
:
fun all(): Flow<PagingData<Word>> = pagingWord { words.queryAll() }
You need to update the code in a few more places to avoid compilation errors.
In WordRepository.kt, update allWords
so that it returns a Flow
instead of List
:
suspend fun allWords(): Flow<PagingData<Word>> = ...
Notice that you can also remove the return type and let the compiler interpret the type.
Now, open MainViewModel.kt and update the following declarations:
private val _words = MutableStateFlow(emptyFlow<PagingData<Word>>()) val words: StateFlow<Flow<PagingData<Word>>> = _words
Next, in WordListUi.kt update WordListUi
to receive a Flow
instead of a List
:
fun WordListUi(words: Flow<PagingData<Word>>) { ... }
To make words work with a LazyColumn
, you have to change how you collect the words. Update the body of WordsContent
as follows:
private fun WordsContent( words: Flow<PagingData<Word>>, onSelected: (Word) -> Unit, ) { // 1 val items: LazyPagingItems<Word> = words.collectAsLazyPagingItems() LazyColumn { // 2 items(items = items) { word -> // 3 if (word != null) { WordColumnItem( word = word ) { onSelected(word) } } } } }
You're doing three new things here:
- Collecting the pages into
LazyPagingItems
instance.LazyPagingItems
manages page loading using coroutines. - Overloading the
items
function with the Paging library. This new version takesLazyPagingItems
instead of a plain List of items. - Checking if the item is null or not. Note that if you have placeholders enabled, the value may be null.
Build and run the app. You'll see that it works the same as before. However, the performance has been improved because now the app does not store the entire list of words in the memory all at once.
Searching the Dictionary
You've loaded a list of words into your app, but a list of words isn't useful on its own. Just try scrolling to find a word that starts with B. It takes a while. You need to give your users a way to search for words.
To do this, you'll first need to be able to represent the current search query in MainViewModel.kt. Add the following inside MainViewModel
at the top:
private val _search = MutableStateFlow(null as String?) val search: StateFlow<String?> = _search fun search(term: String?) { _search.value = term }
A private StateFlow
, called _search, holds the current query. When someone calls search
, it will send updates to collectors.
Next, you have to update WordListUi
parameters as follows:
fun WordListUi( words: Flow<PagingData<Word>>, search: String?, onSearch: (String?) -> Unit, )
Here, you added the string to search for and a callback to trigger the actual search.
Inside WordListUi
, replace the MainTopBar
with a SearchBar
:
topBar = { SearchBar( search = search, onSearch = onSearch, ) }
The SearchBar Composable isn't built-in to the Jetpack libraries, but it's included in the starter project if you want to check it out. You can find it in ui.bars.
In MainActivity.kt, add the following inside setContent
at the top to collect the search state as follows:
val search by viewModel.search.collectAsState()
Then, update the call to WordListUi
. Pass the search term and search function from the ViewModel
:
WordListUi( words = words, search = search, onSearch = viewModel::search )
Build and run. You'll see a new top bar with a search icon. Click the icon to expand the search input field:
At this point, your search function doesn't respond to typing in a search term. You'll address this issue now.
Reacting to Searches
To make your search function fully functional, you need to retrieve the data and update the UI for each search. To do that, you'll add searchAll
to WordDao:
@Query("select * from word where value like :term || '%' order by value") fun searchAll(term: String): PagingSource<Int, LocalWord>
The key difference between searchAll
and the previous function, queryAll
, is the where
condition. Take a closer look:
where value like :term || '%'
where
filters words that start with a given :term
string.
Next, add all
in WordStore.kt to use searchAll
:
fun all(term: String): Flow<PagingData<Word>> = pagingWord { words.searchAll(term) }
In WordRepository.kt, add this overload of allWords
as follows:
suspend fun allWords(term: String): Flow<PagingData<Word>> = wordStore.ensureIsNotEmpty().all(term)
Basically, you're passing a term
to the all
function. As before, use ensureIsNotEmpty
to make sure the Store isn't empty.
Next, you need to make sure the app can show the current search results. Start by adding the following code in MainViewModel.kt inside MainViewModel
at the top:
private val allWords = MutableStateFlow(emptyFlow<PagingData<Word>>()) private val searchWords = MutableStateFlow(emptyFlow<PagingData<Word>>())
Using the code above, you're declaring two separate MutableStateFlow
properties: one for all words and another for searched words.
Next, update load
so it uses allWords
instead of _words
. The code will look like this:
fun load() = effect { _isLoading.value = true allWords.value = wordRepository.allWords() _isLoading.value = false }
Now, find the place at the top of MainViewModel
where you declare words
:
val words: StateFlow<Flow<PagingData<Word>>> = _words
Replace words with the following:
@OptIn(ExperimentalCoroutinesApi::class) val words: StateFlow<Flow<PagingData<Word>>> = search .flatMapLatest { search -> words(search) } .stateInViewModel(initialValue = emptyFlow())
The compiler will not recognize words yet, but you'll fix that in a bit.
Here, you're using the search StateFlow
to generate a new Flow
. The new
Flow
selects allWords
if there's no search request or searchWords
if there is a search request. This is thanks to flatMapLatest
.
Since you're not using _words
anymore, you can delete it.
Finally, add the following functions at the bottom of MainViewModel
:
// 1 private fun words(search: String?) = when { search.isNullOrEmpty() -> allWords else -> searchWords } // 2 private fun <T> Flow<T>.stateInViewModel(initialValue : T): StateFlow<T> = stateIn(scope = viewModelScope, started = SharingStarted.Lazily, initialValue = initialValue) fun search(term: String?) = effect { _search.value = term // 3 if (term != null) { searchWords.value = wordRepository.allWords(term) } }
Delete the old version of search
.
Here's what's happening in your app now that you've added the code above:
-
words
is deciding whether to useallWords
orsearchWords
depending on if the search is null or empty. - You're using
flatMapLatest
to return aFlow
instead of aStateFlow
. WithstateIn
, you can return theFlow
as aStateFlow
. The returnedStateflow
is bound toviewModelScope
. Then, it waits for a collector before emitting any values. It also provides an initial value. - If the search term isn't null, your app will update
searchWords
with the new term.
Build and run to test your hard work building the search function. Relaunch the app and open the search input field. Search for a word like "Hello":
Hooray! Your search function works; it filters out all the other words and only shows the word you searched for.