This document describes how to migrate an existing Room implementation to one that uses Kotlin Multiplatform (KMP).
Migrating Room usages in an existing Android codebase to a common shared KMP module can vary widely in difficulty depending on the Room APIs used or if the codebase already uses Coroutines. This section offers some guidance and tips when trying to migrate usages of Room to a common module.
It is important to first familiarize yourself with the differences and missing
features between the Android version of Room and the KMP version along with
the setup involved. In essence, a successful migration involves refactoring
usages of the SupportSQLite*
APIs and replacing them with SQLite Driver APIs
along with moving Room declarations (@Database
annotated class, DAOs,
entities, and so on) into common code.
Revisit the following information before continuing:
The next sections describe the various steps required for a successful migration.
Migrate from Support SQLite to SQLite Driver
The APIs in androidx.sqlite.db
are Android-only, and any usages need to be
refactored with SQLite Driver APIs. For backwards compatibility, and as long as
the RoomDatabase
is configured with a SupportSQLiteOpenHelper.Factory
(i.e.
no SQLiteDriver
is set), then Room behaves in 'compatibility mode' where
both Support SQLite and SQLite Driver APIs work as expected. This enables
incremental migrations so that you don't need to convert all your Support SQLite
usages to SQLite Driver in a single change.
The following examples are common usages of Support SQLite and their SQLite Driver counterparts:
Support SQLite (from)
Execute a query with no result
val database: SupportSQLiteDatabase = ...
database.execSQL("ALTER TABLE ...")
Execute a query with result but no arguments
val database: SupportSQLiteDatabase = ...
database.query("SELECT * FROM Pet").use { cursor ->
while (cusor.moveToNext()) {
// read columns
cursor.getInt(0)
cursor.getString(1)
}
}
Execute a query with result and arguments
database.query("SELECT * FROM Pet WHERE id = ?", id).use { cursor ->
if (cursor.moveToNext()) {
// row found, read columns
} else {
// row not found
}
}
SQLite Driver (to)
Execute a query with no result
val connection: SQLiteConnection = ...
connection.execSQL("ALTER TABLE ...")
Execute a query with result but no arguments
val connection: SQLiteConnection = ...
connection.prepare("SELECT * FROM Pet").use { statement ->
while (statement.step()) {
// read columns
statement.getInt(0)
statement.getText(1)
}
}
Execute a query with result and arguments
connection.prepare("SELECT * FROM Pet WHERE id = ?").use { statement ->
statement.bindInt(1, id)
if (statement.step()) {
// row found, read columns
} else {
// row not found
}
}
Database transaction APIs are available directly in SupportSQLiteDatabase
with
beginTransaction()
, setTransactionSuccessful()
and endTransaction()
.
They're also available through Room using runInTransaction()
. Migrate these
usages to SQLite Driver APIs.
Support SQLite (from)
Perform a transaction (using RoomDatabase
)
val database: RoomDatabase = ...
database.runInTransaction {
// perform database operations in transaction
}
Perform a transaction (using SupportSQLiteDatabase
)
val database: SupportSQLiteDatabase = ...
database.beginTransaction()
try {
// perform database operations in transaction
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
SQLite Driver (to)
Perform a transaction (using RoomDatabase
)
val database: RoomDatabase = ...
database.useWriterConnection { transactor ->
transactor.immediateTransaction {
// perform database operations in transaction
}
}
Perform a transaction (using SQLiteConnection
)
val connection: SQLiteConnection = ...
connection.execSQL("BEGIN IMMEDIATE TRANSACTION")
try {
// perform database operations in transaction
connection.execSQL("END TRANSACTION")
} catch(t: Throwable) {
connection.execSQL("ROLLBACK TRANSACTION")
}
Various callback override also need to be migrated to their driver counterparts:
Support SQLite (from)
Migration subclasses
object Migration_1_2 : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// ...
}
}
Auto migration specification subclasses
class AutoMigrationSpec_1_2 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
// ...
}
}
Database callback subclasses
object MyRoomCallback : RoomDatabase.Callback {
override fun onCreate(db: SupportSQLiteDatabase) {
// ...
}
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
// ...
}
override fun onOpen(db: SupportSQLiteDatabase) {
// ...
}
}
SQLite Driver (to)
Migration subclasses
object Migration_1_2 : Migration(1, 2) {
override fun migrate(connection: SQLiteConnection) {
// ...
}
}
Auto migration specification subclasses
class AutoMigrationSpec_1_2 : AutoMigrationSpec {
override fun onPostMigrate(connection: SQLiteConnection) {
// ...
}
}
Database callback subclasses
object MyRoomCallback : RoomDatabase.Callback {
override fun onCreate(connection: SQLiteConnection) {
// ...
}
override fun onDestructiveMigration(connection: SQLiteConnection) {
// ...
}
override fun onOpen(connection: SQLiteConnection) {
// ...
}
}
To summarize, replace usages of SQLiteDatabase
, with SQLiteConnection
when a
RoomDatabase
is not available, such as in callback overrides (onMigrate
,
onCreate
, etc). If a RoomDatabase
is available, then access the underlying
database connection using RoomDatabase.useReaderConnection
and
RoomDatabase.useWriterConnection
instead of
RoomDatabase.openHelper.writtableDatabase
.
Convert blocking DAO functions to suspend functions
The KMP version of Room relies on coroutines to perform I/O
operations on the configured CoroutineContext
. This means that you
need to migrate any blocking DAO functions to suspend functions.
Blocking DAO function (from)
@Query("SELECT * FROM Todo")
fun getAllTodos(): List<Todo>
Suspending DAO function (to)
@Query("SELECT * FROM Todo")
suspend fun getAllTodos(): List<Todo>
Migrating existing DAO blocking functions to suspend functions can be complicated if the existing codebase does not already incorporate coroutines. Refer to Coroutines in Android to get started on using coroutines in your codebase.
Convert reactive return types to Flow
Not all DAO functions need to be suspend functions. DAO functions that return
reactive types such as LiveData
or RxJava's Flowable
shouldn't be converted
to suspend functions. Some types, however, such as LiveData
are not KMP
compatible. DAO functions with reactive return types must be migrated to
coroutine flows.
Incompatible KMP type (from)
@Query("SELECT * FROM Todo")
fun getTodosLiveData(): LiveData<List<Todo>>
Compatible KMP type (to)
@Query("SELECT * FROM Todo")
fun getTodosFlow(): Flow<List<Todo>>
Refer to Flows in Android to get started on using Flows in your codebase.
Set a Coroutine context (Optional)
A RoomDatabase
can optionally be configured with shared application
executors using RoomDatabase.Builder.setQueryExecutor()
to perform database
operations. Since executors are not KMP compatible, Room's setQueryExecutor()
API is not available for common sources. Instead the RoomDatabase
must
be configured with a CoroutineContext
. A context can be set using
RoomDatabase.Builder.setCoroutineContext()
, if none is set then the
RoomDatabase
will default to using Dispatchers.IO
.
Set a SQLite Driver
Once Support SQLite usages have been migrated to SQLite Driver APIs then a
driver has to be configured using RoomDatabase.Builder.setDriver
. The
recommended driver is BundledSQLiteDriver
. See Driver implementations for
descriptions of available driver implementations.
Custom SupportSQLiteOpenHelper.Factory
configured using
RoomDatabase.Builder.openHelperFactory()
are not support in KMP, the
features provided by the custom open helper will need to be re-implemented with
SQLite Driver interfaces.
Move Room declarations
Once most of the migrations steps are completed, one can move the Room
definitions to a common source set. Note that expect
/ actual
strategies can
be used to incrementally move Room related definitions. For example, if not all
blocking DAO functions can be migrated to suspend functions, it is possible to
declare an expect
@Dao
annotated interface that is empty in common code, but
contains blocking functions in Android.
// shared/src/commonMain/kotlin/Database.kt
@Database(entities = [TodoEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun getDao(): TodoDao
abstract fun getBlockingDao(): BlockingTodoDao
}
@Dao
interface TodoDao {
@Query("SELECT count(*) FROM TodoEntity")
suspend fun count(): Int
}
@Dao
expect interface BlockingTodoDao
// shared/src/androidMain/kotlin/BlockingTodoDao.kt
@Dao
actual interface BlockingTodoDao {
@Query("SELECT count(*) FROM TodoEntity")
fun count(): Int
}