mobile-qaofflinesync

Офлайн-режим мобильного приложения: что сломается, когда пропадёт сеть

Самый частый способ «проверить офлайн», который я видел: включить авиарежим, открыть приложение, увидеть заглушку «Нет соединения», закрыть тикет. Заглушка есть — значит, офлайн работает.

Только пользователь теряет сеть не на главном экране, а посреди действия. Он нажал «Сохранить» в лифте, лайкнул пост в метро, дописал заметку в самолёте и убил приложение, пока ждал посадки. Баги офлайна живут не в том, что приложение показывает без сети, а в том, что происходит с его действиями, пока сети нет, и в момент, когда она возвращается.

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

Сначала договориться, что вообще должно работать

Офлайн — это не одно требование, а таблица. Для каждого экрана и действия нужно заранее решить одно из трёх:

  • Работает полностью. Чтение уже скачанного, черновики, локальные настройки, прохождение уровня в игре.
  • Работает с отложенной отправкой. Лайк, комментарий, сохранение, отметка «прочитано» — действие принимается сразу, а на сервер уходит позже.
  • Честно недоступно. Оплата, первая регистрация, поиск по серверу. Здесь нужен понятный экран, а не бесконечный спиннер.

Если такой таблицы нет, ваша первая задача — не тестировать, а спросить. Половина офлайн-багов на самом деле баги спецификации: разработчик решил, что лайк без сети можно просто выбросить, а продукт думал иначе.

Главный антипаттерн третьей категории — вечная загрузка. Запрос висит до системного таймаута в 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 на ответе. Это главный инструмент для дублей: запрос дошёл, ответ — нет.
  • Второе устройство или веб-версия — чтобы видеть, что реально доехало до сервера, а не верить экрану.

Чек-лист

  1. Есть таблица: что работает офлайн, что откладывается, что недоступно.
  2. Недоступные экраны без сети показывают понятное сообщение быстро, без вечного спиннера.
  3. Действия офлайн после reconnect все доходят до сервера.
  4. Обрыв ответа на запросе не создаёт дубль на сервере.
  5. Порядок офлайн-операций сохраняется.
  6. Отменённое офлайн-действие не отправляется или отправляется итоговым состоянием.
  7. Ошибка одного действия из очереди не блокирует остальные, пользователь о ней узнаёт.
  8. Конфликт правок с двух устройств решается по описанному правилу, удалённое не воскресает.
  9. Очередь и черновики переживают force-stop, при следующем запуске очередь уходит сама.
  10. Первый запуск без сети не падает и не висит.
  11. Устаревший кэш помечен, после возврата сети данные обновляются сами.
  12. Wi-Fi без интернета, captive portal и мигание сети не ломают приложение.

Офлайн редко тестируют целиком, потому что в офисе сеть всегда есть. А у пользователя её регулярно нет. Час с авиарежимом, прокси и двумя телефонами находит баги, которые иначе приходят в отзывах со словами «всё пропало».