Room (Kotlin multiplataforma)

A biblioteca de persistência Room oferece uma camada de abstração sobre o SQLite para permitir um acesso mais robusto ao banco de dados, aproveitando toda a capacidade do SQLite. Esta página se concentra no uso do Room em projetos Kotlin Multiplatform (KMP). Para mais informações sobre como usar o Room, consulte Salvar dados em um banco de dados local usando o Room ou nossas amostras oficiais.

Configurar dependências

Para configurar o Room no seu projeto KMP, adicione as dependências dos artefatos no arquivo build.gradle.kts do módulo KMP.

Defina as dependências no arquivo libs.versions.toml:

[versions]
room = "2.7.2"
sqlite = "2.5.2"
ksp = "<kotlinCompatibleKspVersion>"

[libraries]
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }

# Optional SQLite Wrapper available in version 2.8.0 and higher
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room = { id = "androidx.room", version.ref = "room" }

Adicione o plug-in do Gradle para Room para configurar esquemas do Room e o plug-in KSP.

plugins {
  alias(libs.plugins.ksp)
  alias(libs.plugins.androidx.room)
}

Adicione a dependência de tempo de execução do Room e a biblioteca SQLite agrupada:

commonMain.dependencies {
  implementation(libs.androidx.room.runtime)
  implementation(libs.androidx.sqlite.bundled)
}

// Optional when using Room SQLite Wrapper
androidMain.dependencies {
  implementation(libs.androidx.room.sqlite.wrapper)
}

Adicione as dependências do KSP ao bloco dependencies root. Adicione todas as metas usadas pelo app. Para mais informações, consulte KSP com Kotlin Multiplatform.

dependencies {
    add("kspAndroid", libs.androidx.room.compiler)
    add("kspIosSimulatorArm64", libs.androidx.room.compiler)
    add("kspIosX64", libs.androidx.room.compiler)
    add("kspIosArm64", libs.androidx.room.compiler)
    // Add any other platform target you use in your project, for example kspDesktop
}

Defina o diretório do esquema do Room. Para mais informações, consulte Definir o local do esquema usando o plug-in do Gradle para Room.

room {
    schemaDirectory("$projectDir/schemas")
}

Definir as classes de banco de dados

Você precisa criar uma classe de banco de dados com a anotação @Database, além de DAOs e entidades no conjunto de origem comum do módulo compartilhado do KMP. Colocar essas classes em fontes comuns permite que elas sejam compartilhadas em todas as plataformas de destino.

// shared/src/commonMain/kotlin/Database.kt

