Flutter под капотом: Owners

Всем привет! Меня зовут Михаил Зотьев, я Flutter-разработчик и тимлид в Surf.

Продолжаю серию материалов о внутреннем устройстве работы Flutter:

  1. Flutter под капотом
  2. Flutter под капотом: Binding
  3. Flutter под капотом: Owners > Вы находитесь здесь


Для полного восприятия советую знакомиться с материалом последовательно. Каждая следующая часть — структурное продолжение предыдущей, которое раскрывает один из аспектов общего внутреннего устройства.

Если вам больше нравится видеоформат, то совсем скоро вы сможете посмотреть на эту тему мой доклад, с которым я выступил на DartUp 2020. Он объединяет и обобщает материал всех трёх статей вместе. Ссылку на доклад я опубликую здесь, когда организаторы DartUP выложат видео в открытый доступ.

0req81sqh0qajhqygwq-pptww9k.png


В первой статье мы рассматривали взаимодействие между Widget, Element и Render Object. Тогда я специально упростил некоторые процессы: дерево виджетов мы получали сразу на вход, а при изменениях свойства Render Object мы сразу видели изменения. На самом деле эти процессы несколько сложнее.

Для начала рассмотрим как фреймворк генерирует кадр. Flutter Engine обращается к SchedulerBinding c целью формирования нового кадра, и его построение делится на десять этапов.

  1. Фаза анимации
    Вызов handleBeginFrame инициирует вызов всех колбеков, которые зарегистрированы в scheduleFrameCallback, — в порядке регистрации. Сюда входят все экземпляры Ticker, которые управляют AnimationController. Все активные анимации обновляются. Почему первой идёт именно это фаза? Важно, чтобы мы получали актуальные изменения зарегистрированных анимаций. Окажись эта фаза в конце списка, обновление сводило бы на нет все проведённые расчёты.
  2. Фаза микротасков
    После завершения handleBeginFrame запускаются запланированные микротаски.
  3. Фаза сборки
    Инициируется обновление всех Element, которые помечены как требующие обновления.
  4. Фаза построения макета
    Инициируется обновление всех Render Object, которым это требуется. Определяется, какие объекты рендера нуждаются в перерисовке.
  5. Фаза композиции частей
    Схожа с предыдущей фазой, но в ней задействованы только Render Object, которые напрямую участвуют в композиции. Также происходит определение, какие объекты нуждаются в перерисовке.
  6. Фаза отрисовки
    Перерисовываются все объекты, нуждающиеся в перерисовке.
  7. Фаза композиции
    Макет превращается в сцену и передаётся GPU для отрисовки.
  8. Фаза семантики
    Происходит обновление семантических свойств объектов рендеринга. Строится дерево семантики — его использует движок, чтобы адаптировать приложение для людей с ограниченными возможностями.
  9. Фаза завершения сборки
    Дерево виджетов находится в финальном состоянии. Неиспользуемые элементы и состояния уничтожаются.
  10. Фаза завершения подготовки кадра
    Выполняются посткадровые колбеки.


BuildOwner


BuildOwner — менеджер сборки и обновления дерева элементов. Он активно участвует в двух фазах — сборки и завершения сборки. Поскольку BuildOwner управляет процессом сборки дерева, в нем хранятся списки неактивных элементов и списки элементов, нуждающихся в обновлении. Рассмотрим подробнее, какими возможностями он обладает и как именно участвует в этих фазах.

Метод scheduleBuildFor даёт возможность пометить элемент как нуждающийся в обновлении.

Механизм lockState защищает элемент от неправильного использования, утечек памяти и пометки на обновления в процессе уничтожения.

Метод buildScope осуществляет пересборку дерева. Работает с элементами, которые помечены как нуждающиеся в обновлении.

Метод finalizeTree завершает построение дерева. Удаляет неиспользуемые элементы и осуществляет дополнительные проверки в режиме отладки — в том числе на дублирование глобальных ключей.

Метод reassemble обеспечивает работу механизма HotReload. Этот механизм позволяет не пересобирать проект при изменениях, а отправлять новую версию кода на DartVM и инициировать обновление дерева.

Как же используются все эти возможности?

На одном из этапов построения вызывается WidgetsBinding.drawFrame, который и является сигналом к обновлению.

if (renderViewElement != null)
    buildOwner.buildScope(renderViewElement);
super.drawFrame();
buildOwner.finalizeTree();


Как мы видим, он соответствует рассмотренному порядку построения кадра.

Отвлечёмся от BuildOwner и посмотрим на один из методов Element: rebuild, осуществляющий сборку.

Он может вызываться в трёх случаях:

  1. BuildOwner во время сборки дерева.
  2. ComponentElement во время встраивания его в дерево. ComponentElement описывает часть интерфейса с помощью других Element, поэтому во время первого появления сборка просто необходима.
  3. При обновлении ComponentElement.


image?w=553&h=226&rev=1&ac=1&parent=1arw


В результате вызова метода rebuild будет вызван метод perfomRebuild. У него разные реализации в зависимости от типа Элемента. Например, RenderObjectElement инициирует обновление объекта рендеринга. А ComponentElement вызывает метод build. Тот самый build, который есть у State и у Stateless элемента.

image?w=553&h=290&rev=1&ac=1&parent=1arw


На выход этот метод отдаёт виджет, который нужно отобразить. Именно он отправляется в метод updateChild, который в зависимости от условий делает выбор: происходит обновление или вставка нового виджета.

image?w=553&h=419&rev=1&ac=1&parent=1arw


