Где кончается готовый клиент: свой dev client

сборка≈ 14 минут

Здесь будет встроен видео-разбор темы (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?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

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 проверяет, где он запущен:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На Android это не предупреждение, а исключение. Зовут проверку функции токенов и подписки на темы — всё, что нужно для пушей с сервера, а они вырезаны из Expo Go начиная с SDK 53. Сам пакет при импорте печатает ещё одно предупреждение: `expo-notifications` functionality is not fully supported in Expo Go — с советом собрать development build. Для «Подхода» это значит: локальное уведомление об окончании отдыха из главы 9 в Expo Go ещё работает, а токен для пушей с сервера на Android уже не получить.

Первые два симптома можно обойти кодом, но не вылечить. Автор так и поступил с аналитикой: SDK подключается не импортом, а через require внутри try, и без нативной половины приложение просто работает без статистики.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Для необязательного это правильный ответ: ради аналитики не стоит ронять запуск. Но если модуль — то, над чем вы работаете сейчас, такая обёртка лишь спрячет, что он не работает. Правило простое: когда фича живёт в нативном коде, которого в 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.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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 и подход?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дефис остался. Комментарий обещает его убрать, а класс символов [^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. Вот что печатает первый запуск:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника @expo/cli 57.0.18 в порядке runAndroidAsync, а не запись сеанса; вывод Gradle пропущен, путь, адрес и имя эмулятора — для примера
✔ Created native directory
✔ Updated package.json
✔ Finished prebuild
› Building app...
…
› Installing /…/android/app/build/outputs/apk/debug/app-debug.apk
› Opening exp+stack-starter://expo-development-client/?url=http%3A%2F%2F192.168.1.5%3A8081 on Pixel_8
› Logs for your project will appear below. Press Ctrl+C to exit.

Строка «Opening» — та самая ссылка из миниатюры: CLI открыл сборку по её собственной схеме. Второй запуск пойдёт быстрее: папка android/ уже есть, prebuild пропускается, и Gradle пересобирает только изменённое.

Облачный путь — EAS Build, ему посвящена следующая статья. Пока хватит того, что в eas.json приложения автора профиль для отладочной сборки уже есть:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

developmentClient: true — собрать Debug с лаунчером, distribution: internal — раздавать ссылкой, а не через магазин. И вот ловушка, на которую проект автора наступит при первом же eas build --profile development: профиль просит dev client, а пакета expo-dev-client в package.json нет. eas-cli проверяет это ещё до отправки в облако:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника eas-cli 24.8.0, build/build/utils/devClient.js, а не запись сеанса; оформление вопроса опущено
You want to build a development client build for platforms: Android
However, we detected that you don't have expo-dev-client installed for your project.
Do you want EAS CLI to install expo-dev-client for you?

Проекту без папок 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.

Запустите сборку без Metro — откроется лаунчер: раздел «Development Servers», подсказка «Start a local development server with: npx expo start» и кнопка «Enter URL manually», если адрес проще вбить руками. Metro лаунчер ищет в локальной сети, и на iOS для этого нужно разрешение «Локальная сеть». Откажете в системном запросе — список серверов останется пустым, а лаунчер покажет экран «Local Network Access Disabled». Это первое, что стоит проверить, когда сборка «не видит» Metro.

С установленным expo-dev-client команда npx expo start сама переключается на сборку: в detectDevClient.js CLI смотрит, есть ли пакет в dependencies или devDependencies. Фрагмент вывода:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника @expo/cli 57.0.18 (interactiveActions.js, commandsTable.js), а не запись сеанса; адрес — для примера
[QR-код]
› Scan the QR code above to open in a development build. (Learn more: https://expo.fyi/start)
› Metro: exp+stack-starter://expo-development-client/?url=http%3A%2F%2F192.168.1.5%3A8081
› Using development build
› Press s │ switch to Expo Go
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
› Press j │ open debugger
› Press r │ reload app
› Press m │ toggle menu
› shift+m │ more tools
› Press o │ open project code in your editor

В строке «Metro:» стоит не http://, а ссылка со схемой сборки — её же и кодирует QR-код. Клавиша s возвращает к Expo Go. Если переключиться на него при установленном пакете, CLI перед открытием проверит, нет ли на устройстве вашей сборки, а не найдя её, скажет прямо: «The expo-dev-client package is installed, but a development build is not installed on …» — и откроет Expo Go.

Меню разработчика открывается так:

ГдеКак открыть меню
Симулятор iOS⌘D или ⌃D
Эмулятор Android⌘M на Mac, Ctrl+M на Windows и Linux: эмулятор шлёт клавишу меню
Телефонвстряхнуть или долгое касание тремя пальцами. Оба жеста выключаются в настройках лаунчера, раздел «Menu Gestures»
Где угодноклавиша m в терминале, где запущен Metro
Пункт менюЧто делает
Reloadзаново скачать бандл с Metro и перезапустить JS; нативная половина при этом не перезапускается
Go homeвернуться в лаунчер, к списку серверов
Toggle performance monitorпоказать поверх экрана частоту кадров потоков UI и JS — тех двух потоков, что в статье «useNativeDriver: два потока»
Toggle element inspectorнажать на элемент и увидеть его компонент, стиль и размеры; DOM здесь нет, инспектор показывает дерево React Native
Open DevToolsоткрыть React Native DevTools — то же, что j в терминале
Fast refreshвключить или выключить подмену изменённых модулей без перезагрузки, с сохранением состояния компонентов

Подписи взяты из исходников expo-dev-menu 57.0.5, из SwiftUI-версии для iOS. В Compose-версии для Android они те же, только «Fast Refresh» с большой буквы. На обеих платформах внизу меню есть ещё кнопка «Open React Native dev menu» — старое меню самого React Native.

j или «Open DevTools» открывает React Native DevTools в Chrome или Edge. Это фронтенд браузерных DevTools, подключённый не к вкладке, а к Hermes на телефоне: консоль с вашими console.log, Sources с точками останова прямо в исходниках TypeScript, память — и вкладки React DevTools с профайлером, который записывает коммиты, — о нём шла речь в статье «Что на самом деле тормозит» части IV. Приёмы из «Отладки в браузере» части I работают почти без изменений. Нет только вкладки Elements: DOM у приложения нет, его место занимает инспектор элементов из меню.

Осталось решить, когда собирать клиент заново. Сборка содержит нативную половину такой, какой она была в момент сборки. Правки TS и JS, новые экраны, пакеты без нативного кода приезжают через Metro, как и в Expo Go. Пересборка нужна, когда меняется бинарник: пакет с нативным кодом, поля конфига, которые проходят через prebuild, — плагины, разрешения, схема, иконка, имя, — и версия SDK. Ошибиться можно и в обратную сторону: expo-haptics в Expo Go есть, а в вашей сборке его не будет, пока вы её не пересоберёте.

И последнее: dev client — сборка Debug, бандл в ней не минифицирован, а React работает в режиме разработки. Скорость приложения на ней не меряют — для этого есть сборка preview. Как отвечать на вопрос «нужна ли пересборка» не на глаз, а вычислением, покажет глава 11. А следующая статья — о профилях eas.json и сборке в облаке без Xcode.

  • 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
2npx expo install expo-haptics — вибрация, когда подход записан
3npm 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+, — берёт только их, самую длинную первой; если нет — все, тоже самую длинную первой. Допишите её.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сперва оставить только схемы с exp+, а если их нет — работать со всеми; в обоих случаях самая длинная идёт первой.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь печатается 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.