7 полезных советов для тех, кто использует Room

Room — это уровень абстракции поверх SQLite, который упрощает организацию хранения данных. Если вы ещё мало знакомы с Room, то посмотрите эту вводную статью.

А в данной статье я хотел бы поделиться несколькими советами о том, как максимально эффективно использовать Room.

1. Предварительное заполнение базы данных

Вам нужно добавить данные по умолчанию в вашу базу данных сразу после её создания или в момент первого обращения к ней? Используйте RoomDatabase#Callback. Вызовите метод addCallback при создании вашей базы данных и переопределите либо onCreate, либо onOpen.

onCreate будет вызываться при первом создании базы данных, сразу после создания таблиц. onOpen вызывается при открытии базы данных. Поскольку доступ к DAO возможен только после завершения этих методов, мы создаём новый поток, в котором получаем ссылку на базу данных, затем получаем DAO и вставляем необходимые данные.

Room.databaseBuilder(context.applicationContext, DataDatabase::class.java, "Sample.db")
    // prepopulate the database after onCreate was called
    .addCallback(object : Callback() {
        override fun onCreate(db: SupportSQLiteDatabase) {
            super.onCreate(db)
            // moving to a new thread
            ioThread {
                getInstance(context).dataDao().insert(PREPOPULATE_DATA)
            }
        }
    })
    .build()

Смотрите полный пример здесь.

Примечание: если вы будете использовать подход с ioThread и приложение даст сбой при первом запуске, между созданием базы данных и вставкой, данные никогда не будут вставлены.

2. Использование возможностей наследования DAO

У вас есть несколько таблиц в вашей базе данных и вы копируете одни и те же методы вставки, обновления и удаления? DAO поддерживают наследование, поэтому создайте класс BaseDao<T> и определите там ваши общие методы @Insert, @Update и @Delete. Пусть каждый DAO расширит BaseDao и добавит методы, специфичные для каждого из них.

interface BaseDao<T> {
    @Insert
    fun insert(vararg obj: T)
}

@Dao
abstract class DataDao : BaseDao<Data>() {
    @Query("SELECT * FROM Data")
    abstract fun getData(): List<Data>
}

Смотрите подробности здесь.

DAO должны быть интерфейсами или абстрактными классами, потому что Room генерирует их реализации во время компиляции, включая методы из BaseDao.

3. Выполнение запросов в транзакциях без шаблонного кода

Аннотирование метода с помощью @Transaction гарантирует, что все операции базы данных, которые вы выполняете в этом методе, будут выполняться внутри одной транзакции. Транзакция не будет выполнена, если в теле метода возникнет исключение.

@Dao
abstract class UserDao {

    @Transaction
    open fun updateData(users: List<User>) {
        deleteAllUsers()
        insertAll(users)
    }

    @Insert
    abstract fun insertAll(users: List<User>)

    @Query("DELETE FROM Users")
    abstract fun deleteAllUsers()
}

Возможно, вы захотите использовать аннотацию @Transaction для методов @Query, которые используют оператор select в случаях:

  • Когда результат запроса довольно большой. Делая запрос одной транзакцией, вы гарантируете, что если результат запроса не поместится в одной «порции» курсора, то он не будет повреждён из-за изменений в базе данных между перестановками курсора.
  • Когда результатом запроса является POJO с полями @Relation. Каждое поле является запросом само по себе, поэтому запуск их в одной транзакции гарантирует согласованные результаты между запросами.

Методы @Delete, @Update и @Insert, имеющие несколько параметров, автоматически запускаются внутри транзакции.

4. Чтение только того, что вам нужно

Когда вы делаете запрос к базе данных, используете ли вы все поля, которые получаете в ответе? Позаботьтесь об объёме памяти, используемой вашим приложением, и загрузите только те поля, которые вы в конечном итоге будете использовать. Это также увеличит скорость ваших запросов за счёт снижения затрат на ввод-вывод. Room сделает сопоставление между столбцами и объектом за вас.

Рассмотрим этот сложный объект User:

@Entity(tableName = "users")
data class User(
    @PrimaryKey
    val id: String,
    val userName: String,
    val firstName: String,
    val lastName: String,
    val email: String,
    val dateOfBirth: Date,
    val registrationDate: Date
)

На некоторых экранах нам не нужно отображать всю эту информацию. Таким образом, вместо этого мы можем создать объект UserMinimal, который содержит только необходимые данные.

data class UserMinimal(
    val userId: String,
    val firstName: String,
    val lastName: String
)

В классе DAO мы определяем запрос и выбираем правильные столбцы из таблицы users.

@Dao
interface UserDao {
    @Query(“SELECT userId, firstName, lastName FROM Users)
    fun getUsersMinimal(): List<UserMinimal>
}

5. Контроль зависимостей между сущностями с внешними ключами

Даже несмотря на то, что Room напрямую не поддерживает связи между сущностями, он позволяет вам определять зависимости между объектами с помощью внешних ключей. В Room есть аннотация @ForeignKey, которая является частью аннотации @Entity. По функциональности она аналогична внешним ключам в SQLite. Она гарантирует сохранение связей между сущностями при изменениях в базе данных. Чтобы добавить её, определите объект, на который необходимо ссылаться, а также столбцы в текущем объекте и том, на который ссылаетесь.

Рассмотрим класс User и Pet. У Pet есть владелец — идентификатор пользователя, на который ссылается внешний ключ.

@Entity(tableName = "pets",
        foreignKeys = [ForeignKey(
            entity = User::class,
            parentColumns = ["userId"],
            childColumns = ["owner"]
        )])
