Unit 6 notes

Use Room for data persistance

Video - Kotlin Flows in Practice

Why should we use Flows?

Request vs observing data

  • Requesting data every time we need it is not so good because we are not sure whether we have new data that we did not fetch or not. Hence, this is inefficient since some requests will not bring new data, so we are wasting resources unnecessarily.
  • Instead of requesting data, we can observe for changes in data. Whenever there’s a change in data, we get the new data. This way is more efficient and avoids making requests unnecessarily.
    • An observer is listening for changes in the data that it observes. Hence, it reacts automatically to changes in the things being observed.
    • Keeping data flowing in just one direction is less prone to errors and easier to manage.

Kotlin Flows:

  • Producers emit data to the flow
    • Example: Data sources or repositories
  • Consumers collect data from the flow
    • Example: UI layer

You don’t need to create Flows most of the time (especially when using libraries such as DataStore, Retrofit, Room and WorkManager). They provide you data using Flows. We don’t know how data is being produced.

  • Example: In Room, you can get notified of changes in the database by exposing a Flow. The Room library acts as a producer and emits the content of the query every time an update happens.

Creating Flows:

The flow{} block takes a suspend block as a parameter (it can call suspend functions).

class UserMesssagesDataSource(
    private val messagesApi: MessagesApi,
    private val refreshIntervalMs: Long = 5000
) {
    val latestMessages: Flow<List<Message>> = flow {
        // Producer block (the while loop)
        while (true) {
            val userMessages = messagesApi.fetchLatestMessages()
            emit(userMessages) // Emits the result to the flow
            delay(refreshIntervalMs) // Suspends for some time
        }
    }
}

Collecting Flows

  • Upstream flow: Flow produced by the producer block and those operators called before the current one.
  • Downstream flow: Everything that happens after the current operator.
val userMessages: Flow<MessagesUiModel> = 
    userMessagesDataSource.latestMessages
        // BEGIN UPSTREAM FLOW
        // Transform data to a different type (Raw Messages -> user messages UI model)
        .map { userMessages ->
            userMessages.toUiModel()
        }
        // Get flow for the messages that contain important notifications
        .filter { messagesUiModel ->
            messagesUiModel.containsImportantNotifications()
        }
        // END UPSTREAM FLOW
        // Catches exceptions that could happen while processing items in upstream flow
        .catch { e ->
            analytics.log("Error loading reserved event")
            if (e is IllegalArgumentException) throw e
            else emit(emptyList())
        }

Collect: Collecting flows usually happens from the UI layer (where we want to display the data). To get all the values in the stream as they are limited, use collect.

  • collect takes a function as a parameter that is called on every new value. Since it is a suspended function, it needs to be executed within a coroutine.

  • When you apply a terminal operator to a flow, the flow is created on demand and starts emitting values.

  • Intermediate operators are the opposite. They just set up a chain of operators that are executed lazily when an item is emitted into the flow.

  • Cold flow: Flows created on demand and emit data only when they are being observed. Example: When we call collect on userMessages, a new flow will be created and its producer blocks will start refreshing the messages from the API at its own interval. These types of flows are called cold flows.

userMessages.collect { messages ->
    listAdapter.submitList(messages)
}

Collect flows from the Android UI (Flows in Android UI)

2 things to consider:

  • Not wasting resources when the app is in background
  • Configuration changes

Collecting from the Activity: For how long should we be collecting from the flow?

  • UI should be a good citizen and stop collecting from the flow when:
    • the UI is not displayed on the screen
    • the information is not going to be displayed on the screen

To do this, there are different alternatives (all of them are aware of the UI lifecycle):

  1. Flow<T>.asLiveData()
  2. Lifecycle.repeatOnLifecycle(state)
  3. Flow<T>.flowWithLifecycle(lifecycle, state)

