Реализация синхронизации данных между телефоном и планшетом (Android)
Синхронизация между устройствами одного пользователя — задача, где большинство архитектурных решений проваливаются при нестабильном соединении. Пользователь создал запись на телефоне, планшет был оффлайн несколько часов, потом снова подключился — и данные либо дублируются, либо один из вариантов молча перетирает другой.
Архитектурный выбор: push vs pull
Pull-синхронизация — устройство периодически запрашивает изменения с сервера. Проще реализовать через WorkManager с PeriodicWorkRequest, но данные всегда немного устаревшие. Подходит для некритичных данных: заметки, настройки.
Push-синхронизация — сервер уведомляет устройства об изменениях через FCM (Firebase Cloud Messaging). Устройство получает data payload с типом события и 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-стратегией — от месяца. Стоимость рассчитывается индивидуально после анализа требований.







