I've been trying to understand how to use databases in Android correctly, so I started making a simple app for travel destinations, but now I'm stuck figuring out how to implement Room. The Android Developer documentation wasn't thorough on how implement to a UI for managing the database, and the official codelabs seem overcomplicated for what I want to achieve. I also don't understand what ViewModels are and why you're supposed to use them.
Here's my code so far:
Destination.kt:
@Entity(tableName = "destinations")
data class Destination(
@PrimaryKey val name: String
)
DestinationDao.kt:
@Dao
interface DestinationDao {
@Query("SELECT * FROM destinations")
fun getAll(): List<Destination>
@Upsert
fun upsert(destination: Destination)
@Delete
fun delete(destination: Destination)
}
AppDatabase.kt:
@Database(entities = [Destination::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun destinationDao(): DestinationDao
}
Simplified version of MainActivity.kt:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TravelApp() {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(title = { Text("Travel destinations") })
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
Row {
var destinationToAdd by remember { mutableStateOf("") }
TextField(
modifier = Modifier.weight(1f),
value = destinationToAdd,
onValueChange = { destinationToAdd = it },
label = { Text("Destination") }
)
Button(
onClick = {
// add 'destinationToAdd' to database
}
) {
Text("Add")
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
// show destinations here
}
}
}
}
How would I go about initializing the database and connecting it to the UI?
You first have to obtain an instance of your AppDatabase. The class is abstract so you cannot directly do that. Instead, you need to use the Room database builder:
val db = Room.databaseBuilder(
context = applicationContext,
klass = AppDatabase::class.java,
name = "app-database",
).build()
context
is a Context
object that is required to access the app's local data on the file system. You should provide the context of the application here, not from an activity or something else that has a lifecycle that is shorter than the one from your database. See below for more.
name
contains the name of the database file that needs to be created in the file system.
The question is now where this code should be placed. You didn't provide anything more of your app's code, so it's hard to tell where this fits best. Usually you would place this in a repository class. If you don't have that you can also place it directly in a view model. Just make sure you will never have more than one database instance for the same database at any time. If you use dependency injection, f.e. Hilt, then you can easily enforce this requirement by letting Hilt provide the database instance as a singleton. Then you would move this setup code to a Hilt module:
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun providesAppDatabase(
@ApplicationContext applicationContext: Context,
): AppDatabase = Room.databaseBuilder(
context = applicationContext,
klass = AppDatabase::class.java,
name = "app-database",
).build()
@Provides
fun providesDestinationDao(db: AppDatabase): DestinationDao = db.destinationDao()
}
(The object and function names can be freely chosen. You will never call them directly, they are only used internally by Hilt.)
providesAppDatabase
is now automatically called whenever you request an AppDatabase from Hilt, and it will always provide the same instance (because of the @Singleton
annotation). The applicationContext
is automatically provided by Hilt, also solving the problem where to get that object from in the first place.
Since you usually never use the database object itself rather than its dao, I also added a providesDestinationDao
function that provides a DestinationDao whenever needed.
In that case your view model that would have to access the dao would only need this:
@HiltViewModel
class TravelViewModel @Inject constructor(
private val dao: DestinationDao,
) : ViewModel() {
// ...
}
Since the constructor is annotated with @Inject
, Hilt will internally call providesDestinationDao
, which depends on an AppDatabase, and if none was created yet providesAppDatabase
is called. Your view model doesn't see any of this, though, it just needs to depend on the DestinationDao, that's it.
The view model can now access the dao and expose its functionality to your composables. When accessing the database (via the dao) there will always be the file system involved. That is comparatively slow, so that shouldn't be done on the same thread that the UI runs on to ensure that it still runs smoothly. The UI runs on the Main thread, so you should move your DAO access off the Main thread. Room actually enforces that by throwing an exception when it detects access from the Main thread.
The easiest way to handle this is to allow Room to move itself to another thread. That is as simple as marking both the DAO functions upsert
and delete
as suspend
functions.
getAll
shouldn't be a suspend function, though, because you should wrap its return value in a Flow. Flows are asynchronous data structures that allow an emitter to send new values (here, the database, when the query was executed), while the receiver (the UI, see below) is notified whenever a new value was sent. Since emitting a new value can only be done in a coroutine due to the asynchronous behavior, you do not need to mark anything as suspend
here, that is handled internally by the flow itself.
A major benefit from all this is also that Room is not restricted to only send a single result in the flow. It will actually monitor the database for changes, and re-execute the query when necessary. It will then just emit another result in the flow. This is also the easiest way to make sure the rest of your app doesn't work with stale data, as it will be notified by the flow when anything changed in the database.
All in all, your DAO should look like this:
@Dao
interface DestinationDao {
@Query("SELECT * FROM destinations")
fun getAll(): Flow<List<Destination>>
@Upsert
suspend fun upsert(destination: Destination)
@Delete
suspend fun delete(destination: Destination)
}
You can then add functions to the view model that can be called from your composables when needed:
fun addDestination(destination: String) {
viewModelScope.launch {
dao.upsert(Destination(destination))
}
}
fun deleteDestination(destination: Destination) {
viewModelScope.launch {
dao.delete(destination)
}
}
viewModelScope.launch
is needed here to create a coroutine, otherwise you wouldn't be able to call the suspend functions from the DAO (and once the code runs in a coroutine, Room can move it to another thread).
What remains is the getAll
flow. Flows are meant to be collected (i.e. read, consumed, received...) at the latest possible moment. That is in your composables, not in the view model. The view model should only transform1 the flow into a specially configured StateFlow. It will always have a value, even when the database didn't provide one yet. It also only contains a single value, representing the latest results. All prior values are thrown away. This behavior aligns with Compose that is state-driven itself. So you won't expose a getAll
function in the view model, you just expose the StateFlow as a property:
val destinations: StateFlow<List<Destination>> = dao.getAll().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = emptyList(),
)
stateIn
is needed here to convert the Flow
into a StateFlow
(btw., State here is entirely unrelated to the Compose State
type; StateFlows are part of the Kotlin programming language itself). It needs a coroutine scope to actively run the Room flow (i.e. checking for changed data), but it will stop 5 seconds after the UI unsubscribed from the StateFlow (i.e. there is no one interested in any new data). This small delay is helpful bridging situations where the UI quickly unsubscribes and resubscribes the flow, which happens, for example, on device reconfigurations like screen rotations. The empty list is used until the database provides the first result.
Your composables must now first obtain an instance of the view model. This is usually done by calling viewModel()
, but since you want Hilt to inject the view model's dependencies (here just the DAO) you need to call hiltViewModel()
instead.
Then you can collect the StateFlow (at the top of TravelApp, for example):
val travelViewModel: TravelViewModel = hiltViewModel()
val destinations by travelViewModel.destinations.collectAsStateWithLifecycle()
collectAsStateWithLifecycle()
converts the StateFlow into a Compose State object, which is then unwrapped by the by
delegation. The new destinations
variable is therefore not a Flow<List<Destination>>
and also not a State<List<Destination>>
, it is just a simple List<Destination>
. It is backed by a State, though, and that in turn by a Flow, so this variable may change anytime, triggering a recomposition of the composable, automatically updating the UI. This happens whenever anything changes in the database, so you do not need to concern yourself anymore with requesting new data after you added or deleted a destination. Thanks to the power of Kotlin Flows and Compose State, this is all handled automatically for you.
Simply pass the list of destinations to your LazyColumn like this:
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
items(destinations) {
Destination(
destination = it,
onDelete = { travelViewModel.deleteDestination(it) },
)
}
}
(Make sure you import the correct items
function.)
I introduced a new composable here to display each destination:
@Composable
fun Destination(
destination: Destination,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier) {
Text(destination.name)
Button(onClick = onDelete) { Text("Delete") }
}
}
It not only displays the destination name, it also provides a button that the user can use to delete that destination. Since you should never pass view model instances to your composables, you need a parameter that takes a function. Whenever onDelete
is called here, travelViewModel.deleteDestination(it)
is called.
And a new destination can be added with this:
Button(
onClick = { travelViewModel.addDestination(destinationToAdd) }
) {
Text("Add")
}
1There are several other flow transformations available that allow changing the values inside the flow without collecting it, like map
or flatMapLatest
. You can even combine
multiple flows into one.