data class Pet(
    @PrimaryKey
    val petId: String,
    val name: String,
    val owner: String
)

При желании вы можете определить, какое действие необходимо предпринять, когда родительский объект удаляется или обновляется в базе данных. Вы можете выбрать один из следующих вариантов: NO_ACTION, RESTRICT, SET_NULL, SET_DEFAULT или CASCADE, которые ведут себя так же, как в SQLite.

Примечание: в Room SET_DEFAULT работает как SET_NULL, т.к. Room ещё не позволяет устанавливать значения по умолчанию для столбцов.

6. Упрощение запросов один-ко-многим с помощью @Relation

В предыдущем примере User-Pet можно сказать, что есть отношение один-ко-многим: у пользователя может быть несколько питомцев. Допустим, мы хотим получить список пользователей со своими питомцами: List<UserAndAllPets>.

data class UserAndAllPets(
    val user: User,
    val pets: List<Pet> = ArrayList()
)

Чтобы сделать это вручную, нам понадобится 2 запроса: один для получения списка всех пользователей и другой для получения списка домашних животных на основе идентификатора пользователя.

@Query(“SELECT * FROM Users”)
fun getUsers(): List<User>

@Query(“SELECT * FROM Pets where owner = :userId”) 
fun getPetsForUser(userId: String): List<Pet>

Затем мы будем перебирать список пользователей и каждый раз обращаться к таблице Pets.

Аннотация @Relation упростит нам жизнь: она автоматически запросит связанные объекты. @Relation можно применять только к List или Set. Обновим класс UserAndAllPets:

class UserAndAllPets {
    @Embedded
    var user: User? = null
    @Relation(parentColumn = “userId”, entityColumn = “owner”)
    var pets: List<Pet> = ArrayList()
}

В DAO мы определяем один запрос, а Room будет запрашивать таблицы Users и Pets и самостоятельно сопоставлять объекты.

@Transaction
@Query(“SELECT * FROM Users”)
fun getUsers(): List<UserAndAllPets>

7. Избежание ложных уведомлений observable-запросов

Допустим, вы хотите получить пользователя по его идентификатору с помощью observable-запроса:

@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): LiveData<User>

// or

@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): Flowable<User>

Вы будете получать новый объект User каждый раз, когда он будет обновляться. Но вы также получите этот объект, когда в таблице Users произойдут другие изменения (удаления, обновления или вставки), которые не имеют никакого отношения к интересующему вас пользователю, что приведёт к ложным уведомлениям. Более того, если ваш запрос включает в себя несколько таблиц, вы будете получать новые сообщения всякий раз, когда что-то поменяется в любой из них.

Вот что происходит за кулисами:

  1. В SQLite есть триггеры, которые срабатывают всякий раз, когда в таблице происходит DELETE, UPDATE или INSERT.
  2. Room создаёт InvalidationTracker, который использует Observers, которые отслеживают все изменения в наблюдаемых таблицах.
  3. И LiveData-, и Flowable-запросы полагаются на уведомление InvalidationTracker.Observer#onInvalidated. Когда оно получено, происходит повторный запрос.

Room знает только то, что таблица была изменена, но не знает, почему и что изменилось. Следовательно, после повторного запроса результат запроса передаётся с помощью LiveData или Flowable. Т.к. Room не хранит никаких данных в памяти, он не может определить, те же самые это данные или нет.

Вы должны убедиться, что ваш DAO фильтрует запросы и реагирует только на необходимые объекты.

Если observable-запрос реализован с использованием Flowables, используйте Flowable#diverUntilChanged.

@Dao
abstract class UserDao : BaseDao<User>() {

    /**
    * Get a user by id.
    * @return the user from the table with a specific id.
    */
    @Query(“SELECT * FROM Users WHERE userid = :id”)
    protected abstract fun getUserById(id: String): Flowable<User>

    fun getDistinctUserById(id: String): Flowable<User> = getUserById(id).distinctUntilChanged()
}

Если ваш запрос возвращает LiveData, вы можете использовать MediatorLiveData, которая будет получать только нужные объекты из источника.

fun <T> LiveData<T>.getDistinct(): LiveData<T> {
    val distinctLiveData = MediatorLiveData<T>()
    distinctLiveData.addSource(this, object : Observer<T> {
        private var initialized = false
        private var lastObj: T? = null

        override fun onChanged(obj: T?) {
            if (!initialized) {
                initialized = true
                lastObj = obj
                distinctLiveData.postValue(lastObj)
            } else if ((obj == null && lastObj != null) || obj != lastObj) {
                lastObj = obj
                distinctLiveData.postValue(lastObj)
            }
        }
    })
    return distinctLiveData
}

В ваших DAO метод, который возвращает LiveData, сделайте public, а метод, который запрашивает базу данных, protected.

@Dao
abstract class UserDao : BaseDao<User>() {

    @Query(“SELECT * FROM Users WHERE userid = :id”)
    protected abstract fun getUserById(id: String): LiveData<User>

    fun getDistinctUserById(id: String): LiveData<User> = getUserById(id).getDistinct()
}

Полный пример кода смотрите здесь.

Примечание: если вы запрашиваете список для отображения, обратите внимание на библиотеку Paging Library, которая будет возвращать LivePagedListBuilder. Библиотека поможет автоматически вычислить разницу между элементами списка и обновить ваш пользовательский интерфейс.

Источник: 7 полезных советов для тех, кто использует Room