Configurare Room Database per KMP

La libreria di persistenza Room fornisce un livello di astrazione su SQLite per consentire un accesso più affidabile al database sfruttando al contempo tutta la potenza di SQLite. Questa pagina è incentrata sull'utilizzo di Room nei progetti Kotlin Multiplatform (KMP). Per ulteriori informazioni sull'utilizzo di Room, consulta Salvare i dati in un database locale utilizzando Room o i nostri esempi ufficiali.

Configurare le dipendenze

Per configurare Room nel tuo progetto KMP, aggiungi le dipendenze per gli artefatti nel file build.gradle.kts per il tuo modulo KMP.

Definisci le dipendenze nel file libs.versions.toml:

[versions]
room = "2.8.4"
sqlite = "2.6.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" }

Aggiungi il plug-in Gradle di Room per configurare gli schemi di Room e il plug-in KSP.

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

Aggiungi la dipendenza di runtime di Room e la libreria SQLite in bundle:

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)
}

Aggiungi le dipendenze KSP al blocco dependencies root. Tieni presente che devi aggiungere tutti i target utilizzati dalla tua app. Per ulteriori informazioni, consulta KSP con 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
}

Definisci la directory dello schema di Room. Per ulteriori informazioni, consulta Impostare la posizione dello schema utilizzando il plug-in Gradle di Room.

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

Definire le classi di database

Devi creare una classe di database annotata con @Database insieme a DAO ed entità all'interno del set di risorse comune del modulo KMP condiviso. Se inserisci queste classi nelle origini comuni, potrai condividerle su tutte le piattaforme di destinazione.

// 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 dichiari un oggetto expect con l'interfaccia RoomDatabaseConstructor, il compilatore Room genera le implementazioni actual. Android Studio potrebbe emettere il seguente avviso, che puoi sopprimere con @Suppress("KotlinNoActualForExpect"):

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

Successivamente, definisci una nuova interfaccia DAO o sposta una esistente in 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>>
}

Definisci o sposta le entità in commonMain:

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

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

Creare il builder di database specifico della piattaforma

Devi definire un builder di database per creare un'istanza di Room su ogni piattaforma. Questa è l'unica parte dell'API che deve trovarsi nei set di origine specifici della piattaforma a causa delle differenze nelle API del file system.

Android

Su Android, la posizione del database viene in genere ottenuta tramite l' Context.getDatabasePath() API. Per creare l'istanza del database, specifica un Context insieme al percorso del database.

// 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

Per creare l'istanza del database su iOS, fornisci un percorso del database utilizzando il NSFileManager, in genere situato in 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 (desktop)

Per creare l'istanza del database, fornisci un percorso del database utilizzando le API Java o 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,
    )
}

Creare un'istanza del database

Una volta ottenuto RoomDatabase.Builder da uno dei costruttori specifici della piattaforma, puoi configurare il resto del database Room nel codice comune insieme alla creazione dell'istanza del database.

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

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

Selezionare un driver SQLite

Lo snippet di codice precedente chiama la funzione del builder setDriver per definire il driver SQLite che deve utilizzare il database Room. Questi driver variano in base alla piattaforma di destinazione. Gli snippet di codice precedenti utilizzano BundledSQLiteDriver. Questo è il driver consigliato che include SQLite compilato dall'origine, che fornisce la versione più coerente e aggiornata di SQLite su tutte le piattaforme.

Se vuoi utilizzare SQLite fornito dal sistema operativo, utilizza l'API setDriver nei set di origine specifici della piattaforma che specificano un driver specifico della piattaforma. Consulta Implementazioni dei driver per le descrizioni delle implementazioni dei driver disponibili. Puoi utilizzare una delle seguenti opzioni:

Per utilizzare NativeSQLiteDriver, devi fornire un'opzione del linker -lsqlite3 in modo che l'app iOS si colleghi dinamicamente a SQLite di 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")
        }
    }
}

Impostare un contesto di coroutine (facoltativo)

Un oggetto RoomDatabase su Android può essere configurato facoltativamente con executor di applicazioni condivise utilizzando RoomDatabase.Builder.setQueryExecutor() per eseguire operazioni di database.

Poiché gli executor non sono compatibili con KMP, l'API setQueryExecutor() di Room non è disponibile in commonMain. L'oggetto RoomDatabase deve invece essere configurato con un CoroutineContext, che può essere impostato utilizzando RoomDatabase.Builder.setCoroutineContext(). Se non viene impostato alcun contesto, l'oggetto RoomDatabase utilizzerà per impostazione predefinita Dispatchers.IO.

Minimizzazione e offuscamento

Se il progetto è minimizzato o offuscato, devi includere la seguente regola ProGuard in modo che Room possa trovare l'implementazione generata della definizione del database:

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

Eseguire la migrazione a Kotlin Multiplatform

Room è stato sviluppato originariamente come libreria Android e successivamente è stato migrato a KMP con un'attenzione particolare alla compatibilità delle API. La versione KMP di Room è leggermente diversa tra le piattaforme e dalla versione specifica di Android. Queste differenze sono elencate e descritte di seguito.

Eseguire la migrazione da Support SQLite a SQLite Driver

Tutti gli utilizzi di SupportSQLiteDatabase e di altre API in androidx.sqlite.db devono essere sottoposti a refactoring con le API SQLite Driver, perché le API in androidx.sqlite.db sono solo per Android (nota il pacchetto diverso dal pacchetto KMP).

Per la compatibilità con le versioni precedenti e finché RoomDatabase è configurato con un SupportSQLiteOpenHelper.Factory (ad esempio, non è impostato alcun SQLiteDriver), Room si comporta in "modalità di compatibilità" in cui le API Support SQLite e SQLite Driver funzionano come previsto. In questo modo è possibile eseguire migrazioni incrementali, in modo da non dover convertire tutti gli utilizzi di Support SQLite in SQLite Driver in un'unica modifica.

Utilizzare il wrapper SQLite di Room (facoltativo)

L'artefatto androidx.room:room-sqlite-wrapper fornisce API per colmare il divario tra SQLiteDriver e SupportSQLiteDatabase durante la migrazione.

Per ottenere un SupportSQLiteDatabase da un RoomDatabase configurato con un SQLiteDriver, utilizza la nuova funzione di estensione RoomDatabase.getSupportWrapper(). Questo wrapper di compatibilità aiuta a mantenere gli utilizzi esistenti di SupportSQLiteDatabase (spesso ottenuti da RoomDatabase.openHelper.writableDatabase) durante l'adozione di SQLiteDriver, in particolare per i codebase con un utilizzo esteso dell'API SupportSQLite che vogliono utilizzare BundledSQLiteDriver.

Convertire le sottoclassi di migrazioni

Le sottoclassi di migrazioni devono essere migrate alle controparti di SQLite Driver:

Kotlin Multiplatform

Sottoclassi di migrazioni

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

Sottoclassi di specifiche di migrazione automatica

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

Solo per Android

Sottoclassi di migrazioni

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

Sottoclassi di specifiche di migrazione automatica

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

Convertire il callback del database

I callback del database devono essere migrati alle controparti di SQLite Driver:

Kotlin Multiplatform

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

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

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

Solo per Android

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

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

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

Convertire le funzioni DAO @RawQuery

Le funzioni annotate con @RawQuery compilate per piattaforme non Android dovranno dichiarare un parametro di tipo RoomRawQuery anziché SupportSQLiteQuery.

Kotlin Multiplatform

Definire la query non elaborata

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

È quindi possibile utilizzare un RoomRawQuery per creare una query in fase di runtime:

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)
}

Solo per Android

Definire la query non elaborata

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

È quindi possibile utilizzare un SimpleSQLiteQuery per creare una query in fase di runtime:

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

Convertire le funzioni DAO di blocco

Room sfrutta la libreria asincrona kotlinx.coroutines ricca di funzionalità che Kotlin offre per più piattaforme. Per una funzionalità ottimale, le funzioni suspend vengono applicate ai DAO compilati in un progetto KMP, ad eccezione dei DAO implementati in androidMain per mantenere la compatibilità con le versioni precedenti del codebase esistente. Quando utilizzi Room per KMP, tutte le funzioni DAO compilate per piattaforme non Android devono essere funzioni suspend.

Kotlin Multiplatform

Sospensione delle query

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

Sospensione delle transazioni

@Transaction
suspend fun transaction() {  }

Solo per Android

Blocco delle query

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

Blocco delle transazioni

@Transaction
fun blockingTransaction() {  }

Convertire i tipi reattivi in Flow

Non tutte le funzioni DAO devono essere funzioni di sospensione. Le funzioni DAO che restituiscono tipi reattivi come LiveData o Flowable di RxJava non devono essere convertite in funzioni di sospensione. Alcuni tipi, tuttavia, come LiveData, non sono compatibili con KMP. Le funzioni DAO con tipi di restituzione reattivi devono essere migrate ai flussi di coroutine.

Kotlin Multiplatform

Tipi reattivi Flows

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

Solo per Android

Tipi reattivi come LiveData o Flowable di RxJava

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

Convertire le API delle transazioni

Le API delle transazioni di database per Room KMP possono distinguere tra transazioni di scrittura (useWriterConnection) e di lettura (useReaderConnection).

Kotlin Multiplatform

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

Solo per Android

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

Transazioni di scrittura

Utilizza le transazioni di scrittura per assicurarti che più query scrivano i dati in modo atomico, in modo che i lettori possano accedere ai dati in modo coerente. Puoi farlo utilizzando useWriterConnection con uno dei tre tipi di transazione:

  • immediateTransaction: in modalità Write-Ahead Logging (WAL) (impostazione predefinita), questo tipo di transazione acquisisce un blocco all'avvio, ma i lettori possono continuare a leggere. Questa è la scelta preferita per la maggior parte dei casi.

  • deferredTransaction: la transazione non acquisisce un blocco fino alla prima istruzione di scrittura. Utilizza questo tipo di transazione come ottimizzazione quando non sei sicuro se sarà necessaria un'operazione di scrittura all'interno della transazione. Ad esempio, se avvii una transazione per eliminare i brani da una playlist dato solo il nome della playlist e la playlist non esiste, non è necessaria alcuna operazione di scrittura (eliminazione).

  • exclusiveTransaction: questa modalità si comporta in modo identico a immediateTransaction in modalità WAL. In altre modalità di journaling, impedisce ad altre connessioni al database di leggere il database durante la transazione.

Transazioni di lettura

Utilizza le transazioni di lettura per leggere più volte in modo coerente dal database. Ad esempio, quando hai due o più query separate e non utilizzi una clausola JOIN. Nelle connessioni di lettura sono consentite solo le transazioni differite. Il tentativo di avviare una transazione immediata o esclusiva in una connessione di lettura genererà un'eccezione, in quanto queste sono considerate operazioni di "scrittura".

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

Non disponibile in Kotlin Multiplatform

Alcune delle API disponibili per Android non sono disponibili in Kotlin Multiplatform.

Callback delle query

Le seguenti API per la configurazione dei callback delle query non sono disponibili in common e quindi non sono disponibili su piattaforme diverse da Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

Abbiamo in programma di aggiungere il supporto per il callback delle query in una versione futura di Room.

L'API per configurare un RoomDatabase con un callback delle query RoomDatabase.Builder.setQueryCallback insieme all'interfaccia di callback RoomDatabase.QueryCallback non è disponibile in common e quindi non è disponibile su altre piattaforme diverse da Android.

Chiusura automatica del database

L'API per abilitare la chiusura automatica dopo un timeout, RoomDatabase.Builder.setAutoCloseTimeout, è disponibile solo su Android e non è disponibile su altre piattaforme.

Database precompilato

Le seguenti API per creare un RoomDatabase utilizzando un database esistente (ovvero un database precompilato) non sono disponibili in common e quindi non sono disponibili su altre piattaforme diverse da Android. Queste API sono:

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

Abbiamo in programma di aggiungere il supporto per i database precompilati in una versione futura di Room.

Invalidazione multi-istanza

L'API per abilitare l'invalidazione multi-istanza, RoomDatabase.Builder.enableMultiInstanceInvalidation, è disponibile solo su Android e non è disponibile in common o su altre piattaforme.