Синхронизация данных между телефоном и планшетом — одна из самых коварных задач в Android-разработке. Представьте: пользователь создаёт заметку на телефоне в метро, планшет остаётся в офисе без сети. Через час планшет подключается — и вместо одной заметки появляются две, или одна бесследно исчезает. Мы видели такие кейсы десятки раз. Около 30% наших проектов требовали синхронизации между двумя и более устройствами. В одном проекте клиент терял до 15% данных из-за конфликтов. После внедрения LWW-стратегии с push-уведомлениями потери снизились до нуля. Наша команда имеет более 5 лет опыта в Android-разработке и реализовала синхронизацию для 20+ проектов. Решения строятся на проверенной комбинации push-уведомлений (FCM) и delta-синхронизации, исключающей потери даже при длительном офлайне. Ниже разберём архитектуру, рабочие стратегии разрешения конфликтов и типовые грабли.
Свяжитесь с нами для оценки архитектуры вашего проекта.
Как синхронизировать данные: push vs pull vs гибрид
Pull-синхронизация — устройство периодически запрашивает изменения с сервера. Проще реализовать через WorkManager с PeriodicWorkRequest (см. WorkManager), но данные всегда немного устаревшие. Подходит для некритичных данных: заметки, настройки.
Push-синхронизация — сервер уведомляет устройства об изменениях через FCM. Устройство получает data payload с типом события и ID изменившегося объекта, затем дозапрашивает данные. Не стоит передавать сами данные в push — лимит payload 4 КБ и нет гарантии доставки.
Гибрид — push как триггер, pull как механизм получения данных. Это продакшн-стандарт. Гибридный подход сокращает задержку данных с минут до секунд — в 60 раз быстрее чистого pull.
| Подход | Задержка данных | Надёжность | Сложность |
|---|---|---|---|
| Pull | Минуты-часы (интервал WorkManager) | Высокая (всегда попытка) | Низкая |
| Push | Секунды | Зависит от FCM | Средняя |
| Гибрид | Секунды | Высокая (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 с серверным. Это снижает нагрузку на сервер в 3 раза по сравнению с полной загрузкой.
Детали реализации LWW с подтверждением
При обновлении записи клиент отправляет PUT /notes/{id} с телом {content, updated_at, device_id}. Сервер проверяет: если полученный updated_at больше серверного, то принимает; иначе возвращает 409 Conflict с актуальной версией. Клиент при 409 либо игнорирует (server wins), либо перезаписывает локально (client wins) — в зависимости от политики.Реализация через Room и WorkManager
Room + WorkManager — современный подход без устаревшего SyncAdapter. Используем CoroutineWorker из WorkManager.
@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, сервер возвращает только изменения после этого момента. Это даёт экономию трафика до 40%.
// 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. Неправильная обработка может привести к 10% дублирования запросов и потерянным изменениям.
Типичные ошибки
Race condition при параллельной синхронизации. Два устройства одновременно отправляют изменения — без идемпотентных операций на сервере (PUT /notes/{id} вместо POST) получаем дублирование. Сервер должен возвращать 200 при повторном PUT с теми же данными.
Не синхронизируются удалённые записи. Soft delete обязателен — is_deleted = true вместо физического удаления. Иначе планшет не узнает, что телефон удалил запись, и при следующей синхронизации восстановит её.
Что входит в реализацию синхронизации?
- Анализ текущей архитектуры и требований к консистентности.
- Проектирование схемы данных с метаданными (device_id, updated_at, sync_status).
- Реализация REST API или GraphQL с идемпотентными операциями.
- Интеграция push-уведомлений (FCM) с обработкой на клиенте.
- Оптимизация delta-синхронизации с курсорами.
- Тестирование сценариев потери сети и разрешения конфликтов.
- Документация и рекомендации по поддержке.
Сроки и стоимость
Базовая LWW-синхронизация без push — от 1 до 2 недель. С push-уведомлениями и сложной логикой конфликтов — от месяца. Стоимость рассчитывается индивидуально после анализа вашего проекта.
Наша команда гарантирует надёжность: более 5 лет опыта, 20+ реализованных проектов с синхронизацией, поддержка после деплоя.
Если нужна надёжная синхронизация для вашего Android-приложения — получите консультацию. Оценим проект и предложим оптимальную архитектуру.







