November 8, 2019

7 шагов к использованию Room

Room — это библиотека, которая является частью архитектурных компонентов Android. Она облегчает работу с объектами SQLiteDatabase в приложении, уменьшая объём стандартного кода и проверяя SQL-запросы во время компиляции.

У вас уже есть Android-проект, который использует SQLite для хранения данных? Если это так, то вы можете мигрировать его на Room. Давайте посмотрим, как взять уже существующий проект и отрефакторить его для использования Room за 7 простых шагов.

TL;DR: обновите зависимости gradle, создайте свои сущности, DAO и базу данных, замените вызовы SQLiteDatabase вызовами методов DAO, протестируйте всё, что вы создали или изменили, и удалите неиспользуемые классы. Вот и всё!

В нашем примере приложения для миграции мы работаем с объектами типа User. Мы использовали product flavors для демонстрации различных реализаций уровня данных:

  1. sqlite — использует SQLiteOpenHelper и традиционные интерфейсы SQLite.
  2. room — заменяет реализацию на Room и обеспечивает миграцию.

Каждый вариант использует один и тот же слой пользовательского интерфейса, который работает с классом UserRepository благодаря паттерну MVP.

В варианте sqlite вы увидите много кода, который часто дублируется и использует базу данных в классах UsersDbHelper и LocalUserDataSource. Запросы строятся с помощью ContentValues, а данные, возвращаемые объектами Cursor, читаются столбец за столбцом. Весь этот код способствует появлению неявных ошибок. Например, можно пропустить добавление столбца в запрос или неправильно собрать объект из базы данных.

Давайте посмотрим, как Room улучшит наш код. Изначально мы просто копируем классы из варианта sqlite и постепенно будем изменять их.

Шаг 1. Обновление зависимостей gradle

Зависимости для Room доступны через новый Google Maven-репозиторий. Просто добавьте его в список репозиториев в вашем основном файле build.gradle:

allprojects {
    repositories {
        google()
        jcenter()
    }
}

Определите версию библиотеки Room в app/build.gradle и добавьте зависимости для нее. Последнюю версию можно узнать на страницах для разработчиков.

dependencies {
    def room_version = '2.2.1'

    implementation “androidx.room:room-runtime:$room_version”
    kapt “androidx.room:room-compiler:$room_version”

    testImplementation “androidx.room:room-testing:$room_version”
}

Чтобы мигрировать на Room, нам нужно увеличить версию базы данных, а для сохранения пользовательских данных нам потребуется реализовать класс Migration. Чтобы протестировать миграцию, нам нужно экспортировать схему. Для этого добавьте следующий код в файл app/build.gradle:

android {
    defaultConfig {
        ...
        // used by Room, to test migrations
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
    }

    // used by Room, to test migrations
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
    ...
}

Шаг 2. Обновление классов модели до сущностей

Room создаёт таблицу для каждого класса, помеченного @Entity. Поля в классе соответствуют столбцам в таблице. Следовательно, классы сущностей, как правило, представляют собой небольшие классы моделей, которые не содержат никакой логики. Наш класс User представляет модель для данных в базе данных. Итак, давайте обновим его, чтобы сообщить Room, что он должен создать таблицу на основе этого класса:

  • Аннотируйте класс с помощью @Entity и используйте свойство tableName, чтобы задать имя таблицы.
  • Задайте первичный ключ, добавив аннотацию @PrimaryKey в правильные поля — в нашем случае это идентификатор пользователя.
  • При необходимости задайте имя столбцов для полей класса, используя аннотацию @ColumnInfo(name = "column_name"). Этот шаг можно пропустить, если ваши поля уже названы так, как следует назвать столбец.
  • Если в классе несколько конструкторов, добавьте аннотацию @Ignore, чтобы указать Room, какой следует использовать, а какой — нет.
@Entity(tableName = "users")
data class User(
    @PrimaryKey
    val userId: String,
    val userName: String,
    @ColumnInfo(name = "last_update")
    val lastUpdate: Date
) {
    @Ignore
    constructor(userName: String): this(
        UUID.randomUUID().toString(),
        userName,
        new Date(System.currentTimeMillis()
    )
}

Примечание: для плавной миграции обратите пристальное внимание на имена таблиц и столбцов в исходной реализации и убедитесь, что вы правильно устанавливаете их в аннотациях @Entity и @ColumnInfo.

Шаг 3. Создание объектов доступа к данным (DAO)

DAO отвечают за определение методов доступа к базе данных. В первоначальной реализации нашего проекта на SQLite все запросы к базе данных выполнялись в классе LocalUserDataSource, где мы работали с объектами Cursor. В Room нам не нужен весь код, связанный с курсором, и мы можем просто определять наши запросы, используя аннотации в классе UserDao.

Например, при запросе всех пользователей из базы данных Room выполняет всю «тяжелую работу», и нам нужно только написать:

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

Шаг 4. Создание базы данных

Мы уже определили нашу таблицу Users и соответствующие ей запросы, но мы ещё не создали базу данных, которая объединит все эти составляющие Room. Для этого нам нужно определить абстрактный класс, который расширяет RoomDatabase. Этот класс помечен @Database, в нём перечислены объекты, содержащиеся в базе данных, и DAO, которые обращаются к ним. Версия базы данных должна быть увеличена на 1 в сравнении с первоначальным значением, поэтому в нашем случае это будет 2.

@Database(entities = [User::class], version = 2)
@TypeConverters(DateConverter::class)
abstract class UsersDatabase : RoomDatabase() { 
    abstract fun userDao(): UserDao
    ...
}

Поскольку мы хотим сохранить пользовательские данные, нам нужно реализовать класс Migration, сообщающий Room, что он должен делать при переходе с версии 1 на 2. В нашем случае, поскольку схема базы данных не изменилась, мы просто предоставим пустую реализацию:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Поскольку мы не изменяли таблицу, здесь больше ничего не нужно делать
    }
}

