Saving and reading data from a local database
We are going to build up on the Pets app, which displays a list of cute cats. We will save our cute cats in a local database, Room, which is a part of the Android Jetpack libraries and provides a wrapper and abstraction layer over SQLite. We will also use the repository pattern to abstract away the data source from ViewModel
. The Room database provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite. It also has inbuilt support for Kotlin coroutines and flows to allow for asynchronous database access. Room is also compile-time safe and hence any errors in SQL queries are caught at compile time. It allows us to do all this with concise code.
To use Room in our project, we need to add its dependency to our libs.versions.toml
file. Let us start by defining the Room version in the versions
section as the following:
room = "2.5.2"
Next, let us add the dependencies in our libraries
section:
room-runtime = { module = "androidx.room:room-runtime" , version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
Sync the project for the changes to be added. Before we add these dependencies to the app level build.gradle.kts
file, we need to set up an annotation processor for the room compiler. Room uses the annotation processor to generate the code that will be used to read, write, update, and delete data from the database. To do this, we need to add the following to the plugins section of the project level build.gradle.kts
file:
id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
We have added the Kotlin Symbol Processing (KSP) plugin to our project. This is a new annotation processing tool that is faster than the Kotlin Annotation Processing Tool (KAPT). KSP analyses the Kotlin code directly and has a better understanding of the Kotlin language constructs. KSP is now the recommended annotation processing tool for Kotlin. Next, we need to add KSP to our app level build.gradle.kts
file:
id("com.google.devtools.ksp")
This allows us to use KSP in our app module. To finalize setting up Room, now let us add the dependencies we declared earlier to the app level build.gradle.kts
file:
implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler)
We have added our Room dependencies and the Room KTX library with the implementation
configuration and the Room compiler with the ksp
configuration. We are now ready to start using Room in our project. Let us start by creating an entity class for our Cat
object. This will be the data class that will be used to store our pets in the database. Inside the data
package, create a new file called CatEntity.kt
and add the following code:
@Entity(tableName = "Cat") data class CatEntity( @PrimaryKey val id: String, val owner: String, val tags: List<String>, val createdAt: String, val updatedAt: String )
This data class represents the Room table for our cats. The @Entity
annotation is used to define the table for our cats. We have passed the tableName
value to specify the name of our table. The @PrimaryKey
annotation is used to define the primary key for our table. The other properties are the columns in our table. One thing to keep in mind is that Room needs type converters to save fields such as tags
, which is a list of strings. Room provides functionality to save non-primitive types using the @TypeConverter
annotation. Let us create a new file named PetsTypeConverters.kt
and add the following code:
class PetsTypeConverters { @TypeConverter fun convertTagsToString(tags: List<String>): String { return Json.encodeToString(tags) } @TypeConverter fun convertStringToTags(tags: String): List<String> { return Json.decodeFromString(tags) } }
This class has two functions annotated with the @TypeConverter
annotation. The first function converts a list of strings to a string. The second function converts a string to a list of strings. We have used the Kotlinx serialization library to convert the list of strings to a string and vice versa. This class will be referenced in our database class that we will create shortly.
We are now ready to create our database. We need to create a Data Access Object (DAO) to access our database. A DAO is an interface that defines the methods to create, read, update, and delete values from our database. Inside the data
package, create a new file called CatDao.kt
and add the following code:
@Dao interface CatDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(catEntity: CatEntity) @Query("SELECT * FROM Cat") fun getCats(): Flow<List<CatEntity>> }
The interface is annotated with the @Dao
annotation to tell Room that we will use this class as our DAO. We have defined two functions in our DAO. The insert
function is used to insert a cat into our database. Notice that this is a suspend
function. This is because we will be using coroutines to insert the cats into our database. Inserting items into the database needs to happen on a background thread since it is a resource-intensive operation. We also use the @Insert
annotation with the onConflict
parameter set to OnConflictStrategy.REPLACE
. This tells Room to replace the cat if it already exists in the database. The getCats
function is used to get all the cats from our database. It has the @Query
annotation, which is used to define a query to get the cats from our database. We are using Flow
to return the cats from our database. Flow
is a stream of data that can be observed. This means that every time we update the database, the changes will be emitted to the view layers immediately without us doing any extra work. Cool, right?
We now need to create our database class. Inside the data
package, create a new file called CatDatabase.kt
and add the following code:
@Database( entities = [CatEntity::class], version = 1 ) @TypeConverters(PetsTypeConverters::class) abstract class CatDatabase: RoomDatabase() { abstract fun catDao(): CatDao }
We have defined an abstract class that extends the RoomDatabase
class. We passed the entities
parameter to specify the entities or tables stored in our database. We have also passed the version
parameter to specify the version of our database. We have used the @TypeConverters
annotation to specify the type converters that we will be using in our database. We have also defined an abstract method that returns our CatDao
. We need to provide an instance of the database to classes that need it. We will do this by using the dependency injection pattern we have been using in our project. Let us head over to the di
package and in the Module.kt
file, add the Room dependency just below the Retrofit dependency:
single { Room.databaseBuilder( androidContext(), CatDatabase::class.java, "cat-database" ).build() } single { get<CatDatabase>().carDao() }
First, we have created a single instance of our database. We have used the databaseBuilder
method to create our database. We have passed the androidContext()
method from Koin to get the context of our application. We have also passed CatDatabase::class.java
to specify the class of our database. We have also passed the name
of our database. We have then created a single instance of our CatDao
. We are using the get
method to get the instance of our database and then calling the catDao
function to get our CatDao
.
Our database is now ready to be used in our repository. We are going to modify PetRepository
and its implementation to be able to do the following:
- Save items to our database
- Read items from our database
- Change our
getPets()
function to return aFlow
of pets
The modified PetRepository.kt
file should look like the following:
interface PetsRepository { suspend fun getPets(): Flow<List<Cat>> suspend fun fetchRemotePets() }
We have modified the getPets
function to return a Flow
of pets. Room does not allow database access on the main thread, therefore, our queries have to be asynchronous. Room provides support for observable queries that read data from our database every time data in our database changes and emits new values to reflect the changes. This is the reason we return a Flow
instance type from the getPets
function. We have also added the fetchRemotePets
function to fetch the pets from the remote data source. Let us now modify PetRepositoryImpl.kt
with a few changes:
class PetsRepositoryImpl( private val catsAPI: CatsAPI, private val dispatcher: CoroutineDispatcher, private val catDao: CatDao ): PetsRepository { override suspend fun getPets(): Flow<List<Cat>> { return withContext(dispatcher) { catDao.getCats() .map { petsCached -> petsCached.map { catEntity -> Cat( id = catEntity.id, owner = catEntity.owner, tags = catEntity.tags, createdAt = catEntity.createdAt, updatedAt = catEntity.updatedAt ) } } .onEach { if (it.isEmpty()) { fetchRemotePets() } } } } override suspend fun fetchRemotePets() { withContext(dispatcher) { val response = catsAPI.fetchCats("cute") if (response.isSuccessful) { response.body()!!.map { catDao.insert(CatEntity( id = it.id, owner = it.owner, tags = it.tags, createdAt = it.createdAt, updatedAt = it.updatedAt )) } } } } }
We have made the following changes:
- We have added the
catDao
property to the constructor of the class. - We have modified the
getPets
function to return aFlow
of pets. Additionally, we have added amap
operator to mapCatEntity
to aCat
object. We have also added anonEach
operator to check if the list of pets is empty. If it is empty, we call thefetchRemotePets
function to fetch the pets from the remote data source. This provides an offline first experience to our users; that is, we first check if we have the data in our database and if we don’t, we fetch it from the remote data source. - Lastly, we have modified the
fetchRemotePets
function that fetches the pets from the remote data source. When the response is successful, we map the response to aCatEntity
instance type and insert it into our database.
We need to update the PetsRepository
dependency in our Module.kt
file to add the CatDao
dependency:
single<PetsRepository> { PetsRepositoryImpl(get(), get(), get()) }
In our PetsRepositoryImpl
class, we have been able to read and fetch data from the Room database. Next, we are going to modify the getPets()
function in PetsViewModel
to accommodate these new changes. Head over to the PetsViewModel.kt
file and modify the getPets()
function to look like the following:
private fun getPets() { petsUIState.value = PetsUIState(isLoading = true) viewModelScope.launch { petsRepository.getPets().asResult().collect { result -> when (result ) { is NetworkResult.Success -> { petsUIState.update { it.copy(isLoading = false, pets = result.data) } } is NetworkResult.Error -> { petsUIState.update { it.copy(isLoading = false, error = result.error) } } } } } }
We have made a few minor changes. We have used the asResult()
extension function to convert the Flow
of pets to a Flow
of NetworkResult
. This is because we are now returning a Flow
of pets from our repository. The rest of the code remains the same as before. We will get an error since we have not created the asResult()
extension function. Let us create it in our NetworkResult.kt
file:
fun <T> Flow<T>.asResult(): Flow<NetworkResult<T>> { return this .map<T, NetworkResult<T>> { NetworkResult.Success(it) } .catch { emit(NetworkResult.Error(it.message.toString())) } }
This is an extension function on the Flow
class. It maps a Flow
of items to the NetworkResult
class. We can now head back to our PetsViewModel
class and add the extension function imports to resolve the error.
The last change we need to make is to provide the application context to our Koin instance in the Application
class. Head over to the ChapterEightApplication.kt
file and modify the startKoin
block to the following:
startKoin { androidContext(applicationContext) modules(appModules) }
We have provided the application context to our Koin instance. Now, we can run the app. You should see the list of cute cats.
Figure 8.1 – Cute cats
The app still works as before, but now we are reading items from the Room database. If you turn off your data and Wi-Fi, the app still shows the list of cute cats! Amazing, isn’t it? We have been able to make the app work offline. One of the benefits of having an architecture in place for our app is that we can change the different layers without necessarily affecting the other layers. We have been able to change the data source from the remote data source to the local data source without affecting the view layer. This is the power of having a good architecture in place.
We know how to insert and read data from our Room database, but what about updating it? In the next section, we will learn how to update the data that is in our Room database. In the process, we will also learn how to migrate from one database version to the other using the Room automated migration feature.