Всем привет! Меня зовут Евгений Прокопьев, я разработчик на React Native с 9-летним стажем. В этой статье расскажу, как мы в Купере написали собственный CodePush, который совсем не похож на продукт Microsoft.
Наш подход позволяет:
-
уменьшить вес доставки изменений примерно в 1 000 раз (для обычных продуктовых фич);
-
сократить время запуска (хотя это зависит от того, насколько большой проект и что нужно инициализировать на старте);
-
разделить ответственность за функционал на команды, где каждая отвечает за свои фичи как за отдельные независимые пакеты.
Если интересно попробовать, загляните на GitHub. Большая часть логики реализована на JS. Проект — это proof of concept, а не отдельная библиотека (хотя почти весь код лежит в отдельной папке, и вытащить его в либу довольно легко).

Важно: по тексту я говорю про JS-файлы, но все работает и для байткода. Просто «JS-файл» звучит для меня более органично.
Почему стандартный CodePush не подходит
В этой статье я не буду подробно разбирать, как работает CodePush от Microsoft, сразу расскажу о его недостатках:
-
Загружается весь JS-бандл, вместе со всеми require-ассетами, даже если изменена всего одна строчка кода.
-
Из-за этого невозможно бесшовно и незаметно накатывать обновления: пользователь каждый раз качает десятки мегабайт.
-
Невозможно релизить или обновлять отдельную фичу, потому что JS полностью пересобирается, изменения не изолированы.
Исторически так сложилось, что весь код собирается в один JS-файл и поставляется вместе с приложением. Ну правда, зачем много отдельных файлов, если приложение доставляется через сторы и в APK/IPA можно положить все что угодно?
Но техническая возможность догружать JS-код в runtime в React Native есть уже из коробки. Нужно всего лишь написать нативный модуль для выноса этой логики в JS.
Нативный модуль на Android и iOS
Для Android код в файле Execute.kt:
package com.codepush import com.facebook.react.bridge.ReactApplicationContext import java.io.File object Execute { fun execute(path: String, reactContext: ReactApplicationContext) { val file = File(path) if (!file.exists()) { throw Exception("File does not exist at path: $path") } val catalystInstance = reactContext.catalystInstance catalystInstance?.let { it.loadScriptFromFile(path, path, false) } ?: throw Exception("CatalystInstance is not available") } }
В общем, ничего специфичного: просто передаем путь к файлу в метод, а дальше React Native делает все сам.
Для iOS все выглядит аналогично, только нужно дополнительно привести к правильному типу объект моста, который React прокидывает в экземпляр модуля. Код есть в Execute.mm:
#import "Execute.h" @interface RCTCxxBridge - (void)executeApplicationScript:(NSData *)script url:(NSURL *)url async:(BOOL)async; @end @implementation Execute + (void)execute:(NSString *)path bridge:(RCTBridge *)bridge { NSFileManager* manager = [NSFileManager defaultManager]; NSData* data = [manager contentsAtPath:path]; NSURL *url = [NSURL URLWithString:path]; __weak RCTCxxBridge *castedBridge = (RCTCxxBridge *)bridge; [castedBridge executeApplicationScript:data url:url async:YES]; } @end
И тут становится ясно: запустить отдельный файл — это скорее легкая часть истории. Теперь нужно как-то при релизной сборке получить разные JS-файлы.
Разбиение на бандлы и порядок сборки
Тут напрашивается идея разбить проект на разные бандлы/пакеты и собирать их по отдельности.
Разбить можно разными способами — например, так:

Это простейший вариант: при старте приложения запускается бандл, в котором собраны все JS-зависимости из package.json и логика, отвечающая за сам code-push, его обновление и старт первого модуля — в данном случае home. На нем уже рисуется какой-то экран, и следующий модуль грузится при навигации на него.
Но проект может быть большим, и хочется предусмотреть разные кейсы. Если стартовый экран может поменяться, если есть дизайн-система, которую используют все модули, если есть ядро приложения, то схема модулей может выглядеть уже так:

В init-бандл вынесены все нативные зависимости (их JS-часть), потому что через code-push их все равно никак не обновить, а также модуль code-push, как и в случае выше.
Дальше загружается модуль с JS-зависимостями: они вынесены в отдельный бандл на случай, если нужно будет их обновить без релиза в сторы.
Еще у приложения, вероятно, есть свое ядро, которое управляет логикой. Оно может обновляться часто — значит, лучше и его в бандл вынести, просто чтобы не грузить каждый раз лишнего.
Ну и бандл с навигацией, в котором создается стек навигации, обрабатываются диплинки, переходы… В этом же бандле живет логика, определяющая, какой модуль стартанет и, наконец, покажет приложение.
Все эти модули можно загружать параллельно, но подгружать в runtime надо именно в таком порядке, потому что навигация, вероятнее всего, зависит от core, а core строит свою логику как минимум поверх React.
Таким образом, все — от стартового бандла до навигации — можно считать основой приложения, в которой порядок загрузки бандлов вряд ли изменится.
В итоге приложение стартует, по цепочке загружаются все бандлы, навигация запускает стартовый экран, и дальше от действий пользователя будет зависеть, какой бандл запустится следующим.
Сборка отдельных бандлов и нюансы Metro
Понятно, как разбить на бандлы. Теперь о том, как отдельно этот бандл собрать (итоговый код — generateBundles.js).
Для варианта простого приложения нам нужно как минимум два бандла: init и home. Это, соответственно, две точки входа.
React Native собирает свой единственный JS-бандл примерно такой командой:
npx react-native bundle --platform ios --minify true --dev false --entry-file ./index.ts --bundle-output ./dist/index.ios.bundle --assets-dest=./dist --reset-cache
Если попробовать запустить ее для home-бандла, Metro соберет бандл — но получится не то, что ожидаешь. Для полного контекста покажу, как именно Metro собирает модули и что вообще попадает в сам бандл.
Как Metro собирает модули
Предположим, я хочу собрать файл только с одной функцией:
export function debounce(func: Function, ms: number) { let timeout: ReturnType<typeof setTimeout> | null = null; return function () { timeout && clearTimeout(timeout); timeout = setTimeout(() => { func.apply(this, arguments); }, ms); }; }
Вызываю команду, которую указывал выше, и получаю в результате файл. В нем можно найти этот debounce, много вопросов и еще кучу всего.
__d( function (g, r, i, a, m, e, d) { Object.defineProperty(e, '__esModule', {value: !0}), (e.debounce = function (n, t) { var u = null; return function () { var o = arguments, c = this; u && clearTimeout(u), (u = setTimeout(function () { n.apply(c, o); }, t)); }; }); }, 5, [], );
Тут __d — это функция, которую добавляет сборщик. Она регистрирует модули (любой файл, который подключается через import или require).
-
Первым аргументом идет функция, которая при вызове отдает результат выполнения модуля.
-
Вторым аргументом сборщик устанавливает id модуля, чтобы потом легко было брать нужные модули и указывать их как зависимости.
-
Третий аргумент — массив зависимостей этого модуля, по сути, все импорты будут перечислены тут как массив id зарегистрированных модулей.
К нюансам
-
В каждый бандл, который собирается, сборщик кладет свой runtime (та самая
__d). -
В каждый бандл попадают все зависимости, которые встретятся на пути.
-
Каждый раз id назначается просто инкрементом: первый файл, который встретился — id 1, следующий — 2, и т. д.
Что нужно от сборщика и как мы это решили
Перед нами стояло три больших задачи:
-
Нужно, чтобы id для одного и того же модуля (читай файла) был всегда одинаковым, независимо от бандла, в котором он собирается или есть в зависимостях.
-
Нужно, чтобы файлы попадали в сборку только один раз. Если это JS-либы, core или код какой-то фичи — все это должно быть в соответствующих бандлах и собрано только один раз.
-
В дев-режиме сборщик должен работать по-старому.
Все это можно настроить под себя в файлe metro.config. Не буду разбирать все возможности — у них есть довольно подробная документация. Сразу к решению проблем.
Первая задача. Надо задать уникальные id для модулей, которые будут одинаковыми вне зависимости от собираемого бандла. По дефолту Metro задает их инкрементом, и в каждой сборке начинает с 1.
В голову сразу приходит задать id строкой. Но Metro так делать не дает — runtime в таком случае падает с ошибкой. Поэтому надо придумать какое-то число.
У нас всегда есть статичный путь к файлу — давайте просто рассчитаем от него хеш, который будет всегда одинаковым для этого пути, и используем во всех местах для генерации id модуля.
export const makeHashFunc = (str) => { const seed = 0; let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); };
Эта найденная на просторах интернета функция для вычисления хеша всегда возвращает 53-битное число. Да, возможно, будут коллизии, но в Купере за все время использования они ни разу не происходили. А если такое все-таки случится, то в дев-режиме сборщик выкинет исключение с просьбой переназвать новый файл.
Вторая задача. Чтобы обеспечить дедубликацию модулей и нахождение их в ожидаемых бандлах, нужно:
-
собирать бандлы в определенном порядке (согласно принципу инверсии зависимостей — см. схему из первой части статьи);
-
где-то хранить информацию об уже собранных модулях.
Для хранения используется обычный текстовый файл, назовем его fileToIdMap.txt. Каждый раз, когда модуль при сборке попадает в бандл, делается запись в этот файл. При сборке следующего бандла становится понятно, был ли этот модуль уже собран.
Помимо этого, для сборки фича-бандлов (это все бандлы, которые не относятся к архитектуре/старту приложения, и отвечают исключительно за фичи/экраны) есть смысл еще сильнее ограничить модули, которые могут в них входить. Эти бандлы могут зависеть:
-
от других фича-бандлов, которые собираются в другой момент;
-
от любых JS-либ, которые уже должны быть собраны в init-бандле.
Поэтому сборку модулей для фича-бандлов я ограничиваю папкой, в которой этот бандл лежит. Таким образом, в фича-бандл попадет только код текущей фичи.
Третью задачу обсудим в конце.
Metro config — магия в serializer
Если все это реализовать, то для простого случая получается два Metro-конфига: metro.config.js и metro.bundle.config.js. Разберу изменения относительно стандартного конфига на примере второго из них.
Вся магия происходит в объекте serializer. Там я переопределил две функции:
-
createModuleIdFactory отвечает за вычисление id модуля. Ее результат должен возвращать целое число, чтобы все правильно работало в runtime. Также именно она записывает в fileToIdMap.txt модули, которые уже были собраны.
-
processModuleFilter отвечает за то, попадет ли текущий модуль в собираемый сейчас бандл или нет. Тут все просто: если вернет true, модуль попадет в бандл, если false — не попадет. Это конфиг для фича-бандлов, поэтому внутри есть проверка на process.env?.MODULE_PATH: в такой бандл может попасть только модуль из папки бандла.
Конфиг для сборки init-бандла выглядит похожим образом, только в нем нет ограничений на то, что туда может попасть.
Как управлять бандлами и подключать их на девайсе
Итак, я написал нативный код для добавления отдельных бандлов в runtime, а также процесс сборки этих бандлов. Осталось в JS добавить возможность запускать нужные бандлы в нужный момент.
Предположим, что все эти бандлы и конфиг (meta.json-файл) с их названиями/версиями/зависимостями есть на девайсе. Нужно просто сделать прослойку для управления ими.
Для этого я написал свой Import — функцию, которая принимает на вход имя модуля, возвращает то, что у него экспортируется из index-файла, и попутно загружает бандл в runtime, если его там еще нет.
Import ищет информацию об актуальной версии пакета в meta.json. На основе имени бандла и нужной версии он строит путь к файлу. Если у бандла есть зависимости, Import рекурсивно запускает их, чтобы они тоже подгрузились в runtime.
После загрузки в runtime всех зависимостей бандла туда подгружается и сам бандл. Важно делать это именно в такой последовательности, иначе при попытке вызвать модуль, которого нет, приложение упадет.
Потом такой кастомный импорт из бандла можно использовать с React.lazy:
const ProfileScreenLazy = React.lazy(async () => { const data = await CPImport('profile'); return {default: data.ProfileScreen}; }); export const ProfileScreen = () => { return ( <Suspense fallback={<Indicator title={'Profile'} />}> <ProfileScreenLazy /> </Suspense> ); };
Здесь ProfileScreen просто импортируется как обычный React-компонент в провайдер навигации, и при навигации на экран будет показана загрузка (или скелетон). Когда бандл подгрузится в runtime, из CPImport вернется содержимое index-файла в виде объекта.
Сборка, деплой, meta.json и работа через сеть
У нас есть весь код для сборки, загрузки в runtime и использования бандлов. Осталось добавить возможность загружать новые бандлы по сети.
Я уже упоминал конфиг meta.json. Его нет на GitHub, но вы можете его получить, запустив команду yarn build в проекте. Эта команда собирает все бандлы. Получается слепок актуального на текущий момент приложения.
Последний шаг — загрузить на сервер все архивы с бандлами и meta.json-файл, докинуть в него урлы к только что загруженным бандлам и создать простой роут, который будет отдавать этот meta-файл. Это уже не относится напрямую к code-push, поэтому реализации никакой не будет.
Это та самая третья задача. Для корректной работы дебаг-сборки я написал Babel-плагин. По сути, он просто заменяет CPImport на обычный импорт, в остальном сборка работает в привычном режиме.
Итого: что дает свой Code Split Push
При переходе на описанный механизм разработки и деплоя приложения вы точно сильно ускорите обновление приложения и сможете перейти на синхронный режим (именно он реализован в репозитории и позволяет видеть изменения в текущей сессии, а не следующей). Работает это следующим образом:
-
Актуальный meta-файл получается на старте.
-
Пользователь переходит на какой-то функционал — в этот момент код обновленного бандла загружается по сети (обычно размер не больше 50 Кб, поэтому работает быстро).
-
Код закидывается в runtime, и пользователь видит актуальный экран.
При этом вы можете обновлять бандлы независимо и делать релизы хоть несколько раз в день. Из-за маленького размера пользователь не заметит разницы по сравнению с обычной загрузкой данных для экрана.
Когда мы в Купере разбили приложение на несколько бандлов и грузили их по очереди, мы заметили, что старт приложения происходил быстрее примерно на 10%. Эффект будет варьироваться в зависимости от того, какую часть получится изолировать от старта и вынести в отдельную сущность.
Небольшая ремарка: в Купере похожая версия использовалась раньше, но пока мы вернулись к классическому CodePush.
Релизьте фичи независимо и часто, разделяйте ответственность по зонам и радуйтесь более чистой архитектуре, в которой ядро, навигация и фичи — это отдельные бандлы с четкими границами.
Если я что-то упустил или у вас есть другие идеи по реализации кастомного CodePush — пишите в комментариях. Буду рад горячей дискуссии!
ссылка на оригинал статьи https://habr.com/ru/articles/932132/
Добавить комментарий