Migrar o Room para o Kotlin Multiplaform

Este documento descreve como migrar uma implementação do Room para uma que usa o Kotlin Multiplatform (KMP).

A migração dos usos do Room em uma base de código do Android para um módulo KMP compartilhado pode variar muito, dependendo das APIs do Room usadas ou se a base de código já usa corrotinas. Esta seção oferece algumas orientações e dicas para tentar migrar os usos do Room para um módulo comum.

É importante se familiarizar primeiro com as diferenças e os recursos que estão faltando entre as versões do Room e do KMP para Android, além da configuração envolvida. Basicamente, uma migração bem-sucedida envolve a refatoração do uso das APIs SupportSQLite* e a substituição delas por APIs SQLite Driver, além da movimentação de declarações do Room (classe anotada @Database, DAOs, entidades e assim por diante) para um código comum.

Consulte as seguintes informações antes de continuar:

As próximas seções descrevem as várias etapas necessárias para uma migração bem-sucedida.

Migrar do driver SQLite para SQLite

As APIs em androidx.sqlite.db são apenas do Android, e qualquer uso precisa ser refatorado com APIs de driver SQLite. Para compatibilidade com versões anteriores, e desde que o RoomDatabase esteja configurado com um SupportSQLiteOpenHelper.Factory (ou seja, nenhum SQLiteDriver esteja definido), o Room se comporta no "modo de compatibilidade" em que as APIs de driver SQLite e SQLite funcionam conforme o esperado. Isso permite migrações incrementais para que você não precise converter todos os usos do Support SQLite para o driver SQLite em uma única mudança.

Os exemplos abaixo são usos comuns do Support SQLite e dos correspondentes do driver SQLite:

Compatibilidade com SQLite (de)

Executar uma consulta sem resultado

val database: SupportSQLiteDatabase = ...
database.execSQL("ALTER TABLE ...")

Executar uma consulta com o resultado, mas sem argumentos

val database: SupportSQLiteDatabase = ...
database.query("SELECT * FROM Pet").use { cursor ->
  while (cusor.moveToNext()) {
    // read columns
    cursor.getInt(0)
    cursor.getString(1)
  }
}

Executar uma consulta com resultado e argumentos

database.query("SELECT * FROM Pet WHERE id = ?", id).use { cursor ->
  if (cursor.moveToNext()) {
    // row found, read columns
  } else {
    // row not found
  }
}

Driver SQLite (para)

Executar uma consulta sem resultado

val connection: SQLiteConnection = ...
connection.execSQL("ALTER TABLE ...")

Executar uma consulta com o resultado, mas sem argumentos

val connection: SQLiteConnection = ...
connection.prepare("SELECT * FROM Pet").use { statement ->
  while (statement.step()) {
    // read columns
    statement.getInt(0)
    statement.getText(1)
  }
}

Executar uma consulta com resultado e argumentos

connection.prepare("SELECT * FROM Pet WHERE id = ?").use { statement ->
  statement.bindInt(1, id)
  if (statement.step()) {
    // row found, read columns
  } else {
    // row not found
  }
}

As APIs de transação do banco de dados estão disponíveis diretamente em SupportSQLiteDatabase com beginTransaction(), setTransactionSuccessful() e endTransaction(). Elas também estão disponíveis no Room usando o runInTransaction(). Migre esses usos para as APIs de driver do SQLite.

Compatibilidade com SQLite (de)

Realizar uma transação (usando RoomDatabase)

val database: RoomDatabase = ...
database.runInTransaction {
  // perform database operations in transaction
}

Realizar uma transação (usando SupportSQLiteDatabase)

val database: SupportSQLiteDatabase = ...
database.beginTransaction()
try {
  // perform database operations in transaction
  database.setTransactionSuccessful()
} finally {
  database.endTransaction()
}

Driver SQLite (para)

Realizar uma transação (usando RoomDatabase)

val database: RoomDatabase = ...
database.useWriterConnection { transactor ->
  transactor.immediateTransaction {
    // perform database operations in transaction
  }
}

Realizar uma transação (usando 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")
}

Também é necessário migrar várias substituições de callback para os drivers:

Compatibilidade com SQLite (de)

Subclasses de migração

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    // ...
  }
}

Subclasses de especificação de migração automática

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(db: SupportSQLiteDatabase) {
    // ...
  }
}

Subclasses de callback do banco de dados

