Что летит по воздуху, а что требует пересборки

обновления≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья кончилась дырой в замке: политика appVersion — это обещание, которое разработчик держит руками. Забыл поднять version, добавляя нативный пакет, — метки совпали, и обновление уехало на сборку, которой оно не по зубам. Политика fingerprint закрывает дыру: метку считают по самому нативу. Соберём отпечаток сами — набор источников, хеш каждого, хеш набора — и научимся отвечать на вопрос до публикации: эта правка долетит по воздуху или ей нужна новая сборка. Всё сверено с @expo/fingerprint 0.20.10 и прогоном в приложении автора.

Разделы темы:·

Вопрос «долетит ли правка» — это вопрос «в какую половину приложения она попала». Половины две, и собираются они из одного дерева файлов. Metro берёт из него JS и ассеты и делает бандл — он летит по воздуху. Gradle и Xcode берут нативные исходники и делают бинарник — он не летит никогда, только через магазин.

Простое правило «правка в JS долетит» на этом и ломается. В проекте есть третья корзина: файлы, которые не едут ни в бандл, ни в бинарник, а читаются инструментами во время сборки и превращаются в нативные файлы. app.config.js — тоже JS, но правка в нём меняет AndroidManifest.xml, а не бандл.

В подложке лежит модель проекта «Подход»: project.files — дерево файлов, путь и содержимое, project.config — публичный конфиг приложения. Прогноз: в какую корзину попадут иконка из конфига, app.config.js и две половины expo-sqlite?

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

Иконка оказалась в бинарнике, хотя лежит в public рядом с обычными картинками. Её имя названо в конфиге, и prebuild раскладывает её по нативным ресурсам — в res/mipmap-* на Android и в Images.xcassets на iOS; ассетом бандла она не становится. app.config.js не попал никуда: он описывает, каким быть бинарнику. А expo-sqlite разъехался пополам, как любой пакет с нативным кодом: src — в бандл, android — в бинарник.

Бандл собирает Metro, бинарник — Gradle и Xcode. Отпечаток считается по второй половине: он отвечает на вопрос, тот ли бинарник стоит на телефоне.

Казалось бы, проще всего взять хеш папки android. Но её может не быть вовсе: в проекте с prebuild нативные папки — результат, а не исходник, и создаются они перед самой сборкой. Поэтому @expo/fingerprint хеширует вход: всё, из чего prebuild и сборка делают бинарник. Список собирает Sourcer, а источники бывают трёх видов — файл, папка и просто строка (contents). У каждого записана причина, по которой он попал в список.

ИсточникВидПричина в списке
Нативная папка каждого автослинкованного пакета, node_modules/<пакет>/androidпапкаexpoAutolinkingAndroid, rncoreAutolinkingAndroid
Публичный app config целиком, с отсортированными ключамистрокаexpoConfig
Файлы, названные в конфиге: иконки, сплэш, шрифты, google-services.jsonфайлexpoConfigExternalFile
Модули плагинов конфига — те самые функции из главы 10файлexpoConfigPlugins
eas.json и .easignoreфайлeasBuild
package.json пакета react-nativeстрокаpackage:react-native
Секция scripts из вашего package.jsonстрокаpackageJson:scripts
Патчи: cng-patches и patch-packageпапка, файлexpoCNGPatches, patchPackage

Настоящий список печатает команда из самого expo-updates — та же, которой пользуется EAS Build. Запись прогона в приложении автора; второй командой вывод сведён к счёту по причинам, потому что целиком это 50 килобайт JSON:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, приложение автора (Expo SDK 57, React Native 0.86)
hash: 1c763a952dc9e1386309cd0d26818d5f688207e9
источников: 49
29 expoAutolinkingAndroid
15 rncoreAutolinkingAndroid
2 expoConfigExternalFile
1 easBuild
1 expoConfig
1 package:react-native
1 packageJson:scripts

Сорок девять источников, и ни одного файла из app или src: своего JS в отпечатке нет вовсе. Зато есть eas.json, две иконки и сорок три нативные папки из node_modules. Причин в сумме пятьдесят, а источников сорок девять: одна папка попала в список дважды, по обеим системам автослинковки, и дубликаты слиты в один источник с двумя причинами.

