Обираємо найкращий компонент-список для React Native серед FlatList, FlashList і recyclerlistview

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

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

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

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

Загальний огляд компонентів FlatList, FlashList і RecyclerListView

Використання списків у React Native, напевно, одна з найболючіших тем для розробників, що працюють над створенням кросплатформних мобільних застосунків. Через обмеження NativeBridge проблематично реалізувати аналоги нативних списків, оскільки неможливо синхронно передавати розміри комірок в, скажімо, UITableView на iOS чи RecyclerView на Android. Тому всі наявні реалізації списків в React Native імплементують віртуалізацію на стороні JS над звичайними ScrollView, що не дозволяє використовувати переваги нативної віртуалізації, яка є в iOS (UITableView) чи Android (RecyclerView) через обмеження NativeBridge.

Ми в appflame мали production-досвід із кожним з описаних нижче списків. Деякі проблеми були підсвічені лише на величезній вибірці користувачів на рівні crashlytics або репортів користувачів у підтримку. Оскільки оцінити всі ризики на етапі попереднього дослідження досить важко, сподіваюсь, наш досвід допоможе іншим командам якісніше проводити такі дослідження.

FlatList

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

Насправді саме використання FlatList проти рендерингу списку в циклі всередині ScrollView є астрономічним покращенням швидкодії списку та споживання ним пам’яті. Проте, працюючи з надважкими елементами — рендерингом медіакарток із фото/відеоконтентом тощо, виникає питання додаткової оптимізації списку через просадки FPS на UI-треді пустими проміжками та завеликим споживанням пам’яті.

Тому розробники фреймворку ReactNative надали список можливих покращень перформансу, а я його трохи доповнив на основі свого досвіду.

  • Специфікація функції для визначення розмірів елементу через властивість getItemLayout. Таким чином, ми позбавляємо список необхідності власноруч виконувати асинхронні калькуляції при рендерингу кожного нового елементу.
  • Використання властивості keyExtractor для задання ключів елементів є необхідністю, оскільки ці ключі використовуються для внутрішнього кешування списку.
  • Встановлення властивості removeClippedSubviews в значення true. Ця властивість форсує видалення всіх елементів поза областю видимості із нативної ієрархії компонентів, чим економить час на оновлення цього дерева на Main-треді.

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

  • Меморізація елементів списку за допомогою React.memo. Напевно, гарна практика для будь-яких списків, про які ми сьогодні говоритимемо. У якийсь момент елементи списку будуть перемальовані, тому оптимізація цього процесу для кожного елемента дозволить значно заощадити час і зменшити навантаження на JS-тред.

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

Recyclerlistview

Recyclerlistview — це 3rd-party компонент, який так само використовує віртуалізацію. Проте він додає перевикористання комірок елементів, що зникають з області видимості. Тим самим заощаджує час і пам’ять на створення нових об’єктів і маунтингу їх у дерево.

Він так само як і FlatList не вивантажує із пам’яті елементи поза екраном і таким чином економить час на garbage collection.

Крім того, RecyclerListView забезпечує більш гнучкий інтерфейс для надання функції-калькулятора розмірів елементів. Це дозволяє реалізувати аналог getItemLayout із FlatList для списків зі змінними розмірами елементів та навіть грідів із декількох колонок. Для кастомізації layoutProvider є чудова бібліотека recyclerlistview-gridlayoutprovider для спрощення створення таких комплексних функцій-калькуляторів.

Я б виокремив два основних недоліки, що має ця реалізація списків:

  1. Складніший інтерфейс у порівнянні із FlatList.
  2. Перформанс на U- треді, коли важко завчасно визначити розміри елементів.

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

FlashList

FlashList — це також 3rd-party компонент, що був побудований на основі Recyclerlistview тим самим розробником, але вже в команді компанії Shopify. На меті в нього були дві основні задачі:

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

Перехід з FlatList на FlashList є простим через однакові назви властивостей, а для забезпечення виконання другої задачі додається властивість estimatedItemSize.

Варто зазначити, що FlashList має RecyclerListView у своїх залежностях і є надбудовою над компонентом ProgressiveListView. Тому більшість переваг і недоліків RecyclerListView також стосуються FlashList.

Виходячи зі спостережень нашої команди, ресайклінг FlashList є трохи менш передбачуваним, ніж у RecyclerListView. Тому доводиться ретельніше слідкувати за тим, щоб внутрішній state компонентів скидався після оновлення компонента.

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

