Всем привет! Меня зовут Евгений Прокопьев, я разработчик на React Native с 9-летним стажем. В этой статье расскажу, как мы в Купере написали собственный CodePush, который совсем не похож на продукт Microsoft.

Наш подход позволяет:

  • уменьшить вес доставки изменений примерно в 1 000 раз (для обычных продуктовых фич);

  • сократить время запуска (хотя это зависит от того, насколько большой проект и что нужно инициализировать на старте);

  • разделить ответственность за функционал на команды, где каждая отвечает за свои фичи как за отдельные независимые пакеты.

Если интересно попробовать, загляните на GitHub. Большая часть логики реализована на JS. Проект — это proof of concept, а не отдельная библиотека (хотя почти весь код лежит в отдельной папке, и вытащить его в либу довольно легко).

Важно: по тексту я говорю про JS-файлы, но все работает и для байткода. Просто «JS-файл» звучит для меня более органично.

Почему стандартный CodePush не подходит

В этой статье я не буду подробно разбирать, как работает CodePush от Microsoft, сразу расскажу о его недостатках:

  1. Загружается весь JS-бандл, вместе со всеми require-ассетами, даже если изменена всего одна строчка кода.

  2. Из-за этого невозможно бесшовно и незаметно накатывать обновления: пользователь каждый раз качает десятки мегабайт.

  3. Невозможно релизить или обновлять отдельную фичу, потому что 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, и т. д.

Что нужно от сборщика и как мы это решили

Перед нами стояло три больших задачи:

  1. Нужно, чтобы id для одного и того же модуля (читай файла) был всегда одинаковым, независимо от бандла, в котором он собирается или есть в зависимостях.

  2. Нужно, чтобы файлы попадали в сборку только один раз. Если это JS-либы, core или код какой-то фичи — все это должно быть в соответствующих бандлах и собрано только один раз.

  3. В дев-режиме сборщик должен работать по-старому.

Все это можно настроить под себя в файл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 — пишите в комментариях. Буду рад горячей дискуссии!

Комментарии (4)


  1. ltmrv
    31.07.2025 09:49

    Спасибо за интересную статью! Давно тут на хабре не было новых статей про РН.

    Подскажите пожалуйста, правильно ли я понял, что приложение на реакт-нейтив может обновляться прямо "на лету" во время пользовательской сессии? Условно, юзер открыл приложение, переходит на какую-нибудь определённую страницу, которая может лениво подгрузиться с новым обновлением?

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


    1. Evgen175 Автор
      31.07.2025 09:49

      Да, все так, на гитхабе есть пример, там как раз фичи/экраны с их логикой грузятся только при переходе на них. Это небольшие кусочки кода (если надо, то с ресурсами/картинками), потом код подгружается в движок в рантайм, запускается и рисуется UI.

      Кейс обновления интерфейса без закрытия апки мы не пытались решить никак. Мысли такие: технически конечно сложная вещь, но звучит вполне реальной. В таком кейсе есть смысл только если мы юзера оставляем на экране, на котором он был, а значит надо стейт сохранять. А тут в голову сразу приходят проблемы: если стейт поменялся между версиями, то это уже миграции писать надо какие-то... ну и все такое. В общем все это сделать как будто можно, но сложность несравнимо высока с профитом от этого.


  1. Mox
    31.07.2025 09:49

    А как у вас построен workflow? И что получает юзер после установки из стора - там сразу качаются все актуальные обновления?

    Мы просто обновления по воздуху (expo-updates) используем только для доставки хотфиксов (есть же правило сторов - не менять существенно функционал в обход).

    А доставку фич реализовали постоянно делая релизы через CI/CD (закрывая не реализованные фичи фиче-флагами).


    1. Evgen175 Автор
      31.07.2025 09:49

      И что получает юзер после установки из стора - там сразу качаются все актуальные обновления?

      Все последние на момент сборки самого приложения бандлы зашиваются в apk/ipa, поэтому юезр на первом старте не качает десятки Мб. Но если на момент запуска есть новые бандлы, то они будут обновляться.

      (есть же правило сторов - не менять существенно функционал в обход).

      На сколько я его понимаю, они просят категорически не менять приложение, а что-то дополнять всем ок. Многие компании используют те же самые флаги для показа того или иного функционала, BDUI или еще что-то и я не знаю случаев что бы за это прилетали баны. Тут подробнее инфа, пункт 3.3.1 B