Соберём тот же набор для модели. Правил будет пять, и они те же, что у настоящего сборщика. Прогноз: сколько выйдет источников и окажется ли среди них хоть один файл из app или src?

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

Семь источников, своего JS среди них нет. Обратите внимание на два решения. Первое: пакет попадает в список целой нативной папкой, а не отдельными файлами, — потому что для Gradle это один модуль, и новый файл внутри него значит ровно то же, что правка старого. Второе: папка android самого проекта пропущена. В проекте с prebuild её содержимое — производное от конфига, и expo-updates так и передаёт в отпечаток ignorePaths: ['android/**/*', 'ios/**/*'], когда проект managed.

Соблазн понятный: список зависимостей вместе с их версиями и есть набор натива. Но он не знает трёх вещей. У какого пакета натив вообще есть — это решает автослинковка, а не package.json. Что содержимое пакета могли поправить, не меняя версии, — на то patch-package и cng-patches, и оба они отдельные источники. И главное: натив меняется без единой правки зависимостей — новым плагином конфига, новым значением в app.config.js, другим eas.json. Поэтому источники берутся из настоящего дерева файлов, а не из списка имён.

Дальше — арифметика, и она короткая. У каждого источника считается свой хеш: у строки — от самой строки, у файла — от содержимого, у папки — от имён и хешей её файлов, по порядку имён. Потом набор сортируется: сначала по виду (файлы, папки, строки), внутри вида — по имени. И в итоговый хеш уходят пары «имя источника плюс его хеш». В Hash.js это два вызова подряд: hasher.update(createSourceId(source)) и hasher.update(source.hash).

Алгоритм по умолчанию — SHA-1, запись — hex; в подложке лежит функция sha1, считающая ровно это. Прогноз: совпадёт ли отпечаток, если те же файлы перечислить в обратном порядке?

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

Совпал — и это не случайность, а условие работы. Порядок, в котором файлы приходят из файловой системы, на машине разработчика и на машине сборки разный; без сортировки отпечаток одного и того же проекта отличался бы от прогона к прогону, и обновление не долетало бы никуда. Вторая обязательная мелочь — имя в итоговом хеше: без него переименование пакета прошло бы незамеченным, хотя для Gradle это другой модуль. Ровно это и предлагается починить в задаче в конце статьи.

Три отличия стоит знать. Первое: файлы читаются потоком и хешируются по кусочкам, а папки обходятся рекурсивно — иначе сорок три папки node_modules не поместились бы в память. Второе: у отпечатка есть список исключений, DEFAULT_IGNORE_PATHS, и в нём не только очевидные android/build и ios/Pods, но и .DS_Store, и android/gradlew.bat — «часто приходит с другими концами строк», как сказано в комментарии исходника. Свои исключения кладут в файл .fingerprintignore. Третье: алгоритм сменный, hashAlgorithm в конфиге — любой, что понимает crypto.createHash; менять его без нужды не стоит, иначе отпечатки старых сборок перестанут совпадать с новыми.

Теперь у нас есть чем отвечать на главный вопрос статьи, и ответ механический: посчитать отпечаток до правки и после. Совпал — правка целиком в бандле и долетит по воздуху. Разошёлся — нужна новая сборка, и старые телефоны этого обновления не увидят.

Шесть правок из настоящей жизни. Прогноз: какие из них потребуют новой сборки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
ПравкаОтпечатокПочему
Новая длина таймера в экранетот жеЭкраны — бандл; в источниках их нет
Поднять react в package.jsonтот жеReact едет в бандле. Из зависимостей в отпечаток входит package.json одного лишь react-native, а ваш — только секцией scripts
Поднять react-nativeдругойЭто натив: новый react-native — новый бинарник, и его package.json для того и взят в источники
Добавить expo-hapticsдругойУ пакета есть нативная половина, автослинковка добавляет её папку в источники
Поднять version в конфигедругойКонфиг входит в отпечаток целиком, вместе с version. Натив при этом не изменился — ложная тревога
Поправить android/app/build.gradle рукамитот жеВ проекте с prebuild эта папка исключена. Отпечаток прав: правка всё равно исчезнет при следующем prebuild

