Уявіть: користувач редагує нотатку на телефоні в офлайні, а інший користувач на планшеті одночасно вносить правки. При синхронізації виникає конфлікт версій — ми вирішуємо це завдання, впроваджуючи механізми конфлікт-резолюції, адаптовані під конкретний тип даних і бізнес-логіку. Наш досвід показує, що правильний підхід економить години ручного вирішення та запобігає втраті даних. Без резолюції 30% сесій синхронізації закінчуються помилкою, а в 80% випадків конфлікти виникають при злитті.
Щоб уникнути таких проблем, ми впроваджуємо CRDT, 3-way merge та векторні годинники — залежно від типу даних. Нижче розберемо кожен підхід із прикладами коду на Kotlin та Swift.
Типові проблеми синхронізації
-
Конфлікт одночасного запису — два клієнти змінюють один об'єкт. Без резолюції переможе останній запис за часом, але через розбіжність годинників може загубитися одна з правок.
-
Несинхронізовані годинники — користувачі переводять час на пристроях. Різниця сягає 5 хвилин, що призводить до невірного визначення «останньої» версії.
-
Втрата даних при злитті — класичний merge перезаписує зміни, якщо не відстежувати історію. Кожен другий конфлікт у текстових редакторах веде до втрати частини контенту.
Векторні годинники та timestamp
Найпростіша стратегія — Last Write Wins (LWW): перемагає той запис, у якого timestamp новіший. Мінус очевидний — при розбіжності годинників клієнтів переможе неправильна версія. Клієнтські годинники ненадійні: користувач може перевести час на пристрої. Різниця може сягати 5 хвилин, що веде до втрати правок.
Надійний варіант — серверний час. Клієнт не довіряє своїм годинникам, а при записі сервер ставить timestamp. Тоді LWW працює коректно.
Більш просунутий підхід — векторні годинники (Vector Clocks). Кожен клієнт має ідентифікатор, і кожна зміна відстежується вектором версій:
data class VectorClock(
val clocks: Map<String, Long> = emptyMap()
) {
fun increment(clientId: String): VectorClock =
copy(clocks = clocks + (clientId to (clocks[clientId] ?: 0L) + 1))
fun happensBefore(other: VectorClock): Boolean =
clocks.all { (k, v) -> v <= (other.clocks[k] ?: 0L) } &&
clocks != other.clocks
fun isConcurrentWith(other: VectorClock): Boolean =
!happensBefore(other) && !other.happensBefore(this)
}
Якщо clockA.happensBefore(clockB) — версія B пізніша, беремо її. Якщо isConcurrentWith — конфлікт, потрібна ручна або автоматична резолюція.
CRDT для автоматичного злиття
CRDT (Conflict-Free Replicated Data Types) — структури даних, які можна безпечно об'єднувати без конфліктів математично. Математична модель CRDT гарантує безконфліктне злиття без втрат (Wikipedia). Кілька типів:
- G-Counter — тільки інкремент. Кожен пристрій зберігає свій лічильник, результат — сума всіх. Застосовується для лічильників переглядів, лайків.
- LWW-Register — регістр із Last Write Wins через timestamp. Примітивно, але працює для атомарних значень.
- OR-Set — набір елементів, де додавання та видалення не конфліктують.
// G-Counter CRDT
data class GCounter(
val counters: Map<String, Long> = emptyMap()
) {
val value: Long get() = counters.values.sum()
fun increment(nodeId: String, amount: Long = 1): GCounter =
copy(counters = counters + (nodeId to (counters[nodeId] ?: 0L) + amount))
fun merge(other: GCounter): GCounter =
copy(counters = (counters.keys + other.counters.keys).associateWith { key ->
maxOf(counters[key] ?: 0L, other.counters[key] ?: 0L)
})
}
Для повноцінного використання CRDT у мобільних додатках є готові бібліотеки: Automerge (Rust-ядро, порти для Swift та Kotlin) та Yjs (JavaScript, працює через React Native).
Тристороннє злиття (3-way merge)
Найкращий підхід для текстового контенту — як у Git. Потрібна спільна база (версія до розходження), зміни клієнта A та зміни клієнта B.
data class DocumentVersion(
val id: String,
val baseVersion: Long, // версія від якої вважаються зміни
val content: String,
val patches: List<Patch> // список змін від base
)
class MergeStrategy {
fun merge(base: String, clientA: String, clientB: String): MergeResult {
val patchesA = diff(base, clientA)
val patchesB = diff(base, clientB)
val conflicts = findOverlappingPatches(patchesA, patchesB)
return if (conflicts.isEmpty()) {
MergeResult.AutoMerged(apply(base, patchesA + patchesB))
} else {
MergeResult.Conflict(
autoMergedContent = apply(base, nonConflictingPatches(patchesA, patchesB)),
conflicts = conflicts
)
}
}
}
При автоматичному злитті — застосовуємо обидві правки. При перетині — пропонуємо користувачеві вибрати або редагувати вручну.
Серверна логіка резолюції
Клієнт при синхронізації надсилає:
{
"entityId": "note-123",
"baseVersion": 7,
"clientVersion": 9,
"changes": [...],
"clientId": "device-abc",
"timestamp": 1712345678000
}
Сервер перевіряє поточну версію. Якщо поточна версія = baseVersion — чистий merge, конфліктів немає, застосовуємо зміни. Якщо поточна версія > baseVersion — хтось встиг змінити після нашої бази. Сервер повертає статус конфлікту та дані для 3-way merge.
Порівняння методів за типами даних
| Тип даних |
Рекомендована стратегія |
| Нотатки, документи |
3-way merge, ручне вирішення при перетині |
| Налаштування користувача |
LWW із серверним часом |
| Лічильники (лайки, перегляди) |
G-Counter CRDT |
| Кошик покупок |
OR-Set CRDT (union обох версій) |
| Статус замовлення |
Server wins — сервер авторитетний |
| Позиція на карті |
LWW |
Вибір стратегії — продуктове рішення: потрібно оцінити, що критичніше — втрата правок чи дублікати.
CRDT проти LWW: чому CRDT виграє
CRDT у 95% випадків виключає втрати даних, тоді як LWW — лише у 70% при несинхронізованих годинниках. При цьому LWW потребує в 2 рази менше часу на реалізацію та підходить для простих сценаріїв. Для складних даних із кількома редакторами CRDT забезпечує конвергенцію без центрального сервера.
Як ми впроваджуємо конфлікт-резолюцію?
Процес складається з 6 етапів. На кожному ми фіксуємо результат, щоб гарантувати цілісність даних.
| Етап |
Тривалість |
Результат |
| Аудит поточної синхронізації |
1–2 тижні |
Звіт із метриками конфліктів та вузькими місцями |
| Вибір стратегії |
1 тиждень |
Документ із рішенням для кожного типу даних |
| Проектування схеми даних |
1–2 тижні |
ER-діаграма з версіонуванням |
| Реалізація на клієнті |
2–4 тижні |
Робочий merge-модуль на Swift/Kotlin/Flutter |
| Написання тестів |
1–2 тижні |
Покриття 90%+ юніт-тестами |
| Деплой та моніторинг |
1 тиждень |
Запуск у production та дашборд конфліктів |
Що впливає на терміни реалізації?
Базова реалізація з LWW або CRDT — від 3 до 6 тижнів. Повноцінне рішення з 3-way merge та історією версій — від 8 до 12 тижнів. Терміни залежать від складності даних та необхідності серверної доробки.
Зберігання історії версій
Для коректної конфлікт-резолюції потрібна історія. Мінімум — зберігати baseVersion та дельти змін від неї. При глибокому merge — повна історія версій або снепшоти.
@Entity(tableName = "document_versions")
data class DocumentVersionEntity(
@PrimaryKey val id: String,
val documentId: String,
val version: Long,
val content: String,
val patch: String, // JSON-diff від попередньої версії
val authorClientId: String,
val createdAt: Long
)
Історія версій зростає. Потрібна стратегія стиснення: зберігати снепшот кожні N версій, видаляти проміжні через N днів.
Якщо вам потрібна консультація щодо вибору стратегії або оцінка проєкту — зв'яжіться з нами. Наші інженери мають 5+ років досвіду в мобільній розробці та гарантують цілісність даних. Для оцінки вартості та термінів отримайте безкоштовну консультацію.
Як вибрати рішення для локального зберігання даних (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 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.