Когда вы только начинаете работать с 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 — это не дополнительная головная боль, а продуманный инструмент, который возвращает вам контроль над эволюцией схемы. Потратив один раз время на написание и тестирование переходов, вы получаете спокойствие за сохранность данных пользователей и лёгкость в поддержке приложения на годы вперёд. Считайте миграции такой же обязательной частью разработки, как код-ревью или юнит-тесты, и ваши проекты будут только крепче.