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.

4.7 (14) · 6 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 5 of this article. Click here to view the first page.

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:

  1. Defining the ViewModel as an AndroidViewModel with an associated application instance. You're not using the application now, but you'll use it later to inject components.
  2. 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:

  1. Added a new parameter, words, to WordListUi.
  2. Passed the list of words to WordsContent. Remember, the word generation is now in MainViewModel.

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:

  1. Making AppHttpClient the default value for the HttpClient.
  2. Using withContext to make sure your code runs in the background, not in the main thread.
  3. Loading all the words as a string using getRemoteWords. This is an extension function that you'll define later.
  4. Reading all lines as a sequence.
  5. Converting each line into a Word.
  6. 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:


List of words in the app

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 MainViewModel to use 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:

  1. Creating an internal MutableState which hosts the list of Words, which is empty right now.
  2. Exposing the MutableState as a non-mutable State.
  3. Adding a function to load the list of words.
  4. 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 the ViewModel 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:

  1. The ViewModel starts loading all the words by calling load.
  2. You consume the words using delegation. Any new updates from the ViewModel come here and trigger a layout recomposition.
  3. 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.