Считаем, а не гадаем: отчего список правда тормозит
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья оставила два вопроса. Первая отрисовка FlatList — десять строк, а сколько строк он держит, когда затихнет? И во что обходится renderItem-стрелка в теле экрана, если сам список — PureComponent? Ответим счётом, а не документацией: сколько разных строк нарисовано, сколько раз вызвана renderItem, сколько узлов в разметке. Счёт приведёт к главной причине тормозов, и она окажется не в ручках FlatList, а в высоте его контейнера. В конце уберём последнюю догадку, на которой держится список, — о высоте строк — через getItemLayout. Всё это нижний этаж, сам React Native, и всё выполняется в песочнице.
Счётчиков три. Строки — множество номеров, которые видела renderItem: сколько разных строк нарисовано. Вызовы — все вызовы renderItem подряд, с повторами. Узлы — помощник подложки countNodes, знакомый по статьям про ScrollView и окно; testID стоит на обёртке, потому что на самом FlatList его не пропускает компилятор песочницы. Четвёртый прибор — onContentSizeChange: FlatList передаёт его своему ScrollView, и он покажет, как росло содержимое.
Рамка 120 точек, строка 30, упражнений тысяча. Что первая отрисовка — десять строк, прошлая статья уже видела. Прогноз: какой высоты будет содержимое по шагам, сколько строк окажется у списка через полторы секунды, когда он затихнет, и сколько к этому времени наберётся вызовов renderItem?
Строк 44. Содержимое росло ступенями по 300 точек, то есть по десять строк. После первой отрисовки FlatList дорисовывает пачками: по maxToRenderPerBatch строк, по умолчанию 10, с паузой updateCellsBatchingPeriod, по умолчанию 50 мс. Последняя пачка — четыре строки, и на 1320 точках рост кончился.
Откуда 1320. Запас windowSize по умолчанию — 21 рамка: видимая и ещё двадцать. Функция computeWindowedRenderLimits из VirtualizeUtils.js делит эти двадцать пополам (leadFactor = 0.5): десять рамок до видимой и десять после. У начала списка «до» пусто, и остаётся 120 + 10 × 120 = 1320 точек — 44 строки. Наше окно из статьи «Собираем окно: список, который рисует только видимое» брало запас по четыре строки с каждой стороны. У FlatList по умолчанию запас вдесятеро больше: по десять рамок, по 40 строк. Меньше пустоты при броске — больше работы.
Узлов 91: по два на строку — ячейка CellRenderer и Text — и ещё три: рамка ScrollView, его внутренняя коробка и нижняя распорка. Распорка здесь нулевой высоты, поэтому содержимое — 1320 точек, а не 30000, как у нашего окна. Почему так, — в разделе про getItemLayout.
А вызовов 144 — это 10 + 20 + 30 + 40 + 44: каждая пачка заново вызвала renderItem для всех уже нарисованных строк. Это особенность песочницы. Ячейка CellRenderer в react-native-web — обычный Component, а на телефоне — PureComponent (таблица прошлой статьи), и там те же 44 строки стоили бы 44 вызова. Поэтому главный счётчик дальше — строки: он одинаков на обеих платформах. Вызовы будем сравнивать только там, где их число совпадает везде.
Компилятор песочницы в этой статье отмечает у FlatList ошибкой onContentSizeChange, onLayout, onScroll, contentContainerStyle, windowSize и ItemSeparatorComponent — и так же отметит testID, maxToRenderPerBatch, onScrollToIndexFailed, ListHeaderComponent. В типах FlatListProps получает их через VirtualizedListProps из пакета @react-native/virtualized-lists, а этого пакета в наборе типов песочницы нет. В проекте это обычные пропы, а пример выполняется и здесь. getItemLayout, initialNumToRender и ref компилятор знает: они объявлены в самом FlatList.d.ts.
С этой статьи в подложке ещё одна поправка, и снова она про браузер, а не про React Native. Пачку, которой не к спеху, — запас за краем рамки, — react-native-web откладывает дважды: 50 мс ждёт таймером, а потом ставит в очередь InteractionManager, которая ждёт requestIdleCallback браузера. Этот вызов кадру примера приходит не всегда. Без поправки пример выше от прогона к прогону то дорисовывает запас до 44 строк, то навсегда остаётся на десяти — и доля та же на одном стенде не держится. На телефоне пачку откладывает обычный setTimeout — так в _scheduleCellsToRenderUpdate из VirtualizedList.js React Native 0.86. Подложка прячет requestIdleCallback, и react-native-web уходит в собственный запасной путь на setTimeout, как телефон.
Теперь тот же список, но у обёртки нет высоты — ни height, ни flex. На телефоне так выходит постоянно: между экраном и списком встаёт View ради отступа или фона. Статья про ScrollView показала, что рамка в таком родителе растягивается до содержимого.
Справочник здесь на 300 строк, а не на тысячу, — ради времени: в песочнице каждая пачка перерисовывает все ячейки, и итог по тысяче пришлось бы ждать секунды. Прогноз: какой будет рамка списка и сколько строк, вызовов и узлов насчитается через три секунды?
Весь справочник. Рамка — 9000 точек, ровно содержимое. Узлов 602: по два на строку, рамка ScrollView и его коробка, а распорки нет — для неё не осталось ненарисованных строк. Это цена наивного списка из первой статьи главы, только теперь её платит FlatList. На тысяче та же картина — 1000 строк и 2002 узла; это проверено тем же счётчиком, только итог приходит через несколько секунд.
Виртуализация при этом не выключена. Она работает честно, от той рамки, которая есть. Высоту рамки VirtualizedList берёт из её onLayout, а окно — это рамка и ещё десять рамок после неё. Нарисованы десять строк — рамка 300 точек, окно 3300, надо дорисовать. Рамка выросла — выросло и окно. Так тридцать пачек подряд, пока не кончится справочник. Вызовов 4650 — это 10 + 20 + … + 300, повторные вызовы ячеек песочницы. На телефоне их было бы 300, но и 300 — ровно та цена, от которой окно должно было избавить.
Первое, что хочется сделать, — уменьшить запас. Тот же список без высоты, но windowSize={1}: окно ровно в рамку, без запаса. Прогноз: сколько строк нарисует список?
Десять — и больше никогда. Окно равно рамке, рамка равна десяти нарисованным строкам, и расти нечему. Остальные 990 упражнений на экран не попадут вовсе: прокрутить такой список нельзя, его рамка и есть содержимое. windowSize — множитель к высоте рамки, а не выключатель. От рамки без высоты любое окно даёт либо весь список, либо обрубок.
Лечится высотой, а не ручками. Рамка списка должна быть меньше содержимого, а для этого у каждого предка до самого экрана — flex: 1 или число. На телефоне корень приложения — это экран, и цепочка flex: 1 доводит его высоту до списка. Рвёт цепочку любой View без flex посередине. Такой экран — первая задача.
Отдельный частый случай — FlatList внутри ScrollView той же ориентации, чтобы вместе со списком прокручивалась шапка. Внешний ScrollView даёт содержимому сколько угодно высоты, и список снова рисует всё: в песочнице тот же счётчик насчитал 300 строк из 300. На телефоне React Native в разработке ещё и предупредит: VirtualizedList печатает console.error «VirtualizedLists should never be nested inside plain ScrollViews with the same orientation…». В react-native-web эта проверка закомментирована, рядом в исходнике ссылка на issue 2239, так что песочница промолчит. Шапку, которая прокручивается вместе со списком, кладут в его проп ListHeaderComponent.
Тормозит список — первым делом сравните высоту его рамки из onLayout с высотой содержимого. Равны — окна нет, какие бы windowSize и initialNumToRender ни стояли.
Список в рамке держит 44 строки. Теперь второй вопрос прошлой статьи: сколько стоит renderItem-стрелка, если FlatList — PureComponent. Посчитаем на таймере отдыха «Подхода». Он тикает раз в секунду и перерисовывает экран, а справочник под ним от таймера не зависит. В примере три тика по 200 мс, когда оба списка уже дорисовали запас. У одного списка renderItem — стрелка в разметке, у другого — функция, объявленная вне экрана. Прогноз: сколько вызовов renderItem у каждого за три тика?
renderItem для каждой смонтированной строки, а не для четырёх видимых. И это число одно и то же в песочнице и на телефоне. Стрелка на каждой перерисовке экрана новая, проп изменился, и FlatList перерисовывается — правило PureComponent то же, что у memo в части IV. А перерисовываясь, он собирает для ячеек новую функцию строки: в его render она строится заново вызовом _renderer. У ячейки новый проп, и то, что на телефоне она PureComponent, её не спасает. Функция вне экрана одна и та же, FlatList пропускает перерисовку целиком — ноль.Одной постоянной renderItem мало. Функцию строки FlatList собирает заново на любой своей перерисовке, а перерисуется он от любого пропа, изменившегося по ===. Запомненная копия, _memoizedRenderer, берётся только при пропе strictMode. Самый частый такой проп — объект стиля, записанный прямо в разметке.
Функция строки вынесена из экрана, а отступы списку даны объектом: contentContainerStyle={{ paddingHorizontal: 12 }}. Прогноз: сколько вызовов за те же три тика?
Снова 132. Объект { paddingHorizontal: 12 } на каждой перерисовке экрана новый, FlatList видит изменившийся проп и перерисовывает все 44 строки, хотя функция строки та же. Лечится так же, как в части IV: объект собирается один раз — в StyleSheet.create или константой вне компонента. Это третья задача.
В обоих случаях цену задаёт число смонтированных строк. При запасе в 44 строки лишняя перерисовка экрана — это 44 вызова строки, в рамке без высоты — весь справочник. Отсюда порядок: сначала рамка, потом постоянные пропы. React Compiler из части IV запоминает стрелки и объекты в разметке сам, если он включён в проекте; без него это ваша забота.
Без подсказки FlatList не знает высоты строки, пока её не нарисует. Нарисованную он меряет через onLayout ячейки, для ещё не нарисованных берёт среднюю по измеренным. А нижнюю распорку не тянет дальше последней измеренной строки. Почему — сказано в комментарии в render у VirtualizedList.js: чтобы нельзя было улететь в неизмеренную область, где содержимое начнёт прыгать. Отсюда распорка нулевой высоты и 1320 точек содержимого в первом разделе.
getItemLayout(data, index) отдаёт { length, offset, index } — ту пару «верх строки и её высота», что во второй задаче статьи «Собираем окно». С ней списку мерить нечего: ячейки даже не подписываются на onLayout (shouldListenForLayout в _pushCells). Ниже два одинаковых справочника, у второго есть getItemLayout. Через секунду оба прыгают к упражнению 500 методом scrollToIndex. Прогноз: какие высоты содержимого напечатаются и где окажется каждый список?
У первого содержимое росло ступенями до 1320, у второго сразу 30000 — тысяча строк по 30, хотя нарисованы те же десять. А к строке 500 первый список не прыгнул вовсе: scrollToIndex бросил исключение прямо из вызова. Строка 500 дальше последней измеренной, и где она, списку узнать неоткуда. Текст исключения один и тот же в react-native-web и в React Native 0.86. Без try оно унесло бы обработчик, который прыгал, — например, нажатие на букву в алфавитном указателе. Второй список встал ровно на 15000 = 500 × 30: деление вместо замера, как в нашем окне.
Второй выход из текста ошибки — onScrollToIndexFailed. Список вызовет его с averageItemLength и highestMeasuredFrameIndex, а вы прыгнете приблизительно, по средней высоте, и доведёте прыжок, когда строка измерится. Для справочника, где все строки одной высоты, это лишняя работа: высота известна заранее.
scrollToOffset исключений не бросает. Список без getItemLayout, прошла секунда, просим смещение 15000. Прогноз: где он окажется?
На 1200. Содержимое по-прежнему 1320 точек — нижняя распорка не выходит за измеренное, — и прокрутка упирается в 1320 − 120. Правило распорки то же и в React Native 0.86. Без getItemLayout далеко из кода не прыгнуть ни одним методом: scrollToIndex бросит исключение, scrollToOffset упрётся в измеренное.
getItemLayout отменяет замер, поэтому ошибку в нём ничто не исправит: разделитель, отступ, строка другой высоты — и прыжки промахиваются. В описании пропа в FlatList.js об этом предупреждают прямо: длину разделителя нужно включать в расчёт.
Сведём в одно место, чем счёт в песочнице отличается от счёта на телефоне. Каждая строка сверена с исходниками, левая колонка — ещё и запуском выше.
| Что | В песочнице (react-native-web 0.21) | На телефоне (React Native 0.86) |
|---|---|---|
| Окно и запас | computeWindowedRenderLimits: видимая рамка и ещё windowSize − 1 рамок, пополам до и после; по умолчанию запас 21, пачка 10, первая отрисовка 10 | то же самое, код совпадает |
| Пачка запаса за краем рамки | таймер на 50 мс, потом очередь InteractionManager и requestIdleCallback; подложка его прячет, и react-native-web берёт запасной setTimeout | обычный setTimeout на updateCellsBatchingPeriod, 50 мс |
Вызовы renderItem при дорисовке | каждая пачка перерисовывает все ячейки: 144 вызова на 44 строки, 4650 на 300 | ячейка — PureComponent: вызов на строку, 44 и 300 |
Новый проп у FlatList: стрелка, объект стиля | все смонтированные строки заново: 132 вызова за три тика | то же: каждая ячейка получает новую функцию строки |
Проп strictMode | функция строки запоминается, но ячейки — Component и перерисовываются всё равно: те же 132 | функция строки запоминается (memoizeOne), и ячейка-PureComponent получает прежнюю (вывод по исходнику) |
| Список без ограниченной высоты | рисует весь справочник | рисует весь справочник |
FlatList внутри ScrollView | рисует всё молча: проверка закомментирована | рисует всё; в разработке — console.error «VirtualizedLists should never be nested inside plain ScrollViews…» |
scrollToIndex без getItemLayout | исключение «scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed…» | то же исключение с тем же текстом |
| Типы | у FlatList ошибкой отмечены onContentSizeChange, onLayout, onScroll, contentContainerStyle, windowSize, ItemSeparatorComponent; getItemLayout и ref — без ошибок | всё это обычные пропы из @react-native/virtualized-lists |
Правило то же, что в прошлых статьях: код пишется так, чтобы быть верным в обеих колонках. Строки считают множеством номеров, а не вызовами. Рамка у списка — всегда ограниченная. Пропы FlatList — постоянные. getItemLayout — там, где высота строки известна заранее, и с разделителями в расчёте. Если и после этого замер упирается в FlatList, замены из npm — FlashList и LegendList — стоят на полке React Native и Expo; там же видно, почему версию FlashList выбирает SDK, а не npm.
- Считают три числа: разные строки — множеством номеров, вызовы
renderItemи узлы в разметке. Разные строки одинаковы в песочнице и на телефоне; вызовы в песочнице раздуты — её ячейки неPureComponent, и каждая пачка перерисовывает все. - В рамке 120 точек
FlatListдержит 44 строки: первые 10, потом пачки поmaxToRenderPerBatchкаждые 50 мс, пока окно не закроет видимую рамку и десять рамок после неё (windowSize21 делится пополам). - Окно считается от высоты рамки. Рамка без ограниченной высоты — во
Viewбезflexили внутриScrollView— равна содержимому, и список рисует всё.windowSizeне спасает: даёт либо всё, либо обрубок. Лечит цепочкаflex: 1до экрана. - Перерисовка
FlatListперерисовывает все смонтированные строки, а не только видимые. Её вызывает любой новый проп — стрелка вrenderItem, объект стиля в разметке. Функция вне компонента иStyleSheet.createдают ноль. getItemLayoutубирает догадку о высоте: содержимое полной высоты с первого кадра,scrollToIndexк любой строке, никаких замеров. Без него прыжок дальше измеренного — исключение, аscrollToOffsetупирается в измеренное. Разделители входят в расчёт.- Подложка с этой статьи прячет
requestIdleCallback: react-native-web дорисовывает пачки поsetTimeout, как телефон, и счёт в песочнице повторяется от запуска к запуску.
1/4Рамка 120 точек, строка 30, у FlatList все настройки по умолчанию, список стоит у начала. Сколько строк он будет держать, когда затихнет?
2/4FlatList справочника лежит во View без height и flex, и счётчик показывает, что нарисованы все строки. Что поможет?
3/4renderItem объявлена вне экрана, а contentContainerStyle={{ padding: 12 }} записан прямо в разметке. Каждую секунду тикает таймер экрана. Сколько строк списка перерисует каждый тик?
4/4У списка без getItemLayout и без onScrollToIndexFailed измерены первые 44 строки. Что сделает scrollToIndex({ index: 500 })?
Справочник под шапкой
Экран справочника: шапка и список. На телефоне высоту экрану дала бы система, в примере она задана числом. Предупреждений нет, список выглядит правильно, а счётчик говорит, что нарисован весь справочник. Почините одной правкой, не трогая сам FlatList.
Список не виноват — виновата обёртка без высоты между экраном и списком. Экран даёт 200 точек, шапка берёт 40, а обёртка вместо остатка растягивается по содержимому, и рамка списка вместе с ней. Правка — flex: 1 у обёртки:
Теперь строк: 59 | узлов: 121. Рамка — 160 точек, окно — 160 + 10 × 160 = 1760, это строки 0…58. Узлов по два на строку и три служебных: рамка ScrollView, его коробка и нижняя распорка. На телефоне то же самое: цепочка flex: 1 от корня экрана до списка, и ни одного звена без неё.
Прыжок мимо упражнения
Между строками справочника появились разделители в одну точку, а getItemLayout остался прежним, и прыжок к упражнению 300 приводит не туда. Почините getItemLayout. Помощник подложки rowAtTop читает, какое упражнение стоит у верхнего края рамки, прямо из разметки: расчёт повторил бы ту же ошибку, которую ищем.
Разделитель рисуется внутри ячейки, сразу после строки: CellRenderer кладёт ItemSeparatorComponent в ту же коробку. Поэтому ячейка занимает не 30 точек, а 31. getItemLayout же говорит 30, и список ставит прокрутку на 9000 — туда, где по его расчёту начинается строка 300. Но распорку над окном список тоже считает по getItemLayout, по 30 точек на строку, а нарисованные ячейки запаса между распоркой и краем — по 31. Их там сорок, на каждой набегает по точке, и у края оказывается «Подъём блина перед собой · вариант 3», строка 298. Замера, который поправил бы расчёт, нет: getItemLayout его и отменил.
Теперь обе строки вывода называют одно упражнение. У последней строки разделителя нет, её настоящая длина 30, а не 31, — содержимое выйдет на точку длиннее, это безвредно. Так и сказано в описании getItemLayout в FlatList.js: длина разделителя входит в расчёт.
Таймер отдыха и справочник
Экран отдыха: таймер и справочник под ним, чтобы выбрать следующее упражнение. От таймера справочник не зависит, а каждый тик перерисовывает весь его запас. Сделайте так, чтобы тики не вызывали renderItem ни разу.
Причин две, и убрать нужно обе. Стрелка в renderItem и объект в contentContainerStyle на каждом тике новые, и любой из них заставляет FlatList перерисоваться и собрать для ячеек новую функцию строки. Функция уезжает из компонента, стиль — в StyleSheet.create:
Выходит 0. Если вынести только функцию, останется 132 — это проверено запуском: объект стиля по-прежнему новый на каждом тике. Объект {{ height: 30 }} в самой строке здесь не мешает: он создаётся, только когда строка уже перерисовывается, и перерисовки списка не вызывает.