Flow<T>.asLiveData(): compares the flow to live data that observes items only while the UI is visible on the screen. In the UI, we just consume the live data as usual.

  • Drawback: It adds a different technology into the mix, which shouldn’t be needed.
// import androidx.lifecycle.asLiveData
class MessagesViewModel(repository: MessagesRepository) : ViewModel() {
    val userMessages = repository.userMessages.asLiveData()
    ...
}

class MessagesActivity: AppCompatActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        viewModel.userMessages.observe(this) { messages ->
            listAdapter.submitList(messages)
        }
    }
}

Lifecycle.repeatOnLifecycle(state): Recommended way to collect flows from the UI layer. repeatOnLifecycle a suspend function that takes a life cycle step as a parameter. It automatically launches a new coroutine with a block pass to it when the lifecycle reaches that step.

  • When the lifecycle falls below that state, the ongoing coroutine is cancelled.
  • Inside the repeatOnLifecycle block, we can call collect since we are in the context of a coroutine.
  • repeatOnLifecycle is a suspend function, so it needs to be called in a coroutine. Since we are in an activity, we can use lifecycleScope to start one.
  • The best practice is to call this function when the lifecycle is initialized. (Example: onCreate in this activity)
  • repeatOnLifecycle restartable behaviour takes into account the UI lifecycle automatically for you.
  • The coroutine that calls repeatOnLifecycle won’t resume executing until the lifecycle is destroyed. If you need to collect from multiple flows, you should create multiple coroutines using launch inside the repeatOnLifecycle block.
  • You can also use flowWithLifecycle operator instead of repeatOnLifecycle when you have only one flow to collect. This API emits items and cancels the underlying producer when the lifecycle moves in and out of the target state.
// import androidx.lifecycle.repeatOnLifecycle

class MessagesActivity: AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.userMessages.collect { messages ->
                    listAdapter.submitList(messages)
                }
            }
        }

        // collect from multiple flows
        // lifecycleScope.launch {
        //     repeatOnLifecycle(Lifecycle.State.STARTED) {
        //         launch {
        //             viewModel.userMessages.collect { ... }
        //         }
        //         launch {
        //             otherFlow.collect { ... }
        //         }
        //     }
        // }

        // Using flowWithLifecycle instead of repeatOnLifecycle
        // lifecycleScope.launch {
        //     viewModel.userMessages
        //         .flowWithLifecycle(lifecycle, State.STARTED)
        //         .collect { messages ->
        //             listAdapter.submitList(messages)
        //         }
        // }
    }
}

Visual representation:

View lifecycle

Things that are not always safe to use because it collects from the flow and updates UI elements, even if the app is in background:

lifecycleScope.launch {
    flow.collect { ... }
}

lifecycleScope.launchWhenX {
    flow.collect { ... }
}

It can also be dangerous since it may prompt dialogues and showing dialogies when the app is in background can make the app crash.

A possible solution is to manually start collecting in onStart and stop collecting in onStop. This is ok, but using repeatOnLifecycle removes all that boilerplate code.

Using launchWhenStarted as an alternative is better than lifecycleScope.launch because it suspends the flow collection while the app is in the background. However, this solution keeps the flow producer active, potentially emitting items in the background that can fill the memory with items that aren’t going to be displayed on the screen.

Using launch/launchWhenStarted

As the UI doesn’t really know how the flow producer is implemented, it is alwats better to play safe and use repeatOnLifecycle or flowWithLifecycle to avoid collecting items and keeping the flow producer active when the UI is in the background.

Using repeatOnLifecycle

Optimize flows for configuration changes

When you expose a flow to a view, you have to take into account that you are trying to pass data between two elements that have different lifecycles.

  • Example: when a device receives configuration changes, all activities might be restarted, but a view model survies that (the lifecycle of a viewModel and the lifecycle of an activity are different).

From a view model, you can’t expose any flow.

// Called again after a configuration change (rotation for instance)
val result: Flow<Result<UiState>> = flow {
    emit(repository.fetchItem())
}

