Zapisywanie danych w lokalnej bazie danych za pomocą biblioteki Room w wersji 2.x

Jeśli Twoja aplikacja przetwarza duże ilości uporządkowanych danych, możesz korzystać z lokalnego przechowywania tych danych. Najczęstszym zastosowaniem jest przechowywanie w pamięci podręcznej odpowiednich fragmentów danych, aby użytkownik mógł przeglądać treści w trybie offline, gdy urządzenie nie ma dostępu do sieci.

Biblioteka trwałości danych Room zapewnia warstwę abstrakcji nad SQLite, aby umożliwić płynny dostęp do bazy danych przy jednoczesnym wykorzystaniu pełnej mocy SQLite.

Konfigurowanie Room 2.x

Aby używać Room 2.x w aplikacji, dodaj te zależności do pliku build.gradle aplikacji:

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

Główne komponenty

Room ma 3 główne komponenty:

  • Klasa bazy danych zawiera bazę danych i służy jako główny punkt dostępu do połączenia z utrwalonymi danymi aplikacji.
  • Encje danych reprezentują tabele w bazie danych aplikacji.
  • Obiekty umożliwiające dostęp do danych (DAO) udostępniają metody umożliwiające aplikacji wykonywanie zapytań, aktualizowanie, wstawianie i usuwanie danych w bazie danych.

Rysunek 1 przedstawia relacje między poszczególnymi komponentami Room.

Rysunek 1. Schemat architektury biblioteki Room.

Przykładowa implementacja

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

Definiowanie danych za pomocą encji

Każda encja Room reprezentuje tabelę w bazie danych. Każdą encję definiujesz jako klasę oznaczoną adnotacją @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
)
  • Niestandardowe nazwy tabel i kolumn: domyślnie Room używa nazwy klasy jako nazwy tabeli, a nazw właściwości jako nazw kolumn. Aby je dostosować, użyj właściwości tableName w @Entity i adnotacji @ColumnInfo(name = "...").
  • Klucz podstawowy: aby zdefiniować klucz podstawowy, użyj @PrimaryKey. W przypadku kluczy złożonych użyj właściwości primaryKeys w @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • Ignorowanie pól: aby zapobiec utrwalaniu pól, użyj @Ignore.

Konwertery typów

Czasami trzeba przechowywać typy niestandardowe, np. Date, w jednej kolumnie. Udostępnij metody @TypeConverter, aby konwertować typy niestandardowe na typy , które Room może utrwalać, i z powrotem.

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() { ... }

Dostęp do danych za pomocą DAO

DAO definiują metody interakcji z bazą danych. Oznacz interfejs lub klasę abstrakcyjną adnotacją @Dao.

Metody pomocnicze

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

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • Wstawianie: metoda wstawiania może zwrócić wartość Long reprezentującą identyfikator wstawionego wiersza lub List<Long> zawierającą identyfikatory wszystkich wstawionych wierszy.
  • Aktualizowanie lub usuwanie: metoda aktualizowania lub usuwania może zwrócić wartość Int reprezentującą liczbę wierszy, których dotyczy zmiana.

Metody zapytań

Oznacz metody adnotacją @Query, aby pisać instrukcje SQL. Room sprawdza poprawność zapytań w czasie kompilacji.

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

Typy zwracane multimap

W Room 2.4 i nowszych metodach zapytań można bezpośrednio zwracać multimapę za pomocą typu Map:

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

Asynchroniczne zapytania DAO

Aby uniknąć zawieszania się interfejsu, zapytania do bazy danych nie mogą być wykonywane w wątku głównym. Użyj jednej z tych integracji, aby zapytania były asynchroniczne:

Współprogramy i Flow w Kotlinie

Wymaga zależności 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 z RxJava

Wymaga room-rxjava2 lub 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 z LiveData i Guava

Wymaga room-guava dla 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>
}

Definiowanie relacji w Room 2.x

Aby zapobiec leniwemu ładowaniu w wątku UI, nie można używać bezpośrednich odwołań do obiektów między encjami. Zamiast tego zdefiniuj relacje za pomocą pośrednich klas danych z @Relation.

Jeden do jednego

Każdy użytkownik ma tylko 1 bibliotekę.

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

Jeden do wielu

Każdy użytkownik może mieć wiele playlist.

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

Wiele do wielu

Playlisty mogą zawierać wiele utworów, a utwory mogą znajdować się na wielu playlistach. Wymaga tabeli łączącej.

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

Relacje zagnieżdżone

Wyszukaj użytkowników, ich playlisty i wszystkie utwory na tych playlistach.

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

Zarządzanie bazami danych

W tej sekcji omówimy różne aspekty zarządzania bazą danych Room, w tym widoki bazy danych, wstępne wypełnianie danych i migracje bazy danych.

Widoki bazy danych

Zamknij złożone zapytanie w klasie oznaczonej adnotacją @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() { ... }

Wstępne wypełnianie bazy danych

Wypełnij bazę danych podczas inicjowania z pliku zasobu lub systemu plików.

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

Migracje

Gdy zmienisz schemat, zwiększ wersję bazy danych i zdefiniuj a Migration obiekt.

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()
  • Automatyczne migracje: jeśli używasz Room 2.4.0 lub nowszej wersji, możesz użyć @AutoMigration, aby automatycznie migrować podstawowe zmiany schematu. Wymaga to ustawienia exportSchema na true w konfiguracji bazy danych: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • Destrukcyjne wycofywanie: jeśli utrata danych jest dopuszczalna w przypadku braku ścieżek migracji, podczas tworzenia bazy danych wywołaj .fallbackToDestructiveMigration.

Testowanie migracji

Aby sprawdzić migracje, użyj MigrationTestHelper z artefaktu room-testing. Aby to zrobić, upewnij się, że eksportujesz schematy w konfiguracji 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
    }
}

Migracja z SQLite do Room

Aby przeprowadzić migrację aplikacji z SQLite do Room, wykonaj te czynności:

  1. Zaktualizuj zależności , aby uwzględnić Room.
  2. Oznacz klasy modeli adnotacjami @Entity, @PrimaryKey i @ColumnInfo.
  3. Utwórz DAO , aby zastąpić metody zapytań pomocniczych.
  4. Utwórz klasę RoomDatabase odwołującą się do encji i DAO. Zwiększ numer wersji.
  5. Zdefiniuj pustą ścieżkę migracji , ponieważ schemat się nie zmienia, tylko framework: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. Zaktualizuj instancję , aby używać Room.databaseBuilder ze ścieżką migracji.