Свернули и вернулись: процесс вместо вкладки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дневник «Подхода» уже умеет всё, чему учила глава: журнал на устройстве, пачки на сервер, курсор, вердикты, надгробия, правило слияния. Не хватает последнего — момента, когда всё это запускается. В браузере таким моментом была вкладка: ушли со страницы, вернулись на страницу. У приложения предмет другой — процесс. Его сворачивают кнопкой «Домой», он висит в фоне, и система может убить его молча, не спросив и не предупредив. Отсюда AppState вместо visibilitychange — и это то самое место, где React Native не повторяет браузер, а заменяет его. А local-first меняет смысл возврата: перечитывать данные не надо, они и так на телефоне; возврат нужен очереди.
В части II уход со страницы ловили visibilitychange со значением hidden и pagehide, а на beforeunload не полагались — на мобильных он часто не срабатывает вовсе («DOMContentLoaded, load и порядок загрузки», «Сохранение и восстановление доски»). У приложения вместо вкладки процесс, и рассказывает о нём AppState. В предпросмотре он настоящий: react-native-web строит его ровно на visibilitychange — это видно в exports/AppState/index.js, где currentState переводит hidden, prerender и unloaded в background, а всё остальное — в active.
В подложке лежат leaveApp и backToApp: они переключают видимость страницы, то есть нажимают за вас «Домой» и возвращают приложение. Прогноз в три этажа: что напечатает первая строка, сколько придёт событий и что покажет currentState после того, как подписку сняли?
Три отличия от браузера видны прямо здесь. Первое: подписка возвращает объект, и снимается она своим remove(), а не removeEventListener с той же функцией. Второе: обработчик получает не событие, а строку состояния — как onChangeText получает текст, а не event. Третье: currentState продолжает жить и после remove — это геттер, он каждый раз смотрит на текущее состояние, поэтому последняя строка и говорит background, хотя события уже никто не слушал.
Состояний на телефоне больше двух, и половину из них предпросмотр показать не может — в типах react-native 0.86 их пять:
| состояние | когда наступает | в предпросмотре |
|---|---|---|
active | приложение на экране, человек с ним работает | есть: страница видима |
background | свернули, ушли в другое приложение, погасили экран | есть: visibilityState стал hidden |
inactive (iOS) | переход между фоном и экраном, шторка уведомлений, переключатель приложений, входящий звонок | нет |
unknown (iOS) | состояние ещё не определено — до первого ответа натива | нет |
extension (iOS) | код работает как расширение, а не как приложение | нет |
| событие | где оно настоящее | в предпросмотре |
|---|---|---|
change | iOS и Android | есть, приходит из visibilitychange |
memoryWarning | iOS и Android | подписка возвращается пустой |
focus и blur | только Android (appStateFocusChange) | ошибка подписки |
Таблица выше — это пересказ двух исходников: списка EVENT_TYPES в react-native-web и AppState.js в react-native 0.86. Проверим её запуском. Прогноз: четыре подписки — что вернёт каждая?
Самая неприятная строка — вторая. memoryWarning в списке разрешённых событий есть, поэтому проверка молчит, но возвращать подписку умеет только ветка change: наружу уходит undefined. Типы при этом обещают NativeEventSubscription, так что sub.remove() компилируется без единого замечания и падает в предпросмотре с TypeError. Это та же ловушка «типы шире рантайма», что в статье «Что в этой песочнице настоящее», только на этот раз внутри объекта, который в остальном работает. focus и blur честнее: их react-native-web не знает вовсе и бросает ошибку сразу.
Теперь про состояние, которого в предпросмотре нет. На iPhone между «на экране» и «в фоне» всегда стоит inactive: его дают и шторка уведомлений, и переключатель приложений, и входящий звонок — причём выйти из inactive можно обратно в active, никуда не уходя. Чтобы увидеть эту последовательность, хватает десяти строк своего эмиттера. Прогноз: сколько раз сработает синхронизация?
Четыре события, одна синхронизация. Правило простое и стоит того, чтобы записать его один раз: реагируем на active, а не на «не background» и не на любое изменение. Иначе каждая шторка уведомлений на iPhone будет стоить пачки в сеть — и это ровно та ошибка, которую вам предстоит починить в первой задаче.
Опираться лучше на события, а не на currentState. В исходнике AppState.js react-native 0.86 рядом с этим полем стоит комментарий авторов: чтобы оно было свежим, приходится держать наблюдателя, даже когда поле никому не нужно, и его стоило бы объявить устаревшим.
В вебе возврат на вкладку означает «сходи за свежим»: кеш запросов помечает данные устаревшими и перезапрашивает. В React Native это не работает само — фокуса окна тут нет, и в настоящем приложении автора фокус сообщают руками: AppState.addEventListener('change', …) зовёт focusManager.setFocused(status === 'active') (providers.tsx в stack-starter). В local-first перечитывать нечего: на экране местная копия, она и есть оригинал. Возврат нужен другому — исходящей очереди, которая всё это время ждала сети.
Экран ниже подписан на хранилище через useSyncExternalStore («Свой хук: логика без разметки» в части IV, заново не объясняем), а на AppState — обычным эффектом с уборкой. Сценарий: в зале сети нет, человек записывает подход, сворачивает телефон и открывает снова; потом выходит на улицу и открывает приложение ещё раз. Прогноз: сколько будет строк AppState и что покажет индикатор после каждого возврата?
Уход в фон не делает ничего — строка AppState: background есть, а отрисовки после неё нет. Первый возврат зовёт sync(), тот падает, и в индикаторе появляется причина. Второй возврат — сеть на месте — отправляет ту же самую очередь, которую никто не трогал, и индикатор возвращается к «сохранено на телефоне». Обратите внимание, чего в этом выводе нет: ни одного состояния запроса, ни «загрузка», ни «ошибка загрузки». Подход всё это время был на экране, потому что он всё это время был в телефоне.
В приложении подписка живёт не на экране, а в корне — там же, где в главе 6 стояла гидратация. Экранов у «Подхода» пять, и пять подписок дали бы пять синхронизаций на каждый возврат:
Возврат — не единственный повод. Их четыре: запуск приложения, появление сети, новая запись в очереди и возврат из фона. Первые три уже были в прошлых статьях, четвёртый закрывает список — и именно он ловит случай «телефон пролежал в шкафчике два часа».
В браузере на visibilitychange вешали сохранение черновика и navigator.sendBeacon: вкладку вот-вот закроют, надо успеть. В local-first успевать нечего — запись легла в журнал в момент нажатия, одним шагом со строкой («Журнал: имена и номера рождаются на телефоне»). В фоне остаётся ровно одно дело: подтолкнуть отправку. И вот её оборвать как раз могут — процесс в фоне система убивает молча, без события и без обещаний.
Прогноз: пачка ушла, сервер её применил, а ответ до телефона не доехал, потому что процесса больше нет. Что останется в очереди и сколько записей окажется в журнале сервера после того, как приложение откроют снова?
Первая строка — главная: два подхода лежат на диске, а сетевых вызовов ещё не было ни одного. Дальше процесс убили посреди отправки, и очередь осталась нетронутой — из неё уходит только то, на что пришёл вердикт. Последние две строки — работа памяти вердиктов из статьи «Ответ потерялся»: повторная пачка не добавила серверу ни строки, ни записи журнала, вердикт пришёл прежний. Убийство процесса в фоне из беды превратилось в обычную задержку — но только потому, что протокол к нему готов.
Проверять это надо на устройстве, и способы простые: на Android включить «Не сохранять действия» в параметрах разработчика или убить процесс командой adb shell am kill, на iPhone — смахнуть приложение из переключателя. Если после такого убийства неотправленное пропало, значит запись всё-таки жила в памяти, а не на диске.
Соблазн, от которого стоит отговорить заранее: «выпрошу у системы фоновое время и всё дошлю». Короткое окно в фоне системы действительно дают, и фоновые задачи в Expo есть, но строить на них доставку нельзя — окно могут не дать вовсе. Правильная опора другая: синхронизация обязана переживать убийство процесса, а не успевать до него. Всё, что для этого нужно, уже написано: журнал на диске, очередь по вердиктам, идемпотентный повтор.
Про сеть телефон спрашивают у NetInfo — @react-native-community/netinfo, пакет сообщества с нативным модулем (в песочнице он не соберётся, поэтому ниже листинг). Полей в его ответе два, и путать их дорого: isConnected — «подключение есть», isInternetReachable — «библиотека сходила наружу и получила ожидаемый ответ». В зале это ровно та разница, которая ломает синхронизацию: Wi-Fi со страницей входа честно подключён и не ведёт никуда.
Прогноз: четыре попытки в четырёх местах. Сколько из них дойдут до сервера и какой будет отсрочка у каждой?
NetInfo пригодился ровно один раз — в подвале, где попытку можно не делать вовсе. В зале он сказал «подключение есть», и это была правда, от которой не было никакого толку: отвечает не Wi-Fi, а сервер. Отсюда правило: NetInfo — это повод попробовать, а не разрешение считать, что получилось. Единственный настоящий ответ — ответ сервера, и до него очередь не трогают.
Заодно в подложке поменялся текст ошибки рубильника: раньше он говорил «сети нет», теперь — «ответа нет». Это не придирка к словам: телефон обрыв связи, портал в зале и упавший сервер не различает никак, для него всё это одно событие «ответа не было». Различать их пытается только человек, читающий индикатор, — и обещать ему больше, чем известно, не стоит.
| поле ответа NetInfo | что значит | как этим пользоваться |
|---|---|---|
type | none, wifi, cellular, ethernet, vpn… | грубая подсказка: например, не тянуть большое по сотовой |
isConnected | подключение к сети есть | повод попробовать отправку |
isInternetReachable | проверка достижимости прошла; null — «ещё не знаем» | ближе к правде, но приходит с задержкой и про ваш сервер ничего не знает |
details | ssid, carrier, isConnectionExpensive | решения «качать ли фото прямо сейчас» |
Лестница отсрочек нужна и ради батареи. Телефон в шкафчике с мёртвым Wi-Fi при постоянном интервале в секунду будит радиомодуль тысячи раз за тренировку; с удвоением до минуты — десяток. Сбрасывать лестницу надо после успеха, а не после каждой ошибки, и это третья задача статьи.
Токен живёт не вечно, и однажды сервер ответит «нужен вход». В приложении на кеше запросов реакция известна и правильна — выйти и вычистить кеш. Вот это место в настоящем проекте автора:
Для кеша запросов это ровно то, что нужно: кеш — копия серверного состояния, потерять её не страшно, а показать чужие данные — страшно. В local-first та же уборка становится катастрофой: журнал — не копия, а оригинал, и неотправленные подходы существуют ровно в одном месте на свете. Прогноз: человек записал в зале два подхода, токен за это время протух. Что останется после уборки и что — после паузы?
Первая строка — это тренировка, которой больше нет нигде: ни на экране, ни в очереди, ни на сервере. Вторая и третья — та же ситуация с паузой: синхронизация встала, очередь цела, вход её отпустил, и оба подхода уехали как были. Различить эти случаи в коде помогает код ошибки, а не её текст: 401 ставит паузу, любая другая неудача — отсрочку из прошлого раздела. И заметьте, чего пауза не делает: она не мешает записывать. Дневник в local-first пишется без токена, токен нужен только синхронизации («Первый кадр: пока хранилище читается»).
Про коды стоит сказать отдельно, потому что путаница здесь дорогая. 401 и 403 — это про запрос целиком: нет токена, нет прав. Отказ по отдельной операции ими не выражают — для него есть вердикт rejected внутри обычного ответа, и это разбиралось в «Сервер — партнёр, а не хозяин». Если перепутать, одна плохая операция начнёт выглядеть как протухший токен, и приложение будет предлагать войти заново человеку, у которого просто опечатка в повторах.
Отдельный случай: вошли ДРУГИМ пользователем. Очередь принадлежит не устройству, а человеку, и отправлять неотправленное в чужой аккаунт нельзя. Поэтому очередь помечают владельцем и при чужом входе она продолжает ждать своего — а «стереть копию на этом телефоне» остаётся отдельным осознанным действием с предупреждением о числе неотправленных.
- У приложения не вкладка, а процесс, и рассказывает о нём
AppState: подписка возвращает объект сremove(), обработчик получает строку состояния, аcurrentState— геттер, живой и после отписки. В предпросмотре состояний два, на телефоне пять:inactive,unknownиextensionесть только на iOS,focusиblur— только на Android, а подписка наmemoryWarningздесь возвращаетundefined, хотя типы обещают подписку. - Синхронизироваться надо на
active, а не на «неbackground»: на iPhone между фоном и экраном всегда стоитinactive, и его же дают шторка уведомлений и переключатель приложений. - Возврат из фона — повод позвать
sync(), а не перечитать экраны: на экране местная копия, она и есть оригинал. Подписка живёт в корне приложения, одна на все экраны. - При уходе в фон успевать нечего — запись легла на диск в момент нажатия. Убийство процесса посреди отправки не страшно: очередь пустеет только по вердиктам, а повтор пачки не добавил серверу ни строки, ни записи журнала.
- «Есть сеть» не значит «сервер доступен»: Wi-Fi зала с порталом входа даёт
isConnected: trueи ноль пользы. NetInfo — повод попробовать, ответ даёт только сервер, а неудача уезжает по лестнице отсрочек, которая сбрасывается после успеха. - Ответ
401ставит синхронизацию на паузу и не трогает ни очередь, ни журнал. Уборка в стиле кеша запросов —signOutиclear()— стирает единственный экземпляр тренировки: в прогоне после неё на экране 0, в очереди 0 и у сервера 0.
1/4Что вернёт AppState.addEventListener("memoryWarning", handler) в предпросмотре?
2/4Приложение вернулось из фона. Что делает клиент в local-first?
3/4NetInfo сообщает isConnected: true, isInternetReachable: false. Что делать синхронизации?
4/4Сервер ответил 401 на пачку с двумя неотправленными подходами. Что делает приложение?
Синхронизация на каждое шевеление
Обработчик подписан на change и синхронизируется на каждое событие. На Android это выглядит терпимо, а на iPhone — нет: между фоном и экраном всегда стоит inactive, и его же дают шторка уведомлений и переключатель приложений. Почините обработчик так, чтобы на уход в фон и возврат приходилась одна синхронизация. Эмиттер с последовательностью iPhone лежит в подложке.
Синхронизируемся на возврате, то есть на одном-единственном состоянии:
Вывод становится таким: четыре строки состояний, затем sync #1 и синхронизаций: 1. Соблазнительный вариант if (state === "background") return; проверку не проходит: inactive он пропускает, и на iPhone остаётся три синхронизации из четырёх. Проверять надо то, что нужно, а не то, что мешает: нужен active.
Пауза, которая не снимается
Синхронизация встаёт на паузу при 401 — это правильно, неотправленное цело. Человек входит снова, приложение кладёт новый токен… и подходы не уезжают никогда. Почините signIn так, чтобы после входа очередь дошла до сервера.
Пауза — это состояние, которое кто-то обязан снять. Вход — единственное событие, которое имеет на это право:
Вывод становится «после входа: отправлено | у сервера строк 2», а последняя строка — «у сервера строк: 2 | ждут отправки: 0». В настоящем приложении тут же уместен schedule(0) из «Цикла синхронизации»: ждать до следующего возврата из фона незачем, человек прямо сейчас смотрит на экран. И обратите внимание, что paused снимает именно вход, а не удачный ответ сервера, — сервер ведь и не спрашивали, пока стояла пауза.
Отсрочка, которая не растёт
Лестница отсрочек написана, массив есть, а телефон всё равно долбится в недоступный сервер раз в секунду. Найдите строку, из-за которой лестница не растёт, и почините — так, чтобы после успешной отправки отсрочка всё-таки возвращалась к началу.
Счётчик попыток сбрасывался в catch — то есть ровно там, где он обязан расти. Сбрасывать его надо после успеха:
Вывод: 1000, 2000, 4000 и «отправлено». Math.min нужен, чтобы лестница упёрлась в последнюю ступень, а не вышла за край массива и не дала undefined вместо задержки — при таком setTimeout сработал бы немедленно, и вместо редких попыток получился бы цикл.