We need some kind of buffer, something that can hold data and share it between multiple collectors no matter how many times they are recreated. StateFlow was created exactly for that.

StateFlow: It holds data even if there are no collectors. You can collect multiple times from it, so it’s safe to use with activities or fragments.

You could use a mutable version of StateFlow, but that’s not very reactive:

private val _myUiState = MutableStateFlow<MyUiState>()
val myUiState: StateFlow<MyUiState> = _myUiState
init {
    viewModelScope.launch {
        _myUiState.value = Result.loading
        _myUiState.value = repository,fetchStuff()
    }
}

Instead, you can convert any flow to a StateFlow. The StateFlow receives all the updates from the upstream flows and stores the latest value. It can have zero or more collectors, so this is perfect for view models. To convert a flow to a StateFlow, you can use the stateIn operator on it. It takes three parameters:

  • initialValue: a StateFlow always needs to have a value
  • scope: coroutine scope, which controls when the sharing is started
  • started: WhileSubscribed(5000) means that the upstream flows are only cancelled 5 seconds after the StateFlow stops being collected.

In a device rotation, we do not want to cancel upstream flows (to make the transition as fast as possible), while when moving the app to the background, we want to cancel the upstream flows (to save battery and other resources).

val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
        initialValue = Result.Loading,
        scope = viewModelScope,
        started = WhileSubscribed(5000)
    )

App background diagram:

App background

Rotation diagram:

App background

Testing Flows

There are two scenarios:

  • The unit and the test: Whatever you’re testing is receiving a flow. The easy way to test this is to replace the dependency with a fake producer. For example, one could replace a repository by a fake one to emit whatever you need for the different test cases (example: a simple call flow). The test itself would make assertions on the output of the subject and the test, which is a flow or something else.

Architecture under test
Using fake repository
Example of code in fake repository

  • Unit under test is exposing a flow and that value (or stream of values) is what you want to verify, you have multiple ways to collect it.
  • Call the first() method on the flow to collect the first item and then stop collecting
  • Use operators such as take(5) and call toList() terminal operator to collect exactly five items.

Using first/take code example

More info about testing flows here.

Persist data with Room

Recommended architecture:

Recommended architecture

Room - library to persist data. Uses SQLite. Room has:

  • Entities: Represent tables in your app’s database. Used to update the data stored in tables and create new rows for insertion.
    • An entity defines a table and each instance represents a row.
    • Each field of the Entity is represented as a column in the database.
    • Every entity instance stored in the database must have a primary key. The primary key is used to uniquely identify every record/entry in your database tables. After the app assigns a primary key, it cannot be modified; it represents the entity object as long as it exists in the database.
  • DAOs (Data Access Object): Provide methods that your app uses to retrieve, update, insert, and delete data in the database.
  • Room Database class: provides your app with instances of the DAOs associated with that database.

Room diagram

Data class:

  • Hold data
  • The compiler automatically generates utilities to compare, print, and copy such as toString(), copy(), and equals().

Data classes must fulfill the following requirements:

  • The primary constructor must have at least one parameter.
  • All primary constructor parameters must be val or var.
  • Data classes cannot be abstract, open, or sealed.

@Entity: The @Entity annotation has several possible arguments. By default (no arguments to @Entity), the table name is the same as the class name. Use the tableName argument to customize the table name.

  • Annotating a field with @PrimaryKey to make the field the primary key. The argument autoGenerate, if set to true, makes Room auto-generate a unique value for the primary key for each row.

DAO (Data Access Object)

Data Access Object architecture

The Data Access Object (DAO) is a pattern you can use to separate the persistence layer from the rest of the application by providing an abstract interface. This isolation follows the single-responsibility principle.

The functionality of the DAO is to hide all the complexities involved in performing database operations in the underlying persistence layer, separate from the rest of the application. This lets you change the data layer independently of the code that uses the data.

The DAO is a custom interface that provides convenience methods for querying/retrieving, inserting, deleting, and updating the database. Room generates an implementation of this class at compile time.

The Room library provides convenience annotations, such as @Insert, @Delete, and @Update, for defining methods that perform simple inserts, deletes, and updates without requiring you to write a SQL statement.

If you need to define more complex operations for insert, delete, update, or if you need to query the data in the database, use a @Query annotation instead.

The database operations can take a long time to execute, so they need to run on a separate thread. Room doesn’t allow database access on the main thread. Hence, the DAO functions need to be marked as suspend to let them run on a separate thread.

When inserting records into the database, conflicts can happen. For example, multiple places in the code tries to update the entity with different, conflicting, values such as the same primary key. So, @Insert has the argument onConflict.

Note: The @Delete annotation deletes a record or a list of records. You need to pass the entities you want to delete. If you don’t have the entity, you might have to fetch it before calling the delete() function.

It is recommended to use Flow in the persistence layer. With Flow as the return type, you receive notification whenever the data in the database changes. The Room keeps this Flow updated for you, which means you only need to explicitly get the data once. Because of the Flow return type, Room also runs the query on the background thread. You don’t need to explicitly make it a suspend function and call it inside a coroutine scope.

Note: Flow in Room database can keep the data up-to-date by emitting a notification whenever the data in the database changes. This allows you to observe the data and update your UI accordingly.

Database instance

The Database class provides your app with instances of the DAOs you define.

Multiple threads can potentially ask for a database instance at the same time, which results in two databases instead of one. This issue is known as a race condition. Wrapping the code to get the database inside a synchronized block means that only one thread of execution at a time can enter this block of code, which makes sure the database only gets initialized once. Use synchronized{} block to avoid the race condition.

Connect database to ViewModel (Add the save functionality)

All database operations need to be run away from the main UI thread; you do so with coroutines and viewModelScope.

Note: The rememberCoroutineScope() is a composable function that returns a CoroutineScope bound to the composition where it’s called. You can use the rememberCoroutineScope() composable function when you want to launch a coroutine outside of a composable and ensure the coroutine is canceled after the scope leaves the composition. You can use this function when you need to control the lifecycle of coroutines manually, for example, to cancel an animation whenever a user event happens.

Example of app using Room:

Dependencies:

//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}"

Entity:

import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int,
    ...
)

Data Access Object:

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}

Database instance:

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}

Repository:


// ItemsRepository.kt

import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}

// OfflineItemsRepository.kt

import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

AppContainer:

...
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}
...

Read and update data with Room

Emit UI state in the ViewModel

When you add methods to the Data Access Object to get records, you specified a Flow as the return type. Recall that a Flow represents a generic stream of data. By returning a Flow, you only need to explicitly call the methods from the DAO once for a given lifecycle. Room handles updates to the underlying data in an asynchronous manner.

Getting data from a flow is called collecting from a flow. When collecting from a flow in your UI layer, there are a few things to consider.

  • Lifecycle events like configuration changes, for example rotating the device, causes the activity to be recreated. This causes recomposition and collecting from your Flow all over again.
  • You want the values to be cached as state so that existing data isn’t lost between lifecycle events.
  • Flows should be canceled if there’s no observers left, such as after a composable’s lifecycle ends.

The recommended way to expose a Flow from a ViewModel is with a StateFlow. Using a StateFlow allows the data to be saved and observed, regardless of the UI lifecycle. To convert a Flow to a StateFlow, you use the stateIn operator.

The stateIn operator has three parameters:

  • scope: The viewModelScope defines the lifecycle of the StateFlow. When the viewModelScope is canceled, the StateFlow is also canceled.
  • started: The pipeline should only be active when the UI is visible. The SharingStarted.WhileSubscribed() is used to accomplish this. To configure a delay (in milliseconds) between the disappearance of the last subscriber and the stopping of the sharing coroutine, pass in the TIMEOUT_MILLIS to the SharingStarted.WhileSubscribed() method.
  • initialValue: Set the initial value of the state flow to HomeUiState().