Які компоненти краще використовувати з огляду на різні вимоги до функціонала застосунку

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

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

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

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

Навряд доведеться колись робити якісь оптимізації для такого списку, але якщо елементи мають задані розміри, можна одразу імплементувати getItemLayout:

const ITEM_WIDTH = 400;
const ITEM_HEIGHT = 300;

const keyExtractor = (item: TDataItem) => item.id.toString();

const getItemLayout = (
  _data: ArrayLike<TDataItem> | undefined | null,
  index: number
) => ({
  length: ITEM_WIDTH,
  offset: ITEM_WIDTH * index,
  index,
});

const NotesList = ({ data }: TNotesListProps): JSX.Element => {
  const renderItem = ({ item }: { item: TDataItem }) => (
    <View style={[styles.item, { height: ITEM_HEIGHT, width: ITEM_WIDTH }]}>
      <Text style={styles.text}>{item.text}</Text>
    </View>
  );

  return (
    <SafeAreaView style={styles.wrapper}>
      <FlatList
        data={data}
        keyExtractor={keyExtractor}
        getItemLayout={getItemLayout}
        renderItem={renderItem}
      />
    </SafeAreaView>
  );
};

Якщо ж елементи списку стають складнішими, а саме містять важкі мультимедійні 3rd-party компоненти, то варто починати досліджувати доцільність вибору між RecyclerListView та FlashList:

У цьому випадку дуже просто можна адаптувати наш наявний код із використанням FlatList до FlashList, додавши одну властивість estimatedItemSize:

const ITEM_WIDTH = 300;
const ITEM_HEIGHT = 250;

const keyExtractor = (item: TDataItem) => item.id.toString();

const NotesList = ({ data }: TNotesListProps): JSX.Element => {
  const renderItem = ({ item }: { item: TDataItem }) => (
    <View style={[styles.item, { height: ITEM_HEIGHT, width: ITEM_WIDTH }]}>
      <Video
        muted
        playWhenInactive
        playInBackground
        source={require("./video.mov")}
        style={styles.video}
      />
      <Text style={styles.text}>{item.text}</Text>
    </View>
  );

  return (
    <SafeAreaView style={styles.wrapper}>
      <FlashList
        data={data}
        keyExtractor={keyExtractor}
        estimatedItemSize={ITEM_HEIGHT}
        renderItem={renderItem}
      />
    </SafeAreaView>
  );
};

На щастя, елементи даного списку є stateless-компонентами, тому проблеми зі збереженням внутрішнього стану елементів FlashList тут не є актуальними.

Проте, якби б в елементі списку був, скажімо, Switcher, я б рекомендував дивитися в бік RecyclerListView. Оскільки в нього більш передбачуваний механізм ресайклінгу React-компонентів, через що простіше очищувати всі useState та useRef-стани.

При використанні FlashList та RecyclerListView буде необхідно писати useEffect для очищення такого стейту при ресайклінгу.

const initialState = true;

const Note = ({ text, id }: TDataItem): JSX.Element => {
  const [value, setValue] = useState(initialState);

  // useEffect for resetting state when recycling happens
  useEffect(() => {
    setValue(initialState);
  }, [id]);

  const handlePressSwitcher = () => {
    setValue(!value);
  };

  return (
    <View style={[styles.item, { height: ITEM_HEIGHT, width: ITEM_WIDTH }]}>
      <Video
        muted
        playWhenInactive
        playInBackground
        source={require("./video.mov")}
        style={styles.video}
      />

      <View style={styles.content}>
        <Text style={styles.text}>{text}</Text>
        <Switch
          value={value}
          style={styles.checkbox}
          onValueChange={handlePressSwitcher}
        />
      </View>
    </View>
  );
};

Проте не завжди цей стан контролюється нами, особливо коли мова йде про 3rd-party компоненти.

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

const NotesList = ({ data }: TNotesListProps): JSX.Element => {
  const dataProvider = useRef(
    new DataProvider((r1: TDataItem, r2: TDataItem) => r1.id !== r2.id)
  ).current;
  const layoutProvider = useRef(
    new LayoutProvider(
      () => "testList",
      (type, dim) => {
        dim.width = ITEM_WIDTH;
        dim.height = ITEM_HEIGHT;
      }
    )
  ).current;

  const renderItem = (
    _type: number | string,
    item: TDataItem,
    _index: number
  ) => <Note id={item.id} text={item.text} />;

  return (
    <SafeAreaView style={styles.wrapper}>
      <RecyclerListView
        dataProvider={dataProvider.cloneWithRows(data)}
        layoutProvider={layoutProvider}
        rowRenderer={renderItem}
      />
    </SafeAreaView>
  );
};

