Реализация миграции схемы базы данных (Room Migration) в Android-приложении
Пользователь обновил приложение — и при первом запуске видит белый экран или крэш. В Logcat: IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. Или хуже: Migration didn't properly handle с потерей всех локальных данных. Это классика неправильно реализованной миграции Room.
Как Room обнаруживает изменения схемы
Room хранит хэш схемы базы данных. При каждом запуске сравнивает хэш скомпилированного @Database с хэшем, хранящимся в room_master_table. Если они не совпадают — Room бросает исключение, если не найдена подходящая миграция.
version в @Database — это не произвольный номер. Это контракт: если схема изменилась, version должен быть увеличен, и должна быть добавлена явная Migration(fromVersion, toVersion).
Типы изменений и их миграция
Добавление колонки (простой случай)
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE transactions ADD COLUMN category TEXT NOT NULL DEFAULT ''")
}
}
NOT NULL DEFAULT '' — обязательно. SQLite не позволяет добавить NOT NULL колонку без DEFAULT в уже существующую таблицу с данными.
Переименование колонки
SQLite не поддерживает ALTER TABLE RENAME COLUMN до версии 3.25.0. На Android API < 29 это недоступно. Универсальный путь — пересоздание таблицы:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. Создаём новую таблицу с правильным именем колонки
db.execSQL("""
CREATE TABLE transactions_new (
id TEXT NOT NULL PRIMARY KEY,
amount REAL NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
""")
// 2. Копируем данные (старая колонка 'note' → новая 'description')
db.execSQL("""
INSERT INTO transactions_new (id, amount, description, created_at)
SELECT id, amount, note, created_at FROM transactions
""")
// 3. Удаляем старую
db.execSQL("DROP TABLE transactions")
// 4. Переименовываем новую
db.execSQL("ALTER TABLE transactions_new RENAME TO transactions")
}
}
Пересоздание таблицы — единственный надёжный путь для любых структурных изменений на всём диапазоне Android API.
Добавление таблицы с внешним ключом
db.execSQL("""
CREATE TABLE IF NOT EXISTS tags (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
color INTEGER NOT NULL DEFAULT 0
)
""")
db.execSQL("""
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
""")
Цепочки миграций и пропущенные версии
Room умеет строить цепочки: если пользователь не обновлял приложение с v1 до v3, Room применит Migration(1,2) + Migration(2,3). Но это работает только если вы зарегистрировали все промежуточные миграции.
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
Если хотите поддержать прямой переход 1→4 (быстрее, одна SQL-операция) — добавьте Migration(1, 4) явно.
Тестирование миграций
Room предоставляет MigrationTestHelper для JUnit тестов:
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@get:Rule val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
fun migrate1To2() {
// Создаём базу версии 1
helper.createDatabase(TEST_DB, 1).apply {
execSQL("INSERT INTO transactions VALUES ('id1', 100.0, 'test', 1700000000)")
close()
}
// Применяем миграцию и проверяем результат
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
val cursor = db.query("SELECT description FROM transactions WHERE id = 'id1'")
assertTrue(cursor.moveToFirst())
assertEquals("", cursor.getString(0))
}
}
Тест на каждую миграцию — не опция, а обязательство. MigrationTestHelper.runMigrationsAndValidate валидирует итоговую схему против ожидаемой.
Экспортируемые JSON-схемы
Включите экспорт схемы в build.gradle:
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
}
Room сохраняет schemas/1.json, schemas/2.json — это снимки схемы каждой версии. Их нужно коммитить в репозиторий. MigrationTestHelper использует их для валидации. Без этих файлов — тестирование миграций невозможно.
Fallback на destructive migration
В крайнем случае — только для dev-сборок или при явном согласии пользователя:
.fallbackToDestructiveMigration() // стирает все данные и пересоздаёт базу
В продакшене это недопустимо без предупреждения пользователя.
Что входит в работу
- Аудит текущей схемы и истории версий
- Написание
Migrationобъектов для всех изменений схемы - Тесты через
MigrationTestHelperдля каждой миграции - Настройка экспорта JSON-схем
- Обработка edge cases: пустые таблицы, внешние ключи, индексы, триггеры
Сроки
1–2 простые миграции (добавление колонок): 0,5–1 день. Сложная реструктуризация (переименование, пересоздание таблиц, цепочки миграций) с полным покрытием тестами: 2–3 дня.







