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: true | Expanded | Sliver-ы (CustomScrollView) |
|---|---|---|---|
| Строит только видимые элементы (лениво) | Нет, строит всё | Да | Да |
| Цена первого кадра для N элементов | O(N) | O(viewport) | O(viewport) |
| Память масштабируется по | числу элементов | размеру viewport | размеру viewport |
| Работает для неограниченного / растущего списка | Нет | Да | Да |
| Требует ограниченную высоту родителя | Нет | Да (родитель обязан) | Да (Scaffold её даёт) |
| Несколько прокручиваемых секций в одной | Борется сам с собой | Только один список | Да, нативно |
Сворачиваемый заголовок (SliverAppBar) | Нет | Нет | Да |
| Строк шаблонного кода | 1 | 3 | ~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 — правильный ответ, когда список является единственной прокручиваемой областью экрана, расположенной под (или над) неким фиксированным обрамлением.
- Заголовок над лентой, проверено на Flutter 3.44. Заголовок или строка поиска в
Column, затем список заполняет остальное.Expandedотдаёт списку всю оставшуюся высоту и сохраняет его ленивым. - Список, который растёт и сжимается вместе с экраном. Поскольку
Expandedделит доступное пространство, а не жёстко прописывает его в коде, список адаптируется к разным высотам устройств и настройкам масштаба текста, в отличие отSizedBox(height: ...). - Вы хотите наименьший код, который остаётся корректным. Три строки, никакого нового словаря виджетов, полная ленивость. Для одного списка это выигрывает у обращения к sliver-ам.
Единственное требование: сам 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-ами, когда длинный список не одинок: он делит прокрутку с другими секциями, или экрану нужен заголовок, уходящий за пределы видимости при прокрутке.
- Две и более прокручиваемых секций в одной прокрутке, например список, перетекающий в сетку. Этот случай разобран от начала до конца в как смешать ListView и GridView в одной прокрутке со sliver-ами. Складывать два
ListViewвColumnи отключать их physics — это антипаттерн, для замены которого и существуют sliver-ы. - Сворачиваемый или плавающий заголовок.
SliverAppBarсpinned,floatingилиexpandedHeightдостаётся почти бесплатно, если у вас уже естьCustomScrollView. Эквивалента на основеColumnне существует. - Заголовок, который должен уходить за пределы видимости вместе с содержимым.
SliverToBoxAdapterоборачивает одиночный box-виджет между ленивыми секциями, чтобы он естественно уходил при прокрутке.
// 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 мало (единицы, низкие десятки) и фиксировано дизайном, а не данными.
Как только 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, не забудьте освободить его, чтобы не допустить утечки при размонтировании виджета.
Источники
- ListView class, Flutter API reference —
shrinkWrap, поведение viewport и явное замечание о том, что список с shrink-wrap строит всех своих потомков. - Expanded class, Flutter API reference — flex fit и как
Columnделит оставшееся пространство. - CustomScrollView class, Flutter API reference — размещение нескольких sliver-ов в одном ленивом viewport.
- Using slivers to achieve fancy scrolling, Flutter docs — протокол sliver-ов и как viewport договаривается о протяжённости отрисовки.
- Flutter docs: Understanding constraints — модель ограниченной и неограниченной высоты, лежащая в основе всего сравнения.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.