Start Debugging

shrinkWrap vs Expanded vs sliver-ы для длинных списков в Flutter: что выбрать?

Для длинного списка никогда не используйте shrinkWrap. Используйте Expanded, когда список является единственным прокручиваемым элементом, и sliver-ы (CustomScrollView), когда он делит прокрутку с другими секциями. Вот почему, с бенчмарком по числу build-ов.

Для длинного списка в Flutter расстановка сил очевидна: используйте Expanded, когда список является единственным прокручиваемым элементом под заголовком, и переходите к sliver-ам (то есть CustomScrollView), как только список начинает делить прокрутку с другими секциями. Никогда не прибегайте к shrinkWrap: true для длинного списка. Это самый короткий диф, и он заглушает ошибку layout, но при этом заставляет список построить каждый элемент на первом кадре, отбрасывая ленивую переработку, которая держит прокрутку на 60fps. В этой статье три подхода сталкиваются на Flutter 3.x (проверено на 3.44, Dart 3.x): матрица возможностей, бенчмарк по числу build-ов, показывающий точную цену, и та единственная деталь, которая решает за вас.

Эти три подхода не являются взаимозаменяемыми ручками одного и того же виджета. Expanded и shrinkWrap — это два способа ответить на вопрос “какая высота у этого ListView внутри Column?”, тогда как sliver-ы полностью заменяют форму Column плюс ListView. Они сталкиваются потому, что это три ответа, которые люди пробуют, когда ListView внутри Column выбрасывает Vertical viewport was given unbounded height. Если именно эта проверка привела вас сюда, полный разбор первопричины есть в как вложить ListView в Column без ошибки неограниченной высоты; эта статья — более острый вопрос о том, какое решение оставить после того, как красный экран исчез.

Матрица возможностей

СвойствоshrinkWrap: trueExpandedSliver-ы (CustomScrollView)
Строит только видимые элементы (лениво)Нет, строит всёДаДа
Цена первого кадра для N элементовO(N)O(viewport)O(viewport)
Память масштабируется почислу элементовразмеру viewportразмеру viewport
Работает для неограниченного / растущего спискаНетДаДа
Требует ограниченную высоту родителяНетДа (родитель обязан)Да (Scaffold её даёт)
Несколько прокручиваемых секций в однойБорется сам с собойТолько один списокДа, нативно
Сворачиваемый заголовок (SliverAppBar)НетНетДа
Строк шаблонного кода13~6 на секцию

Прочитайте сначала три верхние строки. В них весь аргумент. shrinkWrap — единственный из трёх, чья цена растёт с числом элементов, а “длинный список” — это ровно тот случай, где эта цена кусается.

Почему shrinkWrap — это O(N), а два других — нет

Обычный ListView — это ленивый viewport: он строит и раскладывает только видимые в его окне элементы плюс небольшое расширение кеша, а затем перерабатывает эти виджеты по мере прокрутки. Постройте список из 10 000 строк, и в любой момент существует лишь ~15 строк. Эта ленивость — вся история производительности списков во Flutter.

shrinkWrap: true отключает её. Чтобы задать себе размер по содержимому (вместо заполнения своей оси), viewport должен знать свою полную высоту, а единственный способ это узнать — построить и измерить каждого потомка заранее. Так что shrinkWrap превращает ленивый список в жадный: N элементов означают N вызовов build на первом кадре, N раскладок RenderBox и память, пропорциональную N. Для тех трёх строк настроек, на которых люди обычно его тестируют, это ничто. Для ленты это подвисание на первом кадре, которое видно на временной шкале (см. как профилировать jank в приложении Flutter с помощью DevTools).

Expanded сохраняет список ленивым. Это потомок Flex, который навязывает жёсткую высоту, равную пространству, оставшемуся после того, как Column измерил остальных своих потомков. ListView получает ограниченный maxHeight, заполняет ровно это окно и перерабатывает строки внутри него. Цена — O(viewport), независимо от числа элементов.

Sliver-ы тоже сохраняют список ленивым, но иным механизмом: SliverList никогда не нуждается в знании своей полной высоты. Он договаривается напрямую с родительским viewport (“вы прокручены до смещения X, я отрисую потомков в этом диапазоне”), так что нет рукопожатия по неограниченной высоте и нет причины строить потомков за пределами экрана. Цена снова — O(viewport).

Бенчмарк по числу build-ов

Чистейший способ это измерить — не миллисекунды (которые варьируются от машины к машине), а число build-ов элементов на первом кадре, которое детерминировано и обусловлено механизмом. Оснастите каждый элемент счётчиком:

// Flutter 3.x (tested 3.44), Dart 3.x
int buildCount = 0;

Widget itemBuilder(BuildContext context, int index) {
  buildCount++;                 // increment on every item build
  return ListTile(title: Text('Row $index'));
}

Отрисуйте список из 5 000 элементов тремя способами и прочитайте buildCount после первого кадра (WidgetsBinding.instance.addPostFrameCallback). Результаты неоспоримы:

Layout для 5 000 элементовBuild-ов элементов на первом кадреМасштабируется по
ListView(shrinkWrap: true)5 000числу элементов
Expanded(child: ListView)~12 до 15только viewport
SliverList в CustomScrollView~12 до 15только viewport

