Головна / Статті

Де ховається затримка у React Native: перетини меж, а не повільний код

Дізнайтеся, чому швидкі API та компоненти з мемоюванням все ще можуть працювати повільно в React Native, та як батчування, обмеження частоти викликів та підхід, характерний для епохи JSI, зменшують витрати між різними версіями движка.

Видавець: REACTAPP.TOP19 хв читання4454 слів

Джерела: The Hidden Latency Cost in React Native Bridge Architectures

Екран React Native може виглядати повільним навіть тоді, коли всі показники працездатності здаються нормальними: API відповідає приблизно за 100 мс, запити індексуються, компоненти зберігаються у пам’яті, а зображення завантажуються з кешу. Проте натискання елементів реєструється з занадто великою затримкою, нижній фоновий екран працює з перешкодами, маркери на карті відстають від реальності, анімація, яка бездоганно працює на флагманському телефоні, «трясеться» на бюджетному пристрої на базі Android, а сутичка сенсорних подій блокує інтерфейс. У багатьох з цих випадків втрачений час витрачається там, де команди рідко використовують інструменти для моніторингу — під час передачі даних між середовищем виконання JavaScript та нативним кодом.

Цей посібник пояснює, як відбувався цей перехід у рамках старої архітектури Bridge, чому її витрати накопичуються замість того, щоб проявлятися у окремому виклику, що саме змінює Нова архітектура (JSI, TurboModules, Fabric та перероблений рендерер), і які принципи проектування дозволяють зберігати низькі витрати на обмін даними між різними середовищами виконання, незалежно від обраної архітектури. Нова архітектура є стандартною з версії React Native 0.76, проте архітектуру Bridge все ще варто розуміти: багато продакшн-додатків використовують старі модулі, шари взаємодії, застарілі бібліотеки та патерни, сформовані саме нею. Що ще важливіше, видалення Bridge не робить погано спроєктований обмін даними між середовищами виконання безкоштовним.

Два середовища виконання в одному додатку

Корисною уявною моделлю є те, що додаток React Native працює принаймні у двох середовищах. Ваші компоненти, стан, бізнес-правила та мережеві функції знаходяться у JavaScript-частині; все, що стосується операційної системи, — у нативній частині.

┌──────────────────────────────┐
│        JavaScript / TS       │
│                              │
│ React Components             │
│ State Management             │
│ Business Logic               │
│ API Handling                 │
└──────────────┬───────────────┘
               │
               │ JS ↔ Native communication
               │
┌──────────────▼───────────────┐
│          Native Side         │
│                              │
│ Android / iOS APIs           │
│ Camera                       │
│ Bluetooth                    │
│ GPS                          │
│ Storage                      │
│ Native Views                 │
│ Sensors                      │
└──────────────────────────────┘

Код React може виконуватися у JavaScript, але майже кожна значуща функція зрештою потребує підтримки платформи. Типові причини для взаємодії з платформою включають:

  • відображення нативних інтерфейсів користувача,
  • запуск камери,
  • читання координат GPS,
  • доступ до файлової системи,
  • активація гаптичних ефектів,
  • взаємодія з пристроями Bluetooth,
  • використання захищеного зберігання даних,
  • виклик нативних SDK від сторонніх розробників,
  • отримання даних з датчиків,
  • використання інших API, специфічних для платформи.

Отже, дані постійно переміщуються між двома середовищами. У класичній архітектурі весь цей трафік проходив через React Native Bridge.

Як старий Bridge передавав виклики

Спочатковий дизайн навмисно розділяв виконання коду на JavaScript та виконання в нативному середовищі. Одна поїздка з одного боку на інший виглядала приблизно так:

JavaScript
    ↓
Prepare parameters
    ↓
Serialize data
    ↓
Queue bridge message
    ↓
Cross execution boundary
    ↓
Native receives message
    ↓
Native performs operation
    ↓
