Room in Practice: Compile-Time SQL Checks and a Full CRUD Notes Database for Android
Getting Started with Room — Entities / DAO / Database Setup and CRUD
Hello Android developers, welcome to Part 5 of the "Jetpack Core in Action" series. In the first four parts, we completed the setup of ViewModel, LiveData, coroutines, and a Repository-based layered architecture, and the project already has a clear skeleton. The one shortcoming is that all data is stored in memory; when the app exits, the data is gone. This part officially introduces the Room database, Google's recommended SQLite abstraction layer. It turns tedious database operations into a clean annotation-driven API, provides compile-time SQL validation, and makes database development safe and efficient. We will build a notes database from scratch and complete full CRUD functionality. All code follows Kotlin conventions and Version Catalog dependency management, and can be compiled and run directly.
1. What is Room? Why give up native SQLite?
Native SQLite is Android's built-in database capability, but the development experience is poor. Room retains all the capabilities of SQLite while making many engineering optimizations and fitting perfectly into the Jetpack architecture.
1.1 Core pain points of native SQLite
| Pain point | Explanation |
|---|---|
| Lots of boilerplate | Requires manually managing database helper classes, cursor parsing, and SQL concatenation; a simple query takes dozens of lines |
| No compile-time validation | SQL syntax errors and wrong table or field names only crash at runtime, with very high troubleshooting cost |
| No thread-safety guarantee | Main-thread access, multithreaded concurrency, and transaction management must be handled manually; any slip can cause ANR or data corruption |
| No reactive support | The UI must be manually refreshed after data changes; cannot work seamlessly with LiveData/Flow |
1.2 Room's core advantages
Room is an annotation-based ORM (object-relational mapping) framework. Its core advantages directly solve the pain points of native SQLite:
| Advantage | Explanation |
|---|---|
| Annotation-driven development, minimal code | Annotations define tables and interfaces define operations; no need to hand-write SQL helper classes or cursor parsing |
| Compile-time SQL validation | All SQL statements are checked for syntax, table names, and field names during compilation, catching problems early and preventing runtime crashes |
| Native coroutine/LiveData support | DAO methods directly support suspend functions and can return LiveData/Flow, integrating seamlessly with the Jetpack architecture |
| Thread-safety encapsulation | Automatically handles thread switching for database operations, avoids main-thread access exceptions, and simplifies transaction management |
1.3 Room's three core components
Room's architecture consists of three core parts, each with its own responsibility, jointly completing the mapping from objects to database tables:
| Component | Annotation | Core responsibility |
|---|---|---|
| Entity class | @Entity | Corresponds to a table in the database; the class's properties correspond to table fields |
| Data access object DAO | @Dao | Defines the database operation interface (CRUD); the only entry point for the business layer to operate the database |
| Database class | @Database | The holder of the database; manages the database version and all DAO instances |
The dependency relationship among the three: Database contains DAO, DAO operates on Entity, and the business layer obtains the DAO through Database, then uses the DAO to operate entity data. This fully matches the design philosophy of a layered architecture.
2. Prerequisite dependency configuration
2.1 Version Catalog configuration
Check whether gradle/libs.versions.toml contains the following configuration:
[versions]
room = "2.6.1"
[libraries]
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
[plugins]
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
2.2 Module-level build.gradle.kts configuration
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt) // enable kapt for annotation processing
}
android {
// ... other configuration
kapt {
correctErrorTypes = true // correct error types to improve Room stability
}
}
dependencies {
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) // coroutine + Flow support
kapt(libs.androidx.room.compiler) // annotation processor
}
Note: kapt is Kotlin's annotation processing tool. Room uses it to generate code at compile time. Without it, the code for @Dao and @Database will not be generated, and you will get an error at runtime saying that the implementation class cannot be found.
3. @Entity: entity definition and common configuration
An entity class is the mapping of a database table; one data class corresponds to one table. Through annotations, you can flexibly configure table names, primary keys, fields, ignored items, and more.
3.1 Basic entity definition
The most basic entity class only needs the @Entity annotation, with @PrimaryKey defining the primary key:
package com.example.jetpacknote.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes") // tableName sets a custom table name; the class name is used by default if omitted
data class Note(
@PrimaryKey(autoGenerate = true) // auto-increment primary key
val id: Long = 0,
val content: String,
val createTime: Long = System.currentTimeMillis(),
val updateTime: Long = System.currentTimeMillis()
)
3.2 Common field configuration
(1) Custom field name: @ColumnInfo
If a table field name must differ from the class property name, use @ColumnInfo to map it. You can also configure a default value:
@ColumnInfo(name = "note_content", defaultValue = "")
val content: String
@ColumnInfo(name = "create_time")
val createTime: Long
(2) Ignoring fields: @Ignore
If a property is used only for business logic and does not need to be stored in the database, add the @Ignore annotation:
@Ignore
val isSelected: Boolean = false
(3) Nested objects: @Embedded
If several fields in an entity can be grouped into one object, use @Embedded to nest the entity. The table still has independent fields, but the code structure is clearer.
data class TimeInfo(
val createTime: Long,
val updateTime: Long
)
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val content: String,
@Embedded
val timeInfo: TimeInfo // the table will have two fields: createTime and updateTime
)
Note: Field names in a nested object cannot duplicate the outer entity's fields. You can use the prefix property on @Embedded to add a prefix to nested fields:
@Embedded(prefix = "time_") // table fields become time_createTime and time_updateTime
val timeInfo: TimeInfo
3.3 Summary of entity class definition conventions
| Convention | Explanation | Example |
|---|---|---|
| Use data class | Automatically generates equals/hashCode/toString | data class Note(...) |
| Primary key must have a default value | Especially when combined with autoGenerate = true | val id: Long = 0 |
| Use val to keep fields immutable | Data classes are recommended to be immutable | val content: String |
| Add @Ignore to non-stored fields | Prevents Room from trying to map them | @Ignore val temp: String? |
| Use @ColumnInfo for explicit naming | Avoids field-name confusion during refactoring | @ColumnInfo(name = "create_time") |
4. @Dao: the four core operations
DAO is short for Data Access Object, the interface layer for database operations. The business layer only interacts with the DAO and does not need to care about the underlying SQL implementation. Room generates the DAO interface implementation class at compile time.
4.1 Basic DAO definition
@Dao
interface NoteDao {
// database operation methods are defined here
}
4.2 @Insert: inserting data
Mark an insert method directly, and Room automatically generates the insert SQL. Single objects and list objects are both supported for batch insertion:
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNote(note: Note): Long // returns the auto-increment ID after insertion
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNotes(notes: List<Note>) // batch insert
Conflict strategies (OnConflictStrategy):
| Strategy | Behavior |
|---|---|
| REPLACE | Replaces old data when the primary key is duplicated |
| IGNORE | Ignores the new data when the primary key is duplicated |
| ABORT | Aborts the operation and throws an exception when the primary key is duplicated (default) |
4.3 @Update: updating data
Updates the corresponding data row by primary key:
@Update
suspend fun updateNote(note: Note): Int // returns the number of affected rows
4.4 @Delete: deleting data
Deletes the corresponding data row by primary key:
@Delete
suspend fun deleteNote(note: Note): Int // returns the number of affected rows
4.5 @Query: custom queries
@Query is the most flexible operation annotation. It supports custom SQL statements and all SQLite syntax. SQL correctness is validated at compile time:
@Dao
interface NoteDao {
// Query all notes (ordered by time descending)
@Query("SELECT * FROM notes ORDER BY createTime DESC")
suspend fun getAllNotes(): List<Note>
// Query a single note by ID
@Query("SELECT * FROM notes WHERE id = :noteId")
suspend fun getNoteById(noteId: Long): Note?
// Fuzzy search for notes
@Query("SELECT * FROM notes WHERE content LIKE '%' || :keyword || '%'")
suspend fun searchNotes(keyword: String): List<Note>
// Delete all notes
@Query("DELETE FROM notes")
suspend fun clearAllNotes()
}
Important convention: Methods in a DAO should all be declared as suspend functions. Room automatically executes database operations on a background thread, with no manual thread switching, perfectly matching Kotlin coroutines.
5. @TypeConverter: handling complex types
SQLite only supports basic data types such as integers, strings, and floats. If an entity contains complex types such as Date, List, or custom objects, you need @TypeConverter for type conversion.
5.1 Date to Long converter
package com.example.jetpacknote.data.converter
import androidx.room.TypeConverter
import java.util.Date
class DateConverter {
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.time
@TypeConverter
fun timestampToDate(timestamp: Long?): Date? = timestamp?.let { Date(it) }
}
5.2 List to JSON converter (using Gson)
If you need to store collection types such as List, you can use Gson to serialize them into a JSON string:
package com.example.jetpacknote.data.converter
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class StringListConverter {
private val gson = Gson()
@TypeConverter
fun listToString(list: List<String>?): String? {
return list?.let { gson.toJson(it) }
}
@TypeConverter
fun stringToList(str: String?): List<String>? {
return str?.let {
val type = object : TypeToken<List<String>>() {}.type
gson.fromJson(it, type)
}
}
}
5.3 Registering type converters
After writing the converters, register them on the Database class with the @TypeConverters annotation:
@Database(entities = [Note::class], version = 1)
@TypeConverters(DateConverter::class, StringListConverter::class)
abstract class AppDatabase : RoomDatabase() {
// ...
}
6. @Database: defining the database class
The @Database annotation defines the database class. It is Room's entry point and uses the singleton pattern to ensure there is only one database instance globally.
package com.example.jetpacknote.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.jetpacknote.data.converter.DateConverter
import com.example.jetpacknote.data.dao.NoteDao
import com.example.jetpacknote.data.entity.Note
@Database(
entities = [Note::class],
version = 1,
exportSchema = false
)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
private const val DATABASE_NAME = "jetpack_note_db"
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext, // must use Application Context
AppDatabase::class.java,
DATABASE_NAME
).build().also { INSTANCE = it }
}
}
}
}
Key points:
- Application Context must be used. Activity Context must not be passed in, otherwise it can cause a memory leak.
- The database instance must be a singleton to avoid repeatedly creating connections.
- @Volatile plus synchronized ensures multithreaded safety.
7. Hands-on: complete notes CRUD
Now combine all the concepts into the notes project and walk through the full flow from creating the database to CRUD.
Step 1: Create the Note entity class
package com.example.jetpacknote.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.Date
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val content: String,
val createTime: Date = Date(),
val updateTime: Date = Date()
)
Step 2: Create NoteDao
package com.example.jetpacknote.data.dao
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 com.example.jetpacknote.data.entity.Note
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNote(note: Note): Long
@Update
suspend fun updateNote(note: Note): Int
@Delete
suspend fun deleteNote(note: Note): Int
@Query("SELECT * FROM notes ORDER BY createTime DESC")
suspend fun getAllNotes(): List<Note>
@Query("SELECT * FROM notes WHERE id = :noteId")
suspend fun getNoteById(noteId: Long): Note?
@Query("DELETE FROM notes")
suspend fun clearAllNotes()
}
Step 3: Create AppDatabase (with converter)
package com.example.jetpacknote.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.jetpacknote.data.converter.DateConverter
import com.example.jetpacknote.data.dao.NoteDao
import com.example.jetpacknote.data.entity.Note
@Database(
entities = [Note::class],
version = 1,
exportSchema = false
)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"jetpack_note_db"
).build().also { INSTANCE = it }
}
}
}
}
Step 4: Create DateConverter
package com.example.jetpacknote.data.converter
import androidx.room.TypeConverter
import java.util.Date
class DateConverter {
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.time
@TypeConverter
fun timestampToDate(timestamp: Long?): Date? = timestamp?.let { Date(it) }
}
Step 5: Refactor the Repository (from memory to database)
This is where the advantage of the layered architecture shows: the Repository switches internally from mock data to the Room database, its external method signatures remain completely unchanged, and the ViewModel and UI layers do not need any modifications.
package com.example.jetpacknote.data.repository
import android.content.Context
import com.example.jetpacknote.data.database.AppDatabase
import com.example.jetpacknote.data.entity.Note
import java.util.Date
class NoteRepository private constructor(context: Context) {
private val noteDao = AppDatabase.getInstance(context).noteDao()
suspend fun getAllNotes(): Result<List<Note>> {
return try {
Result.success(noteDao.getAllNotes())
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getNoteById(id: Long): Result<Note?> {
return try {
Result.success(noteDao.getNoteById(id))
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun addNote(content: String): Result<Boolean> {
return try {
val note = Note(content = content)
noteDao.insertNote(note)
Result.success(true)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updateNote(note: Note): Result<Boolean> {
return try {
val updated = note.copy(updateTime = Date())
noteDao.updateNote(updated)
Result.success(true)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteNote(note: Note): Result<Boolean> {
return try {
noteDao.deleteNote(note)
Result.success(true)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun clearAllNotes(): Result<Unit> {
return try {
noteDao.clearAllNotes()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
companion object {
@Volatile
private var INSTANCE: NoteRepository? = null
fun getInstance(context: Context): NoteRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: NoteRepository(context.applicationContext).also { INSTANCE = it }
}
}
}
}
Step 6: Update the ViewModel
package com.example.jetpacknote.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.data.repository.NoteRepository
import kotlinx.coroutines.launch
sealed class NoteUiState {
data object Loading : NoteUiState()
data class Success(val notes: List<String>) : NoteUiState()
data class Error(val message: String) : NoteUiState()
}
class NoteViewModel(application: Application) : AndroidViewModel(application) {
private val repository = NoteRepository.getInstance(application)
private val _uiState = MutableLiveData<NoteUiState>()
val uiState: LiveData<NoteUiState> = _uiState
fun loadNotes() {
viewModelScope.launch {
_uiState.value = NoteUiState.Loading
val result = repository.getAllNotes()
result.onSuccess { notes ->
_uiState.value = NoteUiState.Success(notes.map { it.content })
}.onFailure { error ->
_uiState.value = NoteUiState.Error(error.message ?: "Failed to load")
}
}
}
fun addNote(content: String) {
viewModelScope.launch {
repository.addNote(content)
loadNotes() // refresh the list automatically after adding
}
}
fun deleteNote(content: String) {
viewModelScope.launch {
// simple demo: delete the first matching item by content
val result = repository.getAllNotes()
result.onSuccess { notes ->
notes.firstOrNull { it.content == content }?.let {
repository.deleteNote(it)
loadNotes()
}
}
}
}
}
Why extend AndroidViewModel? Because the Repository needs Context, and AndroidViewModel holds Application Context, which is safe and does not leak memory.
Step 7: Update the Activity layout
Add an input field and an add button in activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="32dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<EditText
android:id="@+id/etInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Enter note content"
android:inputType="textMultiLine" />
<Button
android:id="@+id/btnAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Add" />
</LinearLayout>
<Button
android:id="@+id/btnRefresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Refresh list" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone" />
<TextView
android:id="@+id/tvContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:textSize="16sp"
android:visibility="gone" />
<TextView
android:id="@+id/tvError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:textColor="#D32F2F"
android:textSize="16sp"
android:visibility="gone" />
</LinearLayout>
Step 8: Update the Activity
package com.example.jetpacknote
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.jetpacknote.databinding.ActivityMainBinding
import com.example.jetpacknote.ui.NoteUiState
import com.example.jetpacknote.ui.NoteViewModel
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val viewModel: NoteViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initClickListeners()
observeUiState()
viewModel.loadNotes()
}
private fun initClickListeners() {
binding.btnAdd.setOnClickListener {
val content = binding.etInput.text.toString().trim()
if (content.isEmpty()) {
Toast.makeText(this, "Please enter note content", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
viewModel.addNote(content)
binding.etInput.text?.clear()
}
binding.btnRefresh.setOnClickListener {
viewModel.loadNotes()
}
}
private fun observeUiState() {
viewModel.uiState.observe(this) { state ->
when (state) {
is NoteUiState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.tvContent.visibility = View.GONE
binding.tvError.visibility = View.GONE
}
is NoteUiState.Success -> {
binding.progressBar.visibility = View.GONE
binding.tvError.visibility = View.GONE
binding.tvContent.visibility = View.VISIBLE
binding.tvContent.text = state.notes.joinToString("\n\n")
}
is NoteUiState.Error -> {
binding.progressBar.visibility = View.GONE
binding.tvContent.visibility = View.GONE
binding.tvError.visibility = View.VISIBLE
binding.tvError.text = state.message
}
}
}
}
}
Run verification
| Operation | Expected result |
|---|---|
| Enter content and click Add | The list refreshes automatically and shows the new note ✅ |
| Kill the app and reopen it | The data still exists; persistence works ✅ |
| Rotate the screen | Data is not lost; the page state restores automatically ✅ |
| Click Refresh | Reloads the database data ✅ |
8. Advantages of Room's compile-time SQL checking
This is one of Room's biggest advantages over native SQLite and deserves separate emphasis:
| Comparison dimension | Native SQLite (string SQL) | Room (@Query annotation) |
|---|---|---|
| SQL syntax errors | Runtime crash ❌ | Compile-time error ✅ |
| Table name/field name typos | Runtime crash ❌ | Compile-time error ✅ |
| Return type mismatch | Runtime crash ❌ | Compile-time error ✅ |
| Field refactoring | Manually check all SQL | Automatically detected ✅ |
| IDE support | No syntax highlighting | SQL syntax highlighting ✅ |
A real scenario: suppose you want to rename createTime to createdAt. With native SQLite, you have to manually search all SQL strings and replace them; it is easy to miss one and cause a runtime crash. With Room, once you change the entity field name, every @Query that uses that field fails at compile time, and none will be missed.
9. FAQ / Pitfall guide
Q1: Why must the Room database be a singleton?
A: There are three core reasons:
| Reason | Explanation |
|---|---|
| Resource overhead | Database connections are heavyweight resources; frequent creation and destruction consume significant memory and performance |
| Data consistency | Multiple instances operating concurrently can cause transaction conflicts, inconsistent reads/writes, and even database lockups |
| Transaction safety | A singleton ensures unified management of global transactions and avoids transaction isolation failures under multiple instances |
Room's official documentation also explicitly recommends creating only one database instance per app.
Q2: What are the conventions for defining an Entity class?
A: Following these conventions avoids most Room development problems:
| Convention | Explanation |
|---|---|
| Prefer data class | Automatically generates equals, hashCode, and toString |
| Primary key must be explicit | A Long auto-increment primary key is recommended |
| Field nullability must be explicit | Non-null fields use non-null types; Room validates at compile time |
| Add @Ignore to non-stored fields | Otherwise Room tries to map them as table fields and the build fails |
| Use @ColumnInfo for explicit naming | Avoids compatibility problems caused by default class/property names |
Q3: What is the advantage of Room's compile-time SQL checking?
A: This is Room's biggest engineering advantage over native SQLite:
- Errors are found early: SQL syntax errors, table/field name typos, and return type mismatches are directly reported at compile time.
- Online crash rates drop: native SQLite SQL errors are runtime exceptions; Room moves the problem to the development stage.
- Safer refactoring: if table or field names are changed without updating the SQL in DAOs, the build fails directly.
- Type safety: fields returned by queries are automatically validated against the entity, avoiding runtime type conversion exceptions.
Q4: Why does the ViewModel extend AndroidViewModel? What should be noted?
A: Because the Repository needs a Context instance, and AndroidViewModel holds Application Context, whose lifecycle matches the app and does not cause memory leaks.
Note: AndroidViewModel can only access Application Context, not Activity Context. If you need Activity-level context, such as showing a Dialog, delegate the operation to the Activity through LiveData or callbacks.
10. Migration summary: from memory to database
| Stage | Data storage method | Characteristics |
|---|---|---|
| Parts 2-3 | Hardcoded data in ViewModel | For demonstration; no persistence |
| Part 4 | In-memory data in Repository | Complete architecture, but lost on restart |
| This part | Room database | Persistent storage; survives restart ✅ |
At this point, our notes data has truly been "persisted". The app closes and reopens, and the data still exists. At the same time, the Repository layered architecture keeps the data source switch completely transparent to upper layers: switching from mock data to the Room database required zero changes in the ViewModel and UI layers.
Next episode preview
This part built the Room database from scratch, mastered the three core components of entities, DAO, and Database, and implemented full CRUD persistence for the notes app, connecting seamlessly with the Repository layered architecture.
But the current queries are still one-shot. After data changes, you must manually call a refresh method; reactive automatic updates are not yet implemented. In the next part, we will move to advanced Room topics:
- Reactive queries: using LiveData/Flow to automatically refresh the list when data changes
- Database migrations: safe handling of adding/removing fields during version upgrades
- Complex queries: multi-table joins, aggregate queries, and fuzzy search optimization
🌟 Like + bookmark + follow to receive updates as soon as they are published 💬 Comment section: what database pitfalls have you run into while using Room? Leave a message and chat!
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Deleting a note by its primary key is more reliable.