Что летит по воздуху, а что требует пересборки
Здесь будет встроен видео-разбор темы (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?
Иконка оказалась в бинарнике, хотя лежит в 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:
Сорок девять источников, и ни одного файла из app или src: своего JS в отпечатке нет вовсе. Зато есть eas.json, две иконки и сорок три нативные папки из node_modules. Причин в сумме пятьдесят, а источников сорок девять: одна папка попала в список дважды, по обеим системам автослинковки, и дубликаты слиты в один источник с двумя причинами.
Соберём тот же набор для модели. Правил будет пять, и они те же, что у настоящего сборщика. Прогноз: сколько выйдет источников и окажется ли среди них хоть один файл из app или src?
Семь источников, своего 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, считающая ровно это. Прогноз: совпадёт ли отпечаток, если те же файлы перечислить в обратном порядке?
Совпал — и это не случайность, а условие работы. Порядок, в котором файлы приходят из файловой системы, на машине разработчика и на машине сборки разный; без сортировки отпечаток одного и того же проекта отличался бы от прогона к прогону, и обновление не долетало бы никуда. Вторая обязательная мелочь — имя в итоговом хеше: без него переименование пакета прошло бы незамеченным, хотя для Gradle это другой модуль. Ровно это и предлагается починить в задаче в конце статьи.
Три отличия стоит знать. Первое: файлы читаются потоком и хешируются по кусочкам, а папки обходятся рекурсивно — иначе сорок три папки node_modules не поместились бы в память. Второе: у отпечатка есть список исключений, DEFAULT_IGNORE_PATHS, и в нём не только очевидные android/build и ios/Pods, но и .DS_Store, и android/gradlew.bat — «часто приходит с другими концами строк», как сказано в комментарии исходника. Свои исключения кладут в файл .fingerprintignore. Третье: алгоритм сменный, hashAlgorithm в конфиге — любой, что понимает crypto.createHash; менять его без нужды не стоит, иначе отпечатки старых сборок перестанут совпадать с новыми.
Теперь у нас есть чем отвечать на главный вопрос статьи, и ответ механический: посчитать отпечаток до правки и после. Совпал — правка целиком в бандле и долетит по воздуху. Разошёлся — нужна новая сборка, и старые телефоны этого обновления не увидят.
Шесть правок из настоящей жизни. Прогноз: какие из них потребуют новой сборки?
| Правка | Отпечаток | Почему |
|---|---|---|
| Новая длина таймера в экране | тот же | Экраны — бандл; в источниках их нет |
Поднять 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 в корне проекта:
Имена в списке — это флаги перечисления SourceSkips; их можно писать строками, а можно сложить побитово. ExpoConfigVersions выбрасывает из конфига version, android.versionCode и ios.buildNumber, ExpoConfigRuntimeVersionIfString — саму runtimeVersion, если она задана строкой. Я снял два отпечатка приложения автора, обычный и с этим пропуском, и сравнил их настоящим fingerprint:diff; вывод команды сведён вторым шагом к одной строке:
Изменился ровно один источник из сорока девяти — expoConfig. Этим же fingerprint:diff отвечают на вопрос «почему мне вдруг нужна новая сборка»: он показывает, какие источники добавились, пропали или поменялись между двумя отпечатками, и по reasons видно, кто их принёс.
Отпечаток видит только то, что перечислено источниками, и не понимает смысла вашего натива. Он сравнивает хеши входных файлов — не больше и не меньше.
Осталось поставить отпечаток на место метки. В конфиге это runtimeVersion: { policy: "fingerprint" }, и дальше всё происходит само. Плагин конфига не может посчитать хеш — на этом шаге ещё неизвестно, каким будет проект, — поэтому он кладёт в нативный конфиг метку-заглушку file:fingerprint. Во время сборки скрипт createFingerprintForBuildAsync считает отпечаток и пишет его в файл fingerprint рядом с бандлом. А дальше, уже на телефоне, getRuntimeVersion из UpdatesConfiguration.kt видит эту метку и читает настоящее значение из ассета fingerprint. Тот же приём на iOS, в UpdatesConfig.swift.
Публикация обновления считает отпечаток тем же самым кодом и кладёт результат в поле runtimeVersion манифеста. Сервер сверяет строки на равенство — он не изменился ни строкой, это тот же сервер из прошлой статьи, лежащий в подложке. Изменилось только то, откуда берутся строки. Прогноз: что получит сборка из магазина, когда выйдет обновление с вибрацией, и что — когда выйдет правка одного экрана?
В прошлой статье ровно этот сценарий кончался ошибкой 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.
Отпечаток не изменился: содержимое файлов то же, а имена в хеш не попали. Значит, обновление, собранное против форка, получила бы старая сборка — ровно та поломка, от которой замок и защищает. В Hash.js имя участвует дважды: внутри папки к каждому файлу приписан его путь, а в итоговый хеш идут пары «имя источника плюс его хеш».
Теперь «отпечаток изменился: true». Обратите внимание, что достаточно было бы и одного из двух исправлений — но в настоящем алгоритме есть оба: имя файла внутри папки ловит переименование внутри пакета, имя источника — переименование самого пакета.
Поднятая версия — не новый натив
Публичный конфиг входит в отпечаток целиком, поэтому поднятая version требует новой сборки на ровном месте. Допишите configSource так, как это делает normalizeExpoConfig при пропуске ExpoConfigVersions: версии выбрасываются из копии конфига, всё остальное остаётся.
Поля удаляются из копии, а не из самого конфига: он нужен сборке целиком, отпечатку — без версий.
Первая строка становится «совпало», вторая остаётся «разошлось»: имя приложения попадает в AndroidManifest.xml, и это настоящая смена натива. Именно так работает sourceSkips: ["ExpoConfigVersions"] в fingerprint.config.js. Цена у пропуска есть: если вы поднимаете versionCode вместе с настоящей правкой натива, метка всё равно изменится — но уже из-за правки, а не из-за номера.