Enregistrer des données dans une base de données locale à l'aide de Room 2.x

Si votre application gère des quantités importantes de données structurées, vous pouvez bénéficier d'une persistance locale de ces données. Le cas d'utilisation le plus courant consiste à mettre en cache les éléments de données pertinents de sorte que, lorsque l'appareil ne peut pas accéder au réseau, vous puissiez continuer à parcourir ce contenu hors connexion.

La bibliothèque de persistance Room fournit une couche d'abstraction sur SQLite afin de vous permettre d'accéder à la base de données de manière fluide, tout en exploitant toute la puissance de SQLite.

Configurer Room 2.x

Pour utiliser Room 2.x dans votre application, ajoutez les dépendances suivantes au fichier build.gradle de votre application :

dependencies {
    val room_version = "2.6.1"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")

    // To use Kotlin Symbol Processing (KSP)
    // ksp("androidx.room:room-compiler:$room_version")

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")

    // optional - RxJava2 support for Room
    implementation("androidx.room:room-rxjava2:$room_version")

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation("androidx.room:room-guava:$room_version")

    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")
}

Composants principaux

Room comporte trois composants principaux :

  • Une classe de base de données qui contient la base de données et sert de point d'accès principal pour la connexion sous-jacente aux données persistantes de votre application.
  • Des entités de données qui représentent les tables de la base de données de votre application.
  • Des objets d'accès aux données (DAO) qui fournissent des méthodes que votre application peut utiliser pour interroger, mettre à jour, insérer et supprimer des données dans la base de données.

La figure 1 illustre la relation entre les différents composants de Room.

Figure 1. Schéma de l'architecture de la bibliothèque Room.

Exemple d'implémentation

// Entity
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

// DAO
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// Usage
val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

Définir des données à l'aide d'entités

Chaque entité Room représente une table dans la base de données. Vous définissez chaque entité en tant que classe annotée avec @Entity.

@Entity(tableName = "users")
data class User (
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Ignore val picture: Bitmap? = null
)
  • Noms de tables et de colonnes personnalisés : par défaut, Room utilise le nom de la classe comme nom de table et les noms de propriétés comme noms de colonnes. Pour les personnaliser, utilisez la propriété tableName dans @Entity et l'annotation @ColumnInfo(name = "...").
  • Clé primaire : pour définir une clé primaire, utilisez @PrimaryKey. Pour les clés composites, utilisez la propriété primaryKeys de @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • Ignorer des champs : pour empêcher la persistance des champs, utilisez @Ignore.

Convertisseurs de types

Vous devez parfois stocker des types personnalisés, tels que Date, dans une seule colonne. Fournissez des méthodes @TypeConverter pour convertir des types personnalisés en types que Room peut conserver et inversement.

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? = date?.time
}

// Register in your Database class
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

Accéder aux données à l'aide des DAO

Les DAO définissent des méthodes d'interaction avec la base de données. Annotez l'interface ou la classe abstraite avec @Dao.

Méthodes de base

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • Insérer : la méthode d'insertion peut renvoyer un Long représentant l'ID de la ligne insérée, ou une List<Long> contenant les ID de toutes les lignes insérées.
  • Mettre à jour ou supprimer : la méthode de mise à jour ou de suppression peut renvoyer un Int représentant le nombre de lignes affectées.

Méthodes de requête

Annotez les méthodes avec @Query pour écrire des instructions SQL. Room valide les requêtes au moment de la compilation.

@Dao
interface UserDao {
    // Simple query
    @Query("SELECT * FROM user")
    fun loadAllUsers(): Array<User>

    // Return a subset of columns using a POJO or tuple
    @Query("SELECT first_name, last_name FROM user")
    fun loadFullName(): List<NameTuple>

    // Pass parameters
    @Query("SELECT * FROM user WHERE age > :minAge")
    fun loadAllUsersOlderThan(minAge: Int): Array<User>

    // Collection of parameters
    @Query("SELECT * FROM user WHERE region IN (:regions)")
    fun loadUsersFromRegions(regions: List<String>): List<User>

    // Join tables
    @Query("SELECT * FROM book INNER JOIN user ON user.id = book.user_id WHERE user.name = :userName")
    fun findBooksBorrowedByName(userName: String): List<Book>
}

Types renvoyés en multimap

Dans Room 2.4 et les versions ultérieures, les méthodes de requête peuvent renvoyer directement un multimap à l'aide du type Map :

@Query("SELECT * FROM user JOIN book ON user.id = book.user_id")
fun loadUserAndBookNames(): Map<User, List<Book>>

Requêtes DAO asynchrones

Pour éviter les blocages de l'interface utilisateur, les requêtes de base de données ne peuvent pas s'exécuter sur le thread principal. Rendez vos requêtes asynchrones à l'aide de l'une des intégrations suivantes :

Coroutines et flux Kotlin

Nécessite la dépendance room-ktx.

@Dao
interface UserDao {
    // One-shot async query
    @Insert
    suspend fun insertUsers(vararg users: User)

    // Observable query using Flow
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flow<User>
}