Создайте объект базы данных в классе UsersDatabase, определив имя базы данных и миграцию:

database = Room.databaseBuilder(applicationContext, UsersDatabase::class.java, "Sample.db")
    .addMigrations(MIGRATION_1_2)
    .build()

Чтобы узнать больше о том, как реализовать миграцию баз данных и как они работают под капотом, посмотрите этот пост.

Шаг 5. Обновление репозитория для использования Room

Мы создали нашу базу данных, нашу таблицу пользователей и запросы, так что теперь пришло время их использовать. На этом этапе мы обновим класс LocalUserDataSource для использования методов UserDao. Для этого мы сначала обновим конструктор: удалим Context и добавим UserDao. Конечно, любой класс, который создаёт экземпляр LocalUserDataSource, также должен быть обновлен.

Далее мы обновим методы LocalUserDataSource, которые делают запросы с помощью вызова методов UserDao. Например, метод, который запрашивает всех пользователей, теперь выглядит так:

fun getUsers(): List<User> {
    return userDao.getUsers()
}

А теперь время запустить то, что у нас получилось.

Одна из лучших функций Room — это то, что если вы выполняете операции с б��зой данных в главном потоке, то ваше приложение упадёт со следующим сообщением об ошибке:

java.lang.IllegalStateException: Cannot access database on the main thread since
it may potentially lock the UI for a long period of time.

Один надёжный способ переместить операции ввода-вывода из основного потока — это создать новый Runnable, который будет создавать новый поток для каждого запроса к базе данных. Поскольку мы уже используем этот подход в варианте sqlite, никаких изменений не потребовалось.

Шаг 6. Тестирование на устройстве

Мы создали новые классы — UserDao и UsersDatabase и изменили наш LocalUserDataSource для использования базы данных Room. Теперь нам нужно их протестировать.

Тестирование UserDao

Чтобы протестировать UserDao, нам нужно создать тестовый класс AndroidJUnit4. Потрясающая особенность Room — это возможность создавать базу данных в памяти. Это исключает необходимость очистки после каждого теста.

@Before
fun initDb() {
    database = Room.inMemoryDatabaseBuilder(
        InstrumentationRegistry.getContext(),
        UsersDatabase::class.java
    ).build()
}

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

@After
fun closeDb() {
    database.close()
}

Например, чтобы протестировать вход пользователя, мы добавим пользователя, а затем проверим, сможем ли мы получить этого пользователя из базы данных.

@Test
fun insertAndGetUser() {
    // Добавление пользователя в базу данных 
    database.userDao().insertUser(USER)

    // Проверка возможности получения пользователя из базы данных
    val users = database.userDao().getUsers()
    assertThat(users.size(), is(1))
    val dbUser = users.get(0)
    assertEquals(dbUser.id, USER.id)
    assertEquals(dbUser.userName, USER.userName)
}

Тестирование использования UserDao в LocalUserDataSource

Убедиться, что LocalUserDataSource по-прежнему работает правильно, легко, поскольку у нас уже есть тесты, которые описывают поведение этого класса. Всё, что нам нужно сделать, это создать базу данных в памяти, получить из нее объект UserDao и использовать его в качестве параметра для конструктора LocalUserDataSource.

@Before
fun initDb() {
    database = Room.inMemoryDatabaseBuilder(
        InstrumentationRegistry.getContext(),
        UsersDatabase::class.java
    ).build()
    dataSource = new LocalUserDataSource(database.userDao())
}

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

Тестирование миграции базы данных

Подробнее почитать о том, как реализовать тесты миграции баз данных, а также как работает MigrationTestHelper, можно в этом посте.

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

Шаг 7. Удаление всего ненужного

Удалите все неиспользуемые классы и строки кода, которые теперь заменены функциональностью Room. В нашем проекте нам просто нужно удалить класс UsersDbHelper, который расширял класс SQLiteOpenHelper.

Если у вас есть большая и более сложная база данных, и вы хотите постепенно перейти на Room, то рекомендуем этот пост.

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

Источник: 7 шагов к использованию Room. Пошаговое руководство по миграции приложения на Room