Офлайн-режим мобильного приложения: что сломается, когда пропадёт сеть
Самый частый способ «проверить офлайн», который я видел: включить авиарежим, открыть приложение, увидеть заглушку «Нет соединения», закрыть тикет. Заглушка есть — значит, офлайн работает.
Только пользователь теряет сеть не на главном экране, а посреди действия. Он нажал «Сохранить» в лифте, лайкнул пост в метро, дописал заметку в самолёте и убил приложение, пока ждал посадки. Баги офлайна живут не в том, что приложение показывает без сети, а в том, что происходит с его действиями, пока сети нет, и в момент, когда она возвращается.
В прошлый раз мы разбирали плохую сеть: медленную, рваную, с потерями пакетов. Сегодня — её полное отсутствие.
Сначала договориться, что вообще должно работать
Офлайн — это не одно требование, а таблица. Для каждого экрана и действия нужно заранее решить одно из трёх:
- Работает полностью. Чтение уже скачанного, черновики, локальные настройки, прохождение уровня в игре.
- Работает с отложенной отправкой. Лайк, комментарий, сохранение, отметка «прочитано» — действие принимается сразу, а на сервер уходит позже.
- Честно недоступно. Оплата, первая регистрация, поиск по серверу. Здесь нужен понятный экран, а не бесконечный спиннер.
Если такой таблицы нет, ваша первая задача — не тестировать, а спросить. Половина офлайн-багов на самом деле баги спецификации: разработчик решил, что лайк без сети можно просто выбросить, а продукт думал иначе.
Главный антипаттерн третьей категории — вечная загрузка. Запрос висит до системного таймаута в 60 секунд, пользователь смотрит на крутилку и уходит. Хорошее поведение: быстро понять, что сети нет, и сказать об этом словами, оставив навигацию живой.
Очередь действий: ничего не потерять и ничего не задвоить
Если действие «принимается сейчас, отправляется потом», у приложения есть очередь. И у очереди есть два классических бага: потеря и дубль.
Потеря. Действие сделано офлайн, а после reconnect его нет на сервере. Проверка простая: включить авиарежим, сделать 3–5 разных действий, вернуть сеть и открыть данные с другого устройства или в вебе. Всё должно доехать.
Дубль. Действие отправилось, сервер его выполнил, но ответ не дошёл — сеть пропала ровно в этот момент. Клиент считает, что отправка не удалась, и повторяет. В итоге два комментария, два заказа, двойное списание валюты. Воспроизводится так: на запросе поставить breakpoint в прокси (Proxyman, Charles), дать запросу дойти до сервера, а ответ оборвать. Защита — идемпотентность: клиент шлёт ключ операции, и сервер не выполняет её второй раз. Подробно про это был пост о повторных запросах.
Ещё три проверки очереди, которые почти всегда находят что-то:
- Порядок. Создал заметку, отредактировал, удалил — всё офлайн. После reconnect операции должны уйти в том же порядке. Если «удалить» прилетит раньше «создать», сервер вернёт ошибку, а заметка воскреснет.
- Отмена. Поставил лайк офлайн и тут же снял. На сервер правильно не отправлять ничего или отправить итоговое состояние, а не два события вперемешку.
- Ошибка при отправке. Одно действие из очереди сервер отклонил, например, пост удалили. Остальные должны уйти, а пользователь — узнать, что одно не прошло. Застрявшая очередь, которая блокирует всё после одной ошибки, — частый и неприятный баг.
Конфликты: правили в двух местах
Пользователь отредактировал заметку на телефоне в самолёте, а на планшете дома её тем временем тоже поменял. Телефон приземлился, сеть есть — чья версия победит?
Вариантов у приложения немного: побеждает последняя запись, поля сливаются, или пользователю показывают оба варианта. Ни один не «правильный», но выбранный должен быть описан и одинаково работать. Что проверять:
- два устройства, одна сущность, изменения офлайн на обоих, затем reconnect по очереди и одновременно;
- изменения разных полей (название на одном, текст на другом) — теряется ли одно из них;
- удаление на одном устройстве и редактирование на другом — воскреснет ли удалённое;
- часы на устройстве переведены назад: если конфликты решаются по времени клиента, «последней» станет старая правка.
Последний пункт особенно коварен: сравнение по времени устройства ломается, стоит пользователю съездить в другой часовой пояс или просто сбить часы.
Убили в офлайне
Самый реалистичный сценарий из всех: сети нет, пользователь сделал действие и смахнул приложение из недавних. Или система выгрузила его из памяти, пока человек отвечал на звонок.
Проверки:
- очередь неотправленных действий переживает убийство процесса — она должна лежать на диске, а не в памяти;
- при следующем запуске, уже с сетью, очередь отправляется сама, без захода на нужный экран;
- прогресс чтения, черновик, позиция в видео — на месте, а не откатились к последней синхронизации;
- первый запуск после установки без сети не падает и не висит на белом экране.
Проще всего убивать так: adb shell am force-stop <package> — это ближе к выгрузке системой, чем смахивание, и не зависит от лаунчера.
Честный кэш
Показывать офлайн последние загруженные данные — хорошо. Показывать их так, будто они свежие, — плохо. Лента трёхдневной давности без намёка на это выглядит как «ничего нового не происходит».
Что проверить:
- видно ли, что данные устаревшие: метка «обновлено вчера», плашка «нет сети»;
- после возврата сети данные обновляются сами, без pull-to-refresh;
- кэш не протухает навсегда: после долгого офлайна и reconnect не остаётся старых картинок и цен;
- цены, остатки, статусы заказов — то, где старые данные вредят, — помечены особенно явно или скрыты.
Переходы, а не состояния
Большинство багов не в «офлайне» и не в «онлайне», а в переходе между ними. Минимальный набор:
- авиарежим включается посреди запроса — индикатор загрузки должен смениться ошибкой, а не зависнуть;
- Wi-Fi без интернета (роутер есть, провайдера нет) — приложение часто думает, что сеть есть, потому что система показывает значок Wi-Fi;
- captive portal в кафе или отеле — запросы получают HTML страницы входа вместо JSON;
- сеть вернулась, пока приложение в фоне — очередь должна уйти, когда пользователь вернётся, а не только после перезапуска;
- быстрое мигание сети: включили, выключили, включили за пару секунд — не должно быть дублей и зависших состояний.
Инструменты
- Авиарежим — базовый и честный вариант, но выключает всё сразу.
- Android:
adb shell svc wifi disableиadb shell svc data disable— выключают Wi-Fi и мобильные данные по отдельности и отлично ложатся в скрипты. - iOS: Network Link Conditioner в настройках разработчика, профиль 100% Loss — сеть «есть», но ничего не проходит. Хорошо имитирует Wi-Fi без интернета.
- Прокси (Proxyman, Charles): блокировка отдельных хостов и breakpoint на ответе. Это главный инструмент для дублей: запрос дошёл, ответ — нет.
- Второе устройство или веб-версия — чтобы видеть, что реально доехало до сервера, а не верить экрану.
Чек-лист
- Есть таблица: что работает офлайн, что откладывается, что недоступно.
- Недоступные экраны без сети показывают понятное сообщение быстро, без вечного спиннера.
- Действия офлайн после reconnect все доходят до сервера.
- Обрыв ответа на запросе не создаёт дубль на сервере.
- Порядок офлайн-операций сохраняется.
- Отменённое офлайн-действие не отправляется или отправляется итоговым состоянием.
- Ошибка одного действия из очереди не блокирует остальные, пользователь о ней узнаёт.
- Конфликт правок с двух устройств решается по описанному правилу, удалённое не воскресает.
- Очередь и черновики переживают force-stop, при следующем запуске очередь уходит сама.
- Первый запуск без сети не падает и не висит.
- Устаревший кэш помечен, после возврата сети данные обновляются сами.
- Wi-Fi без интернета, captive portal и мигание сети не ломают приложение.
Офлайн редко тестируют целиком, потому что в офисе сеть всегда есть. А у пользователя её регулярно нет. Час с авиарежимом, прокси и двумя телефонами находит баги, которые иначе приходят в отзывах со словами «всё пропало».