object MyRoomCallback : RoomDatabase.Callback {
  override fun onCreate(db: SupportSQLiteDatabase) {
    // ...
  }

  override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
    // ...
  }

  override fun onOpen(db: SupportSQLiteDatabase) {
    // ...
  }
}

Driver SQLite (para)

Subclasses de migração

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(connection: SQLiteConnection) {
    // ...
  }
}

Subclasses de especificação de migração automática

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(connection: SQLiteConnection) {
    // ...
  }
}

Subclasses de callback do banco de dados

object MyRoomCallback : RoomDatabase.Callback {
  override fun onCreate(connection: SQLiteConnection) {
    // ...
  }

  override fun onDestructiveMigration(connection: SQLiteConnection) {
    // ...
  }

  override fun onOpen(connection: SQLiteConnection) {
    // ...
  }
}

Para resumir, substitua os usos de SQLiteDatabase por SQLiteConnection quando um RoomDatabase não estiver disponível, como em substituições de callback (onMigrate, onCreate etc.). Se um RoomDatabase estiver disponível, acesse a conexão do banco de dados usando RoomDatabase.useReaderConnection e RoomDatabase.useWriterConnection em vez de RoomDatabase.openHelper.writtableDatabase.

Converter funções DAO de bloqueio para suspender funções.

A versão KMP do Room depende de corrotinas para executar operações de E/S no CoroutineContext configurado. Isso significa que é necessário migrar todas as funções DAO de bloqueio para suspender funções.

Como bloquear a função DAO (de)

@Query("SELECT * FROM Todo")
fun getAllTodos(): List<Todo>

Suspendendo a função DAO (para)

@Query("SELECT * FROM Todo")
suspend fun getAllTodos(): List<Todo>

A migração de funções de bloqueio DAO para suspender funções poderá ser complicada se a base de código ainda não incorporar corrotinas. Consulte Corrotinas no Android para começar a usar corrotinas na sua base de código.

Converter tipos de retorno reativos para fluxo

Nem todas as funções DAO precisam ser de suspensão. As funções DAO que retornam tipos reativos, como LiveData ou Flowable do RxJava, não podem ser convertidas para funções de suspensão. No entanto, alguns tipos, como LiveData, não são compatíveis com KMP. As funções DAO com tipos de retorno reativos precisam ser migradas para fluxos de corrotinas.

Tipo de KMP incompatível (de)

@Query("SELECT * FROM Todo")
fun getTodosLiveData(): LiveData<List<Todo>>

Tipo de KMP compatível (para)

@Query("SELECT * FROM Todo")
fun getTodosFlow(): Flow<List<Todo>>

Consulte Fluxos no Android para começar a usar fluxos na sua base de código.

Definir um contexto de corrotina (opcional)

Opcionalmente, um RoomDatabase pode ser configurado com executores de aplicativos compartilhados usando RoomDatabase.Builder.setQueryExecutor() para realizar operações de banco de dados. Como os executores não são compatíveis com KMP, a API setQueryExecutor() do Room não está disponível para fontes comuns. Em vez disso, o RoomDatabase precisa ser configurado com um CoroutineContext. Um contexto pode ser definido usando RoomDatabase.Builder.setCoroutineContext(). Se nenhum contexto for definido, o RoomDatabase usará Dispatchers.IO por padrão.

Definir um driver SQLite

Depois que o suporte ao uso do SQLite foi migrado para as APIs de drivers SQLite, um driver precisa ser configurado usando RoomDatabase.Builder.setDriver. O driver recomendado é BundledSQLiteDriver. Consulte Implementações de driver para ver descrições das implementações de driver disponíveis.

O KMP não oferece suporte a SupportSQLiteOpenHelper.Factory personalizados configurados usando RoomDatabase.Builder.openHelperFactory(). Os recursos fornecidos pelo auxiliar aberto personalizado precisarão ser reimplementados com interfaces do driver SQLite.

Mover declarações de salas

Depois que a maioria das etapas de migração é concluída, é possível mover as definições do Room para um conjunto de origem comum. As estratégias expect / actual podem ser usadas para mover definições relacionadas ao Room de forma incremental. Por exemplo, se nem todas as funções DAO de bloqueio puderem ser migradas para funções de suspensão, é possível declarar uma interface com a anotação expect @Dao vazia no código comum, mas contendo funções de bloqueio no 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
}