Оптимізація Load Time для React Native-застосунків

Усім привіт! Мене звати Артем Герасимов, я — Senior React Native розробник у продуктовій компанії appflame.

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

У цій статті я розповім про те, що треба знати React Native розробнику перед тим, як оптимізовувати Load Time для React Native-застосунків, а також про те, які засоби ми в компанії використовували, щоб пришвидшити старт застосунку вдвічі. Наприкінці поділюся результатами оптимізації та розповім про челенджі, які виникали під час її виконання. Сподіваюсь, ця стаття буде корисною для досвідчених React Native розробників.

Що треба знати React Native розробнику перед тим, як оптимізовувати Load Time

Продуктові метрики

Швидкість старту застосунку — це не лише показник технічного професіоналізму команди розробників, але й важливий чинник успішного збільшення певних продуктових показників. Наприклад, однією з ключових метрик, на які впливає load time застосунку, є Day Zero Retention, або відсоток користувачів, що повернуться до продукту продовж перших 24 годин після встановлення. За статистикою 21% користувачів відкриють ваш застосунок лише один раз після встановлення. Тому надзвичайно важливо скласти потужне перше враження. Справді, на цю метрику впливає дуже багато компонентів онбордингу користувачів: дизайн, загальна цінність запропонованого функціоналу. Але швидкість старту є саме тим критерієм, на який ми, як розробники, можемо безпосередньо вплинути.

Контрольні виміри

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

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

Час старту нативних компонентів застосунку

Ми вирішили не витрачати багато часу на цей пункт, оскільки React Native-екосистема вже використовує досить оптимізовані конфігурації gradles з усіма можливими мініфікаціями коду, а популярне налаштування Enable Bitcode вже ввімкнене для iOS.

Заміри часу на завантаження нативної порції застосунку

JS Load duration

Найпопулярнішою панацеєю для пришвидшення запуску JS бандла є інтеграція Hermes engine, який дає середній приріст швидкості старту 30%. Проте на момент початку вимірів він уже був увімкнений як для iOS, так і для Android. Наступним кроком, який може вплинути на цей показник, — є зменшення розміру JS-бандла. Зараз ми в процесі експериментування із заміною metro bundler на Re.Pack, який підтримує tree shaking.

Initial Logics duration

Тут досить суттєво впливають усі виклики з await до моменту першого рендеру. Сюди входять ініціалізації нативних SDK із JS-коду, read/write операції з AsyncStorage тощо.

Рекомендую звернути увагу на react-native-mmkv як заміну AsyncStorage та провести ревізію необхідності синхронності виконання коду в цій фазі.

Заміри часу на виконання початкового JS-коду разом із завантаженням бандлу

Час виконання початкових API-запитів

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

Середній час виконання початкових запитів до імплементації оптимізацій — 1.77 секунди.

Initial components mounting duration

Початкові компоненти виявилися досить легкими й час їхнього маунту не перевищує 15 мілісекунд на low end-девайсах, тому додаткові оптимізації малоефективні.

Заміри часу на маунтинг початкового компонента

Які засоби ми використовували, аби вдвічі пришвидшити початкові запити

Паралелізація запитів

Очевидним покращенням швидкості виконання початкових запитів стала їхня паралелізація. У першій ітерації використані суто JS-інструменти, а саме: Promise.all.

const [config, user, settings] = await Promise.all([

request1(),

request2(),

request3(),

]);

Така реалізація уже пришвидшила фазу початкових запитів на 30%.

Зверну увагу, що залежно від вимог до цих запитів має сенс порівнювати Promise.all з Promise.allSettled, оскільки другий варіант більш доцільним у випадках допустимості фейлу одного з реквестів.

Виконання запитів із нативу

У наступній ітерації виникла ідея запустити ці реквести раніше, під час старту застосунку. Справді, оскільки ми вичерпали можливості для пришвидшення процесів, що ведуть до початкових запитів, логічним було перенесення самих запитів раніше. За принципом «Якщо гора не йде до Магомета, то Магомет іде до гори» 🙂.

iOS-реалізація

Виклик запитів відбувається всередині didFinishLaunchingWithOptions-методу в AppDelegate-файлі.

