Реализация синхронизации офлайн-данных с сервером
Синхронизация — это не просто «загрузить данные при старте». Это двусторонний процесс: клиент накапливает изменения оффлайн, сервер накапливает изменения от других клиентов, при восстановлении связи оба должны прийти к согласованному состоянию. Правильная архитектура синхронизации — одна из самых технически сложных задач в мобильной разработке.
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 недель в зависимости от объёма данных и количества типов сущностей. Стоимость рассчитывается индивидуально.







