Экран настроек: тема, единицы, сброс
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья кончилась описью подмен и одной дырой, которая подменой не была: у «Подхода» до сих пор нет настроек. Экран настроек кажется самым простым в приложении — переключатель, список, две кнопки, — и потому его обычно пишут не думая: всё в один ящик на устройстве. В local-first он устроен иначе, потому что настройки здесь двух разных пород. Единицы веса и длина отдыха — данные человека: они обязаны оказаться на втором устройстве вместе с дневником. Тема и «не гасить экран на тренировке» — свойства устройства, и ехать им некуда. А кнопка «Сбросить» там, где главная копия лежит в телефоне, распадается на два противоположных действия, и перепутать их — значит стереть дневник насовсем. Соберём экран целиком: одну строку в журнале, один ящик на устройстве, одну подписку на все экраны и два предупреждения, каждое со своим числом.
В настройках «Подхода» пять пунктов: единицы веса, длина отдыха между подходами, тема, «не гасить экран на тренировке» и вход в аккаунт. Готовых ответов на вопрос «где это хранить» ровно два, и оба звучат убедительно. Первый: настройки — дело устройства, значит, ящик на телефоне, тот самый из статьи «Диск под памятью: перезапуск и миграция схемы». Второй: настройки — такие же данные, значит, обычная строка журнала, которая уезжает синхронизацией вместе с подходами.
Проверим оба разом. Человек включил фунты и тёмную тему на телефоне, а потом открыл веб-версию — второе устройство того же человека. Прогноз: что он увидит в её настройках в первом случае и во втором?
Ящик не отдал ничего: у второго устройства он свой, и человек заново ищет переключатель фунтов — при том что вес в дневнике уже его. Строка журнала отдала всё, включая тему: телефон включил тёмную вечером в зале, и планшет на кухне утром стал тёмным без спросу. Обе догадки неверны не наполовину, а целиком: каждая права ровно про часть списка.
Значит, полок две, и к каждому пункту настроек вопрос один: это про человека или про устройство в руке? Ответ решает не вкус, а то, где настройка перестаёт иметь смысл. Фунты бессмысленно спрашивать дважды — человек мыслит вес одинаково везде. Тему бессмысленно переносить — у планшета другой экран, другой свет и другая система.
| Настройка | Где лежит | Почему |
|---|---|---|
| единицы веса | строка дневника, поле units | человек читает свой вес в своих единицах на любом устройстве |
| длина отдыха | строка дневника, поле restSec | это про тренировку, а не про телефон |
| тема | ящик устройства | у телефона в зале и планшета дома разный экран и разная системная тема |
| «не гасить экран» | ящик устройства | про батарею этого устройства; в вебе такого разрешения может не быть вовсе |
| вход: токен синхронизации | SecureStore, отдельно от всего | это секрет, а не данные: setItemAsync и getItemAsync из expo-secure-store 57.0.1 |
Возражение «а я хочу одинаковую тему везде» решается не переносом темы в дневник. Так же, как в операционных системах: тема следует за системной, а настройка приложения — переопределение для этого устройства. Синхронизировать при этом нечего.
Раз единицы едут с человеком, они — строка. И первая неожиданность: имя этой строки не рождается на телефоне. В статье «Журнал: имена и номера рождаются на телефоне» каждая новая строка получала имя вида A-1 — устройство плюс счётчик, — и это было правильно ровно потому, что строк много. Настроек у человека одни: телефон завёл бы A-1, веб-версия B-1, и две строки настроек не поспорили бы и не слились никогда — они просто разные строки. Поэтому имя здесь постоянное: settings.
Серверу про неё пришлось рассказать. Его проверка из статьи «Сервер — партнёр, а не хозяин: журнал, курсор, приём пачки» требовала от каждой строки полей подхода — exercise, weightKg, reps, — и отвергла бы настройки с «нет полей». В подложке этой статьи у него появилось правило для строки settings: единицы бывают kg или lb, остальное не его дело. Партнёр проверяет то, что понимает, а не всё подряд по одному шаблону.
Дальше начинается то, обо что разбивается привычный экран настроек. Вечер: на телефоне человек включает фунты, а на вебе в этот же вечер ставит отдых 90 секунд. Один и тот же экран, написанный двумя способами: «Сохранить» отправляет строку целиком — так пишут формы; переключатель отправляет одно поле. Прогноз: что окажется в настройках, когда оба устройства синхронизируются?
Форма потеряла фунты. Она отправила все поля, что были на экране, включая units: "kg" — значение, которое веб-версия видела до того, как телефон его сменил, и которого человек там не трогал. Слияние сравнивает поле с полем: отметка времени у обеих правок одна и та же — часы в подложке счётчик тактов, и такт 2 получают обе: у телефона правка вторая, а у веб-версии первая, но её гибридные часы уже подтянулись до чужого такта, — поэтому спор решает второй ключ порядка, opId, и B:1 оказывается новее A:2 («Кто прав, если оба правы: последняя запись, слияние по полям и часы»). Переключатель, отправивший одно поле, не спорил ни с кем: телефон правил units, веб-версия — restSec, и оба остались.
Правило знакомое: в «Цикле синхронизации» так накладывали пришедшее — полями, а не строкой. Здесь то же требование обращено к вашему собственному экрану: поле, которого человек не касался, не должно попадать в операцию вовсе. Экран настроек на useState с кнопкой «Сохранить» это требование нарушает по построению — он не знает, что человек трогал, а что просто видел.
Единицы — это вид, а не данные. В журнале вес всегда лежит в поле weightKg, а фунты появляются в момент отрисовки; переключатель меняет одно поле настроек и ни одной строки дневника. Соблазн «переключили — пересчитали дневник» выглядит заботой о человеке, а стоит дорого: в первой задаче статьи 82,5 килограмма превращаются в 401 фунт, и сервер тут ни при чём.
Вторая полка — ящик устройства. Внутри он устроен как хранилище дневника из «Главной копии — в телефоне»: значение в памяти, подписка, снимок, который не меняется без записи. Отличий два, и оба по сути: в журнал он не пишет ни одной операции, и на сервер не уезжает ни одним полем. Диск под ним тот же, что в «Диске под памятью»: строки по ключу, каждый вызов — промис.
Прогноз: в прошлый раз человек выбрал тёмную тему, приложение запускается снова. Что напечатает экран?
Кадра два. Первый светлый: ящик ещё не прочитан, и подписка честно отдаёт значения по умолчанию. Второй — тот, что человек выбирал. Это ровно ловушка из «Первого кадра: пока хранилище читается», только там она врала «у вас ещё нет тренировок», а здесь бьёт по глазам белым экраном в тёмной раздевалке. Механизм тот же: экран смонтирован раньше, чем прочитан диск.
Лечится тем же, чем там: ящик читается до первого кадра, рядом с лестницей миграций в boot. Заодно видно, что именно переживает перезапуск.
До чтения диска в ящике светлая тема, после — тёмная, и кадр ровно один. На телефоне промежуток между второй и третьей строкой закрывает нативный сплэш, придержанный preventAutoHideAsync; в вебе сплэша нет, и там этот промежуток — единственная причина, по которой тему читают не в эффекте, а до отрисовки.
На телефоне у ящика два соседа. Сам ящик — AsyncStorage той же формы, что в примерах. А «не гасить экран» — не поле, которое где-то лежит, а следствие поля: пока человек на экране тренировки и настройка включена, замок держит модуль expo-keep-awake 57.0.2. Хук нельзя позвать по условию, поэтому настройка включает не хук, а маленький компонент:
В вебе модуль тоже есть, но опирается на Wake Lock, который поддержан не всеми браузерами; рядом в модуле лежит isAvailableAsync(), и её описание прямо говорит: true везде, кроме браузеров без Wake Lock. Это ещё один довод не класть настройку в дневник: на одном устройстве она исполнима, на другом — нет.
У устройства есть тема и помимо вашего приложения, и сообщает о ней Appearance. Прогноз здесь особенный: ответ зависит от вашей собственной системы, поэтому сверять его надо не с учебником, а с тем, что стоит у вас.
В песочнице отвечает браузер: react-native-web смотрит на prefers-color-scheme. На телефоне — система. Поэтому в настоящем приложении у темы три значения — «системная», светлая и тёмная, а «системная» стоит по умолчанию; в примерах их два, чтобы вывод оставался коротким.
У ящика устройства нет ни журнала, ни отметок времени, ни надгробий — и это не упрощение по лени. Сливать его не с чем: второй копии этих настроек не существует нигде, и «последняя запись побеждает» здесь просто означает «человек только что нажал».
В приложении на запросах экран настроек — отдельная машинерия: сохранить, дождаться ответа, разослать событие, не забыть сбросить кеши, на которые настройка влияет. Здесь ничего этого не нужно. Настройки — строка того же хранилища, экраны подписаны на него с главы 6, а переключатель пишет в журнал такую же операцию, как запись подхода. Одно следствие пришлось учесть в подложке: строка настроек живёт среди подходов, и ленте она не нужна — поэтому рядом с visible у хранилища появился sets, те же живые строки без settings.
На экране четыре части: шапка, лента дневника, экран таймера отдыха и сам список настроек. Человек нажимает переключатель единиц. Прогноз: сколько строк напечатается до нажатия, сколько после и что покажет лента?
Три строки до нажатия и три после: одна операция в журнале — и все части экрана уже другие. Ни шапка, ни лента про переключатель не знают, между ними нет ни события, ни контекста, ни пробрасывания. Вес при этом в дневнике не менялся: 82.5 в журнале, 181.9 lb на экране — перевод живёт в отрисовке.
Одна строка вывода стоит того, чтобы на ней остановиться: таймер отдыха напечатался второй раз, хотя restSec остался прежним. Его функция чтения возвращает всю строку настроек, а её версия изменилась — значит, изменился и снимок. Это та же история, что в задаче «Шапке нужно только число» из «Главной копии»: если лишняя отрисовка дорога, функция чтения должна возвращать не строку, а то поле, ради которого экран подписывался.
«Переживают перезапуск» у двух полок обеспечено по-разному, и обе половины уже собраны раньше. Единицы лежат в журнале, журнал — на диске, лестницей миграций поднимается в boot («Диск под памятью», на телефоне — «expo-sqlite: настоящая база в телефоне»). Тема лежит в ящике устройства и читается перед первым кадром. Честная оговорка: в песочнице журнал живёт в памяти кадра — это одна из пяти подмен, описанных в «Что в подложке было ненастоящим», и перезапуск с ним здесь показать нечем. Ящик показать можно, что и сделано выше: «убили процесс», прочитали диск, получили тёмную тему.
Там, где правда на сервере, «Сбросить» значит «выбросить кеш»: данные приедут снова, и худшее, что случится, — несколько секунд загрузки. Здесь на телефоне оригинал, а неотправленное существует ровно в одном экземпляре. Поэтому кнопок две, и делают они противоположное. «Стереть копию на этом телефоне» — про устройство: дневник остаётся у человека, но на сервере. «Удалить дневник везде» — про данные: надгробие на каждую строку, и они исчезают на всех устройствах. У первой есть условие — пустая исходящая очередь.
Прогноз: что ответит «стереть» сразу после зала и что — после синхронизации; что покажет телефон сразу после стирания и что останется у сервера, когда дневник удалят везде?
Сразу после зала стирать нельзя: подход записан, но нигде, кроме этого телефона, его нет — предупреждение называет число, а не спрашивает «вы уверены?». После синхронизации то же действие безопасно, и тут выясняется главное: следующая же синхронизация принесла дневник обратно. «Стереть копию здесь» — про место на диске и про чужие руки, а не про удаление; удаление — вторая кнопка, и после неё на обоих устройствах ноль.
У сервера при этом осталась строка. Это надгробие: «Удаление — это запись: надгробие» объясняет, почему его нельзя выбросить сразу, а «Что в подложке было ненастоящим» — почему срок ему назначит сервер части VI, а не телефон. Для человека это выглядит так: дневника нет нигде, но «нигде» стоит одной строки на сервере, и сколько её хранить, решает сервер части VI.
Третья кнопка того же списка — «Выйти», и она умеет потерять запись тише всех. Прогноз: что она скажет человеку, который вышел из зала без сети?
Выход при непустой очереди — родственник уборки при 401 из «Свернули и вернулись: процесс вместо вкладки». Там приложение стирало единственный экземпляр тренировки, спутав журнал с кешем запросов; здесь то же самое делает человек своими руками, если ему не сказать цену. Очередь принадлежит не устройству, а человеку: выйти можно, но неотправленное или уезжает до выхода, или ждёт своего хозяина.
Три кнопки — два предупреждения: их требуют те две, что теряют неотправленное, и ни одно не «Вы уверены?». Человеку нужно число: «не отправлено 2 подхода». Диалог для такого вопроса в вебе берётся не у Alert.alert — он там пустой метод, — а свой, на Modal, из статьи «Modal и свой Alert вместо пустышки».
Настройки собраны: строка в журнале, ящик на устройстве, одна подписка на все экраны и два предупреждения с числами. Следующая статья доводит идею владения до конца: дневник выгружается в файл и загружается обратно без дублей — «Дневник принадлежит вам: выгрузка и загрузка обратно».
- Настройки в local-first делятся на две полки. Данные человека — единицы веса, длина отдыха — едут строкой дневника; свойства устройства — тема, «не гасить экран» — лежат в ящике и не синхронизируются вовсе. Токен живёт отдельно от обоих, в SecureStore, потому что он секрет, а не данные.
- Строка настроек одна на человека, поэтому имя у неё постоянное —
settings, а неA-1от журнала: имя с устройства дало бы по строке настроек на каждое устройство, и они никогда бы не слились. - Экран настроек отправляет только тронутое поле, а вес в журнале всегда лежит в килограммах. «Сохранить» строкой целиком проигрывает слиянию по полям — фунты стёрло значение
kg, которого веб-версия не меняла, — а пересчёт дневника при переключении даёт двойной перевод: 82,5 кг превращаются в 401 фунт. - Ящик устройства читается до первого кадра, рядом с лестницей миграций: прочитанный в эффекте после
mount, он даёт светлый кадр перед тёмным — та же ловушка, что ложное «у вас ещё нет тренировок» в «Первом кадре». - Один переключатель меняет все экраны сразу, без единого события: шапка, лента и таймер подписаны на хранилище, а переключатель пишет обычную операцию журнала.
- «Сбросить» — это две кнопки. «Стереть копию на этом телефоне» разрешено только при пустой очереди, и следующая синхронизация вернёт дневник с сервера; «удалить дневник везде» ставит надгробие на каждую строку. «Выйти» при непустой очереди предупреждает числом — это продолжение паузы при
401.
1/4Почему имя строки настроек — постоянное settings, а не выданное журналом A-1?
2/4Экран настроек написан как форма: «Сохранить» отправляет все поля разом. Телефон включил фунты, веб-версия в тот же вечер поменяла отдых. Что станет с фунтами?
3/4Тема лежит в ящике устройства, а ящик читается после mount. Что увидит человек, выбравший тёмную тему?
4/4Человек нажал «Стереть копию на этом телефоне» при пустой очереди, а потом приложение синхронизировалось. Что он увидит в дневнике?
Фунты, которые уехали в дневник
Переключатель единиц написан заботливо: выбрали фунты — пересчитали дневник, чтобы в нём лежало ровно то, что человек видит. Телефон переключился и синхронизировался, веб-версия забрала изменения. Запустите, посмотрите на вторую строку вывода и почините переключатель.
Единицы — это вид, а не данные: вес всегда хранится в килограммах, а перевод делает экран. Переключателю остаётся одна строка:
Вывод становится честным: в журнале лежит: 82.5, веб-версия показывает: 181.9 lb, операций в журнале: 3.
Откуда взялся 401 фунт: телефон записал в weightKg уже пересчитанное число, а веб-версия, получив вместе с ним настройку units: "lb", добросовестно перевела килограммы в фунты ещё раз. Пересчёт случился дважды, и заметить это по экрану телефона невозможно — там всё выглядит правильно.
У пересчёта есть и вторая цена, видная в третьей строке: каждое переключение дописывает по операции на каждый подход. На дневнике в тысячу строк одно нажатие даёт тысячу операций в исходящую очередь — и тысячу поводов для конфликта с другим устройством.
Стёрли копию, а телефон остался прежним
Человек стёр копию дневника на телефоне — очередь была пуста, всё по правилам, — открыл приложение заново и записал присед. Запустите и объясните все три строки: почему у новой строки такое имя, куда делся жим и что лежит у сервера. Потом почините стирание.
Имя устройства — часть данных, а не свойство железа: стирание копии стирает и его. После стирания устройство представляется заново:
Вывод: имя новой строки: 3f2a91c4-1, подходов на телефоне: 2, и у сервера обе строки — жим и присед.
Что ломалось до починки, ломалось дважды. Счётчики начались с нуля, и новая операция получила имя A:1 — уже известное серверу; память вердиктов из «Ответ потерялся» ответила «уже было» и не применила её, поэтому присед не уехал никуда. А жим телефон не забрал даже с нулевого курсора: фильтр эха из «Цикла синхронизации» считает операции с префиксом A: своими и пропускает их молча.
На телефоне новое имя даёт randomUUID() из expo-crypto — в песочнице оно счётчик, и это одна из подмен, перечисленных в «Что в подложке было ненастоящим». Отсюда правило: имя устройства рождается там же, где заводится журнал, и умирает вместе с ним.