Kotlin Room Database
Introduction
Room is a persistence library that provides an abstraction layer over SQLite, making database operations in Android applications more robust and straightforward while taking advantage of SQLite's full power. Room is part of Android Jetpack and serves as the recommended approach for storing structured data on a device's file system.
In this tutorial, we'll explore how to implement Room in Kotlin Android applications. We'll cover everything from basic setup to executing complex queries, providing you with a solid foundation to build data-persistent applications.
Why Use Room?
Before diving into the implementation, let's understand why Room is preferred over direct SQLite operations:
- Compile-time verification of SQL queries
- Less boilerplate code compared to using SQLite APIs directly
- Streamlined database migration paths
- Easy integration with other Jetpack components like LiveData and ViewModel
Setting Up Room in Your Project
Step 1: Add Dependencies
First, you need to add the Room dependencies to your app-level build.gradle
file:
dependencies {
def room_version = "2.5.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// Optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
}
Don't forget to enable kapt in your build.gradle:
plugins {
id 'kotlin-kapt'
}
Step 2: Create an Entity
An entity represents a table within your database. Each instance of an entity represents a row in the corresponding table.
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val age: Int,
val email: String
)
In this example, we've created a User
entity with four columns: id
, name
, age
, and email
. The id
column is the primary key and will auto-generate values.
Step 3: Create a Data Access Object (DAO)
A DAO contains the methods used for accessing the database. It provides an abstract interface to your database, allowing you to separate different components of your database architecture.
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: Int): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User): Long
@Update
suspend fun updateUser(user: User)
@Delete
suspend fun deleteUser(user: User)
}
In this DAO, we've defined methods to:
- Retrieve all users as a Flow (reactive stream)
- Get a specific user by ID
- Insert a new user
- Update an existing user
- Delete a user
Step 4: Create the Database
Now, let's create the database class, which serves as the main access point for the underlying connection to your app's persisted data.
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
This database class:
- Is annotated with
@Database
to specify entities and version - Contains an abstract method to get the DAO
- Uses a singleton pattern to ensure only one instance of the database is created
Using Room in Your App
Now that we've set up our Room database, let's see how to use it in our application.
Creating a Repository
It's a good practice to create a repository to handle data operations:
class UserRepository(private val userDao: UserDao) {
val allUsers = userDao.getAllUsers()
suspend fun insert(user: User): Long {
return userDao.insertUser(user)
}
suspend fun update(user: User) {
userDao.updateUser(user)
}
suspend fun delete(user: User) {
userDao.deleteUser(user)
}
suspend fun getUserById(userId: Int): User? {
return userDao.getUserById(userId)
}
}
Using the Repository in a ViewModel
class UserViewModel(application: Application) : AndroidViewModel(application) {
private val repository: UserRepository
val allUsers: Flow<List<User>>
init {
val database = AppDatabase.getDatabase(application)
val dao = database.userDao()
repository = UserRepository(dao)
allUsers = repository.allUsers
}
fun insert(user: User) = viewModelScope.launch {
repository.insert(user)
}
fun update(user: User) = viewModelScope.launch {
repository.update(user)
}
fun delete(user: User) = viewModelScope.launch {
repository.delete(user)
}
suspend fun getUserById(userId: Int): User? {
return repository.getUserById(userId)
}
}
Collecting Data in the UI
Here's how you might collect and display data in a fragment using Flows:
class UserListFragment : Fragment() {
private val viewModel: UserViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.allUsers.collect { users ->
// Update UI with the list of users
adapter.submitList(users)
}
}
}
// Setup click listener to add a new user
addButton.setOnClickListener {
val name = nameEditText.text.toString()
val age = ageEditText.text.toString().toInt()
val email = emailEditText.text.toString()
if (name.isNotEmpty() && email.isNotEmpty()) {
viewModel.insert(User(name = name, age = age, email = email))
// Clear fields
nameEditText.text.clear()
ageEditText.text.clear()
emailEditText.text.clear()
}
}
}
}
Advanced Room Features
1. Relations (One-to-Many, Many-to-Many)
Let's consider a scenario where each user can have multiple pets:
@Entity(tableName = "pets")
data class Pet(
@PrimaryKey(autoGenerate = true) val petId: Int = 0,
val name: String,
val species: String,
val ownerId: Int // Foreign key referencing User
)
To establish a relationship between User and Pet:
data class UserWithPets(
@Embedded val user: User,
@Relation(
parentColumn = "id",
entityColumn = "ownerId"
)
val pets: List<Pet>
)
// In UserDao
@Transaction
@Query("SELECT * FROM users")
fun getUsersWithPets(): Flow<List<UserWithPets>>
2. Type Converters
Room can only store primitive types and Strings. For complex objects, you need to use Type Converters:
class DateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}
// Add to your database
@Database(entities = [User::class, Pet::class], version = 1)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {
// ...
}
3. Database Migrations
When you need to update your database schema:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN address TEXT")
}
}
// In your database builder
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2)
.build()
Real-World Example: Note-Taking App
Let's build a simple note-taking app to demonstrate Room in action:
Entity
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val title: String,
val content: String,
val createdTime: Long = System.currentTimeMillis(),
val isPinned: Boolean = false
)
DAO
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY isPinned DESC, createdTime DESC")
fun getAllNotes(): Flow<List<Note>>
@Insert
suspend fun insertNote(note: Note): Long
@Update
suspend fun updateNote(note: Note)
@Delete
suspend fun deleteNote(note: Note)
@Query("SELECT * FROM notes WHERE id = :noteId")
suspend fun getNoteById(noteId: Int): Note?
@Query("SELECT * FROM notes WHERE title LIKE :searchQuery OR content LIKE :searchQuery")
fun searchNotes(searchQuery: String): Flow<List<Note>>
}
Repository
class NoteRepository(private val noteDao: NoteDao) {
val allNotes = noteDao.getAllNotes()
suspend fun insert(note: Note): Long {
return noteDao.insertNote(note)
}
suspend fun update(note: Note) {
noteDao.updateNote(note)
}
suspend fun delete(note: Note) {
noteDao.deleteNote(note)
}
fun searchNotes(query: String): Flow<List<Note>> {
return noteDao.searchNotes("%$query%")
}
}
ViewModel
class NoteViewModel(application: Application) : AndroidViewModel(application) {
private val repository: NoteRepository
val allNotes: Flow<List<Note>>
init {
val database = AppDatabase.getDatabase(application)
repository = NoteRepository(database.noteDao())
allNotes = repository.allNotes
}
fun insert(note: Note) = viewModelScope.launch {
repository.insert(note)
}
fun update(note: Note) = viewModelScope.launch {
repository.update(note)
}
fun delete(note: Note) = viewModelScope.launch {
repository.delete(note)
}
fun searchNotes(query: String): Flow<List<Note>> {
return repository.searchNotes(query)
}
}
Fragment Implementation
class NoteListFragment : Fragment() {
private val viewModel: NoteViewModel by viewModels()
private lateinit var adapter: NoteAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = NoteAdapter { note ->
// Handle note click - open edit screen
findNavController().navigate(
NoteListFragmentDirections.actionNoteListToEdit(note.id)
)
}
recyclerView.adapter = adapter
// Collect notes from the ViewModel
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.allNotes.collect { notes ->
adapter.submitList(notes)
}
}
}
// Handle search
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
if (newText != null) {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.searchNotes(newText).collect { notes ->
adapter.submitList(notes)
}
}
}
return true
}
})
// FAB for adding new notes
addNoteFab.setOnClickListener {
findNavController().navigate(
NoteListFragmentDirections.actionNoteListToAdd()
)
}
}
}
Best Practices
-
Use abstraction layers: Separate your database operations from your UI using repositories and ViewModels.
-
Perform database operations on background threads: Room doesn't allow database operations on the main thread. Use coroutines for asynchronous operations.
-
Keep your entities focused: Design entities to represent specific tables, not to hold unrelated data.
-
Use transactions for related operations: When multiple operations need to succeed or fail together, use the
@Transaction
annotation. -
Use migrations: Always provide migration paths when updating your database schema to avoid data loss.
-
Test your database operations: Write tests for your database operations to ensure they work as expected.
Common Issues and Solutions
1. "Cannot access database on the main thread"
Solution: Use coroutines or background threads for database operations:
lifecycleScope.launch(Dispatchers.IO) {
// Database operation
}
2. Migration issues
Solution: Either provide explicit migrations or use .fallbackToDestructiveMigration()
(only in development).
3. Type errors
Solution: Use Type Converters for complex types like Date or custom objects.
Summary
In this tutorial, we've learned how to:
- Set up Room in an Android application
- Create entities, DAOs, and the database
- Use a repository pattern to abstract database operations
- Implement a ViewModel to manage UI-related data
- Handle advanced scenarios like relationships and migrations
- Build a practical note-taking app with Room
Room provides a robust abstraction over SQLite, helping you to build persistent storage solutions in your Android applications while avoiding many common pitfalls. By following the architecture patterns outlined in this tutorial, you'll create maintainable, testable, and efficient database implementations.
Additional Resources
Exercises
- Extend the note-taking app to include categories for notes
- Implement a feature to archive notes instead of deleting them
- Add a date filter to show notes created within specific time periods
- Implement data export/import functionality for notes
- Add support for attaching images to notes using Room's TypeConverter
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)