Обираємо найкращий компонент-список для React Native серед FlatList, FlashList і recyclerlistview
Усім привіт! Мене звати Артем Герасимов, я — 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 для спрощення створення таких комплексних функцій-калькуляторів.
Я б виокремив два основних недоліки, що має ця реалізація списків:
- Складніший інтерфейс у порівнянні із FlatList.
- Перформанс на 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.
18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів