Миграции баз данных Room в 2026 году: как менять схему, не теряя ни одной строки
Практическое руководство по миграциям баз данных Room на Android — AutoMigration, написанные вручную объекты Migration и как протестировать миграцию до того, как её найдут ваши пользователи.
Любому local-first приложению рано или поздно понадобится схема, с которой оно не выходило в версии 1. Новый столбец, переименованная таблица, поле, которое было String, а должно стать Int. В тот момент, когда это происходит, вас отделяет одна неосторожная аннотация от удаления данных каждого пользователя при следующем обновлении. Миграции Room существуют именно для того, чтобы этого не допустить, и большая часть боли, с которой сталкиваются люди, происходит от пропуска частей, которые кажутся необязательными, но таковыми не являются.
Ловушка: fallbackToDestructiveMigration()
Room выбрасывает IllegalStateException в тот момент, когда номер версии вашей @Database увеличивается без соответствующего пути миграции. Исправление, которое встречается в каждом ответе на Stack Overflow, — это одна строка:
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
Это компилируется, разворачивается и прекрасно работает — в debug-сборках, на вашем собственном устройстве, где потеря тестовых данных не имеет значения. В продакшене это означает: в следующий раз, когда вы поднимете версию схемы, Room удалит все таблицы и пересоздаст их пустыми. Для Granyn это значит, что чьи-то бюджетные записи за два года тихо исчезают при обновлении, которое пользователь даже не просил. Ни диалога, ни предупреждения, ни отмены. Это просто тихо происходит при следующем открытии приложения. Если эта строка существует за пределами debug-конфигурации сборки, относитесь к ней как к багу, а не к удобству.
AutoMigration покрывает больше случаев, чем принято думать
Для распространённых случаев — добавление столбца со значением по умолчанию, добавление или удаление таблицы, переименование столбца — @AutoMigration в Room может сгенерировать миграцию за вас на основе двух снимков схемы:
@Database(
version = 2,
entities = [Entry::class],
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()
Это работает только если вы включили экспорт схемы (room.schemaLocation в конфигурации Gradle), чтобы у Room был JSON-снимок каждой версии для сравнения. Пропустите эту настройку — и AutoMigration будет не с чем сравнивать, и вы узнаете об этом на этапе сборки, а не в 2 часа ночи из отчёта о сбое. Для переименованного или удалённого столбца вам также нужен небольшой класс спецификации @RenameColumn или @DeleteColumn, указывающий Room старое и новое имя — он не может вывести намерение только из диффа.
Где AutoMigration перестаёт помогать
Всё, что требует преобразования существующих данных — разделение одного столбца на два, преобразование хранимой суммы из String в центы как Int, заполнение нового обязательного поля на основе других строк, — выходит за рамки AutoMigration. Это не ограничение, которое нужно обходить; это сигнал, что вам нужен настоящий объект Migration с настоящим SQL внутри.
Написание миграции вручную
Написанная вручную Migration — это просто пара версий и блок SQL, за который вы отвечаете:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE entries ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'"
)
db.execSQL(
"UPDATE entries SET currency = (SELECT default_currency FROM user_prefs LIMIT 1)"
)
}
}
Зарегистрируйте её рядом с любыми AutoMigration через .addMigrations(MIGRATION_2_3) в билдере базы данных. ALTER TABLE в SQLite ограничен — нельзя удалить столбец, нельзя изменить тип столбца на месте, — поэтому всё, что выходит за рамки добавления столбца, обычно требует классического танца в три шага: создать новую таблицу нужной формы, скопировать данные через INSERT INTO ... SELECT, удалить старую таблицу, переименовать новую. Это больше SQL, чем хотелось бы писать, и именно этот SQL должен быть верным, потому что он выполняется один раз, тихо, на каждом устройстве при следующем запуске.
Тестируйте миграцию, а не только итоговую схему
Ошибка, которая порождает больше всего багов здесь, — не в SQL, а в том, что миграцию никогда не запускают против реальной, “состарившейся” базы данных. MigrationTestHelper в Room существует именно для этого:
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate2To3_preservesExistingRows() {
helper.createDatabase(TEST_DB, 2).apply {
execSQL("INSERT INTO entries (id, amount) VALUES (1, 4200)")
close()
}
helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3)
}
Это создаёт базу данных версии 2, заполняет её так, как выглядело бы устройство реального пользователя, затем запускает вашу миграцию против неё и проверяет, что итоговая схема совпадает с тем, что ожидает Room. Это ловит два режима отказа, которые реально случаются в продакшене: миграцию, которая работает на пустой базе данных, но падает на базе со строками, и миграцию, чей SQL в порядке, но чья итоговая схема не совпадает с определениями ваших сущностей (Room проверяет это строго, вплоть до порядка столбцов и значений по умолчанию).
Чек-лист
Перед выпуском любого повышения версии схемы: экспорт схемы включён, у каждого скачка версии есть либо AutoMigration, либо зарегистрированная написанная вручную Migration, fallbackToDestructiveMigration() не встречается нигде за пределами тестового кода, и хотя бы один тест MigrationTestHelper заполняет реальные строки и проверяет, что они выживают. Ничего из этого не назовёшь увлекательной работой. Но именно в этом разница между изменением схемы, которое никто не заметит, и почтовым ящиком поддержки, полным сообщений “мои данные пропали” на следующее утро после релиза.
// По теме
Ещё из журнала
Jetpack Glance в 2026 году: как создать виджет для главного экрана, который никогда не врёт о ваших данных
Практическое руководство по виджетам Jetpack Glance на Android — состояние, обработка нажатий и ловушка квоты обновлений, из-за которой виджеты показывают устаревшие данные.
Разработка календаря приверженности OldSchool: превращаем отметки о приёме в надёжный день
Месячный календарь приверженности выглядит как одна цветная точка на день. Вот модель событий, правила дневного статуса и математика своевременности — всё на устройстве.
Как устроен Subly: календарная математика за датами продления подписок
Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.