Так, схема оказалась замкнута, кроме одного вызова — BuildOwner.buildScope. В этом методе rebuild вызывается на элементах, отмеченных как требующие обновления. В этот список элементы добавляются посредством метода scheduleBuildFor, а происходит это в нескольких случаях:  

  1. Элемент активируется из деактивированного состояния при переиспользовании.
  2. При присоединении дерева отрисовки в момент старта приложения.
  3. При вызове setStateу состояния.
  4. При использовании HotReload.
  5. Когда изменились зависимости элемента (использование InheritedElement).


Так BuildOwner и управляет всем процессом пересборки дерева элементов.

PipelineOwner


Ещё один менеджер, активно используемый в процессе формирования кадра, —  PipelineOwner. Его зона ответственности — работа с деревом отображения.

Он участвует в фазах 4—8 в нашем списке. В этом можно убедиться, вновь посмотрев код WidgetsBinding.drawFrame:

if (renderViewElement != null)
    buildOwner.buildScope(renderViewElement);
super.drawFrame();
buildOwner.finalizeTree();


Между началом и завершением сбора дерева виджетов вызывается super.drawFrame. Поскольку WidgetsFlutterBinding сформирован из различных binding, а порядок их присоединения важен, этот вызов приведёт нас к RendererBinding.drawFrame.

@protected
void drawFrame() {
    assert(renderView != null);
    pipelineOwner.flushLayout();
    pipelineOwner.flushCompositingBits();
    pipelineOwner.flushPaint();
    if (sendFramesToEngine) {
        renderView.compositeFrame();
        pipelineOwner.flushSemantics();
        _firstFrameSent = true;
    }
}


Вызовы, которые представлены в данном методе, определяют порядок следующих фаз формирования кадра: фазы построения макета, фазы композиции частей, фаза отрисовки, фазы композиции, фазы семантики.

Как и BuildOwner, PipelineOwner хранит в себе список объектов, нуждающихся в обновлении. В данном случае этот список — лист объектов рендера. Они попадают в него при обновлении свойств. Стандартная ситуация: updateRenderObject, в данном методе отображающие виджеты, актуализируют свойства объекта рендера, согласно своим текущим значениям. Если пришедшее значение не совпадает, Render Object отправляется в лист для обновления.

При вызове flushLayout будут обработаны все RenderObject, отмеченные как требующие пересчёта макета. Для них будет выполнен перерасчёт макета, и эти элементы будут отмечены как требующие перерисовки.

Вызов flushCompositingBits обработает все RenderObject, которые напрямую участвуют в композиции, если были отмечены как требующие обновления. Механизм схож с предыдущим, но для определённых RenderObject. В результате выполнения будут выделены элементы, которые требуют перерисовки.

На этапе вызова flushPaint ранее составленный список требующих отрисовки объектов будет обработан — и для объектов в нем будет вызвана перерисовка.

По окончании процесса у корневого элемента дерева отрисовки будет вызван метод compositeFrame, который отправит данные на отрисовку движку.

final ui.SceneBuilder builder = ui.SceneBuilder();
final ui.Scene scene = layer.buildScene(builder);
if (automaticSystemUiAdjustment)
    _updateSystemChrome();
_window.render(scene);


Расчёт макета


Важная особенность процесса — алгоритм расчёта макета.

При большом количестве виджетов и объектов рендеринга важно использовать эффективные алгоритмы. Flutter нацелен на линейную зависимость  производительности для первоначального расчёта макета, и сублинейную — для обновления. Это означает, что время, затрачиваемое на расчёт макета, должно расти медленнее, чем количество визуализируемых объектов.

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

Чтобы обеспечить это во Flutter, используется следующий подход:

  1. Ограничения спускаются вниз по дереву, от родителей к детям.
  2. Размеры идут вверх по дереву от детей к родителям.
  3. Родители устанавливают положение детей.


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

Постоянный расчёт «в лоб» не позволит достигнуть подобной производительности. Поэтому есть особенности, которые дополнительно упрощают построение:

  1. Если дочерний объект не пометил свой собственный макет требующим обновления, он может не выполнять расчёт, пока родитель дает ему те же самые ограничения.
  2. Каждый раз, когда родитель вызывает у дочернего объекта метод layout, родитель указывает, использует ли он информацию о размере, возвращаемую дочерним объектом. Часто бывает, что родительский элемент не использует эту информацию. Значит, ему не приходится повторно вычислять свой размер, даже если дочерний элемент меняет свой размер. Ему гарантируется, что новый размер будет соответствовать существующим ограничениям.
  3. Жёсткие ограничения — это те ограничения, которым может удовлетворять только один допустимый размер. Если максимальная и минимальная высота равны между собой, а максимальная и минимальная ширина также идентичны, единственный подходящий размер — заданные ограничения. В случае задания жёстких ограничений родительский элемент не должен повторно вычислять свой размер при перерасчёте дочернего элемента. Даже в случае использования родителем размеров ребёнка в своём макете, потому что дочерний элемент не может изменить размер без новых ограничений от своего родителя.
  4. RenderObject может объявить, что использует для вычисления своих размеров только ограничения, предоставленные родителем. Это значит, что родительскому объекту этого объекта рендеринга не нужно выполнять перерасчёт при повторном вычислении у самого объекта. Даже если ограничения не жёсткие или макет родительского элемента зависит от размера дочернего элемента, потому что дочерний элемент не может изменить свои размеры без новых ограничений от его родителя.


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

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

Спасибо, за внимание! Stay at the Dart side!

Используемые материалы:  
Исходники Flutter.

© Habrahabr.ru