Где кончается готовый клиент: свой dev client
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Всю часть приложение запускалось в Expo Go: npx expo start, QR-код — и через секунду на телефоне ваш экран. Прошлая статья показала, откуда берутся папки ios/ и android/, но Expo Go их не видел ни разу. Это чужое приложение из магазина: нативную половину в нём собрала команда Expo, а от вас оно берёт только JavaScript. Пока проекту хватает того, что туда вшито, это удобно. Но как только нужен модуль, которого там нет, или правка конфига, которую делает prebuild, Expo Go упирается в стену, и правкой кода её не пробить. Разберём симптомы этой стены, соберём свою отладочную сборку — dev client, — поставим её на устройство и откроем меню разработчика.
Приложение на телефоне состоит из двух половин. Нативная — бинарник: Hermes, React Native, нативные модули, Info.plist и манифест с именем, иконкой, схемами и строками разрешений. Её собирают Xcode и Gradle, и в магазин уходит именно она. Вторая половина — бандл: ваш код, собранный Metro. В отладке он не зашит в бинарник, а скачивается с Metro при каждом запуске, поэтому правка в редакторе видна на телефоне через секунду.
Expo Go подменяет только нативную половину. Это готовый бинарник, в который Expo вшила модули своего SDK и фиксированный список сторонних библиотек, а ваш бандл он скачивает с Metro, как любая отладочная сборка. Своя сборка отличается от него ровно одним: бинарник собран из вашего проекта.
| Expo Go | Своя сборка | |
|---|---|---|
| JS-код и картинки | бандл с Metro | бандл с Metro |
| Нативные модули | что вшила Expo: модули SDK и её список сторонних | что autolinking нашёл в ваших node_modules |
Info.plist и манифест: имя, иконка, схема, строки разрешений | свои, от Expo Go | из app.config.js через prebuild и плагины |
| Версия SDK | та, под которую собран этот Expo Go | та, что в package.json проекта |
Вопрос «хватит ли Expo Go» сводится к реестру из статьи «Модуль — это запись в реестре»: пакет ищет свой модуль по имени, а найдётся ли он, решает бинарник. Даже isRunningInExpoGo() из пакета expo 57 устроена так же — это поиск модуля ExpoGo, который есть только в бинарнике Expo Go. В пакете поиск делается один раз при импорте, в миниатюре — при каждом вызове. Сам пакет expo в песочнице не соберётся: expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет. Поэтому обе функции примера переписаны с исходников.
В подложке — реестр и цепочка поиска из той статьи. Один и тот же «бандл» загружается в два бинарника: в Expo Go и в свою сборку. Бандлу нужны три модуля: ExpoSecureStore из SDK Expo, RNCSafeAreaContext из react-native-safe-area-context и AppMetrica — SDK аналитики Яндекса, который стоит в приложении автора. Последние два ищутся не через requireNativeModule, а через TurboModuleRegistry React Native: так написано в их спецификациях. AppMetrica зовёт getEnforcing, который бросает, безопасные зоны — мягкий get, который вернёт null; миниатюре хватает одного getEnforcing. Прогноз: что напечатает каждый бинарник и на каком модуле споткнётся Expo Go?
Expo Go знает ExpoSecureStore — в нём весь SDK Expo — и RNCSafeAreaContext: библиотека безопасных зон входит в его список. AppMetrica в список не входит, и строка ошибки дословно та, что бросает TurboModuleRegistry.getEnforcing в react-native 0.86. Своя сборка прошла все три: autolinking нашёл AppMetrica в node_modules и вшил её. Зато модуля ExpoGo в ней нет, и isRunningInExpoGo() честно отвечает false.
Одно отличие от телефона. В миниатюре ошибку ловит try, а в пакете getEnforcing стоит на верхнем уровне файла: export default TurboModuleRegistry.getEnforcing("AppMetrica"). Он выполняется при первом импорте, то есть при загрузке бандла, и на телефоне вместо экрана «Подхода» появляется красный экран с этой строкой. Падает не кнопка аналитики, а весь запуск.
npm install меняет только JS-половину. Доставить модуль в бинарник Expo Go нельзя никак: его собрали без вас и до вас.
Стена выглядит по-разному, но причина всегда одна: нужное живёт в нативной половине, а она чужая.
| Симптом | Где видно | Причина |
|---|---|---|
TurboModuleRegistry.getEnforcing(...): 'AppMetrica' could not be found или Cannot find native module 'X' | красный экран на старте | в бинарнике Expo Go нет модуля библиотеки: AppMetrica, @react-native-firebase, любой пакет с нативным кодом вне его списка |
| Предупреждение пакета со словами «Expo Go» | консоль, а иногда исключение | модуль в Expo Go есть, но урезан — так сделано с удалёнными уведомлениями |
Правка app.config.js ни на что не влияет: ссылка appstarter:// не открывает приложение, строки разрешений чужие, имя и иконка — от Expo Go | телефон | prebuild для Expo Go не запускается: плагины и поля конфига доходят только до своей сборки |
Expo Go … is recommended for SDK … | терминал, при открытии на симуляторе или эмуляторе | Expo Go собран под одну версию SDK. На симулятор CLI предложит поставить подходящий, а на телефон из магазина встанет только тот, что лежит там сейчас |
Второй симптом легко принять за мелочь. Вот как expo-notifications 57.0.14 проверяет, где он запущен:
На Android это не предупреждение, а исключение. Зовут проверку функции токенов и подписки на темы — всё, что нужно для пушей с сервера, а они вырезаны из Expo Go начиная с SDK 53. Сам пакет при импорте печатает ещё одно предупреждение: `expo-notifications` functionality is not fully supported in Expo Go — с советом собрать development build. Для «Подхода» это значит: локальное уведомление об окончании отдыха из главы 9 в Expo Go ещё работает, а токен для пушей с сервера на Android уже не получить.
Первые два симптома можно обойти кодом, но не вылечить. Автор так и поступил с аналитикой: SDK подключается не импортом, а через require внутри try, и без нативной половины приложение просто работает без статистики.
Для необязательного это правильный ответ: ради аналитики не стоит ронять запуск. Но если модуль — то, над чем вы работаете сейчас, такая обёртка лишь спрячет, что он не работает. Правило простое: когда фича живёт в нативном коде, которого в Expo Go нет, нужна своя сборка. Для всего остального Expo Go по-прежнему хорош: чистый JS, прототип, показать экран коллеге с его телефона. Какие пакеты тянут за собой свою сборку, а каким хватает Expo Go, разбирает полка React Native и Expo — по трём вопросам: знает ли пакет SDK, есть ли у него нативная половина и вшит ли его модуль в Expo Go.
Своя отладочная сборка — это ваше приложение в конфигурации Debug плюс две вещи, за которые любят Expo Go: экран-лаунчер, который находит Metro и загружает бандл, и меню разработчика. Это пакеты expo-dev-launcher и expo-dev-menu, а expo-dev-client ставит их вместе. Для SDK 57 последняя версия — expo-dev-client 57.0.19; исходники, на которые дальше ссылается статья, — из 57.0.5 того же SDK.
app.config.js после этой команды не меняется, и это не забывчивость. expo-dev-client стоит в том же встроенном списке @expo/prebuild-config, из-за которого в прошлой статье сам собой применился expo-image-picker: prebuild применит его плагин, едва найдёт пакет. А expo install пакеты из этого списка в plugins не дописывает — в autoAddConfigPlugins.js для них стоит отдельная проверка.
Не уедет ли лаунчер в магазин? Нет, хотя платформы решают это по-разному. На iOS в expo-module.config.json обоих пакетов, в секции apple, стоит "debugOnly": true, и их поды подключаются только к Debug. На Android такого флага нет: пакеты компилируются и в релиз, но из набора исходников disableInRelease, где лаунчер и меню выключены. Настоящий код попадёт туда, только если явно включить свойства Gradle expo.devlauncher.configureInRelease и expo.devmenu.configureInRelease. Релизная сборка того же проекта — профили preview и production из следующей статьи — выйдет без работающего лаунчера и меню.
Кроме подключения двух соседей плагин expo-dev-client делает главное — даёт сборке свою URL-схему. Expo Go открывается по ссылкам exp://, а QR-код от expo start должен открыть не его, а вашу сборку. Схема выводится из slug функцией getDefaultScheme, и над заменой символов в ней стоит комментарий «Remove unallowed characters. Also remove `-` to keep this shorter.»
Соберём это на цепочке плагинов из прошлой статьи: она в подложке, и наш плагин встаёт в тот же реестр. В списке он дважды — так бывает, если вписать его в plugins руками, скажем ради параметра launchMode, а prebuild применит ещё и встроенную копию. Прогноз: какие схемы окажутся в Info.plist, какой адрес уйдёт в QR-код и что выйдет из slug Podhod_App и подход?
Дефис остался. Комментарий обещает его убрать, а класс символов [^A-Za-z0-9+\-.] его разрешает — и правильно разрешает: по RFC 3986 дефис в схеме допустим. Верьте коду, а не комментарию. Второй проход ничего не задвоил: плагин сперва ищет, нет ли уже такой схемы, как hasScheme в настоящем, а сам настоящий плагин вдобавок обёрнут в createRunOncePlugin.
Подчёркивание из Podhod_App выброшено по тому же RFC, и это кстати: constructDevClientUrl в @expo/cli отказывается строить адрес со схемой, где есть _, — в комментарии сказано, что Node такой URL не разберёт. Кириллический slug даёт ошибку уже здесь, поэтому slug держат латиницей, а русское имя живёт в name — как «Приложение» у автора.
Вторая строка — ровно тот формат, что строит UrlCreator.constructDevClientUrl в @expo/cli 57.0.18: схема сборки, служебный хост expo-development-client и адрес Metro в параметре url. Адрес закодирован целиком, иначе его : и / смешались бы с частями внешней ссылки. Телефон получает ссылку с незнакомой схемой, спрашивает систему, кто её зарегистрировал, и открывает вашу сборку, а та идёт за бандлом по адресу из параметра.
Путей два. Локальный — npx expo run:android или npx expo run:ios. Если папки платформы нет, команда сама запускает prebuild — только для этой платформы, — затем собирает проект Gradle или Xcode в Debug, ставит результат на эмулятор или подключённый телефон и запускает Metro. Для Android нужен Android Studio с SDK, для iOS — Xcode, а значит, Mac. Вот что печатает первый запуск:
Строка «Opening» — та самая ссылка из миниатюры: CLI открыл сборку по её собственной схеме. Второй запуск пойдёт быстрее: папка android/ уже есть, prebuild пропускается, и Gradle пересобирает только изменённое.
Облачный путь — EAS Build, ему посвящена следующая статья. Пока хватит того, что в eas.json приложения автора профиль для отладочной сборки уже есть:
developmentClient: true — собрать Debug с лаунчером, distribution: internal — раздавать ссылкой, а не через магазин. И вот ловушка, на которую проект автора наступит при первом же eas build --profile development: профиль просит dev client, а пакета expo-dev-client в package.json нет. eas-cli проверяет это ещё до отправки в облако:
Проекту без папок ios/ и android/ eas-cli предлагает поставить пакет сам, а отказ обрывает сборку. В проекте с закоммиченными папками он только предупреждает — «If you proceed anyway, you might not get the build you want.» — и собирает, если согласиться. Выйдет сборка без лаунчера и без схемы exp+, и QR-коду открыть будет нечего.
Готовая сборка попадает на устройство по-разному — смотря для чего она собрана:
| Сборка | Что получается | Как попадает на устройство |
|---|---|---|
Android, distribution: internal | .apk | по ссылке или QR-коду: eas-cli печатает «Open this link on your Android devices (or scan the QR code) to install the app», и QR ведёт на страницу сборки с кнопкой скачивания; телефон попросит разрешить установку из неизвестного источника. На эмулятор eas-cli ставит сам, спросив «Install and run the Android build on an emulator?»; с компьютера на телефон — adb install |
iOS, ios.simulator: true | .app для симулятора | только симулятор на Mac: eas-cli спрашивает «Install and run the iOS build on a simulator?». На iPhone такую сборку не поставить |
iOS, distribution: internal без simulator | .ipa с подписью ad hoc | только на iPhone, чей UDID зарегистрирован до сборки: eas device:create, платный аккаунт Apple Developer. Новый телефон — новая сборка: список устройств зашит в профиль подписи |
У автора профиль development собирает iOS только под симулятор. Чтобы отлаживать на своём iPhone, нужен второй профиль — без ios.simulator — и зарегистрированный телефон.
В eas-cli 24 есть и короткий путь для эмулятора и симулятора: eas build:dev ищет готовую dev-сборку с тем же отпечатком нативной части и собирает новую, только если такой нет. Профиль автора ему подходит: команда требует developmentClient: true, distribution: internal, а для iOS ещё ios.simulator: true или withoutCredentials: true. Но назвать его придётся явно, eas build:dev --profile development: по умолчанию команда ищет профиль development-simulator и, не найдя, предлагает его создать. Что такое отпечаток и почему по нему можно не пересобирать, разберёт глава 11.
- Expo Go — чужой бинарник: нативную половину в нём собрала Expo, от вас он берёт только бандл с Metro. Модуль, которого в нём нет, туда не доставить никак, а
isRunningInExpoGo()— всего лишь поиск модуляExpoGoв реестре. - Симптомы стены:
could not be foundилиCannot find native moduleна старте, предупреждения пакетов про Expo Go, правкиapp.config.js, которые ни на что не влияют, и несовпадение версии SDK. Обёртка вtryгодится для необязательного, для фичи в работе нужна своя сборка. expo-dev-client— это лаунчер и меню разработчика, которые работают только в Debug (на iOS пакеты помеченыdebugOnly, на Android в релиз идут выключенные заглушки), плюс плагин, который даёт сборке схемуexp+со slug. По этой схеме QR-код открывает вашу сборку, а та идёт за бандлом по адресу из параметраurl.- Собрать можно локально —
npx expo run:androidилиrun:ios— или в облаке:eas build --profile development, и тогда пакет обязан стоять в проекте. Сборка для симулятора на iPhone не встанет,.ipaдля внутренней раздачи — только на зарегистрированных устройствах. - Меню открывается встряхиванием,
⌘Dв симуляторе iOS,⌘Mв эмуляторе Android или клавишейmв терминале, аjоткрывает React Native DevTools. Клиент пересобирают, когда меняется нативная половина: пакет с нативным кодом, поля конфига для prebuild, версия SDK.
1/4В проект добавили @appmetrica/react-native-analytics и импортировали его в корневом файле. Что покажет Expo Go?
2/4Как isRunningInExpoGo() из пакета expo узнаёт, где запущен бандл?
3/4Пакет expo-dev-client стоит в dependencies. Попадут ли лаунчер и меню разработчика в сборку для магазина?
4/4Почему QR-код от npx expo start с установленным dev client открывает вашу сборку, а не Expo Go?
Пересобирать или нет
Клиент собран вчера. Сегодня в проекте шесть правок. Для каждой решите: хватит перезагрузки через Metro или нужна новая сборка клиента?
| № | Правка |
|---|---|
| 1 | Новый экран со списком упражнений на FlatList |
| 2 | npx expo install expo-haptics — вибрация, когда подход записан |
| 3 | npm install zustand — хранилище для экрана настроек |
| 4 | В app.config.js поменяли name с «Приложение» на «Подход» |
| 5 | В plugins у expo-image-picker выключили микрофон |
| 6 | Проект переехал с SDK 57 на SDK 58 |
Хватит Metro: 1 и 3. Экран — это JS, а zustand — пакет без нативного кода; оба приедут в бандле, как в Expo Go.
Нужна новая сборка: 2, 4, 5 и 6.
2 — у expo-haptics есть нативный модуль. В Expo Go он вшит, поэтому там всё работало бы сразу, а в вашей вчерашней сборке его нет: первый же импорт закончится «Cannot find native module». Это самая коварная строка списка: в Expo Go ошибки не было бы.
4 — name становится CFBundleDisplayName в Info.plist и app_name в strings.xml, а их пишет prebuild при сборке.
5 — параметр плагина меняет Info.plist и манифест, то есть снова работу prebuild.
6 — новый SDK несёт новые версии нативных модулей и самого React Native; старый бинарник с новым бандлом разойдётся в реестре.
Общее правило: если правка меняет что-то из нативной половины — модули или файлы, которые пишет prebuild, — клиент пересобирается.
Какую схему CLI положит в QR-код
Когда папка ios/ лежит в проекте — как после выхода из CNG в прошлой статье, — CLI не вычисляет схему из slug, а читает все схемы из Info.plist. Их там несколько: ваша appstarter, идентификатор приложения, exp+… от dev client, а у кого вход через Google или Facebook — ещё и их схемы. Функция в @expo/cli 57.0.18 выбирает так: если есть схемы, начинающиеся с exp+, — берёт только их, самую длинную первой; если нет — все, тоже самую длинную первой. Допишите её.
Сперва оставить только схемы с exp+, а если их нет — работать со всеми; в обоих случаях самая длинная идёт первой.
Теперь печатается exp+stack-starter и com.appstarter.app. В @expo/cli сортировка идёт на месте, прямо по массиву, а копия здесь — чтобы не портить входной список.
Зачем exp+ в приоритете: остальные схемы в Info.plist часто вписывают чужие SDK для своих возвратов. У входа через Google это перевёрнутый идентификатор клиента вида com.googleusercontent.apps.…, и он длиннее любой вашей схемы: без exp+ правило «самая длинная» выбрало бы его, хотя ссылки с этой схемой ждёт SDK входа, а не лаунчер. Так и написано в комментарии к функции: схемы exp+ отбираются, чтобы обойти сторонние. У самого правила «самая длинная» авторы CLI отмечают и минус — QR-код от него длиннее. Надёжнее всего иметь схему exp+, то есть expo-dev-client, или указать схему явно: npx expo start --scheme exp+stack-starter.