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?
Creating the Main ViewModel
ViewModel is an architecture component from Android Jetpack. ViewModel's
primary feature is to survive configuration changes, like rotation.
Create MainViewModel.kt in a new file within the package com.raywenderlich.android.words:
// 1
class MainViewModel(application: Application) : AndroidViewModel(application) {
// 2
val words: List<Word> = RandomWords.map { Word(it) }
}
In this ViewModel
, you're:
- Defining the
ViewModel
as anAndroidViewModel
with an associated application instance. You're not using the application now, but you'll use it later to inject components. - Returning the same values that you currently have in
WordListUi
.
Next, get MainViewModel
in MainActivity.kt with delegation. Add the following line of code inside MainActivity above onCreate
:
private val viewModel by viewModels<MainViewModel>()
The framework automatically injects the current application instance into MainViewModel
.
Now, you'll prepare WordListUi
to receive the data. Replace WordListUi
with:
@Composable
fun WordListUi(words: List<Word>) { // 1
Scaffold(
topBar = { MainTopBar() },
content = {
WordsContent(
words = words, // 2
onSelected = { word -> Log.e("WordsContent",
"Selected: $word") }
)
}
)
}
With this code, you:
- Added a new parameter,
words
, toWordListUi
. - Passed the list of words to
WordsContent
. Remember, the word generation is now inMainViewModel
.
Next, go to MainActivity and populate the word list with the words
from the viewModel
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WordsTheme {
WordListUi(words = viewModel.words)
}
}
}
If you run the app, everything will look the same as before. But now the app persists the components between configuration changes. Isn't that a great feeling? :] Now that the ViewModel
is in place, it's time to build the repository.
Building the WordRepository
Next, you'll create the WordRepository
and collaborators, starting with the remote data source.
To load data from the internet, you'll need a client. Create a file named AppHttpClient.kt in the data package. Then, add a top-level property called AppHttpClient
:
val AppHttpClient: HttpClient by lazy {
HttpClient()
}
This code lazily initializes a Ktor client for triggering HTTP requests.
Next, within the package data.words, create a new package, remote, and create a file named WordSource.kt. Then, add the following code to it:
// 1
class WordSource(private val client: HttpClient = AppHttpClient) { // 2
suspend fun load(): List<Word> = withContext(Dispatchers.IO) {
client.getRemoteWords() // 3
.lineSequence() // 4
.map { Word(it) } // 5
.toList() // 6
}
}
The code above is:
- Making
AppHttpClient
the default value for theHttpClient
. - Using
withContext
to make sure your code runs in the background, not in the main thread. - Loading all the words as a string using
getRemoteWords
. This is an extension function that you'll define later. - Reading all lines as a sequence.
- Converting each line into a
Word
. - Converting the sequence into a list.
Next, add the following code below the WordSource
declaration:
private suspend fun HttpClient.getRemoteWords(): String =
get("https://pablisco.com/define/words")
This extension function executes a network GET request on an HttpClient
. There are many get
overloads, so make sure you import this exact one:
import io.ktor.client.request.*
Now, create a new class called WordRepository.kt under the package data.words. Then, add the following code to it:
class WordRepository(
private val wordSource: WordSource = WordSource(),) {
suspend fun allWords(): List<Word> = wordSource.load()
}
WordRepository
uses WordSource
to get the complete list of words.
Now that the repository is ready, open WordsApp.kt and add it inside the class as a lazy property:
val wordRepository by lazy { WordRepository() }
Then, replace the body of MainViewModel
with:
private val wordRepository =
getApplication<WordsApp>().wordRepository
val words: List<Word> = runBlocking { wordRepository.allWords() }
Build and run. After a short wait, you'll see a list of words that loaded from the network:
With the repository in place, it's time to manage the UI State with Jetpack Compose.
Working With State in Compose
Compose has two complementary concepts: State and MutableState. Take a look at these two interfaces that define them:
interface State<out T> {
val value: T
}
interface MutableState<T> : State<T> {
override var value: T
}
Both provide a value but MutableState
also lets you update the value. Compose watches changes in these states. An update on these states triggers a recomposition. Recomposition is a bit like the way old-fashioned Views used to get redrawn when the UI needed an update. However, Compose is smart enough to redraw and update the Composables that rely on a changeable value when the value changes.
Keeping all that in mind, update State
instead of only List
:
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val wordRepository = getApplication<WordsApp>().wordRepository
private val _words = mutableStateOf(emptyList<Word>()) // 1
val words: State<List<Word>> = _words // 2
fun load() = effect {
_words.value = wordRepository.allWords() // 3
}
private fun effect(block: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { block() } // 4
}
}
With these changes, you're:
- Creating an internal
MutableState
which hosts the list ofWords
, which is empty right now. - Exposing the
MutableState
as a non-mutable State. - Adding a function to load the list of words.
- Adding a utility function to launch operations in the
ViewModel's
coroutine's scope. Using this scope, you can make sure the code only runs when theViewModel
is active and not on the main thread.
Now, in MainActivity.kt, update the content of the main activity. Replace the code in onCreate
with:
super.onCreate(savedInstanceState)
viewModel.load() // 1
setContent {
val words by viewModel.words // 2
WordsTheme {
WordListUi(words = words) // 3
}
}
Here is what's happening:
- The
ViewModel
starts loading all the words by callingload
. - You consume the words using delegation. Any new updates from the
ViewModel
come here and trigger a layout recomposition. - You can now give the words to
WordListUi
.
All this means that the UI will react to new words after calling load()
.
Next, you'll get a bit of a theory break as you learn about Flows and how they'll feature in your app.
Upgrading State to Flow
Exposing State instances from the ViewModel, as the app is doing now, makes it depend too much on Compose. This dependency makes it hard to move a ViewModel to a different module that doesn't use Compose. For example, moving a ViewModel would be difficult if you share logic in a Kotlin Multiplatform module. Creating a coroutine solves this dependency issue because you can use StateFlow instead of State.
Flows, which live in the coroutines library, are a stream of values consumed by one or many components. They're cold by default, which means that they start producing values only when consumed.
SharedFlow is a special type of flow: a hot flow. This means that it emits a value without a consumer. When a SharedFlow emits a new value, a replay cache keeps it, re-emitting the SharedFlow to new consumers. If the cache is full, it drops old values. By default, the size of the cache is 0.
There is a special type of SharedFlow called StateFlow. It always has one value, and only one. Essentially, it acts like States in Compose.
In the next steps, you'll utilize StateFlow to deliver the updated results to the UI and improve the structure of the app.