Синхронізація даних між телефоном та планшетом (Android)
Синхронізація між пристроями одного користувача — завдання, де більшість архітектурних рішень проваливаються при нестійкому з'єднанні. Користувач створив запис на телефоні, планшет був оффлайн кілька годин, потім знову підключився — і дані або дублюються, або один з варіантів мовчки перетирає інший.
Архітектурний вибір: push vs pull
Pull-синхронізація — пристрій періодично запитує зміни з сервера. Простіше реалізувати через WorkManager з PeriodicWorkRequest, але дані завжди трохи застарілі. Підходить для некритичних даних: замітки, налаштування.
Push-синхронізація — сервер сповіщає пристрої про зміни через FCM (Firebase Cloud Messaging). Пристрій отримує data payload з типом eventi та ID змінленого об'єкта, потім дозапитує дані. Не передавайте самі дані в push-сповіщенні — ліміт FCM payload 4 КБ та немає гарантії доставки.
Гібрид — push як тригер, pull як механізм отримання даних. Це продакшн-стандарт.
Конфлікти при одночасному редагуванні
Найскладніша частина. Стратегії:
| Стратегія | Описання | Коли використовувати |
|---|---|---|
| Last Write Wins (LWW) | Побеждає запис з пізнішим updated_at |
Прості дані без критичних втрат |
| Server Wins | Локальні зміни відкидаються при конфлікті | Дані, які контролює сервер |
| Client Wins | Локальні зміни завжди застосовуються | Користувальницькі замітки, чернетки |
| Merge | Слияние на рівні полів | Документи з незалежними полями |
| CRDT | Conflict-free Replicated Data Types | Real-time співпраця |
Для більшості додатків — LWW з метаданими device_id та updated_at. Сервер зберігає останню версію та timestamp, клієнт при синхронізації порівнює свій updated_at з серверним.
Реалізація через Room + SyncAdapter або WorkManager
Room + WorkManager — сучасний підхід без застарілого SyncAdapter:
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val content: String,
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = DeviceInfo.getDeviceId(),
val syncStatus: SyncStatus = SyncStatus.PENDING
)
enum class SyncStatus { SYNCED, PENDING, CONFLICT }
syncStatus = PENDING — запис створений/змінений локально, ще не надісланий на сервер.
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val pendingNotes = noteDao.getPendingNotes()
pendingNotes.forEach { note ->
val serverNote = api.getNote(note.id)
when {
serverNote == null -> api.createNote(note)
serverNote.updatedAt > note.updatedAt -> {
// сервер новіше — оновити локально
noteDao.insert(serverNote.copy(syncStatus = SyncStatus.SYNCED))
}
else -> {
// локальна запис новіше — надіслати на сервер
api.updateNote(note)
noteDao.updateSyncStatus(note.id, SyncStatus.SYNCED)
}
}
}
// отримати зміни з сервера за період
val serverChanges = api.getChangesSince(lastSyncTimestamp)
noteDao.insertAll(serverChanges.map { it.copy(syncStatus = SyncStatus.SYNCED) })
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
Delta-синхронізація
Завантажувати всі дані при кожній синхронізації — неефективно. Сервер зберігає cursor або checkpoint: час останньої успішної синхронізації для кожного пристрою. Клієнт при запиті передає свій lastSyncTimestamp, сервер повертає лише зміни після цього моменту.
// SharedPreferences або Room
val lastSyncTimestamp = prefs.getLong("last_sync_${deviceId}", 0L)
val changes = api.getChangesSince(lastSyncTimestamp)
prefs.edit().putLong("last_sync_${deviceId}", System.currentTimeMillis()).apply()
Адаптивний UI: телефон vs планшет
Синхронізація — не тільки дані. На планшеті часто використовується two-pane layout (список + деталі), на телефоні — one-pane. При реалізації через SlidingPaneLayout або NavigationSuiteScaffold (Compose) потрібно враховувати, що ViewModel для списку та деталей можуть бути різними або спільними — залежно від режиму. При переході з телефону на планшет (складні пристрої) UI повинен підстроюватися без втрати стану через WindowSizeClass.
Типові помилки
Race condition при паралельній синхронізації. Два пристрої одночасно надсилають зміни — без ідемпотентних операцій на сервері (PUT /notes/{id} замість POST) отримуємо дублювання. Сервер повинен повертати 200 при повторному PUT з тими ж даними.
Не синхронізуються видалені записи. Soft delete обов'язковий — is_deleted = true замість фізичного видалення. Інакше планшет не знатиме, що телефон видалив запис, і при наступній синхронізації відновить його.
Реалізація синхронізації з нуля: 1–2 тижні для базової LWW-стратегії з push-сповіщеннями. Складні сценарії з merge-стратегією — від місяця. Вартість розраховується індивідуально після аналізу вимог.







