
Привет, Хабр! Это Ахмед Шериев, сооснователь стартапа VoxOps, а сегодня — еще и гостевой автор блога Friflex. Это вторая статья из серии про опыт разработки офлайн-приложений — первая была про кэширование.
Если пользователи в офлайне должны менять данные, а потом синхронизировать изменения с сервером, есть два основных подхода:
Синхронизировать сами данные.
Синхронизировать команды или события.
Подход №1: синхронизация данных

Тут все просто. Приложение работает с локальной базой напрямую, не задумываясь о синхронизации. А где-то в фоне отдельный механизм находит изменения, сериализует их, отправляет на сервер и обрабатывает ответы.
1.1. Автоматическая синхронизация
Хорошая новость: для этого подхода есть готовые решения. Можно подключить библиотеку, и она магическим образом синхронизирует базу. Все просто — и кажется, что на этом можно заканчивать доклад.
Если вам нужно просто быстро стартовать — берите готовое облачное решение вроде Firebase или Supabase. Но! Очень много проектов потом жалуются, что «переезд с Firebase — это боль». В интернете можно найти какие опасности ждут вас с использованием готовых решений для синхронизации данных (например, «Firebase чуть не погубила мой стартап»).
Главное — успеть вовремя переписать на кастомное решение, пока не стало слишком поздно.
Незначительно отойдем от темы и посмотрим, какие преимущества SQLlite перед NoSql решениями, что может заставить отказаться от готовых БД или фреймворокв синхронизации данных.
1. Если вам нужен контроль, мощь SQL, агрегации, JOIN'ы и все прочее — придется писать свою синхронизацию. Я предпочитаю SQLite именно за это: если сущностей много, нужны выборки, фильтрации и сложные экраны — это сильно упрощает жизнь. Да, NoSQL тоже предлагают базовые возможности языка SQL, но зачастую эти возможности гораздо ограниченнее.
2. Холодный старт приложения: после первой авторизации вы можете с сервера вернуть готовую БД SQLlite, что позволит гораздо быстрее инициализировать данные, особенно если это массивные данные в виде каталога. Это может быть критично, если первая синхронизация средствами API занимает 5-15 минут и более.
Кроме того, какие-нибудь общие для всех пользователей данные можно сразу заложить в билд и накатывать инкрементные обновления при синхронизации. Так как SQLlite — очень популярный движок данных, практически все языки программирования и фреймворки, которые работают на бэкенде, умеют работать и формировать базу данных в формате SQLlite.
4. Во Flutter есть великолепная поддержка SQLlite. Вы можете делать динамические условия фильтрации, которые будут валидироваться на этапе компиляции, если поменяете структуры данных. Не все NoSQL фреймворки могут предоставить такую поддержку.
if(stage!=null) {
query = query..where((tbl) => tbl.stage.like(stage));
}
if (contractId != null) {
query = query..where((tbl) =>
tbl.contractId.equals(contractId.value));
}
Никаких join as string, where = 'abc', все безопасно на этапе компиляции.
1.2. Ручная синхронизация
Если вы решили полностью контролировать процесс синхронизации, то, конечно, все будет зависеть от протоколов, которые вы разработаете вместе с командой бэкенд-разработчиков.
Самая частая проблема при реализации протокола по схеме REST заключается в логике Update, которую многие разработчики используют по умолчанию.
К примеру, один пользователь поменял цену продукта, а другой — описание продукта. В этом случае на бэкенд уходит типичный PUT (давайте будем честными, скорее всего это будет POST запрос), который содержит все поля, хотя обновилось только одно.
{
"name": "Bread",
"description": ""
"price": "25"
}
В итоге пользователь, который чуть обновил описание товара, может затереть цену установленную другим пользователем за секунду до этого.
Чтобы минимизировать подобные ситуации, важно обсудить протокол обмена вместе с бэкенд-разработчиками. К примеру, вместо PUT использовать только PATCH, и отправлять на бэкенд не все поля, а только то, что изменилось.
На клиенте я видел самые разные реализации этого подхода со всеми достоинствами и недостатками.
Самое простое решение, которое приходилось встречать на практике, это сравнение сохраняемого и существующего значения и создание флажков для каждого поля:
var dbProduct = db.get(product.id);
if(dbProduct.name != product.name) {
dbProduct.name = product.name;
dbProduct.nameUpdated = true;
}
if(dbProduct.description != product.description) {
dbProduct.description = product.description;
dbProduct.descriptionUpdated = true;
}
if(dbProduct.price != product.price) {
dbProduct.price = product.price;
dbProduct.priceUpdated = true;
}
Дальше при последующей синхронизации из обновленных полей собирается patch-запрос, и флажки сбрасываются в false.
Чтобы сэкономить место, вместо отдельного флажка для каждой сущности можно сделать одно поле, где для каждого поля будет бит со смещением. И выделить эту логику в отдельный интерфейс для каждой сущности. Но придется строго следить за сохранением порядка полей. Добавлять новые поля можно только в конец (для обеспечения обратной совместимости с существующими данными).
var dbProduct = db.get(product.id);
var updatedField = dbProduct.updated;
if(dbProduct.name != product.name) {
dbProduct.name = product.name;
updatedField = (updatedField >> 0) & 1;
}
if(dbProduct.description != product.description) {
dbProduct.description = product.description;
updatedField = (updatedField >> 1) & 1;
}
if(dbProduct.price != product.price) {
dbProduct.price = product.price;
updatedField = (updatedField >> 2) & 1;
}
Минус в том, что такой код сложнее читать. Плюс в том, что вы можете выделить эту логику в отдельный интерфейс и упростить или обобщить логику синхронизации:
abstract class ISyncEntity {
String get id;
int get updated;
}
В языках вроде C#, где есть рефлексия, можно строить логику обработки во время выполнения. В Dart, если у вас очень много сущностей, можно написать кодогенератор (к сожалению, на практике не пробовал с кодогенератором).
Подход №2: синхронизация команд или событий (event sourcing)