З якими проблемами можна зіштовхнутись під час роботи з цими компонентами

Загалом, проблеми можна поділити на дві категорії: порушення user experience (краші, некоректне функціонування компонентів) та обмеження в реалізації функціонала на етапі розробки.

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

FlatList

  • Наявність пустих проміжків при швидкому скролі важких списків через повільне перетворення комірок для рендеру.
  • Неможливість виконати scrollToIndex, якщо елементи списку мають різні розміри.
  • Відсутність можливості передати функцію-калькулятор getItemLayout, якщо список має декілька колонок.

RecyclerListView

  • Складний інтерфейс встановлення параметрів layoutProvider та dataProvider для забезпечення коректного ресайклінгу.
  • Інколи складно коректно прописати LayoutProvider із розмірами елементів, коли ці розміри динамічні. Через це можливі пусті проміжки при швидкому скролі.
  • Завжди варто пам’ятати про найпоширеніший креш, викликаний RecyclerListView, коли масив елементів пустий і висота списку дорівнює нулю. Рекомендую завжди огортати його в View із {flex: 1} та тестувати edge-cases із різними даними. Детальніше можна почитати в треді на GitHub.
  • Необхідно слідкувати за скидом внутрішнього стейту та ефектів усієї ієрархії елементів списку при ресайклінгу. Інколи повністю відсутня можливість менеджменту такого стейту при використанні 3rd-party компонентів без інтерфейсу доступу до стану.

FlashList

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

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

Висновки та поради наостанок

  • Перед початком інтеграції певного списку необхідно проаналізувати доцільність його використання, виходячи із власних вимог. А також проводити заміри критичних для цих вимог показників.
  • На мою думку, стандартної реалізації віртуалізованого списку FlatList вистачить для дев’яти із десяти задач, що можуть трапитись у реалізації будь-якого функціонала. Якщо ж мова про випадки виключних вимог до швидкодії, споживання пам’яті та плавності інтерфейсу, то моєю першою рекомендацією буде RecyclerListView.
  • Дійсно, FlashList є хоч і перспективною, але досить молодою бібліотекою. Що все ще тестується як контриб’юторами, так і розробниками в продакшені. Тому нам у певний момент довелось відмовитись від неї та видалити з проєкту через постійний дебагінг проблем із некоректним відображенням елементів.
  • Неможливість контролювати state 3rd-party компонентів всередині FlashList та RecyclerListView стала для нас проблемою, яку неможливо розвʼязати. Наприклад, не вдалось коректно скидати стан компонента Swipeable від react-native-gesture-handler. Через це рекомендую розглядати цей недолік як ключовий і обрати стандартний FlatList, якщо навіть теоретично всередині елементів списку можуть бути інкапсульовані компоненти зі сторонніх бібліотек.
  • Для проведення бенчмарків можна скористатися наступними інструментами: Shopify надає компонент для профайлінгу FlatList і FlashList, а Flipper пропонує плагін для візуалізації результатів.

Також порекомендую корисні джерела для глибшого вивчення матеріалу:

  • блог-пост від Shopify щодо мотивації до створення FlashList та їхні бенчмарки;
  • Інструкція з використання інструменту FlashList для профайлингу списків;
  • додаткові приклади бенчмарків FlatList vs FlashList;
  • поради щодо оптимального використання FlashList;
  • порівняння трьох альтернатив від інженерів компанії Rently;
  • компонент для профайлингу FlastList та FlashList;
  • плагін для Flipper для профайлингу списків.

Якщо у вас залишилися питання або ви хотіли б поділитися фідбеком та своїми думками щодо статті — залюбки поспілкуюся в коментарях або у своєму LinkedIn.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному5
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

Ще вартує згадати про SectionList для порівняння. В FlashList цікавий підхід shopify.github.io/...​docs/guides/section-list не використовують додаткові пропси тому важко у міграції із FlatList

Дякую за статтю.

Дякую за допис, корисна інформація!
Я розробляв модуль календаря, який виглядає як iOS календар у лендскейп моді, тільки замість розбивки на години, кожен рядок був для окремого робітника (в списку їх 50+), а в середині дня (клітинки) були бари, яких могло бути ще 10+. Загалом, стиль діаграми Ганта. Так от recyclerlistview, він реально врятував, дуже допоміг.

