Реалізація міграції кешу при оновленні мобільного додатку
Уявіть: ви випустили оновлення, а користувачі масово скаржаться на вильоти та старі дані. Причина — неінвалідований кеш. Старі зображення завантажені під старими ключами, JSON-відповіді серіалізовані в застарілий формат, HTTP-кеш містить заголовки з битими URL. Якщо не очистити кеш при оновленні — користувач бачить перемішані дані з різних версій, або додаток падає на десеріалізації. Розглянемо на прикладі реального проекту: додаток для доставки, де після оновлення користувачі бачили ціни зі старої відповіді API, що призводило до збитків. Правильна інвалідація кешу вирішила проблему за один день. Міграція кешу — обов'язковий етап будь-якого оновлення. Ми, розробники з 10-річним досвідом, зібрали перевірені методи для iOS та Android.
Чому кеш стає невалідним після оновлення?
Причина — несумісність форматів даних між версіями. Припустимо, у версії 1.0 API повертав об'єкт { "name": "foo" }, а в 2.0 — { "full_name": "foo bar" }. Якщо додаток закешував старий JSON і намагається десеріалізувати його в нову модель — крах. Аналогічно із зображеннями: змінилися шляхи, розміри або хеші. Згідно з документацією Apple URLCache, HTTP-кеш також може містити застарілі заголовки, що призводить до помилок завантаження.
Версіонування ключів кешу
Найпростіший і найнадійніший підхід: прив'язати ключі кешу до версії додатку або до версії API.
// Android — простір імен кешу за версією
object CacheKeyBuilder {
private val appVersion = BuildConfig.VERSION_CODE
fun forImage(imageId: String) = "img_v${appVersion}_$imageId"
fun forApiResponse(endpoint: String) = "api_v${appVersion}_$endpoint"
}
// iOS — ключ з номером білда
struct CacheKeyBuilder {
static let appVersion = Bundle.main.buildVersionNumber
static func imageKey(_ id: String) -> String { "img_v\(appVersion)_\(id)" }
static func apiKey(_ endpoint: String) -> String { "api_v\(appVersion)_\(endpoint)" }
}
При оновленні VERSION_CODE або buildVersionNumber всі ключі змінюються — старий кеш перестає використовуватися. Але при цьому старі файли залишаються на диску і потрібне явне очищення.
Очищення застарілого кешу при старті
// iOS
class CacheManager {
private let defaults = UserDefaults.standard
private let lastVersionKey = "lastCachedVersion"
func cleanupIfNeeded() {
let current = Bundle.main.buildVersionNumber
let last = defaults.string(forKey: lastVersionKey) ?? ""
guard current != last else { return }
clearDiskCache()
clearURLCache()
defaults.set(current, forKey: lastVersionKey)
}
private func clearDiskCache() {
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
try? FileManager.default.removeItem(at: cacheDir.appendingPathComponent("ImageCache"))
}
private func clearURLCache() {
URLCache.shared.removeAllCachedResponses()
}
}
Викликаємо в application(_:didFinishLaunchingWithOptions:) до ініціалізації UI.
// Android — очищення при старті
class CacheCleaner(private val context: Context) {
fun cleanIfNeeded() {
val prefs = context.getSharedPreferences("cache", Context.MODE_PRIVATE)
val lastVersion = prefs.getInt("lastVersion", 0)
val currentVersion = BuildConfig.VERSION_CODE
if (lastVersion == currentVersion) return
Glide.get(context).clearDiskCache()
val cacheDir = File(context.cacheDir, "http-cache")
cacheDir.deleteRecursively()
prefs.edit().putInt("lastVersion", currentVersion).apply()
}
}
Виклик з Application.onCreate() у фоновому потоці.
Kingfisher (iOS) та Glide (Android)
Обидві бібліотеки використовують власні дискові кеші. Kingfisher зберігає кеш у Library/Caches/com.onevcat.Kingfisher.ImageCache. Очищення:
KingfisherManager.shared.cache.clearDiskCache()
KingfisherManager.shared.cache.clearMemoryCache()
Glide: Glide.get(context).clearDiskCache() — тільки з фонового потоку.
Офіційні репозиторії: Kingfisher, Glide.
Порівняння підходів
| Підхід |
Складність |
Час виконання |
Ризик втрати даних |
| Повне очищення кешу |
Низька |
0.5 дня |
Високий (всі тимчасові дані зникають) |
| Версіонування ключів + фонове очищення старих |
Середня |
1 день |
Низький (важливий кеш зберігається до застарівання) |
| Інкрементальна міграція |
Висока |
2-3 дні |
Дуже низький (тільки несумісні записи) |
Як обрати стратегію міграції кешу?
Для невеликих проектів достатньо повного очищення при кожному оновленні — це швидко реалізувати і легко перевірити. Якщо важливі швидкість запуску та користувацький досвід, обирайте версіонування ключів з фоновим очищенням. Для фінансових або медичних додатків, де кожен запис критичний, використовуйте інкрементальну міграцію з точковою інвалідацією.
Що входить в роботу
| Етап |
Результат |
| Аудит кешування |
Опис усіх точок кешування, поточні ризики |
| Проектування стратегії |
Вибір підходу під вашу архітектуру |
| Реалізація модулів |
Native-код для iOS та Android з Unit-тестами |
| Інтеграція та тестування |
Перевірка на кількох версіях додатку |
| Документація |
Інструкція з підтримки та деплою |
| Навчання команди |
Розбір коду та best practices |
Процес роботи
- Аудит кешування — 0.5 дня.
- Проектування стратегії — 0.5 дня.
- Реалізація на iOS та Android — 1-2 дні.
- Тестування на реальних оновленнях — 0.5 дня.
- Деплой та моніторинг — 0.5 дня.
Терміни
Базова інвалідація кешу при оновленні: від 0,5 дня. З версіонованими ключами, збереженням важливого кешу та фоновим очищенням: від 1 дня. Для складних проектів з кількома джерелами кешу: від 2 до 3 днів.
Чек-лист міграції кешу
Чек-лист міграції кешу
- [ ] Версіонування ключів для зображень та API
- [ ] Код очищення кешу при старті (залежно від версії)
- [ ] Очищення HTTP-кешу (URLCache / HttpCache)
- [ ] Фонове очищення старих файлів (не блокує запуск)
- [ ] Тест: оновлення з версії N-1 до N та перевірка даних
- [ ] Моніторинг крашів після оновлення (Crashlytics)
Версіонування ключів надійніше за повне очищення кешу — воно не вимагає видалення всіх файлів, що економить час старту. Однак старі файли все одно необхідно видаляти при старті, щоб не заповнити сховище.
Наші інженери мають 10+ років досвіду в мобільній розробці та гарантують коректну міграцію кешу без втрати даних. Зв'яжіться з нами для аудиту поточної системи кешування. Отримайте консультацію з вирішення проблем з кешем. Економія часу на розробку — до 2 днів, а зниження витрат на підтримку покриває вартість реалізації протягом місяця.
Як вибрати рішення для локального зберігання даних (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 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.