Skip to main content

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:

kotlin
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:

kotlin
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.

kotlin
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.

kotlin
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.

kotlin
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:

kotlin
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

kotlin
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:

kotlin
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:

kotlin
@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:

kotlin
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:

kotlin
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:

kotlin
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

kotlin
@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

kotlin
@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

kotlin
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

kotlin
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

kotlin
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

  1. Use abstraction layers: Separate your database operations from your UI using repositories and ViewModels.

  2. Perform database operations on background threads: Room doesn't allow database operations on the main thread. Use coroutines for asynchronous operations.

  3. Keep your entities focused: Design entities to represent specific tables, not to hold unrelated data.

  4. Use transactions for related operations: When multiple operations need to succeed or fail together, use the @Transaction annotation.

  5. Use migrations: Always provide migration paths when updating your database schema to avoid data loss.

  6. 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:

kotlin
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:

  1. Set up Room in an Android application
  2. Create entities, DAOs, and the database
  3. Use a repository pattern to abstract database operations
  4. Implement a ViewModel to manage UI-related data
  5. Handle advanced scenarios like relationships and migrations
  6. 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

  1. Extend the note-taking app to include categories for notes
  2. Implement a feature to archive notes instead of deleting them
  3. Add a date filter to show notes created within specific time periods
  4. Implement data export/import functionality for notes
  5. 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! :)