Реалізація синхронізації офлайн-даних з сервером
Синхронізація — це не просто «завантажити дані при старті». Це двусторонній процес: клієнт накопичує зміни офлайн, сервер накопичує зміни від інших клієнтів, при відновленні зв'язку обидва мають прийти до узгодженого стану. Правильна архітектура синхронізації — одна з найбільш технічно складних задач у мобільній розробці.
Delta sync vs Full sync
Найочевидніша реалізація — при відновленні мережи запросити всі дані заново. Працює на малих обсягах. При 10,000 записах кожен раз скачувати повний список — це трафік, час та навантаження на сервер.
Delta sync — клієнт присилає lastSyncTimestamp, сервер повертає тільки змінені та видалені записи з того моменту.
data class SyncRequest(
val lastSyncTimestamp: Long,
val clientId: String
)
data class SyncResponse(
val serverTimestamp: Long, // час відповіді сервера
val updated: List<ProductDto>, // змінені чи нові
val deletedIds: List<String> // видалені на сервері
)
На клієнті зберігаємо lastSuccessfulSyncTimestamp в MMKV або SharedPreferences. При наступній синхронізації використовуємо як фільтр.
Важливо: час має бути серверним. Якщо клієнт використовує своє час, розбіжність часів дає пропуски. Сервер видає свій timestamp в відповіді — клієнт зберігає саме його.
Архітектура SyncManager
class SyncManager(
private val api: SyncApi,
private val dao: ProductDao,
private val pendingOpsDao: PendingOperationDao,
private val prefs: SyncPreferences
) {
suspend fun sync(): SyncResult {
// 1. Відправляємо накопичені offline-операції
val pending = pendingOpsDao.getAll()
if (pending.isNotEmpty()) {
try {
val uploadResult = api.uploadOperations(pending.map { it.toRequest() })
// Видаляємо тільки успішно обробляються
pendingOpsDao.deleteByIds(uploadResult.processedIds)
// Невдалі залишаються в очереди
} catch (e: NetworkException) {
return SyncResult.NetworkError
}
}
// 2. Скачуємо зміни з сервера
return try {
val response = api.sync(
SyncRequest(
lastSyncTimestamp = prefs.lastSyncTimestamp,
clientId = prefs.clientId
)
)
dao.applyDelta(
updated = response.updated.map { it.toEntity() },
deletedIds = response.deletedIds
)
prefs.lastSyncTimestamp = response.serverTimestamp
SyncResult.Success(
updatedCount = response.updated.size,
deletedCount = response.deletedIds.size
)
} catch (e: Exception) {
SyncResult.Error(e)
}
}
}
applyDelta в транзакції — атомарно. Або застосовуємо все, або нічого:
@Transaction
suspend fun applyDelta(updated: List<ProductEntity>, deletedIds: List<String>) {
upsertAll(updated)
softDeleteByIds(deletedIds, System.currentTimeMillis())
}
Soft delete обов'язковий: не видаляємо фізично, ставимо флаг is_deleted = true та зберігаємо timestamp. Інакше при наступному delta sync ми знову «забудемо» про це видалення.
Триггери синхронізації
Запускаємо синхронізацію в кількох сценаріях:
class SyncScheduler(
private val workManager: WorkManager,
private val syncManager: SyncManager,
private val networkMonitor: NetworkMonitor
) {
init {
// Періодична фонова синхронізація
val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.build()
workManager.enqueueUniquePeriodicWork(
"periodic-sync",
ExistingPeriodicWorkPolicy.KEEP,
periodicSync
)
}
// При відновленні мережи — немедленна синхронізація
fun observeNetworkAndSync() {
networkMonitor.isOnline
.filter { it } // тільки перехід offline→online
.distinctUntilChanged()
.onEach { triggerImmediateSync() }
.launchIn(applicationScope)
}
// При повертанні в foreground
fun onAppForeground() {
val lastSync = prefs.lastSyncTimestamp
val tooOld = System.currentTimeMillis() - lastSync > 5 * 60 * 1000L
if (tooOld) triggerImmediateSync()
}
}
На iOS аналог WorkManager — BGAppRefreshTask та BGProcessingTask. Фонові задачі iOS виконуються за розсудом ОС та обмежені за часом (30 секунд для appRefresh, до 3 хвилин для processing).
Синхронізація зображень та файлів
Бінарні дані — окремо від метаданих. Синхронізуємо список файлів (імена, URL, хеші), скачуємо файли по окремих запитах з пріоритизацією:
class MediaSyncManager {
suspend fun syncMedia(mediaList: List<MediaMeta>) {
val toDownload = mediaList.filter { meta ->
!fileCache.exists(meta.localPath) ||
fileCache.getHash(meta.localPath) != meta.serverHash
}
// Скачуємо паралельно, але обмежуємо конкурентність
toDownload.chunked(4).forEach { batch ->
batch.map { meta ->
async { downloadFile(meta) }
}.awaitAll()
}
}
}
Чанки по 4 — не перегружаємо з'єднання, при втраті зв'язку втрачаємо максимум 4 файли з поточного батчу.
Стан синхронізації в UI
Користувач має видіти актуальність даних. Мінімум: timestamp останньої синхронізації. Краще: іконка статусу (synced / syncing / sync error) поруч з даними, які можуть бути застарілими.
При sync error — не блокуємо UI. Показуємо попередження, дозволяємо роботу з локальними даними, пропонуємо повторити.
Повна реалізація двусторонньої дельта-синхронізації з очередою операцій та обробкою конфліктів: 4–8 тижнів залежно від обсягу даних та кількості типів сутностей. Вартість розраховується індивідуально.