— (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

{

ApiService *api = [[ApiService alloc] init];

[api makeInitialRequests];

Паралелізація виконання запитів у Swift була виконана з допомогою dispatchGroup.

@objc func makeInitialRequests() {

var userResponse: Any?

var settingsResponse: Any?

var configResponse: Any?

let dispatchGroup = DispatchGroup()

 dispatchGroup.enter()

 fetchUser() { result in

guard let data = try? result.get() else {

dispatchGroup.leave()

return

}

  userResponse = try? JSONSerialization.jsonObject(with: data, options:.fragmentsAllowed)

dispatchGroup.leave()

}

 dispatchGroup.enter()

 fetchSettings() { result in

guard let data = try? result.get() else {

dispatchGroup.leave()

return

}

 settingsResponse = try? JSONSerialization.jsonObject(with: data, options:.fragmentsAllowed)

dispatchGroup.leave()

}

 dispatchGroup.enter()

 fetchConfig() { result in

guard let data = try? result.get() else {

dispatchGroup.leave()

return

}

 configResponse = try? JSONSerialization.jsonObject(with: data, options:.fragmentsAllowed)

dispatchGroup.leave()

}

 dispatchGroup.notify(queue:.main) {

guard let _userResponse = userResponse,

let _settingsResponse = settingsResponse,

let _configResponse = configResponse else {

return

}

  let result = [

«user»: self.stringify(json: _userResponse),

«settings»: self.stringify(json: _settingsResponse),

«config»: self.stringify(json: _configResponse),

]

  ApiService.result = result

}

}

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

Android-реалізація

Виклик запитів відбувається всередині onCreate-методу в MainApplication.java-файлі. Тут створюється корутина й асинхронно запускається на одному з I/O-потоків.

@Override

public void onCreate() {

try {

SharedHandler.Companion.init(getApplicationContext(), SharedStorageModule.SHARED_STORAGE_NAME);

BuildersKt.async(

(CoroutineScope) GlobalScope.INSTANCE,

(CoroutineContext) Dispatchers.getIO(),

CoroutineStart.DEFAULT,

(coroutineScope, continuation) -> {

return Api.makeInitialRequests(getApplicationContext(), continuation);

}

          );

 } catch (Exception e) {

// implement error handling if needed

}

   …

}

У методі makeInitialRequests реалізований асинхронний запуск запитів та їхній подальший запис у статичну змінну аналогічно до iOS-реалізації.

@JvmStatic

suspend fun makeInitialRequests(context: Context) {

try {

    coroutineScope {

       val deferredUserResult = async(Dispatchers.IO) {

fetchUser(context)

   }

             val deferredSettingsResult = async(Dispatchers.IO) {

fetchSettings(context)

}

                val deferredConfigResult = async(Dispatchers.IO) {

fetchConfig(context)

 }

               results.putString(«user», deferredUserResult.await())

results.putString(«config», deferredConfigResult.await())

results.putString(«funnelSettings», deferredFunnelsResult.await())

}

           } catch (e: Exception) {

// implement error handling if needed

}

       }

JS-реалізація

Під час старту JS-порції коду ми намагаємося дістати отримані відповіді з нативних модулів.

export const readInitialRequestsFromNative = async (): Promise<TReadInitialRequestsFromNativeResult> => {

let results;

try {

results = await InitialRequests.getResults();

InitialRequests.clearResults();

} catch (e) {

logError(e);

InitialRequests.clearResults();

return null;

}

if (!results) {

return null;

}

try {

const user = results.user? (JSON.parse(results.user) as {user: TUser}): null;

const settings = results.settings? (JSON.parse(results.settings) as TSettings): null;

const config = results.config? (JSON.parse(results.config) as TConfig): null;

return {user, settings, config};

} catch (e) {

logError(e);

return null;

}

}

Якщо вони не встигли виконатися до цього моменту, то запускаємо fallback-сценарій, у якому реквести стартують через axios із використанням Promise.all.

const {

user: prefetchedUser,

config: prefetchedConfig,

settings: prefetchedSettings,

} = (await readInitialRequestsFromNative()) || {};

const userRequest = () => getUser(prefetchedUser);

const settingsRequest = () => getSettingRequest(prefetchedSettings);

const configRequest = () => getConfig(prefetchedConfig);

const [config, user, funnelSettings] = await Promise.all([configRequest(), userRequest(), settingsRequest()]);

Результати, які ми отримали після оптимізації

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

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

Загальна динаміка time to interactive-метрики

Про челенджі, які виникали під час виконання оптимізації

На перших етапах планування цих оптимізацій у команди було багато занепокоєння щодо можливих неочевидних багів через розподілення бізнес-логіки між різними середовищами, недостатню експертизу в написанні асинхронного Swift/Kotlin-коду, обробки edge-кейсів тощо. І заради справедливості скажу: ми зіштовхнулися майже з усіма можливими edge-кейсами.

Найбільшим челенджем став менеджмент токенів для авторизації, оскільки нативний запит міг натрапити на 401 помилку. Токен рефрешився, і після цього JS-запит уже не міг коректно зарефрешити свій токен.Зрештою довелося переосмислити загальний підхід до сервісу авторизації.

Перша ітерація передбачала запис результатів у нативний key-value сторедж, що створило багато проблем із некоректною очисткою значень між сесіями та подальшим «кешуванням» респонсів. Проте розв’язання цієї проблеми (а саме: перехід на зберігання значень у локальних змінних нативних модулів) покращило час виконання початкових запитів через відсутність read/write-операцій у сторедж.

Висновки

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

React Native на цьому етапі свого розвитку надає досить ефективну платформу «з коробки», тому більшість вузьких місць у плані перформансу створюють самі розробники через якість написання коду. Для того щоб мінімізувати ці недоліки, я б дав такі поради:

  1. Необхідно мати моніторингову систему, яка допоможе оцінити поточний baseline перформансу застосунку та відстежити його динаміку. Оскільки написання свого сервісу для моніторингу досить ресурсомістка задача, а сторонні сервіси можуть бути дорогими, рекомендую почати з бібліотеки reassure для написання performance-тестів. Вони можуть запускатися в будь-якому пайплайні та надсилати репорти про зміну часу рендеру та апдейту React-компонентів;
  2. Зробити ревізію нативних конфігурацій проєкту. Переконатися, що Hermes engine увімкнений для обох платформ, оновити build.gradle/Build Settings до актуальних рекомендованих фейсбуком версій;
  3. Користуватися best practice для написання JS/React-коду. Важко переоцінити значення таких речей, як стандартні React-мемоізації, оскільки в екосистемі React Native кожен зайвий ререндер компонента коштує досить дорого через ререндер відповідних важких нативних компонентів. Також треба максимально ефективно використовувати асинхронність JavaScript для запобігання великої кількості блокувальних процесів.

Додаткові матеріали, що можуть надихнути на нові ідеї

  1. The Ultimate Guide to React Native Optimization.
  2. React Native official documentation on Performance.
  3. Набір інструментів для профайлингу React Native-застосунків від компанії Shopify.
  4. Бібліотека для написання performance тестів.
  5. Матеріал, що стосується роботи польської компанії Callstack (спеціалізується на оптимізації React Native-застосунків).

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую! Дуже цікавий підхід.

Якщо так сильно хвилює оптимізація то потрібно думати про нативну або щось типу KMM (Compose Multiplatform) розробку.

Дуже дякую за статтю і додаткові матеріали.

Дякую за статтю, дуже цікаво.

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

Це не правда, якщо у компонентів, таких як View та Text не змінилися стилі/текст, нативні компоненти не чипаються, реконсайлер в цьому плані досить розумний. Але звісно розумна мемоізація все ж потрібна, щоб не виконувати зайвий JS код рендерінга компонентів.

Дякую за поправку, дійсно!

Цікава ідея.
Дякую за статтю.

Добрий день, дякую за статтю.

оновити build.gradle/Build Settings до актуальних рекомендованих фейсбуком версій;

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

Вітаю! Я рекомендую завжди використовувати React Native Upgrade Helper при оновленні версій, бо там постійно щось змінюється в build.gradle, app/build.gradle, gradle.settings.
react-native-community.github.io/upgrade-helper

Референтні значення для версії 0.72.6 зараз такі
buildscript {     ext {         buildToolsVersion = "33.0.0"         minSdkVersion = 21         compileSdkVersion = 33         targetSdkVersion = 33         kotlinVersion = '1.9.0'         ndkVersion = "23.1.7779620"     }     repositories {         google()         mavenCentral()     }     dependencies {         classpath("com.android.tools.build:gradle")         classpath("com.facebook.react:react-native-gradle-plugin")         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0"     } }

Також рекомендую додати в app/build.gradle, якщо ще немає. developer.android.com/build/shrink-code
def enableProguardInReleaseBuilds = true

Дякую за цікаву статтю!

Непогано! Слід зазначити, що це також працює і для інших фреймворків (паралельні запити та ін.).

Підписатись на коментарі