Всем привет! Меня зовут Евгений Прокопьев, я разработчик на 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 — пишите в комментариях. Буду рад горячей дискуссии!
Комментарии (6)
 - Mox31.07.2025 09:49- А как у вас построен workflow? И что получает юзер после установки из стора - там сразу качаются все актуальные обновления? 
 Мы просто обновления по воздуху (expo-updates) используем только для доставки хотфиксов (есть же правило сторов - не менять существенно функционал в обход).
 А доставку фич реализовали постоянно делая релизы через CI/CD (закрывая не реализованные фичи фиче-флагами). - Evgen175 Автор31.07.2025 09:49- И что получает юзер после установки из стора - там сразу качаются все актуальные обновления? - Все последние на момент сборки самого приложения бандлы зашиваются в apk/ipa, поэтому юезр на первом старте не качает десятки Мб. Но если на момент запуска есть новые бандлы, то они будут обновляться. - (есть же правило сторов - не менять существенно функционал в обход). - На сколько я его понимаю, они просят категорически не менять приложение, а что-то дополнять всем ок. Многие компании используют те же самые флаги для показа того или иного функционала, BDUI или еще что-то и я не знаю случаев что бы за это прилетали баны. Тут подробнее инфа, пункт 3.3.1 B 
 
 
           
 
ltmrv
Спасибо за интересную статью! Давно тут на хабре не было новых статей про РН.
Подскажите пожалуйста, правильно ли я понял, что приложение на реакт-нейтив может обновляться прямо "на лету" во время пользовательской сессии? Условно, юзер открыл приложение, переходит на какую-нибудь определённую страницу, которая может лениво подгрузиться с новым обновлением?
И может ли быть такое, что юзер зашел на эту страницу, увидел, условно, старый интерфейс. Потом закрыл её и спустя какое-то время снова её открыл, а там уже новый обновлённый интерфейс? При этом саму апку он не закрывал?
Evgen175 Автор
Да, все так, на гитхабе есть пример, там как раз фичи/экраны с их логикой грузятся только при переходе на них. Это небольшие кусочки кода (если надо, то с ресурсами/картинками), потом код подгружается в движок в рантайм, запускается и рисуется UI.
Кейс обновления интерфейса без закрытия апки мы не пытались решить никак. Мысли такие: технически конечно сложная вещь, но звучит вполне реальной. В таком кейсе есть смысл только если мы юзера оставляем на экране, на котором он был, а значит надо стейт сохранять. А тут в голову сразу приходят проблемы: если стейт поменялся между версиями, то это уже миграции писать надо какие-то... ну и все такое. В общем все это сделать как будто можно, но сложность несравнимо высока с профитом от этого.
ltmrv
Спасибо за ответ! Про стейт то я сразу и не подумал )
У меня возник вопрос по одному гипотетическому случаю. Возможно это будет звучать как "высосанный из пальца" пример, но я был бы благодарен, если бы вы помогли мне с ним разобраться.
Представим, что в приложении есть сервис А, который запускается при старте приложения и экран Б, который использует данные из сервиса А
1. Пользователь заходит в приложение и сервис А запускается
2. В этот момент происходит новый релиз, в котором был обновлен экран Б и сервис А (предположим был расширен стейт или апи)
3. Спустя некоторое время пользователь открывает экран Б, к нему подгружается бандл с новым экраном, который требует больше данных из сервиса А. Но сервис А был запущен со старой версией и там необходимых данных нет
Как-будто при такой последовательности могут возникнуть проблемы. Хотя, вероятно, сам шанс такого крайне невелик и им можно пренебречь? Или я упускаю какой-то момент и этой проблемы, в принципе, не существует?
Evgen175 Автор
Не такая уж и высосанная из пальца ситуация, в этом случае все отработает хорошо, такой порядок:
При запуске будет скачена вся мета инфа о самых актуальных модулях (этакий пекедж джейсон)
Если после старта будет новый релиз, то юзер все равно до перезапуска про это не узнает
Ну а бандлы будут качаться при заходе на экраны в соответствии с последней загруженной метой