Большинство разработчиков думают об офлайн-режиме в последнюю очередь - когда приложение уже готово, дизайн согласован, а PM давит на дедлайн. В результате пользователь видит белый экран, зависший спиннер или, что хуже - молча потерянные данные. Эта статья про то, как выстроить честный UX для состояний без сети: от психологии тревоги до кода с экспоненциальным откатом, от визуального языка ошибок до стратегий разрешения конфликтов. Всё это пригодится при разработке любого мобильного или веб-приложения, которое работает в условиях нестабильного соединения - а таких большинство.
Психология офлайн-состояний
Есть один паттерн, который я наблюдал в юзабилити-тестах снова и снова: пользователь нажимает кнопку «Отправить», ничего не происходит, он нажимает ещё раз - и ещё раз. Через десять секунд он либо закрывает приложение, либо начинает злиться. Это не проблема сети. Это проблема неопределённости.
Неопределённость порождает тревогу. Тревога - поведение избегания. Пользователь не знает, отправилось ли его сообщение, сохранился ли документ, прошла ли транзакция. Мозг интерпретирует молчание системы как угрозу: «Что-то пошло не так, и я не знаю что». Именно поэтому хорошо спроектированный офлайн-UX - это не техническая задача, а задача управления тревогой.
Три состояния, которые нужно различать: система работает нормально, система работает медленно или нестабильно, система полностью офлайн. Каждое из них требует своего визуального ответа. Смешать их - значит запутать пользователя ещё больше. Если при медленном соединении показывать те же индикаторы, что и при полном отсутствии сети, пользователь не понимает, стоит ли ему ждать или уже поздно.
Ещё один важный момент: люди значительно лучше переносят ожидание, когда знают его причину и хоть примерную длительность. Это давно известно из исследований очередей и UX загрузки. Офлайн - не исключение. «Нет интернета» - уже лучше, чем пустой экран. «Нет интернета, данные сохранены локально и будут отправлены при восстановлении соединения» - это уже доверие.
Визуальный язык офлайн-состояний
Первое правило - не прятать. Я видел интерфейсы, где при отсутствии сети просто отключали кнопки и не давали никаких объяснений. Пользователь смотрит на серую кнопку и не понимает: это фича? Это баг? Почему именно сейчас?
Офлайн-состояние должно быть явным. Баннер вверху экрана с нейтральным (не красным - красный сигнализирует об ошибке, а не о состоянии среды) цветом, иконка в статус-баре, тонкое изменение UI - всё это работает. Главное правило: элементы интерфейса, которые недоступны из-за отсутствия сети, должны быть видимы, но явно задизейблены с объяснением причины. Не скрыты, а именно задизейблены с тултипом или иным сообщением.
Хорошая практика для мобильных приложений - показывать состояние соединения в toolbar или navigationBar. На iOS можно наблюдать за NWPathMonitor и обновлять UI реактивно:
import Network final class NetworkMonitor: ObservableObject { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") @Published var isConnected: Bool = true init() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied } } monitor.start(queue: queue) } }
В SwiftUI это тривиально подключается через @EnvironmentObject и позволяет любому экрану реагировать на изменение состояния сети без лишней связанности.
Для иконографики: я предпочитаю SF Symbol wifi.slash на iOS - он мгновенно считывается пользователем. На вебе - аналогичный подход с SVG-иконками, но важно не злоупотреблять анимацией. Пульсирующая иконка офлайна раздражает через 30 секунд. Статичная - просто информирует.
Очередь действий для последующей синхронизации
Вот где начинается настоящая инженерия. Когда пользователь совершает действие офлайн - ставит лайк, создаёт задачу, редактирует документ - это действие нужно куда-то сохранить и выполнить позже. Простая реализация через массив в памяти не переживёт перезапуск приложения. Нужен персистентный store: Core Data, SQLite, или хотя бы UserDefaults для простых случаев.
Но персистентность - это только половина задачи. Вторая половина - идемпотентность операций.
Идемпотентность означает, что повторное выполнение одной и той же операции не изменит результат. Это критически важно для очереди синхронизации, потому что мы не знаем, дошёл ли запрос до сервера до потери соединения. Если операция не идемпотентна, повторная отправка может создать дубликат или сломать данные.
Практически это решается двумя способами. Первый - использовать клиентский UUID для каждой операции и передавать его в заголовке Idempotency-Key. Сервер хранит этот ключ и при повторном запросе возвращает тот же ответ, не выполняя операцию снова. Второй способ - проектировать операции как PATCH с конкретными полями вместо POST с полным объектом, или использовать event sourcing на сервере.
Конфликты - отдельная история. Если пользователь редактировал документ офлайн, а другой пользователь тем временем изменил тот же документ на сервере, у нас конфликт.
Стратегии разрешения:
Last Write Wins - простейший вариант, часто неправильный. Теряются изменения одной из сторон. Подходит для некритичных данных вроде настроек.
Merge - для структурированных данных (текст, списки) можно применить three-way merge: база, версия клиента, версия сервера. Именно так работает Git. CRDT (Conflict-free Replicated Data Types) - более мощный подход, который используют, например, Figma и Linear для коллаборативного редактирования.
Ask the User - когда автоматическое слияние невозможно, нужно показать пользователю обе версии и дать выбор. Плохо, если это происходит часто, но иногда единственный честный вариант.
Я стараюсь проектировать очередь как конечный автомат: каждое действие имеет статус pending, syncing, synced или failed. Это упрощает отображение состояния в UI и логику повторных попыток.
Отображение «Pending» и «Failed» - разные состояния требуют разных решений
Это различие часто игнорируют, и зря. «Ожидает отправки» и «Не удалось отправить» - принципиально разные ситуации с точки зрения пользователя. В первом случае ничего делать не нужно, во втором - нужно принять решение.
Для pending подходит subtle-индикатор: серая галочка, часики, небольшой бейдж. Пользователь должен понимать, что действие зафиксировано, но ещё не синхронизировано. Не нужно кричать об этом - достаточно тихого сигнала.
Для failed нужна явная обратная связь. К��асная иконка, inline-сообщение об ошибке, и - что особенно важно - возможность отмены. Если пользователь отправил сообщение, оно не дошло, и он видит статус failed, у него должна быть кнопка «Отменить» или «Попробовать снова». Без неё он застрял: действие как будто произошло, но на самом деле нет.
Undo для failed-состояний работает по принципу «отложенного удаления»: само действие остаётся в очереди, но помечается как отменённое. Это проще, чем откат состояния. Для чатов и лент - показывать failed-сообщения серым с иконкой ошибки и кнопкой повтора рядом. Это паттерн, который пользователи уже знают из WhatsApp и Telegram, не нужно изобретать велосипед.
Автоповтор с экспоненциальной задержкой
Наивная реализация повтора - это цикл с фиксированным интервалом. Проблема: если тысяча клиентов потеряла соединение одновременно (например, упал WiFi в офисе), то при восстановлении они все начнут долбить сервер синхронно. Это thundering herd, и он способен положить бэкенд.
Экспоненциальная задержка с джиттером решает эту проблему. Каждая следующая попытка ждёт вдвое дольше предыдущей, а случайный джиттер рассыхает запросы по времени. Вот реализация на Swift с async/await:
enum RetryError: Error { case maxAttemptsReached case cancelled } func withExponentialBackoff<T>( maxAttempts: Int = 5, baseDelay: TimeInterval = 1.0, operation: @escaping () async throws -> T ) async throws -> T { var attempt = 0 while attempt < maxAttempts { do { return try await operation() } catch { attempt += 1 guard attempt < maxAttempts else { throw RetryError.maxAttemptsReached } let delay = baseDelay * pow(2.0, Double(attempt - 1)) let jitter = TimeInterval.random(in: 0...delay * 0.3) try await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000)) } } throw RetryError.maxAttemptsReached }
Использование:
let result = try await withExponentialBackoff { try await apiClient.syncPendingActions() }
Для Combine-стека это выглядит иначе, но идея та же - .retry() в Combine не поддерживает задержку из коробки, поэтому нужен кастомный оператор или flatMap с Deferred + Future и DispatchQueue.asyncAfter.
Важный нюанс: повторы нужно останавливать, когда пользователь явно отменил действие или когда приложение ушло в фон. Иначе батарея и трафик улетают в никуда. Я обычно привязываю lifecycle повторов к жизненному циклу задачи в очереди и отменяю Task при деинициализации контроллера или view model.
Максимальное количество попыток - вопрос политики приложения. Для критичных транзакций (платёж, медицинские данные) - больше попыток и явное уведомление при исчерпании. Для некритичных (аналитика, логи) - меньше попыток, тихий fail.
Тестирование офлайн-сценариев
Здесь большинство команд срезают углы, и это заметно в продакшне. Тестирование офлайна - это не «выключить WiFi и посмотреть, что будет». Это систематическая проверка всех переходов между состояниями.
На iOS есть Network Link Conditioner - инструмент в Additional Tools for Xcode. Он позволяет симулировать разные качества сети: 100% потеря пакетов, высокий latency, нестабильное соединение. Устанавливается как системное расширение на Mac или прямо на устройство через Settings для реального железа. Я использую профиль «100% Loss» для теста полного офлайна и «Very Bad Network» для теста деградированного соединения - это разные UX-ситуации.
На Android аналог - adb shell с командами для управления состоянием сети:
# Отключить мобильный интернет adb shell svc data disable # Отключить WiFi adb shell svc wifi disable # Включить обратно adb shell svc data enable adb shell svc wifi enable
Для веба - Chrome DevTools, вкладка Network, тротлинг до «Offline» или кастомные профили.
Но помимо инструментов, важна методология. Нужно тестировать конкретные сценарии: что происходит, если соединение пропадает в процессе загрузки? В процессе отправки формы? Что если пользователь закрыл приложение, пока действие ждало в очереди, и открыл его снова? Что если токен авторизации протух за время офлайна?
Последний сценарий - особенно коварный. Пользователь возвращается онлайн, очередь начинает синхронизацию, получает 401 Unauthorized, и теперь у нас конфликт между «нужно повторить» и «нужно сначала обновить токен». Это нужно тестировать отдельно и явно обрабатывать в коде. В комментариях к статье хотелось бы услышать вашу версию обработки такой ситуации.
Unit-тесты для логики очереди стоит писать с mock-сетевым слоем, который позволяет контролировать успех/провал запросов. XCTest позволяет это сделать через протоколы и dependency injection - никакого URLSession напрямую в продакшн-коде.
Когда предупреждать о потере данных
Это самый болезненный сценарий, и он требует честности по отношению к пользователю. Если данные могут быть потеряны - нужно об этом предупредить. Не после факта, а до.
Два типичных случая. Первый: пользователь пытается выйти из несохранённого документа офлайн. Классический диалог «Сохранить / Не сохранять / Отмена» здесь недостаточен - нужно явно сказать: «У вас есть несохранённые изменения. Они не смогут быть синхронизированы прямо сейчас и сохранятся только локально». Это честнее.
Второй случай: когда очередь синхронизации слишком выросла и мы не уверены, что все данные переживут очистку кэша или обновление приложения. Здесь нужна проактивная нотификация - не обязательно пуш, но хотя бы in-app banner при следующем открытии.
Отдельная ситуация - критические данные, которые вообще нельзя сохранять только локально: медицинские записи, финансовые транзакции, юридически значимые подписи. На мой взгляд, для таких кейсов правильный UX - заблокировать действие при отсутствии сети и чётко объяснить почему. Да, это неудобно. Но потерянная транзакция или неверно синхронизированная медкарта - несравнимо хуже.
Общий принцип, которым я руководствуюсь: пользователь должен иметь возможность принять информированное решение. Если мы не можем гарантировать сохранность данных - это нужно сказать явно и дать выбор: ждать сети, сохранить локальную копию с риском, отменить действие. Молчание здесь - это ложь.
Хорошо реализованный офлайн-режим - это одно из тех мест, где приложение либо зарабатывает долгосрочное доверие пользователя, либо теряет его навсегда. Потерянное сообщение, незапомненная форма, молчащий интерфейс в метро - всё это копится. Инвестиции в офлайн-UX оку��аются не метриками, а репутацией.