Java avec RxJava

Nécessite room-rxjava2 ou room-rxjava3.

@Dao
interface UserDao {
    @Insert
    fun insertUsers(users: List<User>): Completable

    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flowable<User>
}

Java avec LiveData et Guava

Nécessite room-guava pour ListenableFuture.

@Dao
interface UserDao {
    // LiveData for observable queries
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): LiveData<User>

    // Guava ListenableFuture for one-shot queries
    @Insert
    fun insertUsers(users: List<User>): ListenableFuture<Integer>
}

Définir des relations dans Room 2.x

Pour éviter le chargement différé sur le thread UI, vous ne pouvez pas utiliser de références d'objet directes entre les entités. Définissez plutôt des relations à l'aide de classes de données intermédiaires avec @Relation.

Un à un

Chaque utilisateur ne possède qu'une seule bibliothèque.

@Entity
data class User(@PrimaryKey val userId: Long, val name: String)

@Entity
data class Library(@PrimaryKey val libraryId: Long, val userOwnerId: Long)

// Intermediate class
data class UserAndLibrary(
    @Embedded val user: User,
    @Relation(
         parentColumn = "userId",
         entityColumn = "userOwnerId"
    )
    val library: Library
)

// DAO Query
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

Un à plusieurs

Chaque utilisateur peut avoir plusieurs playlists.

@Entity
data class Playlist(@PrimaryKey val playlistId: Long, val userCreatorId: Long)

data class UserWithPlaylists(
    @Embedded val user: User,
    @Relation(
          parentColumn = "userId",
          entityColumn = "userCreatorId"
    )
    val playlists: List<Playlist>
)

Plusieurs à plusieurs

Les playlists peuvent contenir de nombreux titres, et les titres peuvent figurer dans de nombreuses playlists. Nécessite une table de jonction.

@Entity
data class Song(@PrimaryKey val songId: Long, val songName: String)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(val playlistId: Long, val songId: Long)

data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

Relations imbriquées

Interrogez les utilisateurs, leurs playlists et tous les titres de ces playlists.

data class UserWithPlaylistsAndSongs(
      @Embedded val user: User,
      @Relation(
          entity = Playlist::class,
          parentColumn = "userId",
          entityColumn = "userCreatorId"
      )
      val playlists: List<PlaylistWithSongs> // Nesting PlaylistWithSongs
  )

Gestion de bases de données

Cette section aborde différents aspects de la gestion de votre base de données Room, y compris les vues de base de données, le préremplissage des données et les migrations de base de données.

Vues de base de données

Encapsulez une requête complexe dans une classe annotée avec @DatabaseView.

@DatabaseView("SELECT user.id, user.name, department.name AS departmentName FROM user INNER JOIN department ON user.departmentId = department.id")
data class UserDetail(val id: Long, val name: String, val departmentName: String)

// Register in Database class
@Database(entities = [User::class], views = [UserDetail::class], version = 1)
abstract class AppDatabase : RoomDatabase() { ... }

Préremplir la base de données

Remplissez la base de données lors de l'initialisation à partir d'un fichier d'asset ou du système de fichiers.

Room.databaseBuilder(appContext, AppDatabase::class.java, "Sample.db")
    .createFromAsset("database/myapp.db")
    .build()

Migrations

Lorsque vous modifiez le schéma, incrémentez la version de la base de données et définissez un Migration objet.

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
  }
}

Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name")
  .addMigrations(MIGRATION_1_2)
  .build()
  • Migrations automatisées : si vous utilisez Room 2.4.0 ou une version ultérieure, vous pouvez utiliser @AutoMigration pour migrer automatiquement les modifications de schéma de base. Pour cela, vous devez définir exportSchema sur true dans la configuration de votre base de données : @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • Rétablissement destructif : si la perte de données est acceptable lorsque les chemins de migration sont manquants, appelez .fallbackToDestructiveMigration lors de la création de la base de données.

Tester les migrations

Pour vérifier les migrations, utilisez MigrationTestHelper à partir de l'artefact room-testing. Pour ce faire, assurez-vous d'exporter les schémas dans votre configuration build.gradle.

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    fun migrate1To2() {
        var db = helper.createDatabase("test-db", 1).apply {
            execSQL("INSERT INTO User VALUES (1, 'John')")
            close()
        }
        db = helper.runMigrationsAndValidate("test-db", 2, true, MIGRATION_1_2)
        // Verify data was migrated correctly
    }
}

Effectuer une migration de SQLite vers Room

Pour migrer votre application de SQLite vers Room, procédez comme suit :

  1. Mettez à jour les dépendances pour inclure Room.
  2. Annotez les classes de modèle avec @Entity, @PrimaryKey et @ColumnInfo.
  3. Créez des DAO pour remplacer vos méthodes de requête d'assistance.
  4. Créez une classe RoomDatabase référençant vos entités et vos DAO. Incrémentez le numéro de version.
  5. Définissez un chemin de migration vide , car seul le framework change, et non le schéma : kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. Mettez à jour l'instanciation pour utiliser Room.databaseBuilder avec le chemin de migration.