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?
Using StateFlow to Deliver Results to the UI
To update the app to use StateFlow
, open MainViewModel.kt and change State
from Compose to StateFlow. Also change mutableStateOf
to MutableStateFlow
. The code should then look like:
private val _words = MutableStateFlow(emptyList<Word>())
val words: StateFlow<List<Word>> = _words
State
and StateFlow
are very similar, so you don't have to update much of the existing code.
In MainActivity.kt, convert StateFlow
to a Compose State
using collectAsState
:
val words by viewModel.words.collectAsState()
Now, MainViewModel
has no dependencies to Compose. Next, the app needs to display a loading state while the data loads.
Showing a Loading State
Right now, the word list loads slowly. But you don't want your users to stare at an empty screen during loading! So, you'll create a loading state to give them visual feedback while they wait.
Start by creating a StateFlow
in MainViewModel.kt by adding the following to the top of MainViewModel
:
private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow<Boolean> = _isLoading
isLoading
represents whether the app is loading or not. Now, update the _isLoading value before and after loading the words from the network. Replace load
with:
fun load() = effect { _isLoading.value = true _words.value = wordRepository.allWords() _isLoading.value = false }
With the code above, you're setting the state as "loading" first and resolving it as "not loading" once it's finished loading all words from the repository.
Use isLoading
inside MainActivity.kt to display the appropriate UI state. Update the code inside of setContent
just below the declaration of words
with:
val isLoading by viewModel.isLoading.collectAsState() WordsTheme { when { isLoading -> LoadingUi() else -> WordListUi(words) } }
Here, if the state is loading, Compose will render LoadingUi
instead of WordListUi
.
Run the app again and you'll see that it now has a loading indicator:
The new loading indicator looks great! However, does the app need to load all the words from the network each time? Not if the data is cached in the local datastore.
Storing Words With Room
The words load slowly right now because the app is loading all the words every time the app is run. You don't want your app to do this!
So, you'll build a Store for the words loaded from the network using Jetpack Room.
To get started, create a package called local in data.words. Then, create a class called LocalWord.kt in the data.words.local package:
@Entity(tableName = "word") // 1
data class LocalWord(
@PrimaryKey val value: String, // 2
)
The local representation has the same structure as Word
but with two key differences:
- The Entity annotation tells Room the name of the entity's table.
- Every Room entity must have a primary key.
Next, define a Data Access Object (DAO) for Word
called WordDao.kt in local:
@Dao // 1
interface WordDao {
@Query("select * from word order by value") // 2
fun queryAll(): List<LocalWord>
@Insert(onConflict = OnConflictStrategy.REPLACE) // 3
suspend fun insert(words: List<LocalWord>)
@Query("select count(*) from word") // 4
suspend fun count(): Long
}
With the code above, you've defined four database operations with Room:
-
@Dao
indicates that this interface is a DAO. -
queryAll
uses the@Query
annotation to define a Sqlite query. The query asks for all the values to be ordered by the value property. -
insert
adds or update words to the database. -
count
finds out if the table is empty.
Now, you'll create a database in a new file called AppDatabase.kt in data.words so Room can recognize the Entity and DAO:
@Database(entities = [LocalWord::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract val words: WordDao
}
This abstract database defines LocalWord
as the only entity. It also defines words as an abstract property to get an instance of WordDao
.
The Room compiler generates all the bits that you need for this to work. How nice! :]
Now that AppDatabase
is ready, your next step is to utilize the Dao in a store. Create WordStore
in a new file called WordStore.kt in data.words.local:
class WordStore(database: AppDatabase) {
// 1
private val words = database.words
// 2
fun all(): List<Word> = words.queryAll().map { it.fromLocal() }
// 3
suspend fun save(words: List<Word>) {
this.words.insert(words.map { it.toLocal() })
}
// 4
suspend fun isEmpty(): Boolean = words.count() == 0L
}
private fun Word.toLocal() = LocalWord(
value = value,
)
private fun LocalWord.fromLocal() = Word(
value = value,
)
The mapper functions, toLocal
and fromLocal
, convert Word
from and to LocalWord
.
The code above does the following to WordStore
:
- Saves an internal instance of
WordDao
aswords
. - Calls
all
usingWordDao
to accessLocalWord
instances. Then,map
converts them to plainWords
. - Takes a list of plain
Words
usingsave
, converts them to Room values and saves them. - Adds a function to determine if there are any saved words.
Since you have added the code to save words to the database, the next step is to update WordRepository.kt to use this code. Replace WordRepository
with:
class WordRepository(
private val wordSource: WordSource,
// 1
private val wordStore: WordStore,
) {
// 2
constructor(database: AppDatabase) : this(
wordSource = WordSource(),
wordStore = WordStore(database),
)
// 3
suspend fun allWords(): List<Word> =
wordStore.ensureIsNotEmpty().all()
private suspend fun WordStore.ensureIsNotEmpty() = apply {
if (isEmpty()) {
val words = wordSource.load()
save(words)
}
}
}
One key component here is the extension function ensureIsNotEmpty
. It populates the database in WordStore
if it's empty.
- For
ensureIsNotEmpty
to work, you addedWordStore
as a constructor property. - For convenience, you added a secondary constructor. It recieves a database which is then used to create
WordStore
. - Then, you called
ensureIsNotEmpty
before calling theall
function to make sure the store has data.
Update WordsApp
with a private database and a public wordRepository
to work with the newly updated WordRepository
. Replace the body of WordsApp
with:
// 1
private val database by lazy {
Room.databaseBuilder(this, AppDatabase::class.java,
"database.db").build()
}
// 2
val wordRepository by lazy { WordRepository(database) }
Each Android process creates one Application object, and only one. This is one place to define singletons for manual injection, and they need an Android context.
- First, you want to define a Room database of type
AppDatabase
calleddatabase.db
. You have to make it lazy because your app doesn't yet exist while you're instantiating the database inthis
. - Then, define an instance of
WordRepository
with the database you just created in the previous step. You also need to make this lazy to avoid instantiating the database too early.
Build and run. You'll see that it still takes a long time to load the first time you run it, but after that, the words will load immediately each time the app is launched.
The next thing you'll tackle is making sure you don't load thousands of words into memory. This can cause a problem when large datasets collide with devices that have low memory. It would be best to only keep the words that are being displayed, or about to be displayed, in memory.