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 3 of 5 of this article. Click here to view the first page.

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:


App screen with loading indicator spinning

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:

  1. The Entity annotation tells Room the name of the entity's table.
  2. 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:

  1. @Dao indicates that this interface is a DAO.
  2. queryAll uses the @Query annotation to define a Sqlite query. The query asks for all the values to be ordered by the value property.
  3. insert adds or update words to the database.
  4. 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:

  1. Saves an internal instance of WordDao as words.
  2. Calls all using WordDao to access LocalWord instances. Then, map converts them to plain Words.
  3. Takes a list of plain Words using save, converts them to Room values and saves them.
  4. 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.

  1. For ensureIsNotEmpty to work, you added WordStore as a constructor property.
  2. For convenience, you added a secondary constructor. It recieves a database which is then used to create WordStore.
  3. Then, you called ensureIsNotEmpty before calling the all 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.

  1. First, you want to define a Room database of type AppDatabase called database.db. You have to make it lazy because your app doesn't yet exist while you're instantiating the database in this.
  2. 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.