Implementing the Room Library with Jetpack Compose
Storing Data Locally on Android
This article will guide you through the process of implementing the Room library in your Android application. It’s assumed that you have a basic understanding of Kotlin and Jetpack Compose. While our main objective is to implement the Room library, the explanations provided will be concise, focusing on implementation rather than in-depth code analysis. This approach is designed to help you integrate Room effectively without delving into every detail of the underlying code.
❓ What is the Room?
The Room library acts as an abstraction layer over SQLite, simplifying database management in Android applications. While direct usage of SQLite can lead to potential errors during SQL query execution, Room enhances safety and efficiency. It offers compile-time verification of SQL queries, significantly reducing the risk of errors.
It’s generally recommended to utilize the Room library for local data storage in SQLite, unless there are specific reasons not to. Room not only makes coding more robust and less error-prone but also streamlines the development process.
For comprehensive details on the Room library, visit the official documentation: Room Library Overview
🤖 Overview of the Sample App
Before we delve into implementing the Room library, let’s get acquainted with the sample app we’ll be developing: “My Friends App,” a simple utility for storing friends’ names.
The app includes the following UI components:
- An input text field for entering names ..(1)
- A save button to store the entered name ..(2)
- A delete button to remove saved names ..(3)
- An area to display saved data ..(4)
Here’s a preview of the app’s interface:
The app is intentionally designed with simplicity to keep the focus on the backend implementation rather than the UI. Therefore, while the UI elements are essential for interaction, our primary focus will be on integrating and utilizing the Room library to manage the app’s data.
In the upcoming section, we’ll start the hands-on process of integrating the Room library into our app!
⚙ Setting Up the Dependencies
To integrate the Room library, we need to configure the necessary dependencies in our build.gradle.kts
files.
Project-Level Configuration: Open your project-level build.gradle.kts
file and add the following code to enable Kotlin Symbol Processing (KSP), which is crucial for Room to analyze and generate code from annotations:
plugins {
/* other plugins settings */
id("com.google.devtools.ksp") version "1.9.23-1.0.19" apply false
}
Ensure that you’re using the latest KSP version available at the time of writing this article.
Module-Level Configuration: Next, navigate to the module-level build.gradle.kts
file and incorporate the necessary dependencies for Room:
plugins {
/* other plugins settings */
id("com.google.devtools.ksp")
}
…
composeOptions {
kotlinCompilerExtensionVersion = "1.5.11"
}
…
dependencies {
/* other plugins settings */
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
Checking libs.versions.toml
: Review your libs.versions.toml
file to confirm or update the versions:
[versions]
agp = "8.3.0"
kotlin = "1.9.23"
coreKtx = "1.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.8.2"
composeBom = "2024.02.02"
lifecycleViewmodelCompose = "2.7.0"
roomCompiler = "2.6.1"
roomKtx = "2.6.1"
roomRuntime = "2.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
It’s crucial to ensure that the kotlinCompilerExtensionVersion
is compatible with your Kotlin version. The latest version of kotlinCompilerExtensionVersion
might not always support the most recent Kotlin release. Before proceeding, verify the compatibility of the Compose Compiler version with your Kotlin version at the official Android Developers site: Compose Compiler Release Notes
Validate Your Setup: After updating the dependencies, build your project to confirm there are no issues. If the build process fails, carefully examine the error messages. Common issues might include missing configurations or incorrect version numbers.
☛ Defining the Entity
As we begin implementing the Room library, let’s first set up our data structure. Create a data
package to organize files related to Room, and within this package, create a file named MyFriendsData.kt
. This file will define the entity for our Room database.
For our “My Friends” app, we need two columns in our database table: one for the ID (of type Int
) and another for the friend's name (of type String
). The ID will serve as a unique identifier for each row.
Here’s how you define the entity in MyFriendsData.kt
:
/* MyFriendsData.kt */
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sample")
data class MyFriend(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "name")
val name: String
)
- The
@Entity
annotation is used to denote a Room entity. This annotation requires a table name, which is set to "sample" in our example. - The
@PrimaryKey
annotation marks a column as the primary key. SettingautoGenerate = true
means that Room will automatically generate unique IDs for each entry. - Use the
@ColumnInfo
annotation to specify a custom column name. Here, the column for storing friend names is named "name."
For more detailed information on defining entities in Room, refer to the official documentation: Defining data using Room entities
☛ Defining the Dao
With the entity defined, the next step in implementing Room is to define the Data Access Object (Dao), which facilitates abstract interactions with our database. Through the Dao, we can simplify database operations.
For our “My Friends” app, we require three fundamental operations:
- Retrieving all data
- Inserting new data
- Deleting all data
Below is the example code for defining the Dao in MyFriendsDao.kt
:
/* MyFriendsDao.kt */
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface MyFriendsDao {
@Query("SELECT * FROM sample")
fun getAll(): Flow<List<MyFriend>>
@Insert
suspend fun insertFriend(myFriend: MyFriend)
@Delete
suspend fun deleteAllMyFriends(allMyFriends: List<MyFriend>)
}
- Begin the Dao definition with the
@Dao
annotation applied to the interface. - The
getAll()
method returns aFlow
type. This does not require thesuspend
modifier because it provides a continuous stream of data. - Other methods, such as inserting or deleting data, should be marked with
suspend
to ensure they're executed asynchronously, respecting coroutine best practices. - The
@Insert
annotation simplifies the definition of data insertion methods. Similarly, the@Delete
annotation is used for methods that remove data from the database.
For more advanced data manipulation methods or to explore further capabilities of Room’s Dao, consult the official documentation: Accessing data with Room
☛ Defining the Database Class
Now, we’ll establish the database class using the Room library. Let’s begin with an overview of the example code:
/* MyFriendsDatabase.kt */
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [MyFriend::class], version = 1)
abstract class MyFriendsDatabase: RoomDatabase() {
abstract fun myFriendsDao(): MyFriendsDao
companion object {
@Volatile
private var Instance: MyFriendsDatabase? = null
fun getMyFriendsDatabase(context: Context): MyFriendsDatabase {
return Instance ?: synchronized(this) {
Room.databaseBuilder(
context = context,
klass = MyFriendsDatabase::class.java,
name = "sample"
)
.build()
.also { Instance = it }
}
}
}
}
- The
@Database
annotation is crucial. Here, you must list all entity classes in theentities
parameter and define the databaseversion
. Database versions are essential for handling schema changes over time. MyFriendsDatabase
extendsRoomDatabase
, indicating it's a Room database class. Defined as anabstract class
, Room takes care of its implementation.- The
myFriendsDao()
method exposes the Dao, enabling database operations through it. - The
Instance
variable, declared within acompanion object
, ensures thatMyFriendsDatabase
adheres to the singleton pattern, creating only one instance throughout the app. - Marking
Instance
with@Volatile
guarantees that its value is always read from and written to the main memory, avoiding caching issues. - The
getMyFriendsDatabase()
function either returns the existingInstance
or creates a new one within asynchronized
block if it'snull
.
If this is your first encounter with implementing a Room database, the code might seem intricate. It’s not essential to grasp every detail immediately. Gradually deepening your understanding and consulting the official documentation when challenges arise can be very effective: Room Database Documentation
☛ Defining the Repository Class
Now, let’s create the MyFriendsRepository
class. This class will serve as an intermediary between our database operations defined in the DAO and the UI or business logic of our application. By passing the DAO as a parameter to the repository's constructor, we enable direct interaction with the database through the DAO's methods.
/* MyFriendsRepository.kt */
class MyFriendsRepository(private val myFriendsDao: MyFriendsDao) {
fun getAll() = myFriendsDao.getAll()
suspend fun insertFriend(myFriend: MyFriend)
= myFriendsDao.insertFriend(myFriend)
suspend fun deleteAllMyFriends(allMyFriends: List<MyFriend>)
= myFriendsDao.deleteAllMyFriends(allMyFriends)
}
In the MyFriendsRepository
, we define methods that correspond to the DAO's operations. The getAll
method retrieves all friends' data, insertFriend
adds a new friend, and deleteAllMyFriends
removes all friends from the database. Notice the use of suspend
for insertFriend
and deleteAllMyFriends
to support coroutines for asynchronous operations.
While we have established the MyFriendsRepository
, instantiating it directly isn't feasible since it requires an instance of MyFriendsDao
. The forthcoming section will address how to instantiate MyFriendsRepository
with the necessary DAO.
☛ Solving Dependency Issues
To instantiate MyFriendsRepository
, we require an instance of MyFriendsDao
. This dependency chain necessitates a structured approach to ensure that all components are correctly instantiated. We'll address this by introducing a container class, MyFriendsContainer
, which will manage the instantiation of MyFriendsRepository
.
/* MyFriendsContainer.kt */
class MyFriendsContainer(private val context: Context) {
val myFriendsRepository: MyFriendsRepository by lazy {
MyFriendsRepository(MyFriendsDatabase.getMyFriendsDatabase(context).myFriendsDao())
}
}
The MyFriendsContainer
class uses a lazy
delegate to ensure that MyFriendsRepository
is instantiated only when needed, using the appropriate Dao
obtained from MyFriendsDatabase
.
To supply the necessary context for our MyFriendsContainer
, we'll create a custom Application
class:
/* MyFriendsApplication.kt */
class MyFriendsApplication : Application() {
lateinit var container: MyFriendsContainer
override fun onCreate() {
super.onCreate()
container = MyFriendsContainer(this)
}
}
This Application
class initializes MyFriendsContainer
with the application context, ensuring it's available throughout the app.
To ensure our Application
class is recognized, modify the AndroidManifest.xml
:
/* AndroidManifest.xml */
<application
android:name=".MyFriendsApplication"
…other settings>
This configuration ensures that our custom application class is used, allowing us to access MyFriendsContainer
across our application.
With this setup complete, we are now ready to integrate the Room database within our app’s architecture. The next section will focus on creating a ViewModel
that utilizes MyFriendsRepository
for data operations.
☛ Defining the ViewModel Class
While this article doesn’t delve deeply into UI management, we’ll briefly outline how to set up the HomeViewModel
class, which interacts with our UI components. Assuming that our UI is defined in a Home
composable within Home.kt
, the corresponding HomeViewModel
can be structured as follows:
/* HomeViewModel.kt */
class HomeViewModel(private val myFriendsRepository: MyFriendsRepository) : ViewModel() {
fun getAll(): Flow<List<MyFriend>>
= myFriendsRepository.getAll()
fun insertFriend(friendName: String) = viewModelScope.launch {
myFriendsRepository.insertFriend(MyFriend(name = friendName))
}
fun deleteAllMyFriends(allMyFriends: List<MyFriend>) = viewModelScope.launch {
myFriendsRepository.deleteAllMyFriends(allMyFriends)
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as MyFriendsApplication)
HomeViewModel(application.container.myFriendsRepository)
}
}
}
}
This ViewModel class utilizes methods from MyFriendsRepository
to perform data operations. The ViewModel is responsible for handling UI-related data operations and lifecycle-aware data manipulation. To solve the dependency issue, we provide a Factory
within the ViewModel to ensure it's instantiated with the necessary repository.
With the ViewModel in place, we’re now poised to complete our sample app by connecting it with the Home
composable.
☛ Creating the Home Composable
Now, let’s briefly explore the Home
composable's structure. This code snippet serves as a basic framework, which you're encouraged to modify and enhance according to your app's requirements.
/* Home.kt */
@Composable
fun HomeView(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory) // ..1
) {
val friendsList by viewModel.getAll().collectAsState(initial = emptyList()) // ..2
var friendNameInput by remember { mutableStateOf("") }
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Display the list of friends
LazyColumn(
modifier = Modifier.weight(.7F),
verticalArrangement = Arrangement.Center
) {
items(friendsList) { friend ->
Card(
modifier = Modifier
.width(200.dp)
.height(80.dp)
.padding(vertical = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = friend.name, style = MaterialTheme.typography.displaySmall)
}
}
}
}
// Input field and buttons
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(.3F)
) {
OutlinedTextField(value = friendNameInput, onValueChange = { friendNameInput = it })
Button(onClick = { viewModel.insertFriend(friendNameInput) }) {
Text(text = "SAVE")
}
Button(onClick = { viewModel.deleteAllMyFriends(friendsList) }) {
Text(text = "ALL DELETE")
}
}
}
}
- In line ..1, the
HomeViewModel
is instantiated using the custom factory we defined. - In line ..2, we retrieve and observe the list of friends from the database, updating the UI in real-time with any changes.
The following video demonstrates the app in action, showcasing data persistence even after the application is closed:
Congratulations! We’ve successfully built a simple app utilizing the Room library to manage local data storage.
<aside>
<p>
Thank you for reading!<br>
If you enjoyed this post, <br>
I'd appreciate a clap. 😄
</p>
</aside>