Once you’ve converted your Flow into a StateFlow, you can collect it using the collectAsState() method, converting its data into State of the same type.

Examples

ViewModel:

val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream().map { HomeUiState(it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = HomeUiState()
        )

Store and access data using keys with DataStore

Save preferences locally with DataStore

To store relational data, you use SQL with Room. To store small and simple data sets with low overhead, one can use DataStore. DataStore has two different implementations: Preferences DataStore and Proto DataStore:

  • Preferences DataStore stores key-value pairs. The values are basic data types, such as String, Boolean, and Integer. It does not store complex datasets. It does not require a predefined schema. Its primary use case is to store user preferences on their device.
  • Proto DataStore stores custom data types. It requires a predefined schema that maps proto definitions with object structures.

Preferences DataStore Example

  • DataStore stores key-value pairs. To access a value you must define a key.
  • Write to the DataStore: You create and modify the values within a DataStore by passing a lambda to the edit() method. The lambda is passed an instance of MutablePreferences. The updates inside this lambda are atomic: executed as a single transaction. This type of update prevents a situation in which some values update but others do not.
    • The value does not exist in DataStore until this function is called and the value is set.
  • Read from the DataStore: The dataStore.data property is a Flow of Preferences objects. The Preferences object contains all the key-value pairs in the DataStore. Each time the data in the DataStore is updated, a new Preferences object is emitted into the Flow. The map function converts the Flow into a Flow.
    • As DataStore reads and writes data from files, IOExceptions can occur when accessing the DataStore. You use the catch{} operator to catch exceptions and handle these failures.
// UserPreferencesRepository.kt
class UserPreferencesRepository(
    private val dataStore: DataStore<Preferences>
) {
    private companion object {
        val IS_LINEAR_LAYOUT = booleanPreferencesKey("is_linear_layout")
        const val TAG = "UserPreferencesRepo"
    }
    
    // Write to the DataStore
    suspend fun saveLayoutPreference(isLinearLayout: Boolean) {
        dataStore.edit { preferences ->
            preferences[IS_LINEAR_LAYOUT] = isLinearLayout
        }
    }

    // Read from the DataStore
    val isLinearLayout: Flow<Boolean> = dataStore.data
    .catch {
        if(it is IOException) {
            Log.e(TAG, "Error reading preferences.", it)
            emit(emptyPreferences())
        } else {
            throw it
        }
    }
    .map { preferences ->
        preferences[IS_LINEAR_LAYOUT] ?: true
    }
}

// DessertReleaseApplication.kt
private const val LAYOUT_PREFERENCE_NAME = "layout_preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = LAYOUT_PREFERENCE_NAME
)

class DessertReleaseApplication: Application() {
    lateinit var userPreferencesRepository: UserPreferencesRepository

    override fun onCreate() {
        super.onCreate()
        userPreferencesRepository = UserPreferencesRepository(dataStore)
    }
}

// AndroidManifest.xml
<application
    android:name=".DessertReleaseApplication"
    ...
</application>

//DessertReleaseViewModel.kt
class DessertReleaseViewModel(
    private val userPreferencesRepository: UserPreferencesRepository
) : ViewModel() {

    ...

    val uiState: StateFlow<DessertReleaseUiState> = userPreferencesRepository.isLinearLayout
        .map { isLinearLayout ->
            DessertReleaseUiState(isLinearLayout)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = DessertReleaseUiState()
        )

    fun selectLayout(isLinearLayout: Boolean) {
        viewModelScope.launch {
            userPreferencesRepository.saveLayoutPreference(isLinearLayout)
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as DessertReleaseApplication)
                DessertReleaseViewModel(application.userPreferencesRepository)
            }
        }
    }
}