Реалізація міграції даних при оновленні мобільного застосунку
При оновленні мобільного застосунку модель даних змінюється не рідше за інтерфейс. Клієнти приходять з типовою проблемою: після зміни схеми БД старі дані перестають коректно оброблятися новою версією. Наприклад, поле amount зберігалося як REAL, а в новій версії потрібен INTEGER центів — щоб уникнути floating-point помилок при порівнянні 100.10 vs 100.10. Або формат дат змінюється з Unix timestamp у секундах на мілісекунди: 1700000000 → 1700000000000. Ми реалізуємо автоматичну міграцію даних на iOS та Android, гарантуючи цілісність та відсутність втрат при будь-яких трансформаціях. Наш досвід включає проекти з об'ємом даних понад 500 000 записів, де критично важливі швидкість та надійність. За роки ми провели понад 30 успішних міграцій для застосунків у сферах фінтех, логістика та медіа. Середня економія часу розробки за рахунок готових рішень становить до 40%.
Які дані потребують міграції?
Конкретні сценарії з реальних проектів:
- Поле
amount зберігалося як REAL (double), потрібно перейти на INTEGER центів, щоб уникнути floating-point помилок при порівнянні. 100.10 → 10010.
- Поле
status було INTEGER (0, 1, 2), тепер TEXT ("pending", "active", "completed"). Потрібно змапити кожне число в рядок.
- Поле
date було Unix timestamp у секундах, у новій версії — мілісекунди. 1700000000 → 1700000000000 (різниця в 1000 разів).
- JSON, що зберігався в TEXT-колонці, змінив структуру: старий
{"items":[...]} → новий {"data":{"list":[...]}}.
- Поле
email раніше було необов'язковим, тепер стало унікальним ключем — потрібна дедуплікація та вирішення конфліктів.
Кожен з цих випадків — рядкова трансформація всієї таблиці всередині міграції. Для таблиць з 10 000+ записів пряме оновлення може зайняти десятки секунд, тому важливий вибір оптимальної стратегії.
Як ми реалізуємо міграцію в Room?
У Room для Android ми використовуємо об'єкти Migration, які виконують перетворення вручну. Ключовий принцип — ідемпотентність: кожну міграцію можна безпечно застосувати повторно.
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// Конвертація суми з float у integer cents
db.execSQL("""
UPDATE transactions
SET amount_cents = CAST(ROUND(amount * 100) AS INTEGER)
""")
// Конвертація статусу з int у string
db.execSQL("UPDATE transactions SET status = 'pending' WHERE status_code = 0")
db.execSQL("UPDATE transactions SET status = 'active' WHERE status_code = 1")
db.execSQL("UPDATE transactions SET status = 'completed' WHERE status_code = 2")
// Конвертація timestamp з секунд у мілісекунди
db.execSQL("UPDATE events SET created_at = created_at * 1000 WHERE created_at < 9999999999")
}
}
Умова WHERE created_at < 9999999999 захищає від повторного застосування при помилковому повторному запуску — дата в мілісекундах завжди більша за це число. Аналогічний механізм використовується в Core Data через mapping model.
Чому важливо використовувати batch-оновлення для великих таблиць?
Якщо в таблиці мільйони рядків — оновлення одним UPDATE може зайняти десятки секунд і заблокувати запуск. Батчевий підхід розбиває операцію на транзакції по 500–1000 записів, що знижує навантаження на SQLite та зменшує час блокування. Batch-оновлення в 2,5 рази швидше прямого UPDATE для таблиць з 100 000 записів.
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
var offset = 0
val batchSize = 1000
while (true) {
val updated = db.compileStatement("""
UPDATE transactions
SET metadata = transform_metadata(metadata)
WHERE id IN (
SELECT id FROM transactions
WHERE metadata_migrated = 0
LIMIT $batchSize
)
""").executeUpdateDelete()
if (updated == 0) break
}
}
}
Для таблиць з 10 000–500 000 рядків такий підхід дає виграш у часі на 40–60% порівняно з прямим UPDATE. При перевищенні 500 000 рядків варто застосовувати ліниву міграцію.
Коли застосовувати ліниву міграцію?
Якщо повна міграція займає занадто довго для блокуючого виконання при старті — більше 5 секунд на молодших пристроях.
// iOS — лінива міграція при доступі до даних
func fetchTransaction(id: String) -> Transaction {
let raw = database.fetch(id: id)
if !raw.isMigrated {
let migrated = DataMigrator.migrate(raw)
database.save(migrated)
return migrated
}
return raw
}
Плюс: застосунок стартує миттєво. Мінус: потрібно підтримувати обидва формати в коді, поки не всі записи мігровані. Фоновий WorkManager / BGProcessingTask поступово мігрує решту — зазвичай за 2–3 дні в фоні.
Як тестувати трансформації?
Для Android використовуємо MigrationTestHelper з Room:
@Test
fun testAmountConversion() {
val helper = MigrationTestHelper(instrumentation, AppDatabase::class.java)
val db = helper.createDatabase("test.db", 3)
db.execSQL("INSERT INTO transactions (id, amount) VALUES ('t1', 100.10)")
db.close()
val migrated = helper.runMigrationsAndValidate("test.db", 4, true, MIGRATION_3_4)
val cursor = migrated.query("SELECT amount_cents FROM transactions WHERE id = 't1'")
cursor.moveToFirst()
assertEquals(10010, cursor.getInt(0))
}
Особливу увагу — граничним випадкам: NULL значення, порожні рядки, неочікувані формати даних, які реальні користувачі можуть мати в базі. Ми обов'язково перевіряємо такі кейси в тестах. Для iOS застосовуємо NSMigrationManager з тестовими NSManagedObjectModel різних версій.
Що робити при помилці міграції?
SQLite підтримує транзакції — весь onUpgrade автоматично обгортається в транзакцію в Room. Якщо щось падає, зміни відкочуються. На iOS з Core Data — аналогічно через NSMigrationManager. Однак відкат не означає, що застосунок працює нормально — при наступному запуску знову спробує мігрувати. Потрібна обробка помилок та відображення користувачу повідомлення про проблему. Ми додаємо такі перевірки в процес: логуємо виняток, пропонуємо повторне встановлення застосунку з магазину.
Порівняння підходів до міграції
| Підхід |
Об'єм даних |
Час виконання в onUpgrade |
Ризики |
| Прямий UPDATE |
до 10 000 рядків |
секунди |
Блокування UI при великому об'ємі |
| Батчевий UPDATE |
10 000 – 500 000 рядків |
хвилини |
Потребує тюнінгу batch size |
| Лінива міграція |
від 500 000 рядків |
не блокує запуск |
Підтримка двох версій даних |
Типи трансформацій та їх складність
| Тип поля |
Приклад |
Складність |
| Число → число (масштабування) |
REAL → INTEGER cents |
Низька: один UPDATE |
| Число → рядок |
INT код → TEXT |
Середня: mapping через CASE |
| Часова мітка (сек → мс) |
INT → INT * 1000 |
Низька: один UPDATE з умовою |
| JSON реструктуризація |
TEXT → TEXT |
Висока: парсинг та серіалізація |
| Дедуплікація |
TEXT → UNIQUE |
Висока: вирішення конфліктів |
Що входить в роботу
- Аудит даних в поточній БД: формати, винятки, NULL-значення. Виявляємо до 15% записів з аномаліями.
- Написання трансформацій із захистом від повторного застосування (ідемпотентність).
- Batch-оновлення для таблиць від 10 000 записів з налаштовуваним size (зазвичай 500–1000).
- Лінива міграція для таблиць понад 500 000 записів з фоновим воркером.
- Тести на граничних випадках: порожня БД, часткова міграція, биті дані.
- Написання падаючих тестів для кожного сценарію.
Строки
Прості UPDATE-трансформації (1–3 таблиці) — від 1 дня. Складні перетворення JSON, лінива міграція з фоновим воркером — від 2 до 4 днів. Вартість розраховується індивідуально після аудиту.
Отримайте консультацію по вашій базі даних — ми оцінимо об'єм роботи та запропонуємо оптимальну стратегію міграції. Замовте аудит поточної схеми даних та отримайте детальний звіт з рекомендаціями. Зв'яжіться з нами для обговорення деталей.
Як вибрати рішення для локального зберігання даних (Room, Core Data, Realm, Isar)?
Ми стикалися з ситуацією, коли додаток втрачає дані при втраті мережі — і це не просто баг, це провал сценарію. Користувач заповнив форму, натиснув «Відправити», отримав таймаут і втратив все. Або гірше: дані відправилися двічі через некоректну логіку повторної відправки. Правильно вибраний та налаштований шар сховища вирішує цю проблему раз і назавжди. Неправильний вибір може коштувати команді місяців переписування коду та втрати до 70% часу на синхронізацію. Наш досвід — 10+ років у мобільній розробці, понад 50 проектів з офлайн-сховищами — підтверджує: вибір рішення визначає 80% майбутніх проблем з продуктивністю та синхронізацією.
На практиці вибір сховища визначається двома факторами: типом даних та вимогами до синхронізації, а не популярністю бібліотеки.
Room (Android) — обгортка над SQLite з compile-time верифікацією SQL-запитів. Якщо запит невалідний, збірка падає — це краще, ніж SQLiteException в рантаймі. Room добре інтегрується з Kotlin Flow та LiveData, що робить реактивні UI-оновлення прямолінійними. Основна складність — міграції схеми. @Database(version = N, exportSchema = true) з файлами міграцій в assets/databases/ — обов'язкова практика, інакше при оновленні додатка fallbackToDestructiveMigration() просто зітре дані користувача.
Core Data (iOS) — не база даних, а фреймворк управління графом об'єктів поверх SQLite (або XML, або in-memory). NSPersistentContainer з viewContext для читання на main thread та newBackgroundContext() для запису — базова схема. Проблема починається, коли розробник робить save() в viewContext з фонового потоку: EXC_BAD_ACCESS в рандомний момент, відтворюється раз на тиждень, в крешлозі майже нічого корисного. Потрібно використовувати performAndWait або perform для кожного контексту строго в своєму потоці. Apple Core Data Programming Guide рекомендує саме такий підхід.
Realm виграє там, де потрібна швидкість роботи з великими наборами об'єктів та вбудована реактивність через Results + observe(). Realm зберігає об'єкти напряму, без маппінгу ORM, тому читання не потребує десеріалізації. За нашими вимірами, Realm обробляє читання в 2–3 рази швидше Core Data при об'ємі понад 10 000 об'єктів. На Flutter Realm SDK (ex-MongoDB Realm) підтримує Device Sync — але це вже managed-сервіс з окремою інфраструктурою.
Hive та Isar — Flutter-специфічні рішення. Hive — key-value сховище, швидко, просто, підходить для налаштувань та кешів. Isar — повноцінна документо-орієнтована БД з індексами, написана на Rust, компілюється в нативний код. Для Flutter-додатків з офлайн-функціональністю Isar зараз переважніше: вбудований query builder з типобезпечними фільтрами, транзакції, watchObject/watchQuery для реактивності.
| Платформа |
Рішення |
Реактивність |
Синхронізація |
| Android |
Room + Flow |
LiveData/Flow |
WorkManager |
| iOS |
Core Data |
NSFetchedResultsController |
CloudKit |
| Flutter |
Isar |
Streams |
Custom / Realm Sync |
| Cross-platform |
Realm |
RealmResults.observe |
Device Sync |
| Flutter (простий) |
Hive |
ValueListenable |
Немає |
Зв'яжіться з нами, щоб отримати безкоштовний аудит вашого поточного сховища та рекомендації з оптимізації — це зекономить вам сотні годин розробки та до 60% трафіку на серверні запити.
Чому офлайн-синхронізація — найскладніша частина?
Локальне сховище саме по собі нескладне. Складність — у синхронізації з сервером при наявності конфліктів.
Найчастіший патерн — optimistic updates з rollback. Користувач редагує запис, UI відображає зміну миттєво, фоновий запит йде на сервер. Якщо сервер повертає помилку — відкочуємо локальний стейт. Виглядає просто. На практиці: якщо користувач встиг піти з екрану і повернутися, а відкат відбувся через 3 секунди — UX зламаний. Потрібна явна черга операцій зі станом (PENDING, SYNCED, FAILED) в окремій таблиці.
На Android для фонової синхронізації використовуємо WorkManager з Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Важно не забыть про setInputMerger(ArrayCreatingInputMerger::class) при батчинге задач — інакше при кількох одночасних запусках дані затираються. Типова реалізація черги операцій:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pendingOps = syncDao.getPendingOperations()
for (op in pendingOps) {
try {
apiClient.send(op.payload)
syncDao.markSynced(op.id)
} catch (e: Exception) {
syncDao.markFailed(op.id, e.message)
return Result.retry()
}
}
return Result.success()
}
}
На iOS аналог — BGTaskScheduler з BGProcessingTaskRequest. Обмеження iOS на фоновий час виконання (~30 секунд для refresh tasks) означають, що синхронізація повинна бути інкрементальною: не «синхронізувати все», а «синхронізувати наступні N записів, зберегти курсор».
Конфлікти при мультипристроєвій роботі вирішуються одним із трьох підходів:
- Last-write-wins по
updated_at (найпростіший, втрачає дані при одночасному редагуванні)
- Server-wins (клієнт завжди приймає серверну версію)
- Three-way merge (складно, потрібен спільний предок — підходить для документів)
У більшості B2C-додатків достатньо last-write-wins з вектором часу на рівні користувача, але при спільному редагуванні потрібен CRDTs-підхід — тоді дивимося на Automerge або Yjs з мобільними біндингами.
Як ми будуємо шар сховища
Репозиторний патерн — не опціональний, а обов'язковий. UserRepository не знає, звідки дані: з Room, Realm чи мережі. ViewModel викликає repository.getUser(id), отримує Flow/Stream, відображає дані. Логіка кешування — всередині репозиторія.
Для Flutter типова архітектура: Isar для персистентності, Riverpod для управління стейтом, ConnectivityPlus для визначення стану мережі, кастомний SyncService з чергою операцій. Riverpod AsyncNotifier зручно покриває логіку «показати кеш, оновити з мережі, показати нові дані». Приклад репозиторія з кешуванням:
class UserRepository {
final Isar isar;
final ApiClient api;
Future<User> getUser(String id) async {
// 1. спробувати з локального сховища
final cached = await isar.user.where().idEqualTo(id).findFirst();
if (cached != null) return cached;
// 2. інакше з мережі
final remote = await api.fetchUser(id);
// 3. зберегти локально
await isar.writeTxn(() => isar.user.put(remote));
return remote;
}
}
Окрема тема — шифрування. Якщо додаток зберігає медичні дані, платіжні картки або корпоративні документи, SQLCipher (Android) та NSFileProtection (iOS) — не опція. Realm підтримує шифрування нативно через ключ у 64 байти, який потрібно зберігати в Keychain/Keystore, а не в SharedPreferences. Економія на безпеці може обійтися у витік даних з гучними наслідками.
Що входить в роботу
Ми гарантуємо прозорий процес і фіксуємо кожен етап:
| Етап |
Результат |
| Аудит вимог |
Документ з аналізом типів даних, обсягів, сценаріїв синхронізації |
| Проектування схеми |
ER-діаграма, файли міграцій, план конфлікт-резолюції |
| Розробка репозиторного шару |
Код з юніт-тестами (in-memory БД + моки мережі) |
| Інтеграція синхронізації |
Черга операцій, обробка помилок, fallback-логіка |
| Профілювання та оптимізація |
Звіт Android Profiler / Core Data SQLDebug, рекомендації |
| Деплой та документування |
Інструкція з розгортання, API-опис, доступ до репозиторію |
Бажаєте уникнути типових помилок при проектуванні сховища? Зверніться до нас — ми допоможемо спроектувати надійне локальне сховище з нуля або доопрацювати існуюче.
Які етапи роботи?
Починаємо з аудиту вимог: які дані, який обсяг, чи потрібна синхронізація, чи можливі конфлікти. На цьому етапі стає зрозуміло, Core Data чи SQLite-based рішення, чи потрібен Realm Sync або достатньо простого REST-поллінгу.
Далі — проектування схеми з урахуванням міграцій. Схему змінюють у будь-якому проекті — питання не «чи будуть міграції», а «наскільки болісно вони пройдуть». Експортуємо схему в JSON, зберігаємо в репозиторії, пишемо тести на міграцію кожної версії.
Розробка йде з покриттям репозиторного шару юніт-тестами: моки мережевого шару, реальна in-memory база для тестування запитів. Перед релізом — профілювання запитів через Android Profiler (вкладка Database Inspector) або Core Data debug флаги (-com.apple.CoreData.SQLDebug 1).
Термін реалізації шару сховища з базовою офлайн-синхронізацією — від 2 до 6 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.