Две строки стоит перечитать. Первая — про React: правка версии react долетает по воздуху, потому что React целиком живёт в бандле, и в этом смысле обновление приложения ничем не отличается от выкладки сайта из части IV. А react-native — это натив, и его package.json взят в источники именно поэтому. Вторая — про android/app/build.gradle: отпечаток не изменился, но радоваться нечему. Такая правка живёт до следующего prebuild; менять нативные файлы в managed-проекте полагается плагином конфига, а файл плагина — источник отпечатка, и он-то метку поменяет.

Остаётся ложная тревога с version. По умолчанию пропускается ровно одно: команды android и ios из секции scripts вашего package.json, если в них нет слова run; версии не пропускаются. Лечится это файлом fingerprint.config.js в корне проекта:

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

Имена в списке — это флаги перечисления SourceSkips; их можно писать строками, а можно сложить побитово. ExpoConfigVersions выбрасывает из конфига version, android.versionCode и ios.buildNumber, ExpoConfigRuntimeVersionIfString — саму runtimeVersion, если она задана строкой. Я снял два отпечатка приложения автора, обычный и с этим пропуском, и сравнил их настоящим fingerprint:diff; вывод команды сведён вторым шагом к одной строке:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, приложение автора
changed contents expoConfig bb0798bf49c5d70e8e5c62bbea8b2b4f36ae0701 -> cf711c3e9d7b2a17431e635feec11a406f115571

Изменился ровно один источник из сорока девяти — expoConfig. Этим же fingerprint:diff отвечают на вопрос «почему мне вдруг нужна новая сборка»: он показывает, какие источники добавились, пропали или поменялись между двумя отпечатками, и по reasons видно, кто их принёс.

Отпечаток видит только то, что перечислено источниками, и не понимает смысла вашего натива. Он сравнивает хеши входных файлов — не больше и не меньше.

Осталось поставить отпечаток на место метки. В конфиге это runtimeVersion: { policy: "fingerprint" }, и дальше всё происходит само. Плагин конфига не может посчитать хеш — на этом шаге ещё неизвестно, каким будет проект, — поэтому он кладёт в нативный конфиг метку-заглушку file:fingerprint. Во время сборки скрипт createFingerprintForBuildAsync считает отпечаток и пишет его в файл fingerprint рядом с бандлом. А дальше, уже на телефоне, getRuntimeVersion из UpdatesConfiguration.kt видит эту метку и читает настоящее значение из ассета fingerprint. Тот же приём на iOS, в UpdatesConfig.swift.

Публикация обновления считает отпечаток тем же самым кодом и кладёт результат в поле runtimeVersion манифеста. Сервер сверяет строки на равенство — он не изменился ни строкой, это тот же сервер из прошлой статьи, лежащий в подложке. Изменилось только то, откуда берутся строки. Прогноз: что получит сборка из магазина, когда выйдет обновление с вибрацией, и что — когда выйдет правка одного экрана?

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

В прошлой статье ровно этот сценарий кончался ошибкой ERR_UNAVAILABLE на телефоне пользователя, потому что автор забыл поднять version. Здесь забывать нечего: метка обновления посчитана по проекту, в котором expo-haptics есть, метка сборки — по проекту, в котором его нет, и они разошлись сами. А правка экрана долетела до той же старой сборки: её отпечаток не изменился, ведь экранов в источниках нет. Замок различает эти два случая, потому что смотрит туда же, куда смотрит сборка.

Цена у такого замка есть, и её стоит знать заранее. Отпечаток меняется чаще, чем номер версии: новый SDK, новый плагин, другой eas.json — и метка другая. Каждая метка — отдельная популяция телефонов, и чтобы починка долетела до всех, обновление публикуют под каждую живую метку. Сам хеш нечитаем: в EAS и в логах его показывают первыми символами, и «на какой сборке это случилось» приходится выяснять по ним. Зато пропадает целый класс происшествий — обновление, приехавшее на сборку, где нужного натива нет.

Команда из прошлой статьи работает и здесь: npx expo-updates runtimeversion:resolve --platform android. При политике appVersion она печатала runtimeVersion и fingerprintSources: null. При политике fingerprint в runtimeVersion окажется хеш, а в fingerprintSources — весь список источников, из которых он получился: по нему и видно, что именно считает сборка. Так устроен resolveRuntimeVersionAsync, и он же зовётся из EAS.

Одно предупреждение из того же исходника: политики appVersion, nativeVersion и sdkVersion требуют managed-проекта, а fingerprint работает и там, и в проекте с нативными папками в репозитории. Только источники будут другими: папки android и ios перестают быть исключением и целиком уходят в отпечаток.

Мы научились отвечать, что долетит, а что нет. Следующий вопрос — как обновление попадает на конкретные телефоны: каналы, ветки и вес того, что едет по сети. Об этом следующая статья.

  • Файлы проекта делятся на три корзины: бандл (летит по воздуху), бинарник (только через магазин) и то, что читают инструменты сборки — app.config.js, eas.json, package.json. Правило «правка в JS долетит» ломается о третью корзину.
  • Отпечаток считается не по папке android, а по входу, из которого её делают: нативные папки автослинкованных пакетов, публичный конфиг, названные в нём файлы, плагины конфига, eas.json, package.json пакета react-native. Своего JS в источниках нет.
  • Хеш набора — это SHA-1 от пар «имя источника плюс его хеш» по отсортированному списку. Сортировка нужна, чтобы порядок обхода файлов не влиял на метку, имя — чтобы переименование не прошло незамеченным.
  • Ответ на вопрос «долетит ли правка» механический: сравнить отпечаток до и после. Правка экрана и новая версия react — долетят; новый нативный пакет, другой react-native и поднятая version — требуют сборки.
  • Поднятая версия — ложная тревога, и её выключают: sourceSkips: ["ExpoConfigVersions"] в fingerprint.config.js. Что именно разошлось между двумя отпечатками, показывает npx @expo/fingerprint fingerprint:diff.
  • При политике fingerprint в нативный конфиг едет метка file:fingerprint, сборка пишет настоящий хеш в ассет fingerprint, а телефон подставляет его при чтении конфигурации. Сервер по-прежнему сравнивает две строки — просто теперь их считает не человек.

1/4Вы подняли версию react в package.json, не трогая react-native. Долетит правка обновлением по воздуху?

2/4В проекте с prebuild вы поправили android/app/build.gradle руками. Что скажет отпечаток?

3/4Зачем в итоговый хеш идут не только хеши источников, но и их имена?

4/4Откуда телефон берёт свою runtimeVersion при политике fingerprint?

Имя — половина отпечатка

Коллега написал свой отпечаток короче: сложил хеши содержимого всех источников и захешировал их. Пакет expo-sqlite заменили на форк с другим именем — для Gradle это другой модуль, а значит другой бинарник. Посмотрите, что скажет такой отпечаток, и почините его так, как это сделано в @expo/fingerprint.

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

Отпечаток не изменился: содержимое файлов то же, а имена в хеш не попали. Значит, обновление, собранное против форка, получила бы старая сборка — ровно та поломка, от которой замок и защищает. В Hash.js имя участвует дважды: внутри папки к каждому файлу приписан его путь, а в итоговый хеш идут пары «имя источника плюс его хеш».

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

Теперь «отпечаток изменился: true». Обратите внимание, что достаточно было бы и одного из двух исправлений — но в настоящем алгоритме есть оба: имя файла внутри папки ловит переименование внутри пакета, имя источника — переименование самого пакета.

Поднятая версия — не новый натив

Публичный конфиг входит в отпечаток целиком, поэтому поднятая version требует новой сборки на ровном месте. Допишите configSource так, как это делает normalizeExpoConfig при пропуске ExpoConfigVersions: версии выбрасываются из копии конфига, всё остальное остаётся.

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

Поля удаляются из копии, а не из самого конфига: он нужен сборке целиком, отпечатку — без версий.

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

Первая строка становится «совпало», вторая остаётся «разошлось»: имя приложения попадает в AndroidManifest.xml, и это настоящая смена натива. Именно так работает sourceSkips: ["ExpoConfigVersions"] в fingerprint.config.js. Цена у пропуска есть: если вы поднимаете versionCode вместе с настоящей правкой натива, метка всё равно изменится — но уже из-за правки, а не из-за номера.