Колись я працював зі складними списками на XamarinForms для додатку, який містив фід, схожий на те, що є у фейсбуці. В тому випадку я фізично розділив один логічний елемент на кілька на функціональних секцій і це суттево покращило продуктивність. Ось які підблоки мав мій айтем:
-------------
заголовок (аватарка, хто що зробив, автор, дата час, три крапочки меню)
-------------
контент (текстовий, html тощо)
-------------
медіа (одне фото, два фото, три фото, чотири та більше фото — усе різні темлейти)
-------------
соціальні лічильники (лайки, шери, репости)
-------------
соціальні кнопки (лайків, шерів, тощо, в залежності від налаштувань)
-------------
блок превью комментів (0...3 останніх комменти та реплаї)
-------------
У цій стуктурі є лише два дінамічних елементи — це комменти та контент користувача, та сам контент користувача, усе інше має фіксований розмір, а деякі секції ще мають і можливість бути у списку чи взагалі (фізично) бути відсутніми. Або. якщо прийшло оновлення (комменти оновились після p2r — оновлюється лише невеликий елемент у списку.

Я за допомогою цього лайфхаку підтягнув перформанс скроллінгу на xamarin forms на складних списках до доволі адекватного рівня. Цим всим керував кастомний контроллер на базі DynamicData бібліотеки.

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

Обираємо найкращий компонент-список для React Native

Тре брати Flutter та й буде файно)

Та ну, він же як був ніяким так ніяким і залишився. Десь 4 роки за ним спостерігав, облизувався, страждаючи на формах, потім перестав, бо шо те шо те таке собі. Заліз на реакт — нормально. Наче на курорті.
То може любов до флаттера пов’язана з тим, що я у світ розробки через сі з плюсами прийшов, а не як більшість інших?

Та ні, наївся Реакту та ЖС у купі з вебом та костилями)

github.com/flutter/flutter/pulse
github.com/...​cebook/react-native/pulse
Ну таке собі...
Не бачу суттєвих переваг.
От у мене веб портал адмінка і мобільний апп мають єдину кодову базу під капотом — шарять повністю коммунікацію з беком, моделькі (які ще і генеруються готовими тулзами, хоч із кастомними правилами після). Для флаттера це неможливо. Скоріше за все. Бо Dart доволі вузькоспеціалізована мова.

Я не фанат JS\TS\Node, але після C# (а точніше, вбогого XamarinForms, і ми-не-можемо-у-інновації MAUI) — писати на реакті (так, я і веб фрон можу, обмежено, по UI, типу зробити топ-1 сторінку це не для мене, але для корпоративного використання — цього з головою, і це я тільки вчусь) — це наче гратись у пісочницю. Замість 40-80% часу на спроби оптимізувати щось на формах, я, нарешті, витрачаю час на те, що я гарно роблю — роблю архітектурні речі, вказую що робити іншим та підправляю за ними недоопрацювання.
Прям, наче, з ручного завантаження вугілля лопатою мене посадили у екскаватор з кондером.

Не бачу суттєвих переваг.

Merged 3 та Merged 92

не варто на це звертати увагу. Варто дивитись на загальну картинку.
363 Active issues
37 Active issues, по новим ішью там теж все сумно. Як і раніше. Дякую вам, оновив інфу. Давно не дивився туди вже.

Загальна статистика і картинка, на мою думку, не на користь флаттера, плюс це гугл. Вони можуть завтра і закопати його. І тоді він нікому не буде потрібен, ну ок, ком’юніті. Але там 99% це ті, хто можуть зробити пулл-реквест. А далі все покотиться у бік зростання issue.

Вони можуть завтра і закопати його.

Флаттер релізнувся у травні 2017
Скільки треба щоб пройшло років аби перестали тягати цей наратив?)

по новим ішью там теж все сумно

Нові ішью можуть вказувати на популярність та на забагованість
Тут дивлячись в якої сторони подивитись

Більш того, враховуючи мої попередні знання у нейтіві (ios-android), та той факт, що я можу у сі-плюси, писати нативні модулі під реакт — це теж можна :)
Зараз, як раз, один розробляю для адміністрування додатків.

На сі та с++ можна писати та через ffi юзати

Для флаттера це неможливо. Скоріше за все.

OpenAPI Generator для апі

логіка, хелпери, бізнеслогіка і тд і тп. ОпенAI такого не вміє. Нажаль.

А що на ЖС на генерує все перелічене?

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