Room Kotlin: безопасные миграции без потери данных

Многие разработчики, внедряя room kotlin в свои проекты, сталкиваются с одной и той же болью — изменение схемы базы данных без уничтожения уже накопленной информации. Стандартное поведение Room при несовпадении версий — снос всех таблиц и создание их заново с нуля. Для пользователя это катастрофа. В этой статье я расскажу, как настраивать миграции так, чтобы ваши обновления проходили гладко, а данные оставались в целости и сохранности.

Когда вы только начинаете работать с room kotlin, первое, что радует — это невероятная лёгкость старта. Пара аннотаций, data-класс для сущности, интерфейс DAO, и вот уже база данных готова обслуживать экраны. Но рано или поздно приходит момент, когда бизнес-требования меняются: нужно добавить новую колонку, переименовать существующую или вовсе перенести часть данных в отдельную таблицу. И здесь без чёткого понимания миграций не обойтись.


Почему fallbackToDestructiveMigration — плохой попутчик

В документации Room часто мелькает строчка .fallbackToDestructiveMigration(). Она действительно помогает на этапе разработки и отладки: если схема не совпадает, база просто пересоздаётся. Но как только приложение попадает к реальным людям, такой подход становится неприемлемым. Представьте, что ваш мессенджер после обновления теряет всю историю переписки — вряд ли пользователь останется доволен.

Именно поэтому при промышленной разработке на room kotlin осознанное управление миграциями — это гигиенический минимум. Механизм, заложенный в Room, позволяет описать последовательность шагов по трансформации схемы от версии X к версии Y, и всё это на чистом SQL, который выполняется ровно один раз при обновлении.


Практический пример: добавляем новое поле

Допустим, у нас есть сущность Note, которая живёт в первой версии базы данных. Позже мы решаем, что каждой заметке нужен тег. Меняем data-класс, добавляем поле tag, увеличиваем версию базы данных, и… без миграции приложение упадёт с исключением IllegalStateException. Room видит, что схема изменилась, и не знает, что делать.

Правильное решение — объект Migration. Вот как он выглядит при добавлении колонки:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE notes ADD COLUMN tag TEXT NOT NULL DEFAULT ''"
        )
    }
}

После этого остаётся лишь подключить миграцию в билдере базы данных:

Room.databaseBuilder(context, AppDatabase::class.java, "notes.db").
    .addMigrations(MIGRATION_1_2)
    .build()

Теперь при переходе с первой версии на вторую Room выполнит ALTER TABLE, и все старые заметки останутся нетронутыми, получив пустой тег. Никакой потери данных, никакого падения.


Сложные сценарии: переименование колонки и перенос данных

SQLite имеет ограниченный набор команд ALTER TABLE, поэтому в room kotlin часто приходится прибегать к более хитрым трюкам. Представим, что поле title нужно переименовать в heading. Прямого запроса RENAME COLUMN в старых версиях SQLite нет (хотя в последних версиях он появился, но мы должны учитывать обратную совместимость).

Классический подход — создать новую таблицу с правильной схемой, скопировать туда все строки из старой, удалить старую таблицу и переименовать новую. Всё это делается внутри одного метода migrate:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
            CREATE TABLE notes_temp (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                heading TEXT NOT NULL,
                content TEXT NOT NULL,
                tag TEXT NOT NULL DEFAULT ''
            )
        """)
        database.execSQL("""
            INSERT INTO notes_temp (id, heading, content, tag)
            SELECT id, title, content, tag FROM notes
        """)
        database.execSQL("DROP TABLE notes")
        database.execSQL("ALTER TABLE notes_temp RENAME TO notes")
    }
}

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


Как тестировать миграции перед релизом

Ошибка в миграции способна положить приложение у сотен тысяч пользователей. К счастью, room kotlin предоставляет встроенный инструмент для тестирования — класс MigrationTestHelper. Вот минимальный пример того, как убедиться, что наша миграция со сменой колонки работает корректно:

@Test
fun migrate1To2() {
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java
    )
    // Создаём базу версии 1 и наполняем данными
    val db = helper.createDatabase(TEST_DB, 1).apply {
        execSQL("INSERT INTO notes (title, content) VALUES ('Test', 'Body')")
        close()
    }
    // Запускаем миграцию и проверяем результат
    helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
        .query("SELECT * FROM notes").use { cursor ->
            assertThat(cursor.count, `is`(1))
            cursor.moveToFirst()
            assertThat(cursor.getString(cursor.getColumnIndex("tag")), `is`(""))
        }
}

Такие тесты легко запускать в CI, и они становятся страховкой от регрессий. Добавил новую миграцию — написал тест, и можно спать спокойно.


Автоматическая генерация схемы для контроля версий

Хороший тон — экспортировать схемы вашей базы в человекочитаемом виде. В build.gradle это настраивается одной строчкой:

room {
    schemaLocationDir = "$projectDir/schemas"
}

После сборки в папке schemas появятся JSON-файлы для каждой версии. Их стоит хранить в репозитории вместе с кодом — это поможет быстро понять, что именно изменилось между релизами, и заметить нежелательные модификации.


Советы от практика

  • Пишите миграции сразу. Как только вы внесли правки в Entity, тут же добавляйте Migration. Отложенные миграции имеют свойство накапливаться и усложнять будущие обновления.
  • Не бойтесь пустых миграций. Если изменение носит чисто косметический характер (скажем, переименовали класс, но не трогали таблицу), создайте Migration без тела — просто чтобы совпали версии.
  • Помните про индексы. При создании новых таблиц не забывайте добавлять индексы на поля, по которым будут частые запросы.
  • Избегайте тяжёлых запросов в migrate(). Если нужно преобразовать гигантский объём данных, подумайте о фоновой джобе, а не о синхронной миграции при старте базы.


Заключение

Миграции в room kotlin — это не дополнительная головная боль, а продуманный инструмент, который возвращает вам контроль над эволюцией схемы. Потратив один раз время на написание и тестирование переходов, вы получаете спокойствие за сохранность данных пользователей и лёгкость в поддержке приложения на годы вперёд. Считайте миграции такой же обязательной частью разработки, как код-ревью или юнит-тесты, и ваши проекты будут только крепче.