Prepare result
    ↓
Serialize result
    ↓
Queue response
    ↓
JavaScript receives result

Ключовою властивістю було те, що весь обмін даними відбувався асинхронно. Аргументи серіалізувалися, поміщалися у чергу, передавалися через межу, десеріалізувалися та оброблялися у нативному середовищі, а будь-який результат повертався назад тим самим шляхом. Команда React Native у своїй новій архітектурі назвала серіалізацію та асинхронне з’єднання основними обмеженнями старої моделі.

Для періодичних викликів така надбавка є нешкідливою. Гарним прикладом є запис рядка у буфер копіювання:

await Clipboard.setString("Hello");

Якою б не була вартість перетину тут, жоден користувач її ніколи не відчує. Проблеми починаються тоді, коли цей межовий канал стає високочастотним каналом, на який додаток постійно покладається.

Один виклик — це дешево; проблема в самій схемі

У дискусіях про продуктивність Bridge часто припускають, що кожен виклик є надто дорогим. Таке тлумачення не відображає суті проблеми. Справжнє уповільнення зазвичай виникає внаслідок поєднання кількох факторів, які множаться один на одного:

частота × розмір даних × тиск у плануванні × вимоги до синхронізації

Припустимо, що один перетин коштує майже нічого. Тепер збільшимо кількість таких перетинів:

1 call
10 calls
100 calls
1,000 calls
10,000 calls

Тоді потрібно збільшувати розмір кожного пакета даних, постійно надсилати оновлення під час прокрутки користувачем або вимагати подорожі від Native → JavaScript → Native перед тим, як можна буде намалювати наступний візуальний стан. Витрати, які раніше були незначними окремо, тепер впливають на критичний шлях відображення. Жодна окрема функція не здається повільною в профілері; затримка розподіляється по всій системі, і саме тому вона залишається непомітною.

Розглядайте затримку взаємодії як ланцюг обробки

Корисно записати кожен етап, через який проходить взаємодія:

Total Latency =
    JS Work
  + Serialization
  + Queue Waiting
  + Context Boundary Cost
  + Native Work
  + Return Communication
  + Rendering Delay

Команди схильні налаштовувати один елемент окремо, зазвичай роботу на рівні Native:

Native Work

або роботу на рівні JavaScript:

JS Work

Однак користувач бачить лише сумарний результат:

TOTAL LATENCY

Розгляньмо операцію на рівні Native, яка завершується за:

2 ms

Саме по собі це чудове число. Але повний розбивок може виглядати так:

JS preparation             2 ms
Serialization              2 ms
Queue waiting              4 ms
Native operation           2 ms
Response communication     3 ms
React update               4 ms
--------------------------------
Total                     17 ms

„Операція тривалістю 2 мс“ перетворилась на взаємодію тривалістю приблизно 17 мс. У документації React Native описується бюджет кадру приблизно 16,67 мс при 60 FPS; якщо його перевищити, кадр може бути пропущений. Багато сучасних телефонів оновлюють зображення швидше, ніж 60 Гц, що ще більше скорочує цей бюджет.

Бюджети кадрів залишають мало простору для додаткових витрат

При 60 Гц арифметика виглядає так:

1000 ms / 60 ≈ 16.67 ms

Це дає приблизно

16.67 ms

на створення кожного кадру. При 120 Гц цей час зменшується вдвічі:

1000 ms / 120 ≈ 8.33 ms

