Bindings представляют собой синглтоны, которые связывают движок с фреймворком, по одному на область, а WidgetsFlutterBinding подмешивает их все в один класс. Каждый миксин отвечает за свою задачу: от событий указателя до build owner.
**GestureBinding: принимает события указателя от движка, выполняет hit testing и рассылает событие по пути попаданий в gesture arena
SchedulerBinding**: колбэки кадров и планирование по vsync: scheduleFrame, затем handleBeginFrame и handleDrawFrame
**ServicesBinding: платформенные каналы (binary messenger), загрузка ассетов, сообщения клавиатуры и жизненного цикла
PaintingBinding: кэш изображений и прогрев шейдеров
SemanticsBinding: включает и выключает дерево доступности
RendererBinding**: владеет render-деревом и корневым PipelineOwner; его drawFrame сбрасывает layout, paint и семантику
**WidgetsBinding**: владеет BuildOwner и корневым Element, реализует runApp и передаёт наблюдателям события жизненного цикла приложения
**ensureInitialized()**: runApp вызывает его сам; вызывайте вручную, только если main() сначала использует плагины (подробности описаны в теме про основы). В тестах ставится TestWidgetsFlutterBinding, и побеждает тот binding, который создан первым
FlutterSenior
Внутреннее устройство Flutter
Три дерева, RenderObject, дерево Element, BuildOwner, конвейер рендеринга
Закрыто вопросов
0 / 22
- 0Повторить
- 4Часто спрашивают
Вопросы
Виджеты служат дешёвыми неизменяемыми описаниями, которые постоянно пересоздаются; Element представляют собой долгоживущие экземпляры, которые держат состояние и позицию; RenderObject выполняют дорогую раскладку и отрисовку. Разделение позволяет свободно перестраивать описания и при этом переиспользовать дорогие объекты.
Widget: чертёж, который вы возвращаете из build(); выбрасывается и создаётся заново при каждом ребилде
Element: место в дереве, которое держит текущий виджет, State и зависимости от inherited-виджетов; BuildContext, который вы получаете в build(), и есть Element
RenderObject: вычисляет размер, позицию, рисует и обрабатывает hit testing; обновляется на месте через сеттеры, а не пересоздаётся
Как они связаны: при ребилде каждый Element сравнивает новый виджет со старым: тот же runtimeType и тот же key означают, что он сохраняет себя и свой RenderObject и просто принимает новую конфигурацию
Два вида Element: ComponentElement (для StatelessWidget, StatefulWidget, InheritedWidget) только строит другие виджеты и RenderObject не имеет; RenderObjectElement (для Padding, RichText внутри Text, Row) владеет ровно одним
Почему не одно дерево: если бы описание и объект отрисовки были одним и тем же, каждый setState пересоздавал бы render objects и заново делал раскладку и отрисовку всего поддерева
Flutter никогда не просит у платформы кнопку или текстовое поле: он сам рисует каждый пиксель своим рендерером на поверхности, которую даёт платформа. ОС только предоставляет эту поверхность и передаёт ввод и события жизненного цикла; AOT-скомпилированный Dart-код исполняется движком внутри процесса вашего приложения.
Фреймворк (Dart): виджеты, раскладка, жесты и анимации, всё построено поверх dart:ui
Движок (C++): запускает Dart VM, предоставляет dart:ui (canvas, текст, планировщик) и растеризует кадры через Impeller, который используется по умолчанию на iOS и современных Android; Skia по-прежнему работает в веб-рендерерах, на десктопе Windows и Linux и на старых Android-устройствах
Embedder: нативная оболочка: FlutterActivity на Android, FlutterViewController на iOS, окно и цикл событий на десктопе, CanvasKit или skwasm в вебе
Почему это всё равно ощущается нативно
Машинный код: релизные сборки AOT-компилируются в ARM, никакого интерпретатора или моста на каждый кадр
Платформенные сервисы остаются нативными: текстовый ввод работает с системной клавиатурой, semantics-дерево переводится в дерево доступности ОС, а настоящий нативный view (карта, WebView) встраивается как platform view ценой дополнительной композиции
Компромисс: одинаковый до пикселя UI на всех ОС, но виджеты Material и Cupertino остаются переписанными копиями, которые сами должны поспевать за внешним видом и поведением платформы
Как он запускается
Нативная точка входа создаёт FlutterEngine, движок запускает Dart VM, загружает AOT-снапшот и вызывает main(); runApp подключает корневой виджет и планирует первый кадр. В add-to-app движок прогревают заранее (через FlutterEngineGroup на Android и iOS), чтобы первый Flutter-экран не открывался пустым.
Потому что в этом слоте меняется тип виджета. Когда _padded переключается, второй ребёнок Column превращается из Counter в Padding; Widget.canUpdate возвращает false, старый Element вместе со State выбрасывается, а новый Counter создаётся с нуля на уровень глубже.
Element.updateChild обрабатывает каждый слот одним из четырёх способов:
Тот же экземпляр виджета: делать нечего, всё поддерево пропускается
**canUpdate true** (тот же runtimeType и тот же key): Element сохраняется и получает новый виджет; State выживает
**canUpdate false**: старый Element деактивируется (его State уничтожается в конце кадра), создаётся новый
Ребёнок удалён или добавлен: деактивация или создание
Сопоставление идёт по слоту и по глубине: Flutter не ищет старый Counter уровнем ниже, и const не помогает, потому что в этом слоте сменился тип Element. Решения: держать форму дерева стабильной (всегда оборачивать в Padding и менять только отступ), поднять счётчик в родителя или в хранилище состояния, либо в крайнем случае GlobalKey, который позволяет Element переехать, но ценой перепривязки (reparenting).
Element монтируется при первом появлении виджета, обновляется на месте, пока тип и key те же, деактивируется при удалении из дерева и размонтируется в конце кадра, если его не забрал обратно GlobalKey. State.dispose вызывается только при unmount, а не при удалении.
mount: createElement, затем mount прикрепляет его к слоту родителя; RenderObjectElement создаёт и подключает свой render object; для StatefulWidget здесь же выполняются initState, didChangeDependencies и первый build
update: пришёл новый виджет с тем же типом и key; выполняется State.didUpdateWidget(oldWidget), затем build
deactivate: родитель от него отказался: render object отсоединяется, Element попадает в список неактивных у BuildOwner, по поддереву вызывается State.deactivate
activate: GlobalKey забрал Element обратно в том же кадре; вызывается State.activate, Element перестраивается, а didChangeDependencies выполняется, если у него были inherited-зависимости; State переживает переезд
unmount: finalizeTree() в конце кадра размонтирует всё, что осталось неактивным, и именно здесь вызывается State.dispose; Element становится defunct
Практические правила: освобождайте контроллеры и подписки в dispose, не освобождайте в deactivate то, что не пересоздаёте в activate, и проверяйте mounted в асинхронных колбэках, потому что setState после dispose бросает setState() called after dispose().
setState лишь помечает Element грязным и запрашивает кадр. На следующем vsync идут анимации, build, layout, paint и композиция на UI-потоке, затем растеризация на raster-потоке; каждая стадия должна уложиться в интервал обновления: около 16 мс при 60 Гц, 8 мс при 120 Гц.
Vsync: планировщик просыпается ради запрошенного кадра
Animate: тикеры и AnimationController продвигаются к метке времени кадра
Build: грязные Element выполняют build() и сверяют своих детей
Layout: ограничения идут вниз, размеры возвращаются вверх, родители задают смещения детей
Paint: render objects записывают команды рисования в слои, а не пиксели
Composite: дерево слоёв превращается в сцену и передаётся движку
Raster: raster-поток превращает сцену в команды GPU
Два потока дают два вида jank: DevTools показывает для каждого кадра полосу UI и полосу raster; на Android и iOS начиная с Flutter 3.29 UI-поток совпадает с главным потоком платформы
Медленная полоса UI: слишком много ребилдов, тяжёлая работа в build(), неограниченные списки, intrinsic-раскладка
Медленная полоса raster: огромные картинки, saveLayer от Opacity, ShaderMask, BackdropFilter и некоторых клипов
Меряйте в profile-режиме: debug-сборки работают через JIT и полны assert'ов, их тайминги ничего не значат
Когда приходит vsync, SchedulerBinding выполняет три вида колбэков в фиксированном порядке: сначала transient, затем persistent, которые строят кадр, и после его завершения post-frame.
Кто просит кадр: простаивающее приложение ничего не рисует; кадр запрашивают setState, грязный render object или работающий Ticker
Transient: одноразовые, выполняются первыми с меткой времени кадра; здесь живут все Ticker, а значит и все AnimationController
Persistent: регистрируются один раз при старте и никогда не удаляются; drawFrame входит в их число, поэтому build, layout, paint и композиция происходят здесь
Post-frame: добавляются через addPostFrameCallback, выполняются один раз после завершения кадра
Почему это важно: размеры существуют только после layout, поэтому измерить виджет или вызвать setState по результату измерения нужно в post-frame-колбэке, а не в build()
Срабатывает один раз: для следующего кадра регистрируйте заново и проверяйте mounted: колбэк может выполниться, когда виджета уже нет
Positioned ничего не рисует. Он записывает top/left в слот parentData render object'а ребёнка, а слот с такими полями туда кладёт только Stack.
У каждого render object есть поле parentData, которое принадлежит его родителю: RenderStack ставит туда StackParentData, а RenderFlex ставит FlexParentData (flex, fit). У ParentDataWidget нет собственного render object, поэтому он находит ближайший render object ниже по дереву, записывает туда свои поля и просит родителя заново выполнить раскладку.
Ошибку выдаёт проверка типа: внутри Column слот имеет тип FlexParentData, и Positioned, которому нужен StackParentData, падает на assert
«Непосредственно внутри» относится к render objects: Builder или InheritedWidget между ними не мешают; Padding мешает, потому что слот занимает его собственный render object
Тот же механизм: Expanded/Flexible для Row/Column, LayoutId для CustomMultiChildLayout; свой multi-child render object тоже может завести такой виджет
Две вещи: markNeedsLayout там, где хватило бы markNeedsPaint, и нет проверки на равенство. updateRenderObject присваивает все свойства при каждом ребилде, поэтому каждый ребилд перезапускает layout до ближайшей relayout boundary, а за ним и paint, даже если цвет не менялся.
У render object есть по одной пометке на каждую фазу пайплайна, и сеттер должен вызывать самую дешёвую из тех, что действительно затрагивает изменение:
**markNeedsLayout**: изменилась геометрия; поднимается до relayout boundary, перезапускает performLayout, следом идёт paint
**markNeedsPaint: изменились только пиксели; поднимается до ближайшей repaint boundary и перерисовывает там, размеры не трогает
markNeedsSemanticsUpdate: изменились только подпись, флаг или действие
Сначала проверка**: if (value == _color) return; делает ребилд без изменений бесплатным
Исправление занимает две строки: добавить проверку и заменить вызов на markNeedsPaint(). debugRepaintRainbowEnabled и debugPrintMarkNeedsLayoutStacks показывают, что на самом деле пересчитывается.
StatusDot не переопределяет updateRenderObject, поэтому новый color так и не доходит до render object. Element сохраняет RenderStatusDot, созданный при первом билде, и тот продолжает рисовать исходный цвет, пока виджет не будет смонтирован заново.
Почему так происходит: при ребилде Widget.canUpdate совпадает (тот же тип, тот же key), поэтому RenderObjectElement оставляет свой render object и вызывает widget.updateRenderObject; базовая реализация пустая, а createRenderObject выполняется только один раз, при монтировании
Исправление: переопределите updateRenderObject и присвойте в нём каждое поле, которое виджет передаёт в createRenderObject; проверка на равенство в сеттере делает это бесплатным, если ничего не изменилось
Почему баг пропускают на ревью: всё, что монтирует виджет заново, маскирует баг: новый key, hot restart, уход с экрана и возврат на него
Как поймать: виджет-тест, который дважды вызывает pump с разными значениями и проверяет render object (tester.renderObject<RenderStatusDot>(...)), или golden-тест; держите createRenderObject и updateRenderObject рядом, чтобы новое поле попадало в оба
При касании Flutter обходит render-дерево от корня и спрашивает каждый RenderBox, попадает ли в него точка; все, кто ответил «да», собираются в HitTestResult (от самого глубокого к корню), и событие рассылается по этому пути.
Проверка: RenderBox.hitTest сначала проверяет size.contains(position), затем hitTestChildren, затем hitTestSelf; бокс, в который не попали, своих детей не спрашивает
Отсюда баг с переполнением: до ребёнка, нарисованного за границами родителя (Stack с clipBehavior: Clip.none, отрицательное смещение), очередь не доходит: родитель уже ответил «нет»
Порядок: родители с несколькими детьми проверяют их в обратном порядке отрисовки, поэтому побеждает тот, кто нарисован сверху; трансформации переводят позицию в координаты ребёнка
Один раз на указатель: путь вычисляется при касании (и при hover), а события move и up идут по тому же пути, даже если палец ушёл с виджета
Кто решает: событие получает каждая запись пути; распознаватели GestureDetector затем соревнуются в gesture arena; HitTestBehavior, IgnorePointer и AbsorbPointer меняют то, кто вообще попадает в путь
Нет. Overlay строит всё, что скрыто за непрозрачным маршрутом, внутри TickerMode(enabled: false), а заглушённый тикер перестаёт вызывать свой колбэк, поэтому анимация замирает до тех пор, пока маршрут не закроют.
Маршруты представлены записями Overlay. Ниже первой непрозрачной записи остальные либо не строятся вообще (maintainState: false), либо сохраняются с заглушёнными тикерами. SingleTickerProviderStateMixin слушает TickerMode напрямую, поэтому ничего вашего не перестраивается.
Заглушён не значит остановлен: isActive остаётся true, просто колбэк больше не вызывается
При возврате прыгает: прошедшее время продолжает считаться, и после снятия заглушки контроллер догоняет его одним шагом
**await controller.forward() не завершится, пока маршрут перекрыт, и это классическое зависание, когда навигацию или логику цепляют к анимации
Offstage и IndexedStack ничего не глушат**: их скрытые дети продолжают тикать; глушит Visibility(maintainAnimation: false) или ваш собственный TickerMode
debugFillProperties служит хуком Diagnosticable, который перечисляет свойства объекта для отладочных инструментов. Переопределите его в своём виджете, State или render object и добавьте типизированные свойства, и тогда их покажут Inspector, debugDumpApp и сообщения об ошибках фреймворка.
У кого он есть: виджеты, State, render objects и большинство классов фреймворка подмешивают Diagnosticable; для обычного класса добавьте with Diagnosticable
Типизированные свойства: IntProperty, DoubleProperty, StringProperty, FlagProperty, EnumProperty, ColorProperty, IterableProperty или DiagnosticsProperty<T> для всего остального; всё доступно через package:flutter/material.dart (ColorProperty из painting.dart, остальное из foundation.dart)
**defaultValue: свойство, равное значению по умолчанию, скрывается из краткого вида, и Inspector остаётся читаемым
Сначала вызывайте super: иначе потеряете унаследованные свойства, например key
Стоимость**: метод вызывается только когда инструмент запрашивает диагностику, а не в обычном кадре; линт diagnostic_describe_all_properties может требовать его в пакете дизайн-системы
Потому что ребилд виджетов лишь создаёт маленькие неизменяемые объекты и сравнивает их; дорогие Element и RenderObject переиспользуются и трогаются только тогда, когда что-то действительно изменилось. Ребилд стоит аллокаций и вызовов в Dart, а не layout и paint.
Что делает его дешёвым
Короткоживущие объекты почти бесплатны: Dart выделяет их сдвигом указателя в молодом поколении, а scavenger собирает мёртвые, не трогая старую кучу
Тот же экземпляр останавливает обход: updateChild сразу выходит, если новый виджет оказывается тем же экземпляром, что и старый, поэтому const-поддерево или ребёнок, переданный без изменений, пропускается целиком
Переиспользование, а не пересоздание: совпавший виджет только обновляет свой Element; updateRenderObject присваивает свойства, а сеттеры с проверкой на равенство ничего не помечают грязным, если значения те же
Layout и paint отдельно: они выполняются только для помеченных render objects и ограничены relayout- и repaint-границами
Когда это перестаёт работать
**Работа внутри build(): сортировка, парсинг, форматирование дат или создание контроллеров выполняются при каждом ребилде
Ребилд высоко в дереве каждый кадр**: анимация вызывает setState у всей страницы вместо AnimatedBuilder вокруг движущейся части
Широкие зависимости: MediaQuery.of(context) или context.watch целого объекта перестраивают виджет из-за посторонних изменений
Смена типа или key: виджет, который на каждом build меняет тип или получает новый key, уничтожает Element и render objects вместо их переиспользования
Один раз. BuildOwner сортирует список грязных Element по глубине, поэтому родитель перестраивается первым, и грязный флаг потомка снимается в том же проходе.
setState лишь вызывает markNeedsBuild: Element помечается грязным, передаётся в BuildOwner.scheduleBuildFor, и запрашивается кадр. Сразу ничего не строится. В кадре build scope сортирует грязные Element от ближайших к корню к самым глубоким и обходит их:
Родитель перестраивается и передаёт вниз новый виджет через updateChild
Ребёнок обновляется на месте: типы совпадают, поэтому обновление заканчивается ребилдом прямо здесь с новой конфигурацией, и грязный флаг снимается
Собственная запись ребёнка идёт позже и пропускается, потому что он уже не грязный; если родитель ребёнка удалил, запись неактивна и тоже пропускается
Этот порядок объясняет и assert setState() or markNeedsBuild() called during build: пометить грязным потомка Element, который сейчас строится, можно, ведь обход до него ещё дойдёт, а пометить предка или соседа означало бы идти назад. Во втором случае изменение нужно отложить в post-frame-колбэк или вообще вынести из build().
GlobalKey работает как запись в реестре: один ключ указывает ровно на один живой Element. Ошибка значит, что два поддерева претендовали на одну запись в одном кадре, например уходящая и приходящая страницы во время анимации push.
Когда фреймворк собирается создать Element для виджета с GlobalKey, он ищет ключ в реестре и пытается забрать существующий Element: отсоединить его от старого родителя и активировать под новым. Поддерево переезжает вместе со State, позициями скролла и render objects. Это и есть reparenting.
Переезд не бесплатный: выполняются deactivate и activate, поддерево перестраивается, didChangeDependencies срабатывает снова, если оно зависит от inherited-виджетов, ведь теперь над ним может быть другой Theme или Navigator
Работает только в пределах одного кадра: после того как finalizeTree размонтировал Element, забирать уже нечего
Откуда дубликат: ключ в статическом или глобальном поле, который используют две страницы, один ключ дважды в одном списке или один ключ у двух Navigator
**Никогда не создавайте ключ в build()**: новый ключ на каждый build означает новый Element на каждый build, то есть тихую потерю состояния; храните его в поле State
Сам экземпляр виджета может встречаться дважды: один и тот же const-виджет в двух местах вполне допустим; уникальным должен быть именно GlobalKey
Это два менеджера кадра: BuildOwner проводит дерево Element через фазу build, PipelineOwner проводит дерево RenderObject через layout, compositing bits, paint и семантику.
**BuildOwner**: держит список грязных Element; в buildScope сортирует его по глубине, вызывает build() и сверяет детей; также держит список неактивных Element, финализирует дерево в конце кадра и ведёт регистрацию GlobalKey
**PipelineOwner**: держит множества «нужен layout», «нужны compositing bits», «нужен paint» и «нужна семантика» и каждый кадр сбрасывает их строго в таком порядке: flushLayout, flushCompositingBits, flushPaint, flushSemantics
Зачем разделять: build выполняет дешёвый diff неизменяемых описаний, а layout и paint меняют тяжёлые объекты по строгим правилам порядка (нельзя рисовать, пока раскладка не завершена); раздельные менеджеры позволяют каждой фазе обходить только то, что она пометила
Два независимых пути: setState идёт через BuildOwner.scheduleBuildFor; markNeedsLayout или markNeedsPaint идут в PipelineOwner; оба в итоге запрашивают кадр, и drawFrame сначала выполняет build, затем пайплайн
Следствие: markNeedsPaint на листе стоит перерисовки, а не ребилда виджета, который его создал; поэтому анимации, которые идут прямо в render objects (FadeTransition, CustomPainter с repaint:), дешёвые
Где вы с ними встречаетесь: WidgetsBinding.instance.buildOwner, RendererBinding.instance.rootPipelineOwner; тесты, offscreen-рендеринг и multi-view embedder'ы создают дополнительные pipeline owners
Она поднимается до ближайшей relayout boundary и там останавливается. Только поэтому изменение размера глубоко внутри списка не приводит к раскладке всего экрана заново.
markNeedsLayout выставляет флаг; если объект не является границей, он вызывает тот же метод у родителя, уровень за уровнем. Граница добавляет себя в список раскладки PipelineOwner и запрашивает кадр; в следующем кадре flushLayout перезапускает performLayout от неё и ниже, но никогда выше.
Render object становится relayout boundary при последней раскладке, если выполнялось хотя бы одно из условий:
Жёсткие (tight) ограничения: его размер не может измениться, что бы ни происходило внутри
**parentUsesSize: false: родитель не читал размер ребёнка, значит, ему нечего пересчитывать
sizedByParent**: размер зависит только от ограничений и задаётся в performResize
Поэтому child.layout(constraints) без parentUsesSize: true означает не просто флаг, а изоляцию ребёнка. Фиксированный SizedBox с шириной и высотой даёт такую же изоляцию бесплатно, а дети Row/Column, даже Expanded, не дают: RenderFlex читает их размер (parentUsesSize: true); IntrinsicHeight делает обратное и добавляет спекулятивный проход раскладки по поддереву. Чтобы увидеть, кто что пометил, включите debugPrintMarkNeedsLayoutStacks = true.
performLayout читает child.size, но раскладывает ребёнка без parentUsesSize: true. В debug-сборке это падает с «RenderBox.size accessed beyond the scope of…»; в release, когда ребёнок потом меняет размер, подпись сохраняет старый размер, и контент вылезает за границы или остаётся пустое место.
Контракт: parentUsesSize сообщает, зависит ли раскладка родителя от размера ребёнка; с false родитель обещает, что не зависит, и ребёнок становится relayout boundary
Что ломается: когда текст внутри меняется, markNeedsLayout ребёнка останавливается на нём самом; flushLayout заново раскладывает только ребёнка и не перезапускает performLayout родителя, поэтому size родителя остаётся от прошлого кадра
Исправление: передавайте parentUsesSize: true всякий раз, когда родитель читает child.size после layout; тогда «грязь» от ребёнка поднимается до родителя. Оставляйте false, только если родитель игнорирует размер ребёнка; именно там граница и окупается
Как поймать: debug-assert срабатывает в любом виджет-тесте или debug-запуске, поэтому симптом со старым размером виден только в release; дешёвая защита состоит в том, чтобы держать parentUsesSize: true рядом с каждым чтением child.size (правило ревью) и иметь виджет-тесты, которые вообще монтируют этот render object
Paint не создаёт битмап: он записывает команды рисования в дерево слоёв, а композиция превращает это дерево в сцену для raster-потока. Отдельный слой позволяет перерисовать, сдвинуть или сделать прозрачным поддерево, не перерисовывая соседей.
Кто создаёт слои
**RepaintBoundary: всегда получает свой слой; записанная картинка его поддерева переиспользуется, пока что-то внутри не пометит её грязной
Эффекты, которым слой нужен**: Opacity (0 < значение < 1), ShaderMask, BackdropFilter, platform views и Texture
Только при необходимости: ClipRRect, ClipPath и Transform рисуют прямо в canvas, если у потомков ещё нет собственного слоя; флаг needsCompositing пересчитывает flushCompositingBits у изменившихся узлов
Сколько это стоит
Изоляция перерисовки: грязный render object перерисовывается только до ближайшей repaint boundary, поэтому тикающие часы внутри неё не перерисовывают страницу вокруг
Память и учёт: каждая граница держит свою картинку и добавляет слой в композицию, поэтому оборачивайте часть, которая постоянно перерисовывается рядом с тяжёлым статичным контентом (или наоборот), а не всё подряд
**Дороже всего обходится saveLayer**: Opacity над сложным поддеревом, ShaderMask, BackdropFilter и Clip.antiAliasWithSaveLayer рисуют во внеэкранный буфер; цвет с альфой (Colors.black.withValues(alpha: 0.6)) этого избегает; FadeTransition и AnimatedOpacity экономят лишь ребилд, внеэкранный буфер остаётся
Как это увидеть
debugRepaintRainbowEnabled (в DevTools это «Highlight repaints») подсвечивает каждый перерисованный слой, debugPaintLayerBordersEnabled обводит слои, а полоса raster в performance view показывает, во что обходятся лишние слои.
Потому что builder выполняется не в фазе build. Он запускается внутри performLayout, в build scope, который фреймворк открывает уже во время раскладки, а к этому моменту build предков закончен.
Element у LayoutBuilder не попадает в список грязных Element кадра; его ребилды планируются как layout-колбэк на его render object. Во время раскладки performLayout выполняет этот колбэк: builder запускается с реальными ограничениями, поддерево сверяется, и только потом ребёнок раскладывается.
Всё вне этого scope заблокировано: пометить предка грязным значило бы запросить build после того, как его раскладка уже началась; отсюда assert setState() or markNeedsBuild() called during build
Перезапускается при каждом изменении ограничений: под анимирующимся родителем или при меняющемся отступе клавиатуры это ребилд поддерева каждый кадр
Intrinsics и dry layout через него не проходят: оба бросают исключение, поэтому LayoutBuilder под IntrinsicHeight или в Row с выравниванием по baseline означает падение, а не медленный путь
Сначала берите инструмент попроще: MediaQuery.sizeOf(context) для размера экрана, FractionallySizedBox или Flexible для пропорций; LayoutBuilder нужен, только когда входными данными действительно служат ограничения родителя
Вы пишете RenderObjectWidget, который создаёт и обновляет RenderBox, а бокс сам реализует раскладку, отрисовку и hit testing. Берите это, только когда нужно правило раскладки, которое не выразить существующими виджетами, или нужно убрать лишние ребилды и слои на горячем пути.
Когда это правильный инструмент
Сначала композиция: Row, Stack, Flow и CustomMultiChildLayout покрывают большинство раскладок; CustomPainter покрывает собственную отрисовку без детей
Свой render object: дети, чьи позиции зависят от размеров друг друга так, как те виджеты не умеют, раскладки с учётом baseline или виджет в сотнях строк списка, где на счету каждый ребилд и каждый слой
Контракт
Сторона виджета: createRenderObject создаёт бокс, updateRenderObject присваивает свойства при каждом ребилде; сеттеры проверяют равенство и вызывают markNeedsLayout или markNeedsPaint
Раскладка: performLayout раскладывает детей, задаёт size в пределах constraints и записывает смещения детей в parentData; передавайте parentUsesSize: true, только если читаете размер ребёнка
Dry layout и intrinsics: реализуйте computeDryLayout по тому же правилу размеров: контейнеры, которые меряют ребёнка без раскладки (intrinsics у Wrap, ListTile, WidgetSpan в Text), вызывают getDryLayout, и стандартная реализация падает с "does not implement computeDryLayout". IntrinsicHeight использует отдельные computeMinIntrinsic*/computeMaxIntrinsic*, которые молча возвращают 0.0, пока вы их не переопределите
Отрисовка и hit testing: paint рисует сам объект и детей по их смещениям; hitTestChildren/hitTestSelf решают, кто получит события указателя
Дети: для одного RenderObjectWithChildMixin; для нескольких ContainerRenderObjectMixin плюс RenderBoxContainerDefaultsMixin (стандартные paint и hit testing) и свой ContainerBoxParentData
Пишите правило размеров один раз и вызывайте его из обоих проходов, а в тестах включайте debugCheckIntrinsicSizes, чтобы поймать расхождение dry- и настоящей раскладки.