«Подход»: что в подложке было ненастоящим
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
«Подход» собран целиком: справочник со своим окном, экраны со стеком и историей, дневник, который пишется в зале без сети, журнал операций, сервер-партнёр, надгробия, правило слияния, свайп по строке и кольцо таймера. Всё это лежит в подложке и работает в предпросмотре — и в этом опасность последней главы проекта: работающий пример легко принять за работающее приложение. Между ними список подмен. Часть объявил сам предпросмотр ещё в первой главе, «Что в этой песочнице настоящее»; часть мы завели сами в главах 6 и 7, когда понадобились имена, часы и сервер. Эта статья проходит по проекту, составляет опись швов и раскладывает её на три полки: что чинится кодом клиента, что проверяется только на телефоне и что не закрыть ни тем, ни другим — это работа сервера части VI. В конце — приёмка на двух устройствах: один вечер из жизни дневника, который стоит прогонять после каждой правки синхронизации.
Первая глава очертила границу платформы: Platform.OS отвечает "web", пакеты Expo не собираются вовсе, а типы react-native шире любого рантайма. Те подмены были чужие — их сделал предпросмотр. Дальше появились свои: в главе 6 имя строке начал выдавать счётчик, время — другой счётчик, а в главе 7 сервером стала функция в этой же памяти. Каждую мы вводили сознательно и с объяснением, но лежат они теперь ровно там же, где настоящий код, и ничем себя не выдают: всё работает.
Пять подмен видны одним запуском. Телефон записывает подход, правит вес, синхронизируется, «перезапускается» и удаляет запись. Прогноз: как выглядят имена и отметки времени, что вернёт pull, сколько операций окажется в журнале после перезапуска и что останется у сервера после удаления?
A-1, имена операций — A:1 и A:2: устройство названо буквой, остальное счётчик. Отметки времени — 1 и 2, то есть такты, а не миллисекунды. pull ответил тем же выражением, в котором его позвали: сервер лежит в этой же памяти, и ни промиса, ни await здесь нет. «Перезапуск» дал пустой журнал — другого места, кроме памяти кадра, у него нет. А удаление оставило у сервера строку и третью запись в журнале: надгробие, которое здесь не выбросит никто и никогда.Синхронный push — не мелочь оформления. В приложении между отправкой пачки и вердиктом проходят десятки и сотни миллисекунд, и в этот промежуток человек успевает записать ещё подход или поправить тот же самый. Изобразим промежуток таймером и посмотрим, что станет с очередью.
Пачка ушла с одной операцией, а к приходу ответа в очереди их две. Вердикт поэтому и адресован операции, а не пачке («Цикл синхронизации: отправить, забрать, не задвоить»): очередь к моменту ответа — уже другая очередь. Код, очищающий её целиком «по ответу», проходит все примеры главы 7 и теряет запись на первом же медленном соединении.
| подмена | что в предпросмотре | что на телефоне |
|---|---|---|
| сервер синхронизации | миниатюра в этой же памяти: push и pull отвечают тем же выражением | сеть: await, задержки, потерянные ответы, 401 |
| журнал операций | массив в памяти кадра, перезапуск начинает с нуля | таблицы sets и ops в expo-sqlite, транзакция на каждую запись |
| имя устройства | буква и счётчик: A-1, A:2 | randomUUID() из expo-crypto один раз при первом запуске |
| время операции | счётчик тактов: at равен 1, 2, 3 | гибридные часы поверх Date.now() |
| надгробия | копятся и живут вечно: выбрасывать их некому | срок — за сервером, и в части VI он решает надгробия не выбрасывать вовсе |
Подмена опасна не тем, что она подмена, а тем, что про неё забывают. Счётчик тактов честно назван счётчиком в статье про часы — и он же тихо держит половину эталонов этой части: поставьте на его место Date.now(), и любой эталон, где напечатано «at 1», станет неправдой.
Три подмены из пяти закрываются кодом клиента, и ни одна не трогает протокол. Имя строки, диск под журналом и часы — это источники, а правила остались прежними: имя должно быть уникальным, время — монотонным и сравнимым, диск — переживать перезапуск.
Проверим это прямо. Один и тот же случай — в зале правят вес, дома повторы того же подхода — прогоним дважды: сначала со счётчиками песочницы, потом с именами вида 3f2a91c4 и часами, начинающимися с 1 768 000 000 000. Прогноз: изменится ли итог слияния и сойдутся ли устройства во втором прогоне?
Поля совпали до знака: вес из зала и повторы из дома пережили оба прогона, устройства сошлись и там, и там. Отличается ровно одно — имя строки, потому что оно составлено из имени устройства. Значит, подмена была законной: от имени протокол просит уникальности, от at — монотонности и сравнимости, и ни одна строка правила из «Кто прав, если оба правы» не спрашивает, откуда взялось число.
Место подмены стоит собрать в одном файле — тогда переход на телефон не разбредается по коду. Приём тот же, что в статье «Одно приложение, три платформы»: рядом лежат два файла, а выбирает между ними сборщик.
Журнал и токен при этом разъезжаются по разным хранилищам: журнал — в expo-sqlite («expo-sqlite: настоящая база в телефоне»), токен синхронизации — в SecureStore, потому что это секрет, а не данные. Выгрузка дневника в файл, которая тоже упирается в настоящие пакеты, — отдельная статья этой главы, «Дневник принадлежит вам».
Проверьте свои приёмки до перехода, а не после: эталон, в котором записано «id»: «A-1», станет красным не потому, что приложение сломалось, а потому, что имя устройства стало настоящим. Это первая задача в конце статьи.
Вторая полка — подмены предпросмотра, и кодом они не чинятся вовсе: чинить нечего, это просто другая среда. Всё, что в таблице, проверено по исходникам react-native-web 0.21.2 — той самой сборки, которая выполняет примеры этой части.
| что подменено | что в предпросмотре | чем проверить на телефоне |
|---|---|---|
| нажатие | fireEvent.press — это click; hitSlop и pressRetentionOffset веб-версия Pressable не разбирает вовсе | палец: срыв нажатия, промах мимо мелкой кнопки, длинное нажатие («Нажатие — это автомат, а не onClick») |
Alert.alert | в исходнике class Alert { static alert() {} } — пустой метод, диалога нет | системный диалог и его кнопки; свой — из статьи «Modal и свой Alert вместо пустышки» |
| безопасные зоны | SafeAreaView ставит отступы из env(safe-area-inset-*), а в рамке примера они нулевые | вырез, домашняя полоска и поворот экрана |
| клавиатура | у KeyboardAvoidingView тело обработчика пустое: onKeyboardChange(event) {} | клавиатура, закрывающая поле веса на экране тренировки |
hairlineWidth | всегда 1: в исходнике рядом сказано, что дробный пиксель браузер округлил бы в ноль | тонкая линия на экране с плотностью 2 и 3 |
| жизнь процесса | «убить процесс» — это вызов функции в примере | система, выгружающая приложение из фона |
| диск и секреты | память кадра: localStorage — заглушка, IndexedDB и Cache бросают SecurityError | expo-sqlite и SecureStore на устройстве |
Ни одна строка таблицы не про ошибку React Native: это веб-сборка, которая делает вид, что она телефон. Предпросмотр при этом честнее, чем кажется, — он ещё и рисует то, чего телефон не покажет вовсе, как строку прямо внутри View («Ни div, ни класса, ни каскада»). Виды оговорок разобраны в описи песочницы из первой главы, и вся эта таблица — их пересчёт на собранный проект.
Проверка на устройстве — не «посмотреть, как выглядит». Это опыты с выключателями, и первые два делаются одной строкой в терминале:
Оба опыта проверяют главное свойство local-first: подход, записанный в зале, обязан пережить смерть процесса, потому что второй копии у него нет. К ним добавляется авиарежим — единственный способ увидеть настоящую потерю сети вместо нашего рубильника net.offline: записать без сети, убить процесс, открыть снова, включить сеть и убедиться, что подход уехал сам.
Список короткий не потому, что предпросмотр хорош, а потому, что отличия называли поимённо десять глав подряд. Собранный вместе, он и есть план первого вечера с телефоном в руке.
Третья полка — вопросы, на которые у телефона нет и не может быть данных. Их три, и все они про других: горизонт курсора (кто из устройств что уже видел), срок надгробий (когда их можно выбросить) и снимок дневника для нового телефона (что отдать тому, у кого курсор нулевой).
Последний виден запуском и стоит дороже, чем кажется. Неделя дневника: три подхода, правки веса, правки повторов, одно удаление. Потом человек покупает новый телефон. Прогноз: сколько операций ему перенесут и сколько строк из них соберётся?
Девять операций ради трёх строк, и одна из строк — надгробие, которое на экране не появится вовсе. Дальше соотношение только ухудшается: pull отдаёт историю, а новому устройству нужна не история, а итог. У дневника за год операций тысячи, и первая синхронизация превращается в тот самый перенос, который в «Цикле синхронизации» пришлось показывать числом перенесённого вместо спиннера.
Снимок вместо истории умеет отдать только сервер: у него готовые строки, а у нас журнал. То же с надгробиями — чтобы выбросить надгробие, надо знать, что его увидели все устройства, а телефон знает ровно один курсор, свой. В «Удаление — это запись» чистка по сроку воскресила подход на устройстве, пришедшем позже; починить это на клиенте нечем.
| вопрос | почему не решается на телефоне | кто отвечает в части VI |
|---|---|---|
| горизонт курсора | устройство знает только свой курсор | сервер, которому горизонт не понадобится: он не выбрасывает надгробий |
| срок надгробий | выбрасывать можно только то, что увидели все | решение сервера: не выбрасывать — цена и прогон в части VI |
| снимок для нового телефона | у клиента есть журнал, а нужны строки | выдача строк целиком в sync.pull |
Шов между частями проходит по именам: sync.push и sync.pull названы так же, как процедуры настоящего API. Когда в части VI на место миниатюры встанет сервер на Node, экраны «Подхода» не изменятся ни строкой — поменяется только то, что стоит за двумя вызовами.
Опись составлена; теперь проверка, которую стоит гонять после каждой правки синхронизации. Это не юнит-тест, а один вечер из жизни дневника, записанный кодом: зал без сети, дорога домой, правка на вебе, удаление лишнего — и сверка итога.
Шагов четыре. Два подхода записаны без сети. Дома сеть появилась, телефон синхронизировался, веб-версия забрала чужое. Дальше веб-версия правит повторы в жиме, а телефон в это же время — вес в нём же, и лишний присед удаляют на вебе. Прогноз: что напечатает каждый шаг и сойдётся ли итог с эталоном?
Первый шаг — то, ради чего всё затевалось: сети нет, ошибка есть, а подходы на экране и в очереди. Дальше очередь уходит одной пачкой, обе правки сливаются по полям — вес из зала и повторы с дивана, — а присед исчезает с обоих экранов, оставшись строкой у сервера: строк у него две, видимая одна. Последняя строка сверяет две разные вещи: итог с эталоном и телефон с веб-версией. В приёмке нужны обе, потому что каждая ловит своё — первая неверное слияние, вторая расхождение устройств.
Что эта приёмка проверяет: запись без сети, доставку по вердиктам, слияние по полям, надгробие и сходимость. Чего не проверяет — всю вторую полку: ни смерть процесса, ни настоящую потерю сети, ни клавиатуру, ни палец. Прогнать её после правки протокола — секунды; пройти тот же вечер руками на двух устройствах перед выпуском — полчаса, и в чек-лист статьи «Выпуск: сборка, магазин, первое обновление» входят обе проверки.
Первую полку закрывают три подмены и один файл, со второй начинается работа с телефоном в руке, третья ждёт части VI. А ближайшая дыра в «Подходе» — не подмена вовсе: у приложения до сих пор нет настроек, и единицы веса, тема и сброс данных в local-first устроены интереснее, чем кажется. Об этом следующая статья.
Держите приёмку рядом с кодом синхронизации, а не в планах «когда-нибудь напишем тесты»: она краснеет ровно в тот день, когда кто-то меняет правило слияния, — и это единственный день, когда об этом полезно узнать.
- Проект собран и работает — в этом и опасность. Подмен в нём пять: сервер в той же памяти, журнал в памяти кадра, имя-счётчик, время-счётчик и надгробия, которые здесь никто не чистит.
- Синхронный
pushпрячет целый класс ошибок: в приложении между отправкой и вердиктом проходит время, и очередь за него меняется — в прогоне из одной операции стало две. Поэтому вердикт адресован операции, а не пачке. - Три подмены чинятся кодом клиента: имя устройства —
randomUUID()из expo-crypto, диск — expo-sqlite, время — гибридные часы поверхDate.now(). Протокол при этом не меняется: от имени он просит уникальности, от отметки — монотонности и сравнимости. - Вторая полка кодом не чинится:
fireEvent.pressвместо пальца, пустойAlert.alert, нулевые безопасные зоны, пустойonKeyboardChange,hairlineWidth, равный единице, смерть процесса и настоящий диск. Это проверяется выключателями на устройстве: убить процесс, «не сохранять действия», авиарежим. - Третья полка ждёт сервера части VI: горизонт курсора, срок надгробий и снимок для нового телефона. У клиента нет данных, чтобы ответить, — он знает один курсор, свой, а
pullотдаёт историю: девять операций ради трёх строк. - Приёмка на двух устройствах — вечер из жизни дневника, записанный кодом: запись без сети, доставка по вердиктам, слияние по полям, надгробие и сходимость. Сверять надо устройства между собой и обязательно давать круг обоим: одного
sync()в конце мало.
1/4В песочнице server.push(batch) отдаёт вердикты тем же выражением, в котором его позвали. Какую ошибку из-за этого нельзя поймать в предпросмотре?
2/4Что из перечисленного закрывается кодом клиента, а не ждёт сервера части VI?
3/4Новый телефон синхронизируется с нуля. Что он получит от pull?
4/4Почему приёмке «два устройства» мало одного вызова sync() в конце?
Приёмка, которая проверяла не то
Эта приёмка синхронизирует телефон и сверяет его с тем, что лежит у сервера. Вечер обычный: подход записан на телефоне, веб-версия поправила повторы, телефон в это же время — вес того же подхода. Посмотрите, что печатают три строки, и почините проверку так, чтобы она говорила правду.
Сверять телефон с сервером бессмысленно: телефон только что сам отправил туда свою правку — конечно, они совпадут. Приёмка обязана сравнивать устройства между собой, а перед сравнением дать круг обоим:
Тогда вывод становится честным: у обоих weightKg 82.5 и reps 6, и приёмка говорит «сошлось»: true.
Третий вызов не лишний. С двумя — web.sync(); phone.sync(); — прогон печатает веб: … "weightKg":80 … и сошлось: false: телефон успел забрать чужие повторы, а его собственная правка веса уехала на сервер тем же вызовом и до веб-версии ещё не добралась. pull приносит только то, что уже отправлено.
Сломанная приёмка хуже отсутствующей: она зелёная при разошедшихся устройствах, а расхождение дневников — ровно та беда, ради которой всю главу 7 писали протокол.
Эталон, который держится на имени устройства
Эта проверка сверяет дневник с эталоном, записанным руками, и она зелёная — ровно до того дня, когда имя устройства станет настоящим: randomUUID() из expo-crypto вместо буквы A. Запустите и почините эталон так, чтобы «совпало» печатали обе строки.
Имя строки составлено из имени устройства, поэтому эталон должен брать его оттуда же, откуда берёт приложение, — у журнала:
Обе строки печатают «совпало». Годится и второй способ — сверять поля, а имя не сверять вовсе: const { id, ...fields } = row;, как в примере про счётчики и uuid.
Правило общее: в эталоне не место тому, что подменено. В этой части подменены и имя, и время, и приёмка, которая запомнила A-1 или at: 1, краснеет не от поломки приложения, а от перехода на настоящий телефон — в самый неподходящий день.