Приблизно вісім мілісекунд — це дуже мало. Якщо частина цього часу витрачається на безпотрібну передачу даних туди-сюди між середовищами виконання, сама архітектура споживає бюджет на відображення. Симптоми знайомі:

  • затримки в анімаціях,
  • жести, які реагують з запізненням,
  • У документації щодо продуктивності також розділяють FPS потоку JavaScript та FPS основного потоку інтерфейсу. Коли потік JavaScript перевантажений, він не може швидко обробляти події дотику чи оновлення інтерфейсу, здійснені за допомогою JavaScript, тоді як взаємодії, які виконуються нативно, наприклад прокручування списку, можуть залишатися плавними. Саме це розділення пояснює класичну проблему під час дебаггінгу: одна частина екрана виглядає плавною, тоді як інша — застиглою.

    Сценарій: передача координат у карту

    Native GPS
        ↓
    Bridge
        ↓
    JavaScript
        ↓
    State Update
        ↓
    React Render
        ↓
    Bridge
        ↓
    Native Map
        ↓
    Marker Position Updated
    

    Тепер запустіть цей цикл для кожної корекції. Ні обчислення положення, ні переміщення маркера самі по собі не є ресурсоємними, але архітектура створює постійні повторювані операції:

    Native → JS → Native
    

    Він працює знову та знову, поки екран залишається відкритим. Якщо показати багато драйверів одночасно, обсяг даних значно зростає:

    Driver 1 position
    Driver 2 position
    Driver 3 position
    Driver 4 position
    ...
    Driver 100 position
    

    Надсилання сотні дрібних оновлень по одному може бути набагато дорожчим, ніж надсилання однієї добре підготовленої групи оновлень, яка описує їх усі. Основною причиною є форма потоку даних, а не механізм відображення у React.

    Сценарій: потоки даних від датчиків

    Дані акселерометра та гіроскопа слідують одній і тій самій схемі. Припустимо, що нативний код генерує:

    60 events / second
    

    Кожна подія потрапляє до JavaScript, обробляється та може спричинити нове оновлення, яке повертається до нативного інтерфейсу. Результатом є потік даних у реальному часі, який проходить крізь цю межу:

    Sensor
      ↓
    Native
      ↓
     JS
      ↓
    Calculation
      ↓
    React
      ↓
    Native UI
    

    При низькій частоті подій це може працювати ідеально. При високій частоті, особливо на слабкішому обладнанні, події можуть надходити швидше, ніж додаток їх обробляє, що створює ще одну приховану витрату.

    Очереді, які зростають швидше, ніж їх споживають

    Припустимо, події надходять кожну

    10 ms
    

    але ефективна обробка кожної з них потребує

    15 ms
    

    Це вже не просто повільна операція; це накопичення завдань, яке зростає без меж. Нові дані надходять, поки старі все ще чекають на обробку, тому інтерфейс постійно відображає минуле. Для реальночасних інтерфейсів це найгірший можливий результат. Користувачеві рідко потрібні всі проміжні дані; йому потрібна найновіша значуща цінність. Дизайни, які об’єднують або видаляють застарілі події, зберігають очередь короткою та відображення актуальним.

    Інтерфейси з можливістю обміну даними збільшують кількість перетинів

    Цей принцип стосується не лише React Native; інженери, які розробляють розподілені системи, стикаються з ним щодня. Порівняйте API модуля, який змінює одне поле за кожен виклик:

    await nativeModule.setName(name);
    await nativeModule.setEmail(email);
    await nativeModule.setAge(age);
    await nativeModule.setCountry(country);
    await nativeModule.setLanguage(language);
    

    з тим, який приймає всі зміни одночасно:

    await nativeModule.updateProfile({
      name,
      email,
      age,
      country,
      language,
    });
    

    Друга версія потребує значно меншої кількості перетинів меж для досягнення того ж результату. Основне правило просте:

    Виконуйте більше роботи за кожен перетин та робіть його рідше.

    Цей самий принцип застосовується до:

    • HTTP API,
    • викликів мікросервісів,
    • доступу до баз даних,
    • робочих потоків,
    • міжпроцесного обміну даними,
    • мобільних мостів,
    • розподілених систем в цілому.

    У мережевих технологіях це називається активною комунікацією. Стара версія Bridge просто перенесла цю класичну проблему розподілених систем у межі одного мобільного додатку.

    Розмір вантажу — це другий фактор

    Частота передачі даних — це лише половина проблеми; важливим є також обсяг даних, які передаються. Розгляньмо можливість передачі чогось подібного через межі старої версії:

    {
      users: [...10000Users]
    }
    

    Відправник повинен закодувати структуру, а отримувач — її відновити та обробити. Великі масиви, глибоко вкладені об’єкти, метадані зображень, двійкові дані та великі дерева конфігурацій створюють значний додатковий навантаження. Найбільш схильними до цього є наступні завдання:

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

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

    Чому канали передачі даних від камери роблять цю проблему очевидною

    Один кадр може містити мегабайти пікселів. Тепер необхідно передавати кадри з

    30 FPS
    

    або навіть

    60 FPS
    

    Якщо кожен з них має бути перетворений у формат, придатний для JavaScript та переданий через асинхронний шар, то сам процес передачі стає вузьким місцем. Варто уникати такої ситуації для кожного окремого кадру:

        Native Camera
             ↓
    Huge serialized payload
             ↓
        JavaScript
             ↓
          Process
             ↓
       Serialized result
             ↓
          Native
    

    Саме тому бібліотеки з високою продуктивністю все частіше розміщують важкі операції обробки безпосередньо біля нативної пам’яті та надають JavaScript вузький, ефективний інтерфейс замість цього. Цей підхід веде прямо до однієї з найважливіших змін у сучасному React Native.

    Як змінюється JSI

    JSI (JavaScript Interface) дозволяє JavaScript взаємодіяти з нативним та C++ кодом значно пряміше. Замість того, щоб повністю покладатися на процес серіалізації та чергування у старому мосту:

    JavaScript
         ↓
     Serialize
         ↓
       Bridge
         ↓
    Deserialize
         ↓
      Native
    

    нова архітектура може надавати нативні функції через прямі інтерфейси:

    JavaScript
         ↓
        JSI
         ↓
    C++ / Native
    

    Згідно з документацією React Native, JSI дозволяє JavaScript тримати посилання на об’єкти C++ та C++ — тримати посилання на об’єкти JavaScript, тож методи можна викликати без моделі серіалізації Bridge. Концептуально виклик нативного методу починає нагадувати виклик функції у господарському об’єкті, а не надсилання повідомлення до іншого процесу. Це фундаментальна зміна в архітектурі.

    Чому JSI не вирішує проблем з продуктивністю

    Поширена хибна думка виглядає так:

    "JSI усуває Bridge, тож проблеми з продуктивністю зникають."

    Це не так. Транспортування даних стає набагато кращим, але погана конструкція системи все одно спричиняє затримки. Сто тисяч синхронних викликів через JSI:

    100,000 synchronous native calls
    

    Кожен з цих підходів може бути дешевшим за виклик Bridge, проте більша частина роботи все одно є зайвою. Те саме стосується постійної передачі величезних об’єктів, виконання ресурсоемної логіки на потоці JavaScript, блокування основного потоку UI чи відрендеровування тисяч компонентів, які насправді не потребували відображення. Синхронний доступ навіть створює новий ризик: повільний синхронний виклик нативного коду тепер безпосередньо блокує потік JavaScript замість того, щоб обробитися пізніше.

    Отже, висновок не полягає у тому, що «Bridge був повільним, а JSI — швидким». Це ближче до наступного:

    Кожен перехід між різними середовищами виконання коштує щось. Структуруйте додаток так, щоб зайві переходи ніколи не відбувалися.

    TurboModules: сучасна система нативних модулів

    TurboModules замінюють застарілу систему Native Modules. Старі модулі були доступні через Bridge; нові інтегруються тісніше за допомогою JSI та Codegen, який генерує типовані зв’язки на основі специфікації. Серед переваг, які наводить React Native, є:

    • синхронний доступ до нативного коду, коли це необхідно у певному сценарії використання,
    • безпека типів через межі інтеграції,
    • сумісність із C++,
    • спільне використання коду між різними платформами,
    • повільне завантаження модулів.

    Повільне завантаження заслуговує на увагу. У старій моделі ініціалізація нативних модулів під час запуску могла збільшити обсяг роботи ще до того, як з’явиться перший екран. Завдяки повільному завантаженню модуль ініціалізується лише тоді, коли він вперше використовується. Замість:

    Application starts
            ↓
    Initialize everything
            ↓
    Eventually show UI
    

    процес запуску може виглядати більш так:

        Application starts
                  ↓
    Initialize required components
                  ↓
               Show UI
                  ↓
    Load additional modules when needed
    

    Такі зміни впливають на те, що користувачі помічають значно більше, ніж цифри тестування: на швидкість, з якою додаток стає готовим до використання.

    Fabric: частина редизайну, пов’язана з відображенням

    Не лише шар комунікації було перебудовано. Fabric, нова система відображення, переміщує більшу частину інфраструктури відображення у спільний C++ та дозволяє використовувати функції, які були складними за старою асинхронною моделлю, зокрема:

    • синхронний доступ до макету там, де це необхідно,
    • краща взаємодія між компонентами,
    • багато рівнів пріоритету оновлень,
    • підтримка сучасних функцій React,
    • більш гнучке планування.

    Пріоритети мають значення, тому що інтерфейси не обробляють кожне оновлення однаково:

    User presses button        → HIGH priority
    Background data refresh    → LOWER priority
    Analytics event            → LOW priority
    Animation                  → HIGH priority
    Prefetching                → LOW priority
    

    Здатний рендерер не повинен сприймати натискання кнопки та запит аналітики як однаково термінові. Пріоритизація є основою реактивного інтерфейсу користувача, а Fabric дозволяє React Native брати участь у моделі планування React. Якщо ви хочете дізнатися, як сам React кодує пріоритет оновлень, стаття про те, як React використовує маски бітів для кодування пріоритету пояснює цей механізм.

    Справжнім ворогом є зміни на межах

    Якби всю проблему можна було описати двома словами, це були б зміни на межах: постійна передача невеликих фрагментів інформації туди-сюди між середовищами виконання.

      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
      ↓
      JS
      ↓
    Native
    

    Коли ви бачите таку структуру, це зазвичай означає, що відповідальність за певну функцію розподілена не там, де слід. Питання полягає у тому, де насправді має знаходитися ця робота. Візьмемо обробку аудіо. Дизайн із високим рівнем змін виглядає ось так:

    Native microphone
    → JS audio frame
    → calculate
    → native
    → next frame
    → JS
    → calculate
    → native
    

    Кращий підхід полягає у тому, щоб обробку високочастотних сигналів залишити у нативному режимі та надсилати JavaScript лише результати, які потребують подальших дій:

    Native microphone
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Native processing
            ↓
    Meaningful result
            ↓
        JavaScript
    

    Обсяг обчислень може бути схожим, але шаблон комунікації повністю відрізняється, як і навантаження на обидві потоки.

    Практичні методи для ефективнішої взаємодії

    Об’єднання операцій у один виклик

    Цикл, який очікує на нативний виклик для кожного елемента, є поширеною причиною проблем:

    for (const item of items) {
      await NativeModule.saveItem(item);
    }
    

    З 500 елементами це може означати сотні окремих операцій перетину, кожна з яких має власні витрати на планування. Кращий нативний API приймає всю колекцію та дозволяє нативному коду виконувати ітерацію:

    await NativeModule.saveItems(items);
    

    Різниця у форматі легко пояснити:

    ❌ 500 boundary crossings × small operation
    
    ✅ 1 boundary crossing × 500 native operations
    

    Групування є однією з найпростіших та найефективніших архітектурних оптимізацій. Один з аспектів дизайну, який потрібно враховувати: для групового запиту необхідна чітка схема обробки помилок, наприклад інформування про ті елементи, які зазнали невдачі, щоб один несправний елемент не призвів до безслідного видалення решти.

    Обмеження частоти або об’єднання високочастотних подій

    Припустимо, що нативний модуль генерує 120 оновлень на секунду. JavaScript рідко потребує всіх з них. Для багатьох інтерфейсів приблизно 20 значущих оновлень на секунду виглядають практично однаково на екрані. Замість того, щоб обробляти

    120 events / second
    

    ви можете обробляти

    20 events / second
    

    або накопичувати події на нативному рівні та передавати лише останній стан. Виконання обмежень на нативному рівні зазвичай краще, ніж у JavaScript, оскільки події, які були втрачені у JavaScript, вже оплатили витрати на передачу. Цей підхід підходить для:

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

    Найдешевшою подією є та, яка ніколи не надсилається.

    Передавайте сенс, а не сирі дані

    Замість того, щоб надсилати щось на кшталт

    10 MB raw dataset
    

    з нативного рівня у JavaScript та просити JavaScript знайти відповідь, обчислюйте відповідь там, де вже знаходяться дані. Замініть цей потік:

    Native
      ↓
    10 MB data
      ↓
    JavaScript
      ↓
    find average
    

    на такий:

            Native
              ↓
      calculate average
              ↓
    JavaScript receives: 42.7
    

    Коротко кажучи, передавайте

    meaning
    

    замість того, щоб

    raw information
    

    коли це дозволяє архітектура. Недолік полягає у тому, що логіка переміщується до нативного коду, який потрібно писати та тестувати для кожної платформи або у спільному C++; використовуйте цей підхід лише для даних, які справді великі або використовуються часто.

    Затримка мережі — це не затримка взаємодії

    Швидкий бекенд може приховати повільний досвід користувача. Припустимо, API відповідає за

    120 ms
    

    а панель моніторингу має зелений статус. Однак після отримання відповіді ще багато чого має відбутися:

    API Response
        ↓
    JSON Parsing
        ↓
    State Transformation
        ↓
    Redux / Zustand / Context
        ↓
    Component Re-rendering
        ↓
    JS ↔ Native interaction
        ↓
    Layout
        ↓
    UI Commit
    

    До моменту, коли результат з’явиться на екрані, користувач вже міг почекати

    200+ ms
    

    Звіти про моніторинг бекенду свідчать про те, що все працює швидко; користувачі ж скаржаться на повільність додатку. Обидві сторони кажуть правду, адже вони вимірюють різні показники. Затримка бекенду та затримка взаємодії — це окремі метрики, і для мобільних додатків саме остання зазвичай визначає, наскільки приємним є продукт.

    Тестування на пристроях, які насправді мають ваші користувачі

    Проблеми з продуктивністю люблять ховатися на обладнанні розробників. Флагманський телефон має швидкий процесор, достатню кількість пам’яті, швидке зберігання, потужну GPU та гарну систему охолодження, тож усе виглядає нормально. Користувачі у реальних умовах запускають ту саму версію додатку на пристроях із значно меншими ресурсами, і

    small inefficiency × thousands of operations
    

    проблеми раптово стають помітними. Тестування лише на преміум-пристроях є ризикованим. Надійна матриця пристроїв має охоплювати принаймні:

    High-end device
    Mid-range device
    Lower-end supported device
    

    Це має найбільше значення, коли ваша аудиторія включає ринки, де значну частку користувачів становлять доступні за ціною Android-телефони.

    Вимірюйте версії для релізу, а не версії для розробки

    Версії для розробки виконують додаткову роботу з попередженнями, відловом помилок та інструментами, а офіційний посібник з продуктивності прямо рекомендує вимірювання з версій для релізу. Перш ніж робити висновок про повільну роботу самого React Native, проведіть аналіз

    Release / Production build
    

    замість того, щоб покладатися лише на

    Development build
    

    Інакше ви можете годинами намагатися виправити уповільнення, з яким ніхто з користувачів ніколи не зіткнеться.

    Чек-лист для діагностики повільного екрана

    Використання функції useMemo() у проблемному екрані рідко є правильним першим кроком. Краще розгляньте ці запитання.

    1. Чи зайнята потік JavaScript?

    Шукайте:

    • дорогі цикли,
  • синхронний аналіз великих об’ємів даних,
  • складні трансформації стану,
  • можливість уникнення повторного відображення,
  • надмірне журналювання,
  • дуже великі дерева компонентів.
  • 2. Чи зайнята потік користувацького інтерфейсу?

    Шукайте:

    • дороге нативне відображення,
    • складну структуру макетів,
    • занадто великі зображення,
    • глибоку ієрархію елементів відображення,
    • анімації, які виконуються у неправильному потоці.

    3. Чи занадто часто перетинається нативний бар’єр?

    Шукайте подорожі у цьому форматі, особливо ті, що відбуваються з високою частотою:

    Native → JS → Native
    

    4. Наскільки великими є навантаження?

    Лише підрахунок кількості викликів недостатньо. Вимірюйте, скільки даних проходить через кожен з них.

    5. Чи можна групувати виклики?

    Якщо це можливо, перетворіть

    many small operations
    

    на

    one meaningful operation
    

    6. Чи справді JavaScript потребує цих даних?

    Якщо нативний код генерує 1 000 пунктів даних, а JavaScript використовує лише їхнє середнє значення, надсилайте саме це середнє.

    7. Чи потрібно це робити прямо зараз?

    Зберегти

    User-critical work
    

    окрім

    background work
    

    Аналітика ніколи не повинна конкурувати з відгуком від дотику у тому самому кадрі.

    8. Чи існує все ще залежність від старої архітектури?

    Текуща документація React Native позначає Legacy Native Modules та Legacy Native Components як застарілі та рекомендує використовувати Turbo Native Modules та Fabric Native Components у відповідних випадках. Коли ви обираєте пакет для функції, чутливої до продуктивності, перевірте, яку архітектуру він підтримує; бібліотека, що працює через шар взаємодії, може не принести очікуваної користі. Статус підтримки швидко змінюється, тому перевіряйте його у поточних примітках до випуску бібліотеки.

    Правило розподілу завдань

    Для будь-якої функції, чутливої до продуктивності, спочатку вирішіть, яка частина додатку насправді відповідає за її виконання.

    • Якщо завдання належить до JavaScript (бізнес-правила, стан інтерфейсу, оркестрація), нехай воно залишається там, і уникайте звернення до нативного коду на кожному кроці.
    • Якщо завдання належить до нативного коду (цикли датчиків, обробка кадрів, буфери аудіо), нехай ці важливі шляхи виконуються нативно та залишаються там.
    • Перетинайте межі лише тоді, коли є щось значуще для обговорення, а не для того, щоб кожен проміжний крок був видимий з обох сторін.

    Здоровий розподіл зазвичай виглядає ось так:

    ┌───────────────────────┐
    │      JavaScript       │
    │                       │
    │ Business decisions    │
    │ Application state     │
    │ UI orchestration      │
    └───────────┬───────────┘
                │
         Meaningful calls
                │
    ┌───────────▼───────────┐
    │        Native         │
    │                       │
    │ Platform APIs         │
    │ High-frequency work   │
    │ Hardware interaction  │
    └───────────────────────┘
    

    а не ось так, сотні разів на секунду:

    JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
    

    Що насправді виправляє нова архітектура

    Нова архітектура усуває кілька структурних обмежень старої моделі. Замість того, щоб будувати все навколо обов’язкового, серіалізованого, асинхронного мосту, сучасний React Native забезпечує прямий інтерфейс через JSI:

           JSI
            ↓
    Direct JS/native interfacing
    

    разом із іншими елементами, які працюють на його основі:

           TurboModules
                ↓
    Modern native module system         Fabric
                ↓
    Modern rendering architecture         Codegen
                ↓
    Typed interfaces between environments   Modern scheduling
                ↓
    Better prioritization of work
    

    Команда React Native пояснює, що видалення мосту зменшує навантаження від серіалізації, покращує час запуску та дозволяє більш прямий зв’язок між JavaScript та нативним середовищем виконання. Глибшою зміною є гнучкість. Тепер фреймворк має більший контроль над:

    • часом виконання завдань,
    • місцем виконання завдань,
    • способом доступу до нативних функцій,
    • тим, які оновлення мають пріоритет.

    Цей урок стосується кожної межі

    Якщо відступити на крок від React Native, ця закономірність залишається загальною: кожен архітектурний кордон має свою ціну. Уявіть собі ці межі:

    Browser → API
    Service → Service
    Application → Database
    Process → Worker
    JavaScript → Native
    Application → Redis
    Backend → Message Broker
    

    Системи часто проектуються так, ніби виклики через ці межі поводяться як звичайні внутрішні функційні виклики. Але це не так. Кожен кордон додає певну комбінацію наступних елементів:

    • серіалізація,
    • копіювання,
    • планування,
    • очікування у черзі,
    • мережеві операції,
    • синхронізація,
    • зміна контексту,
    • обробка помилок,
    • керування пам’яттю.

    Bridge робить цей принцип надзвичайно помітним у мобільних додатках.

    Виправте архітектуру перед мікрооптимізаціями

    Є спокуса спочатку вдатися до таких інструментів:

    useMemo(...)
    useCallback(...)
    React.memo(...)
    

    У них є реальні застосування. Але якщо додаток передає 5 МБ непотрібних даних між етапами виконання 30 разів на секунду, скорочення часу виконання на 0,2 мс за допомогою useMemo() не матиме значення. Робота над продуктивністю є найефективнішою, коли вона виконується у такій послідовності:

    Architecture
        ↓
    Data flow
        ↓
    Scheduling
        ↓
    Rendering
        ↓
    Algorithms
        ↓
    Micro-optimizations
    

    а не навпаки.

    Ключові висновки

    Система Bridge не була просто невдалим інженерним рішенням. Вона дозволяла JavaScript та нативному коду залишатися відокремленими під час асинхронного обміну даними та значно сприяла розвитку React Native. Коли додатки почали використовувати функції реального часу, складні анімації, камери, технології машинного навчання, карти, обробку аудіо, складні жести та інші форми високочастотної нативної взаємодії, обмеження серіалізованого асинхронного обміну даними швидко ставали помітними. JSI, TurboModules, Fabric та перероблена модель планування та відображення усувають багато з цих обмежень, але не саму основну економіку роботи системи.

    • Вимірюйте весь процес взаємодії, а не лише виклик нативного коду чи відповідь API.
    • Враховуйте час однієї кадрової періоди: при 60 Гц це близько 16,67 мс, а при 120 Гц — близько 8,33 мс.
    • Групуйте операції, обмежуйте кількість подій або об’єднуйте їх на джерелі та надсилайте результати замість сирих даних.
  • Вирішіть, який движок виконання несе кожну відповідальність, та тримайте цикли високої частоти з одного боку.
  • Протестуйте версії програми на пристроях середнього та низького класу та перевірте, чи залежності все ще використовують старі модулі.
  • Розглядайте JSI як швидший спосіб передачі даних, а не як ліцензію на більшу кількість викликів.
  • Якщо додаток працює повільно, хоча кожен компонент вимірюється як швидкий, перевірте зв’язки між цими компонентами. Часто дорогим є не сам процес обробки, а відстань, яку дані мають пройти, щоб до нього дістатися.