Если вы работали с Redux, Bloc, CQRS, то подход вам знаком: состояние не мутируется напрямую, а меняется в ответ на событие. Например, пользователь меняет цену товара три раза: сначала ставит 30, потом 45, потом 55.
В предыдущем подходе где вы синхронизируется только данные (финальный результат) вы отправите значение 55. А на бэкенде стоит ограничение: «не выше 50». Если вы просто отправите финальное значение (55), оно не пройдет валидацию. Сервер вернет ошибку и откатит значение назад на 15 (последнее валидное значение, которое есть на бекенде).
А вот если вы синхронизируете события по отдельности, то и каждое изменение попадет на сервер по отдельности:
setPrice(30) — ок,
setPrice(45) — ок,
setPrice(55) — ошибка.
В итоге сервер сохранит последнее валидное состояние — 45. То есть вы не потеряете все. Для многих бизнес-процессов это критично. Особенно когда речь идет о заполнении форм, документах, согласованиях.
Команды vs события
Если коротко: события — это факт («произошло»), команды — это инструкция («сделай»). События легче откатывать, логировать, проверять. Команды — проще писать. Но они не всегда идемпотентны. То есть если выполнить команду дважды — результат может отличаться.
Поэтому главное, о чем нужно заранее договориться с бэкендом — как сделать так, чтобы команда или событие при повторной отправке всегда давали одинаковый результат.
Что в итоге выбрать?
Несмотря на все, что я рассказал выше — отдавайте приоритет синхронизации через данные (data to data). Если сомневаетесь, что лучше — тоже выбирайте подход data-to-data. Обычно data-to-data гораздо проще в реализации. Если у вас мини-CRM с динамическими полями и так далее — обязательно берите data-to-data. Будет гораздо проще обеспечить консистентность данных.
Если у вас четко описанные домены и вам критически необходимо, чтобы прогресс, достигнутый пользователям, сохранялся, или вы хотите создать возможность откатывать не все, а только некоторые шаги, только тогда приступайте к реализации синхронизации через события или команды.
Я не буду приводить примеры имплементации этого подхода в статье, потому что их еще больше, чем в случае с data-to-data — это тема отдельной и очень большой статьи. Самое сложное — обработка логики, создающей данные. Особенно большое количество проблем в подходе с event sourcing вы получите, если используете и клиентские, и серверные идентификаторы.
Идентификаторы
Возникает вопрос, а что делать с идентификаторами новых сущностей? Не один раз мне приходилось участвовать в обсуждении с командой, что новые ID должны создаваться только бэкендом. Что клиент не может генерировать новые ID из соображений безопасности. Кроме того, а что если два разных клиента сгенерируют одинаковые ID?
Очень часто никакие аргументы малой вероятности совпадения ID не помогут (и доля правды в этом есть, на моей практике было, что генераторы случайных чисел на некоторых китайских noname устройствах работали крайне и крайне нерандомно — но это было до появления Flutter с очень дешевыми китайскими планшетами).
В этот момент может возникнуть соблазн использовать два идентификатора. Один обязательный, клиентский, а второй — nullable поле для серверного идентификатора, который появляется, только когда синхронизируешься с сервером.
На практике это приводит к существенному усложнению логики синхронизации. Самая большая проблема: если по какой-то причине не удалось создать продукт, следует ли повторять запрос создания снова или нет? В этих случаях важно вместе с бэкенд-разработчиками договориться, как вы будете обеспечивать идемпотентность запросов.
Вы должны гарантировать, что на создание продукта вы будете отправлять один и тот же request-id, и сервер будет вместо ошибки возвращать такой же ответ, что продукт создан, или возвращать ошибку со специальным кодом, что продукт уже создан с этим request-id (и другую ошибку, если request-id был другой — это сильно упростит жизнь в отладке).
Скорее всего, бэкенд-разработчики откажутся хранить в БД requestId. В этом случае можно договориться, что эта информация будет храниться в памяти с очень коротким временем жизни (например, час).
Множественные идентификаторы
Бывают случаи, когда приходится создавать несколько сущностей за один раз или не создавать ничего. К примеру, у нас был случай, когда надо было создать DayWork со своим Id и WorkItem, где у каждого свой идентификатор. Бизнес-логика запрещала создавать DayWork с пустыми работами. Это просто не считалось валидным состоянием.
Поэтому в рамках одного запроса мы отправляли массив WorkItem, где сервер возвращал ответ, удалось создать список или не удалось.
А как теперь сопоставить клиентские идентификаторы и серверные идентификаторы на клиенте?
В качестве костыля пришлось на бэкенд добавлять логику, где мы отправляли клиентские идентификаторы новых сущностей, а бэкенд в ответе возвращал массив пар клиентских и серверных идентификаторов.
Единый серверный идентификатор (компромисс)
В таких сложных случаях максимально простым решением может быть использование единого идентификатора как на клиенте, так и на сервере. Если у вас строгая политика безопасности и вы не можете использовать идентификаторы, которые сгенерированы клиентским приложением, можно локально сохранять идентификаторы, которые для вас сгенерирует заранее бэкенд.
В самом простом виде вы можете скачивать с бэкенда определенный набор идентификаторов (от 100+, в зависимости от того, какой объем обычно предполагается генерировать в офлайне за раз) и пополнять его новыми идентификаторами при следующей синхронизации.
Такое не очень удобное костыльное решение может сэкономить вам кучу времени и нервов в очень сложных сценариях.
Интерфейс
Адаптация интерфейса для поддержки офлайн-режима — тоже тема большой отдельной статьи. Но здесь я приведу несколько базовых тезисов, над которыми можно подумать и обсудить с командой при реализации поддержки офлайн-режима:
Как показать ошибки. Например, пользователь создал или обновил продукт, но при синхронизации возникла ошибка. Где и как показать эту ошибку? (пуш, уведомление на главном экране, уведомление в меню и так далее);
Как сделать навигацию до экрана с ошибкой (диплинки? — для пушей);
Как дать возможность повторить отправку (допустим, пользователь только создал продукт, и затем обновил его. При синхронизации возникла ошибка, какую кнопку ему надо показать для исправления ошибки).
Оценка сроков и факапы
Вот тут важный вывод: почти все проблемы, переработки и факапы случаются не в happy path — кейсах, при которых приложение хорошо работает (то, что мы, разработчики, в основном держим в голове), а в обработке кейсов: когда нет сети, данные устарели, конфликт между клиентом и сервером, невалидные события и так далее.
С офлайном таких кейсов в разы больше. Особенно если вы все еще решили использовать event sourcing. Там вообще отдельный ад: если первое событие не прошло, а вы уже накопили пачку изменений — что с ними делать? Отменять? Пытаться повторить? Группировать?
Мы даже писали логику «схлопывания» событий, чтобы повторно воспроизводить действия — и это много работы. Готовых решений — почти нет. Нужно писать свое.
Что почитать
Если вы хотите лучше понимать event sourcing, доменное моделирование и согласование данных в распределенных системах — обязательно прочитайте эти две книги:
1. Building Microservices, Sam Newman
2. Designing Data-Intensive Applications, Martin Kleppmann
Они помогут вам даже в клиент-серверных приложениях, не только в микросервисах. Книги и статьи, посвященные офлайн-синхронизации в чистом виде найти сложно.
Подход с data-to-data подход упоминается почти везде, особенно в статьях Google (в том числе про Android).
Надеюсь, эта статья поможет оценить объем работы и предугадать, какие подводные камни ожидают вас при реализации поддержки offline first в ваших приложениях :)