Offline-first приложения: локальное хранение и синхронизация данных
Продвинутое4 минРедакция Innovialabs
Коротко
В offline-first приложении интерфейс читает и пишет только в локальную базу, а синхронизация с сервером идёт в фоне через очередь изменений. Сложность не в хранении, а в разрешении конфликтов и повторной отправке без дублей.
Что значит offline-first
Offline-first — архитектура, в которой источник правды для интерфейса — локальная база на устройстве. Экран читает и записывает данные локально и не ждёт сети. Отдельный слой синхронизации в фоне отправляет изменения на сервер и забирает новые.
Такой подход нужен, когда люди работают при плохой связи: курьеры, торговые представители, полевые сотрудники, склады, приложения для заметок и задач. Для каталога, который просто показывает данные с сервера, обычно достаточно кеша.
Локальная база: что выбрать
| Решение | Платформа | Когда подходит |
|---|---|---|
| SQLite | Везде | Полный контроль над схемой и запросами; через обёртки вроде GRDB, SQLDelight, drift, sqflite |
| Room | Android | Стандарт для Android: SQLite с проверкой запросов при компиляции и миграциями |
| Core Data / SwiftData | iOS | Нативный стек Apple, интеграция с SwiftUI и CloudKit |
| Realm | iOS, Android, Flutter, React Native | Объектная модель без SQL. MongoDB объявила о прекращении поддержки Atlas Device Sync, поэтому синхронизацию для Realm придётся строить самим |
Во Flutter часто берут drift (на базе SQLite), в React Native — SQLite-обёртки или WatermelonDB.
Независимо от базы:
- планируйте миграции схемы с первого релиза — у пользователей будут старые версии данных;
- храните у каждой записи серверный ID, локальный ID, время изменения и статус синхронизации;
- удаление делайте мягким (tombstone), иначе сервер не узнает, что запись удалена офлайн.
Стратегии синхронизации
Получение изменений (pull):
- Дельта-синхронизация: клиент передаёт курсор или метку последней синхронизации, сервер отдаёт только изменения после неё. Основной вариант.
- Полная перезагрузка: для маленьких справочников или как запасной путь при повреждении данных.
- Push-уведомление как сигнал: сервер сообщает «есть изменения», приложение делает pull.
Отправка изменений (push) — через очередь исходящих изменений (outbox): каждое изменение сначала сохраняется в базу вместе с записью в очереди, а фоновый обработчик отправляет очередь по порядку.
CREATE TABLE outbox (
id TEXT PRIMARY KEY, -- ключ идемпотентности (UUID)
entity TEXT NOT NULL,
operation TEXT NOT NULL, -- create | update | delete
payload TEXT NOT NULL, -- JSON
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
Очередь запросов без дублей
- Запись в данные и в outbox — в одной транзакции.
- Каждый запрос несёт ключ идемпотентности: если ответ потерялся и клиент отправил повторно, сервер не создаст дубль.
- Повторы с экспоненциальной задержкой и лимитом попыток; после лимита — показать пользователю, что изменение не отправлено.
- Фоновая отправка через WorkManager на Android и BGTaskScheduler на iOS; система сама решает, когда запустить задачу, поэтому отправляйте очередь и при открытии приложения.
- Учитывайте зависимости: заказ нельзя отправить раньше клиента, созданного офлайн. Отправляйте по порядку или передавайте локальные ID и сопоставляйте их на сервере.
Разрешение конфликтов
Конфликт — когда одну запись изменили на двух устройствах до синхронизации.
| Стратегия | Как работает | Риск |
|---|---|---|
| Last-write-wins | Побеждает последнее изменение по времени | Тихая потеря данных; часы устройств могут врать |
| Версии записей | Клиент отправляет версию, сервер отклоняет устаревшую | Нужно решить, что делать после отказа |
| Слияние по полям | Изменённые поля объединяются, конфликт только при правке одного поля | Сложнее реализовать |
| CRDT | Структуры данных, которые сливаются без конфликтов | Сложность, подходит для совместного редактирования |
| Решает пользователь | Показываем обе версии | Подходит для важных документов, утомляет при частых конфликтах |
Для большинства бизнес-приложений рабочая схема — версии записей плюс слияние по полям, а last-write-wins только для неважных данных. Время для сравнения лучше брать с сервера.
Как тестировать плохую сеть
- Network Link Conditioner на iOS и настройки сети в эмуляторе Android для медленной связи и потерь пакетов.
- Прокси вроде Charles или Proxyman для ограничения скорости и подмены ответов.
- Ручные сценарии: авиарежим посреди отправки, закрытие приложения с непустой очередью, два устройства правят одну запись, обновление приложения с несинхронизированными данными.
- Автотесты слоя синхронизации с подменой сети, которая отвечает ошибкой, таймаутом или дублирует ответ.