@Database(entities = [TodoEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun getDao(): TodoDao
}

// The Room compiler generates the `actual` implementations.
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

Quando você declara um objeto expect com a interface RoomDatabaseConstructor, o compilador do Room gera as implementações de actual. O Android Studio pode emitir o seguinte aviso, que pode ser suprimido com @Suppress("KotlinNoActualForExpect"):

Expected object 'AppDatabaseConstructor' has no actual declaration in module`

Em seguida, defina uma nova interface DAO ou mova uma existente para commonMain:

// shared/src/commonMain/kotlin/TodoDao.kt

@Dao
interface TodoDao {
  @Insert
  suspend fun insert(item: TodoEntity)

  @Query("SELECT count(*) FROM TodoEntity")
  suspend fun count(): Int

  @Query("SELECT * FROM TodoEntity")
  fun getAllAsFlow(): Flow<List<TodoEntity>>
}

Defina ou mova suas entidades para commonMain:

// shared/src/commonMain/kotlin/TodoEntity.kt

@Entity
data class TodoEntity(
  @PrimaryKey(autoGenerate = true) val id: Long = 0,
  val title: String,
  val content: String
)

Criar o criador de banco de dados específico da plataforma

Você precisa definir um criador de banco de dados para instanciar o Room em cada plataforma. Essa é a única parte da API que precisa estar em conjuntos de origem específicos da plataforma devido às diferenças nas APIs do sistema de arquivos.

Android

No Android, o local do banco de dados geralmente é obtido pela API Context.getDatabasePath(). Para criar a instância do banco de dados, especifique um Context junto com o caminho do banco de dados.

// shared/src/androidMain/kotlin/Database.android.kt

fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
  val appContext = context.applicationContext
  val dbFile = appContext.getDatabasePath("my_room.db")
  return Room.databaseBuilder<AppDatabase>(
    context = appContext,
    name = dbFile.absolutePath
  )
}

iOS

Para criar a instância de banco de dados no iOS, forneça um caminho de banco de dados usando o NSFileManager, geralmente localizado em NSDocumentDirectory.

// shared/src/iosMain/kotlin/Database.ios.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFilePath = documentDirectory() + "/my_room.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFilePath,
    )
}

private fun documentDirectory(): String {
  val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
    directory = NSDocumentDirectory,
    inDomain = NSUserDomainMask,
    appropriateForURL = null,
    create = false,
    error = null,
  )
  return requireNotNull(documentDirectory?.path)
}

JVM (computador)

Para criar a instância do banco de dados, forneça um caminho usando APIs Java ou Kotlin.

// shared/src/jvmMain/kotlin/Database.desktop.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFile = File(System.getProperty("java.io.tmpdir"), "my_room.db")
    return Room.databaseBuilder<AppDatabase>(
        name = dbFile.absolutePath,
    )
}

Instanciar o banco de dados

Depois de receber o RoomDatabase.Builder de um dos construtores específicos da plataforma, é possível configurar o restante do banco de dados Room em um código comum junto com a instanciação real do banco de dados.

// shared/src/commonMain/kotlin/Database.kt

fun getRoomDatabase(
    builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
  return builder
      .setDriver(BundledSQLiteDriver())
      .setQueryCoroutineContext(Dispatchers.IO)
      .build()
}

Selecionar um driver do SQLite

O snippet de código anterior chama a função de builder setDriver para definir qual driver SQLite o banco de dados Room deve usar. Esses drivers variam de acordo com a plataforma de destino. Os snippets de código anteriores usam o BundledSQLiteDriver. Esse é o driver recomendado, que inclui o SQLite compilado da origem e fornece a versão mais consistente e atualizada do SQLite em todas as plataformas.

Se você quiser usar o SQLite fornecido pelo SO, use a API setDriver nos conjuntos de origem específicos da plataforma que especificam um driver específico da plataforma. Consulte Implementações de driver para descrições das implementações de driver disponíveis. Você pode usar uma das seguintes opções:

Para usar NativeSQLiteDriver, forneça uma opção de vinculador -lsqlite3 para que o app iOS seja vinculado dinamicamente ao SQLite do sistema.

// shared/build.gradle.kts

kotlin {
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "TodoApp"
            isStatic = true
            // Required when using NativeSQLiteDriver
            linkerOpts.add("-lsqlite3")
        }
    }
}

Definir um contexto de corrotina (opcional)

Um objeto RoomDatabase no Android 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 o KMP, a API setQueryExecutor() do Room não está disponível em commonMain. Em vez disso, o objeto RoomDatabase precisa ser configurado com um CoroutineContext, que pode ser definido usando RoomDatabase.Builder.setCoroutineContext(). Se nenhum contexto for definido, o objeto RoomDatabase vai usar Dispatchers.IO por padrão.

Minificação e ofuscação

Se o projeto for minificado ou ofuscado, inclua a seguinte regra do ProGuard para que o Room possa encontrar a implementação gerada da definição do banco de dados:

-keep class * extends androidx.room.RoomDatabase { <init>(); }

Migrar para o Kotlin Multiplatform

O Room foi originalmente desenvolvido como uma biblioteca do Android e depois migrado para KMP com foco na compatibilidade da API. A versão KMP do Room difere um pouco entre plataformas e da versão específica do Android. Essas diferenças são listadas e descritas da seguinte forma.

Migrar do Support SQLite para o driver SQLite

Todos os usos de SupportSQLiteDatabase e outras APIs em androidx.sqlite.db precisam ser refatorados com as APIs do driver SQLite, porque as APIs em androidx.sqlite.db são exclusivas do Android. Observe o pacote diferente do pacote KMP.

Para garantir a compatibilidade com versões anteriores e desde que o RoomDatabase esteja configurado com um SupportSQLiteOpenHelper.Factory (por exemplo, nenhum SQLiteDriver está definido), o Room se comporta no "modo de compatibilidade", em que as APIs do Support SQLite e do SQLite Driver funcionam como esperado. Isso permite migrações incrementais para que não seja necessário converter todos os usos do Support SQLite para o driver SQLite em uma única mudança.

Converter subclasses de migrações

As subclasses de migrações precisam ser migradas para as contrapartes de driver do SQLite:

Kotlin Multiplatform

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) {
    // …
  }
}

Somente Android

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) {
    // …
  }
}

Callback de conversão do banco de dados

Os callbacks de banco de dados precisam ser migrados para as contrapartes do driver SQLite:

Kotlin Multiplatform

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

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

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

Somente Android

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

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

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

Converter funções DAO @RawQuery

As funções anotadas com @RawQuery e compiladas para plataformas que não são Android precisam declarar um parâmetro do tipo RoomRawQuery em vez de SupportSQLiteQuery.

Kotlin Multiplatform

Definir a consulta bruta

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: RoomRawQuery): List<TodoEntity>
}

Um RoomRawQuery pode ser usado para criar uma consulta no tempo de execução:

suspend fun AppDatabase.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
    val query = RoomRawQuery(
        sql = "SELECT * FROM TodoEntity WHERE title = ?",
        onBindStatement = {
            it.bindText(1, title.lowercase())
        }
    )

    return todoDao().getTodos(query)
}

Somente Android

Definir a consulta bruta

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: SupportSQLiteQuery): List<TodoEntity>
}

Um SimpleSQLiteQuery pode ser usado para criar uma consulta no tempo de execução:

suspend fun AndroidOnlyDao.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
  val query = SimpleSQLiteQuery(
      query = "SELECT * FROM TodoEntity WHERE title = ?",
      bindArgs = arrayOf(title.lowercase())
  )
  return getTodos(query)
}

Converter funções DAO de bloqueio

O Room se beneficia da biblioteca assíncrona kotlinx.coroutines rica em recursos que o Kotlin oferece para várias plataformas. Para uma funcionalidade ideal, as funções suspend são aplicadas a DAOs compilados em um projeto KMP, exceto DAOs implementados em androidMain para manter a compatibilidade com versões anteriores do código-fonte atual. Ao usar o Room para KMP, todas as funções de DAO compiladas para plataformas não Android precisam ser funções suspend.

Kotlin Multiplatform

Como suspender consultas

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

Suspender transações

@Transaction
suspend fun transaction() {  }

Somente Android

Consultas de bloqueio

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

Bloqueio de transações

@Transaction
fun blockingTransaction() {  }

Converter tipos reativos em Flow

Nem todas as funções de DAO precisam ser funções de suspensão. Funções DAO que retornam tipos reativos, como LiveData ou Flowable do RxJava, não devem ser convertidas em 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.

Kotlin Multiplatform

Tipos reativos Flows

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

Somente Android

Tipos reativos, como LiveData ou Flowable do RxJava

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

Converter APIs Transaction

As APIs de transação de banco de dados para KMP do Room podem diferenciar entre transações de gravação (useWriterConnection) e leitura (useReaderConnection).

Kotlin Multiplatform

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

Somente Android

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

Gravar transações

Use transações de gravação para garantir que várias consultas gravem dados atomicamente, para que os leitores possam acessar os dados de forma consistente. Para isso, use useWriterConnection com qualquer um dos três tipos de transação:

  • immediateTransaction: no modo registro prévio de escrita (WAL) (padrão), esse tipo de transação adquire um bloqueio quando começa, mas os leitores podem continuar lendo. Essa é a opção preferida na maioria dos casos.

  • deferredTransaction: a transação não vai adquirir um bloqueio até a primeira instrução de gravação. Use esse tipo de transação como uma otimização quando não tiver certeza se uma operação de gravação será necessária dentro da transação. Por exemplo, se você iniciar uma transação para excluir músicas de uma playlist usando apenas o nome dela, e ela não existir, nenhuma operação de gravação (exclusão) será necessária.

  • exclusiveTransaction: esse modo se comporta de maneira idêntica a immediateTransaction no modo WAL. Em outros modos de registro em diário, ele impede que outras conexões de banco de dados leiam o banco enquanto a transação está em andamento.

Ler transações

Use transações de leitura para ler do banco de dados várias vezes de forma consistente. Por exemplo, quando você tem duas ou mais consultas separadas e não usa uma cláusula JOIN. Somente transações adiadas são permitidas em conexões de leitura. A tentativa de iniciar uma transação imediata ou exclusiva em uma conexão de leitor vai gerar uma exceção, já que essas são consideradas operações de "gravação".

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

Não disponível no Kotlin Multiplatform

Algumas das APIs disponíveis para Android não estão disponíveis no Kotlin Multiplatform.

Callback de consulta

As APIs a seguir para configurar callbacks de consulta não estão disponíveis em comum e, portanto, não estão disponíveis em plataformas que não sejam o Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Pretendemos adicionar suporte para callback de consulta em uma versão futura do Room.

A API para configurar um RoomDatabase com um callback de consulta RoomDatabase.Builder.setQueryCallback e a interface de callback RoomDatabase.QueryCallback não estão disponíveis em comum e, portanto, não estão disponíveis em outras plataformas além do Android.

Banco de dados de fechamento automático

A API para ativar o fechamento automático após um tempo limite, RoomDatabase.Builder.setAutoCloseTimeout, está disponível apenas no Android e não em outras plataformas.

Pré-pacote de banco de dados

As APIs a seguir para criar um RoomDatabase usando um banco de dados existente (ou seja, um banco de dados pré-empacotado) não estão disponíveis em comum e, portanto, não estão disponíveis em outras plataformas além do Android. Estas são as APIs:

  • RoomDatabase.Builder.createFromAsset
  • RoomDatabase.Builder.createFromFile
  • RoomDatabase.Builder.createFromInputStream
  • RoomDatabase.PrepackagedDatabaseCallback

Pretendemos adicionar suporte a bancos de dados pré-empacotados em uma versão futura do Room.

Invalidação de várias instâncias

A API para ativar a invalidação de várias instâncias, RoomDatabase.Builder.enableMultiInstanceInvalidation, está disponível apenas no Android e não em plataformas comuns ou outras.