Точное число в ленивом режиме зависит от высоты строки и стандартного cacheExtent (250 логических пикселей за пределами viewport с каждой стороны), но форма фиксирована: shrinkWrap строит все 5 000, два других строят только то, что помещается, плюс кеш. На Pixel 7 в режиме profile первый кадр shrinkWrap намного превысил бюджет в 16ms, строя эти 5 000 tile-ов, тогда как версии с Expanded и со sliver-ами отрисовали первый кадр в пределах бюджета и оставались там при прокрутке. Значение, которое важно, — это отношение: ~5 000 build-ов против ~15, разница, которая растёт каждый раз, когда растёт список.

Чтобы воспроизвести это самостоятельно, запускайте в режиме profile (flutter run --profile), а не в debug, потому что измерения в режиме debug доминируются проверками и не репрезентативны. Отношение по числу build-ов идентично в любом режиме; различается только время по часам.

Когда выбирать Expanded

Expanded — правильный ответ, когда список является единственной прокручиваемой областью экрана, расположенной под (или над) неким фиксированным обрамлением.

Единственное требование: сам Column должен иметь ограниченную высоту, чтобы Expanded было что делить. Внутри body у Scaffold она есть. Если Column сам находится в неограниченном контексте, вы лишь передвинули проблему на уровень выше.

// Flutter 3.x (tested 3.44)
Column(
  children: [
    const SearchBar(),
    Expanded(
      child: ListView.builder(       // stays lazy
        itemCount: items.length,
        itemBuilder: (context, i) => ItemTile(items[i]),
      ),
    ),
  ],
)

Когда выбирать sliver-ы

Обращайтесь к CustomScrollView со sliver-ами, когда длинный список не одинок: он делит прокрутку с другими секциями, или экрану нужен заголовок, уходящий за пределы видимости при прокрутке.

// Flutter 3.x (tested 3.44)
CustomScrollView(
  slivers: [
    const SliverAppBar(title: Text('Explore'), floating: true),
    SliverList.builder(              // lazy, shares the one scroll position
      itemCount: items.length,
      itemBuilder: (context, i) => ItemTile(items[i]),
    ),
  ],
)

Sliver-ы многословнее, около шести строк на секцию вместо трёх, и эта многословность — единственная причина не использовать их для одиночного списка. Когда прокручиваемый элемент ровно один, Expanded даёт ту же ленивость с меньшей церемонией.

Когда shrinkWrap действительно уместен

shrinkWrap: true — это не баг, это инструмент с узкой правильной областью применения: короткий, ограниченный список, число элементов которого вы контролируете и который должен задавать себе размер по содержимому, чтобы другие виджеты могли располагаться над ним и под ним в том же Column.

Как только N начинает определяться данными, которые могут расти (посты, фото, результаты поиска, сообщения), shrinkWrap — неправильный инструмент, потому что его цена растёт вместе с этими данными. Если вы используете его для короткого списка внутри прокручиваемого Column, также задайте physics: const NeverScrollableScrollPhysics(), чтобы внутренний список не боролся с внешним SingleChildScrollView за жест прокрутки.

// Flutter 3.x (tested 3.44) -- fine ONLY because this list is short and fixed
Column(
  children: [
    const Text('Settings'),
    ListView(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      children: const [
        SwitchListTile(title: Text('Notifications'), value: true, onChanged: null),
        SwitchListTile(title: Text('Dark mode'), value: false, onChanged: null),
      ],
    ),
  ],
)

Деталь, которая решает за вас

Два ограничения полностью перекрывают предпочтения.

Если список может расти без предела, shrinkWrap исключён. Это не вопрос вкуса и не пара пропущенных кадров, которые можно стерпеть. Неограниченная жадная сборка — это проблема корректности и памяти: и время build, и память масштабируются с числом элементов, так что список, который в норме при тестировании с 20 строками, может заблокировать первый кадр на сотни миллисекунд в продакшене с 2 000. Фреймворк не предупредит вас, потому что shrinkWrap сделал ровно то, о чём вы попросили. Это самый распространённый способ, которым список Flutter тихо деградирует.

Если у вас более одной прокручиваемой секции, sliver-ы — единственный чистый ответ. Expanded обрабатывает ровно один список. Два списка с Expanded в Column делят высоту между собой на два отдельных scroll view, что почти никогда не то, что вам нужно. В тот миг, когда у вас есть список плюс сетка, или список плюс ещё один список, или любая секция, которая должна прокручиваться как единая поверхность со списком, CustomScrollView — структурно правильная форма, а всё остальное — обходной путь.

Всё прочее — адаптивность к экрану, шаблонный код, сворачиваемый заголовок — это критерий разрешения при равенстве в рамках этих двух жёстких правил.

Рекомендация, повторно

Для длинного списка расставьте их так: sliver-ы, если список делит прокрутку с чем угодно ещё, Expanded, если он единственный прокручиваемый элемент, и shrinkWrap — никогда. Держите shrinkWrap: true в наборе инструментов только для коротких, фиксированных списков, которые должны задавать себе размер по содержимому внутри Column. Ловушка в том, что все три заставляют ошибку неограниченной высоты исчезнуть, так что легко отправить в релиз тот, который вдобавок тихо меняет громкую проверку layout на регрессию производительности, проявляющуюся только под реальными данными. Читайте выбор как “как мне сохранить список ленивым?”, и ответ всегда — Expanded или sliver-ы, никогда shrinkWrap.

Если строка внутри любого из этих подходов когда-либо выйдет за пределы собственной ширины, это отдельная проблема RenderFlex overflowed на уровне tile, не связанная с выбором внешнего layout. И какой бы layout вы ни выбрали, если он использует ScrollController, не забудьте освободить его, чтобы не допустить утечки при размонтировании виджета.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад