Connect with us

Разработка

Я попытался сделать Offline-First приложение, и это чуть не уничтожили проект

Мы, разработчики, иногда проводим эксперименты ради удовольствия. А иногда такие эксперименты превращаются в фильмы ужасов.

Опубликовано

/

     
     

Мы, разработчики, иногда проводим эксперименты ради удовольствия. А иногда такие эксперименты превращаются в фильмы ужасов.

Мой эксперимент с приоритетом офлайн-подключения должен был быть простым: сделать так, чтобы всё iOS-приложение работало даже при отсутствии сети.

Никаких индикаторов загрузки.

Никаких «пожалуйста, повторите попытку».

Никаких «проверьте ваше соединение».

Просто мгновенный пользовательский интерфейс, локальная запись и фоновая синхронизация, которая волшебным образом всё исправляет, когда восстанавливается сеть.

В теории? Прекрасно.

В реальности? Я случайно открыл финального босса в прохождении архитектуры — конфликты данных.

И не те милые, что описаны в учебниках.

Я говорю о реальных, запутанных, противоречивых ошибках, поступающих от реальных людей, использующих приложение на разных устройствах, способами, которые ни один менеджер по продукту не мог предсказать.

К концу проекта моя локальная база данных знала о предательстве и несогласованности больше, чем любые человеческие отношения.

Вот всё, что прошло прекрасно, всё, что сломалось, и главный урок: приложения, ориентированные на работу в офлайне, кажутся простыми… до поры до времени.

Мечта: настоящее Offline-First приложение

Каждый инженер рано или поздно мечтает об одном и том же: «А что, если моё приложение вообще не будет зависеть от интернета?»

Быстрое. Надежное. Функционирующее даже в лифтах, подвалах, поездах и самолётах.

Поэтому я принял решение: ни один экран не должен зависать из-за отключения сети. Ни одна функция не должна давать сбой.

И в отличие от Offline-Fallback приложений (которые всё ещё требуют интернета для многих процессов), я хотел добиться истинного поведения, ориентированного на локальные ресурсы:

  • Приложение запускается мгновенно
  • Данные загружаются из локального хранилища
  • Все обновления сначала записываются в локальную память
  • Синхронизация происходит незаметно в фоновом режиме
  • В случае конфликта приложение автоматически разрешает конфликт

Звучит просто, не правда ли?

Однако приложения, ориентированные на работу в офлайн-режиме, таят в себе ту же ловушку, что и все фильмы ужасов.

Вы думаете, что контролируете ситуацию… пока созданное вами существо не начинает сопротивляться.

Созданный мной стек технологий (который позже обернулся против меня)

Для этого приложения я использовал:

  • Swift + SwiftUI
  • BackgroundTasks для периодической синхронизации
  • URLSession для синхронизации push-and-pull
  • CoreData в качестве локального хранилища
  • Временные идентификаторы на основе UUID перед присвоением сервером реальных идентификаторов
  • Кастомный механизм синхронизации с разрешением конфликтов

Архитектура выглядела аккуратно на бумаге:

Локальное хранилище → Очередь изменений → Менеджер синхронизации → API сервера → Резолвер слияний

Всё локально. Всё отслеживается. Всё в безопасности.

Пока к этому не прикасаются реальные пользователи.

Первая победа: офлайн режим сделал приложение сверхчеловеческим

Когда приложение впервые запустилось в автономном режиме, я почувствовал себя волшебником.

Мгновенная загрузка. Никакого спиннера. Все данные отображались мгновенно. Редактирование происходило без задержки.

Нажимаете «Добавить новый элемент»? Бум — появляется мгновенно. Редактируете что-то? Обновления происходят мгновенно. Удалить? Готово.

Сеть больше не была узким местом. Приложение работало быстрее в автономном режиме, чем большинство приложений в онлайн-режиме.

Я даже слишком самоуверенно себя вёл. Я написал в своих заметках:

«Синхронизация проста. Просто отслеживайте изменения и отправляйте их при подключении».

Ах, прошлый я. Милый, невинный дурачок.

Начинается развал

Всё рухнуло в тот момент, когда реальные пользователи начали использовать несколько устройств. В этот момент ты понимаешь: ориентация на офлайн — это не функция, это экосистема, которую нужно строить и поддерживать.

Вот ошибки, которые чуть не уничтожили проект.

1. Фантомные элементы (также известные как «Призрачные заметки, которые отказываются умирать»)

Первое сообщение об ошибке: «Я удалил элемент, но он постоянно возвращается».

Классика. Вот что пошло не так:

  • Пользователь удаляет элемент в офлайне на устройстве A
  • Устройство B никогда не получает это удаление
  • Устройство B обновляет элемент (считая его действительным)
  • Движок синхронизации видит два конфликтующих состояния
  • Сервер выбирает «последнюю обновленную» версию
  • Устройство A получает восстановленный элемент

Пользователь подумал, что в приложении призраки.

Оказывается, в системах, ориентированных на автономный режим, удаление — самая сложная операция, а вовсе не создание или обновление.

Удаленный объект не может защитить себя. Любое другое устройство, у которого все еще есть копия, может его восстановить.

Поэтому мне пришлось ввести:

  • надгробия (маркеры удаленных объектов)
  • события удаления с метками времени
  • правила приоритета конфликтов

Это был мой первый опыт offline-first работы:

В тот момент, когда вы переходите на офлайн, вы перестаете работать с данными. Вы начинаете работать с событиями.

2. «Какое редактирование должно победить?» Парадокс работы на многих устройствах

Реальные пользователи делают непредсказуемые вещи. Один пользователь отредактировал одну и ту же запись:

  • На своем iPhone в 9:05 утра (в офлайне)
  • На своем iPad в 9:07 утра (в сети)
  • Снова на своем iPhone в 9:09 утра (все еще в офлайне)

Когда запустилась синхронизация, у меня внезапно появились:

  • Версия A
  • Версия B
  • Версия C

Цепочка перезаписей, которая не имела смысла. Какая из версий правильная?

Простые правила, такие как «последняя запись побеждает», быстро рушатся, потому что локальным меткам времени нельзя доверять:

  • Часы отстают на секунды или минуты
  • Пользователи перемещаются между часовыми поясами
  • Время сервера ≠ время клиента
  • Редактирование в автономном режиме не имеет представления о том, что вообще означает «текущее время»

У меня были элементы, внезапно скачущие назад во времени или переключающиеся между старым и новым состояниями.

Я реализовал:

  • логические метки времени, назначаемые сервером
  • последовательность операций
  • векторные часы (да, я углубился в эту тему)

Даже после этого конфликты продолжали меня удивлять.

3. Конфликты идентификаторов: временные и реальные ID

Приложениям, работающим в офлайне, требуются временные идентификаторы, поскольку идентификаторы сервера еще не существуют.

Мой план:

  1. Создать элемент с UUID
  2. Синхронизация отправляет данные
  3. Сервер возвращает реальный идентификатор
  4. Локальная база данных заменяет его

Просто. Пока пользователи не начали:

  • Создавать элементы в автономном режиме
  • Редактировать их
  • Удалять их
  • Синхронизировать через 3 дня
  • Создавать элементы с тем же содержимым

Система начала путать:

  • Новые элементы со старыми элементами
  • Удаленные элементы с воссозданными
  • Дубликаты с оригиналами

Мне пришлось написать алгоритм дедупликации, который выглядел как работа детектива. «Похож ли этот элемент на другой?». «Было ли это удаление преднамеренным или случайным?». «Являются ли эти два элемента результатом одного и того же намерения человека, но с разными техническими идентификаторами?».

Работа в офлайне создает ощущение, что ваша база данных потеряла память.

4. Самая неожиданная проблема: перегрузка фоновой синхронизации

Когда пользователи снова подключались к сети, приложение взрывалось:

  • внесенными в очередь изменениями
  • повторами
  • слияниями
  • многоэтапным согласованием
  • циклами загрузки + выгрузки

Один пользователь с 300 офлайн-редактированиями сгенерировал более 900 сетевых вызовов за одну сессию синхронизации.

Это привело к разрядке батареи, скачкам загрузки ЦП, завершению фоновой задачи и ограничению скорости сервером.

Мне пришлось всё обрабатывать пакетно:

  • Группировать обновления
  • Сжимать полезные нагрузки
  • Вводить экспоненциальные задержки
  • Отменять избыточные запросы

Offline-first подход превратил мой чистый сетевой уровень в переговоры между системами, которые не доверяли друг другу.

5. Реальные пользователи нарушили все предположения

Это были реальные проблемы.

Часы устройства установлены на 2023 год
Нарушение логики упорядочивания.

Пользователь использовал приложение одновременно на трёх устройствах
Хаос синхронизации.

Пользователь включил «Режим экономии данных»
Некоторые запросы на синхронизацию незаметно прерывались.

Пользователь принудительно завершил работу приложения в процессе слияния
В результате изменения были применены частично.

Резервная копия iCloud восстановила старую базу данных, перезаписав новые локальные данные
Катастрофическая ситуация без надлежащего версионирования.

У пользователя было 15,000 элементов.
Хранилище с приоритетом локальных данных испытывало трудности.

Каждая новая проблема выявляла одну и ту же истину.

Offline-first — это не фича. Это распределённая система.

Худший момент: разделение данных

Момент, который чуть не погубил проект. Продвинутый пользователь с двумя устройствами создал и отредактировал сотни элементов в автономном режиме в течение 4 дней.

Когда оба устройства подключились к сети:

  • Оба попытались синхронизироваться одновременно
  • У обоих была противоречивая история
  • Оба отправили огромные объемы данных
  • Сервер попытался объединить данные
  • Одно устройство неправильно интерпретировало объединение
  • Локальное хранилище разделилось на две версии

На одном устройстве получилось 823 элемента, на другом — 797. Один и тот же аккаунт. Один и тот же сервер. Совершенно разные вселенные.

В тот момент я наконец признал — этому приложению нужны CRDT, иначе оно никогда не будет работать правильно.

Решение: создание настоящего движка синхронизации

Чтобы спасти проект, я перестроил систему синхронизации на основе этих принципов.

1. Каждое действие должно быть событием, а не изменением состояния

Запись — это не «вот новые данные». Запись выглядит так:

  • «Пользователь добавил элемент X»
  • «Пользователь изменил заголовок с A на B»
  • «Пользователь удалил элемент с ID Y»

События не имеют времени. События можно воспроизводить. События можно объединять.

Это сделало синхронизацию предсказуемой.

2. Время сервера — единственный источник истины

Временные метки клиента? Удалите их.

Вместо этого:

  • Клиент регистрирует операции
  • Сервер присваивает авторитетные порядковые номера
  • Клиенты воспроизводят операции по порядку

Это полностью исключает проблемы с расхождением во времени.

3. Надгробия обязательны

Удаленный элемент остается элементом. Он просто находится на кладбище.

Без надгробий воскресшие призраки всегда возвращаются.

Я храню:

  • deletedAt
  • deletedBy
  • lastSyncedVersion

Это решило 80% проблем с призраками.

4. Логика автоматического слияния должна быть явной, а не волшебной

Больше никаких «последняя запись побеждает».

Вместо этого:

  • Слияние на уровне полей
  • Подсказки на основе конфликтов (в крайних случаях)
  • Правила для каждого атрибута (заголовок побеждает иначе, чем текст заметки)
  • Идентификация устройства для обнаружения циклов

Слияния стали предсказуемыми.

5. Синхронизация должна быть пакетной и транзакционной

Каждая синхронизация:

  • Получает изменения
  • Применяет их в фоновом контексте
  • Проверяет целостность
  • Только затем заменяет основное хранилище
  • Записывает локальные операции после слияния операций сервера

Никаких частичных состояний. Никаких сбоев в середине слияния. Никакого неопределенного поведения.

Результат: приложение наконец-то заработало в офлайне… по-настоящему

После нескольких месяцев перестройки, тестирования и размышлений в стиле распределенных систем, приложение наконец-то стало:

  • Быстрым в автономном режиме
  • Стабильным на разных устройствах
  • Надежным во время синхронизации
  • Устойчивым к конфликтам в пограничных случаях
  • Масштабируемым

Но это имело свою цену: Offline-First разработка в 10 раз сложнее Online-First разработки.

Не из-за кода, а из-за непредсказуемости человеческого фактора.

Что я узнал (чтобы вы не страдали так, как я)

Вот уроки, запечатленные в моей душе.

1. Offline-First это не фича, а архитектурное обязательство

Вы должны мыслить как инженер распределенных систем с первого дня.

2. Разрешение конфликтов — это настоящий продукт

Большинство ошибок возникали не из-за поведения в автономном режиме, а из-за интерпретации синхронизации.

3. Удаление намного сложнее, чем добавление

Если вы не используете «надгробия», призраки будут преследовать приложение вечно.

4. Фоновая синхронизация требует оркестровки, а не грубой силы

Пакетная обработка и дедупликация сохраняют всё.

5. Вам нужны инструменты, выходящие за рамки CRUD

События. Версии. Стратегии слияния. Операции на основе намерений.

6. Если вашему приложению действительно не нужен офлайн… не делайте его

Вместо этого создайте резервный вариант для работы в офлайне. Сэкономьте себе волосы.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: