3.15. Слои и RenderObject

В этом параграфе мы начнём увязывать RenderObject и PaintingContext. Рассмотрим, как реализуется связь между RenderObject и операциями FlutterView.

Здесь уже появляются абстракции слоя, которые можно разделить на четыре категории:

  • слой содержания (PictureLayer, TextureLayer, PlatformViewLayer, PerformanceOverlayLayer) — преобразуется непосредственно в изображение, не может содержать вложенных слоёв;
  • слой преобразования (OffsetLayer, TransformLayer, OpacityLayer и другие) — может содержать вложенные слои, применяет на них пиксельное или матричное преобразование;
  • слои для зависимого изменения преобразованийLeaderLayer и FollowerLayer;
  • слой метаданныхAnnotatedRegionLayer, применяется для маркировки системных областей (например, области уведомлений), но также может применяться для пометки фрагментов экрана и дальнейшего поиска через findAnnotations<S>.

Более подробно типы слоёв мы рассмотрим чуть ниже в этом параграфе, а сейчас поговорим преимущественно про PictureLayer и OffsetLayer.

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

Слои могут перемещаться в пределах дерева. На каждом кадре дерево обрабатывается целиком (здесь нет понятия dirty, как в случае с деревом элементов), но часть слоёв может быть переиспользована через применение метода addRetained.

Переиспользование изображения из кэша будет выполняться автоматически, но можно его отключить через переопределение get-метода с возвратом значения true для alwaysNeedsAddToScene или вызов метода markNeedsAddToScene() для слоя на этапе выполнения метода paint().

В приложении на Flutter корневой RenderObject представляется классом RenderView, он создаёт слой TransformLayer для всего приложения, в который уже добавляются дочерние слои.

Для любого RenderObject есть связанный с ним debugLayer, но не каждый из них создаёт собственный слой. Это помогает оптимизировать использование памяти: ведь каждый слой растеризируется независимо, что увеличивает потребление ресурсов. Однако если несколько RenderObject используют один слой, любое изменение в одном из них приводит к полной перерисовке слоя целиком.

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

Работа со слоями: создание вручную

Начнём с примера, в котором создаётся минимальное дерево RenderObject без использования виджетов. Мы добавим RenderView, настроим RendererBinding и выведем информацию о слое через debugLayer:

1import 'dart:ui';  
2  
3import 'package:flutter/cupertino.dart';  
4import 'package:flutter/rendering.dart';  
5import 'package:flutter/scheduler.dart';  
6  
7void main() {  
8  // инициализация связи Binding и Flutter Engine  
9  WidgetsFlutterBinding.ensureInitialized();  
10  // получение основного view (поверхность для рисования)  
11  final view = PlatformDispatcher.instance.implicitView!;  
12  // создание корневого RenderObject (RenderView)
13  final renderView = RenderView(view: view, configuration: ViewConfiguration(  
14    devicePixelRatio: view.devicePixelRatio,  
15    physicalConstraints: BoxConstraints.fromViewConstraints(  
16        view.physicalConstraints),  
17    logicalConstraints: BoxConstraints.fromViewConstraints(  
18        view.physicalConstraints / view.devicePixelRatio),  
19  ));  
20  // связывание RenderView и экземпляра RendererBinding  
21  final rendererBinding = RendererBinding.instance;  
22  rendererBinding.addRenderView(renderView);  
23  renderView.attach(rendererBinding.rootPipelineOwner);  
24  
25  // подготовка первого кадра  
26  renderView.prepareInitialFrame();  
27  rendererBinding.scheduleWarmUpFrame();  
28  SchedulerBinding.instance.addPostFrameCallback((_) {  
29    // вывод связанного с RenderView слоя  
30    print(renderView.debugLayer);  
31  });
32}

Здесь мы можем увидеть, что RenderView создает слой и его реализацию во Flutter Engine (TransformEngineLayer)

1TransformLayer#a6db6(owner: RenderView#b6c9d, engine layer: TransformEngineLayer#f56b4, handles: 1, offset: Offset(0.0, 0.0), transform: [2.6,0.0,0.0,0.0; 0.0,2.6,0.0,0.0; 0.0,0.0,1.0,0.0; 0.0,0.0,0.0,1.0])

Также мы могли использовать RenderView непосредственно для создания сцены, через вызов метода buildScene (принимает экземпляр builder).

Добавление пользовательского RenderObject

Теперь добавим собственную реализацию RenderBox, который отрисовывает прямоугольник на экране, — и разместим его в дереве. Для этого создадим дополнительный RenderObject для отображения заполнения экрана сплошным цветом.

Поскольку реализация RenderObject для ColoredBox — это приватный класс (_RenderColoredBox), выполним собственную реализацию в своём коде (более подробно про методы RenderObject можно прочитать в этом параграфе):

1class ColoredRenderBox extends RenderBox {  
2  @override  
3  void performLayout() => size = constraints.biggest/2;  
4  
5  @override  
6  void paint(PaintingContext context, Offset offset) {  
7    context.canvas.drawRect(  
8        offset & size,  
9        Paint()  
10          ..color = const Color(0xFFFF0000)  
11          ..style = PaintingStyle.fill);  
12    super.paint(context, offset);  
13  }  
14}

И добавим новый RenderObject в наше самодельное дерево:

1void main() {  
2  // инициализация связи Binding и Flutter Engine  
3  WidgetsFlutterBinding.ensureInitialized();  
4  // получение основного view (поверхность для рисования)  
5  final view = PlatformDispatcher.instance.implicitView!;  
6  // создание корневого RenderObject (RenderView) и ColoredRenderBox  
7  final renderView = RenderView(  
8    child: ColoredRenderBox(),  
9    view: view,  
10  );  
11  // связывание RenderView и экземпляра RendererBinding  
12  final rendererBinding = RendererBinding.instance;  
13  rendererBinding.addRenderView(renderView);  
14  renderView.attach(rendererBinding.rootPipelineOwner);  
15  renderView.configuration = ViewConfiguration(  
16    devicePixelRatio: view.devicePixelRatio,  
17    physicalConstraints: BoxConstraints.fromViewConstraints(view.physicalConstraints).loosen(),  
18    logicalConstraints: BoxConstraints.fromViewConstraints(  
19            view.physicalConstraints / view.devicePixelRatio)  
20        .loosen(),  
21  );
22  // здесь для logincalConstraints установлено минимальный размер (0,0)
23  // через вызов loosen, чтобы можно было сделать прямоугольник,
24  // с размером меньше, чем размер экрана
25  // подготовка первого кадра (первоначальный layout - paint)  
26  renderView.prepareInitialFrame();  
27  rendererBinding.scheduleWarmUpFrame();  
28  
29  SchedulerBinding.instance.addPostFrameCallback((_) {  
30    // вывод связанного с RenderView слоя  
31    debugDumpLayerTree();  
32  });
33}

Здесь можно увидеть, что в метод paint передается не Canvas, как это можно было бы ожидать для метода создания виджета из графических примитивов, а PaintingContext. Это связано с необходимостью поддержки слоёв, но сейчас пока ограничимся тем, что из свойства canvas в PaintingContext можно получить Canvas, связанный с текущим слоем.

При запуске увидим, что теперь в нашем дереве слоёв есть два слоя, которые легко соотносятся с операциями построения сцены:

1TransformLayer#13ddf
2  owner: RenderView#10678
3  creator: RenderView
4  engine layer: TransformEngineLayer#7bfd9
5  offset: Offset(0.0, 0.0)
6  child 1: PictureLayer#b6f86
7    handles: 1 
8    paint bounds: Rect.fromLTRB(0.0, 0.0, 540.0, 1168.5)
9    picture: _NativePicture#ed451
10    raster cache hints: isComplex = false, willChange = false

Эти два слоя могут быть преобразованы в последовательность операций: pushTransform, addPicture, pop. Размер слоя изображения определяется по размеру RenderObject (из поля size после performLayout), и это помогает минимизировать использование памяти (изображение будет кэшировано после первого создания, флаг isComplex позволяет отключить кэширование, а willChange — запросить обновление на следующем кадре).

Проблема совместного слоя

Основная проблема такого подхода в том, что при добавлении нескольких RenderObject они все будут переиспользовать один и тот же слой PictureLayer. Давайте проверим — для этого будем использовать RenderObject для виджета StackRenderStack. Добавим поддержку смещения и цвета в ColoredRenderObject:

1class ColoredRenderBox extends RenderBox {  
2  Color color;  
3  Offset shift;  
4  
5  ColoredRenderBox({  
6    required this.color,  
7    required this.shift,  
8  });  
9  
10  @override  
11  void performLayout() => size = constraints.biggest / 2;  
12  
13  @override  
14  void paint(PaintingContext context, Offset offset) {  
15    context.canvas.drawRect(  
16        (offset + shift) & size,  
17        Paint()  
18          ..color = color  
19          ..style = PaintingStyle.fill);  
20    super.paint(context, offset);  
21  }  
22}

И изменим определение дерева RenderObject:

1final firstRenderBox = ColoredRenderBox(  
2  color: const Color(0xFFFF00FF),  
3  shift: Offset.zero,  
4);  
5final secondRenderBox = ColoredRenderBox(  
6  color: const Color(0xFFFF0000),  
7  shift: const Offset(32, 32),  
8);  
9final renderView = RenderView(  
10  view: view,  
11  child: RenderStack(  
12    children: [  
13      firstRenderBox,  
14      secondRenderBox,  
15    ],  
16    textDirection: TextDirection.ltr,  
17  ),  
18);
Результат выполнения будет таким

flutter

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

Для изоляции содержания есть два способа:

  • Использовать RepaintBoundary.
  • Использовать стек операций PaintingContext.

Рассмотрим оба способа подробнее.

Способ №1 — использовать RepaintBoundary

Чтобы избежать полной перерисовки, можно задать isRepaintBoundary = true. В этом случае для каждого RenderObject создаётся отдельный OffsetLayer, который может быть кэширован и переиспользован.

Добавим следующий код в определение ColoredRenderObject:

1@override
2bool get isRepaintBoundary => true;

Теперь в дереве слоёв будет следующая иерархия:

1TransformLayer
2  - OffsetLayer
3    - PictureLayer
4  - OffsetLayer
5    - PictureLayer

Теперь каждый RenderBox получает собственный слой и может обновляться независимо.

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

Это может быть реализовано через регистрацию callback-функции для PlatformDispatcher.instance.onPointerDataPacket, которая принимает список событий с указателями (для поддержки мультитач-жестов), из которых можно получить информацию о положении указателя physicalX, physicalY, типе события change и другую информацию о взаимодействии с устройством.

Изменим свойства одного из RenderObject и запросим повторное перестроение кадра при обнаружении события прикосновения к поверхности экрана, для этого добавим следующий фрагмент кода после вызова метода scheduleWarmUpFrame():

1PlatformDispatcher.instance.onPointerDataPacket = (pointer) {
2  if (pointer.data.first.change == PointerChange.down) {
3    firstRenderBox.color = const Color(0xFF00FFFF);
4    firstRenderBox.markNeedsPaint();
5    rendererBinding.scheduleFrame();
6    SchedulerBinding.instance.addPostFrameCallback((_) {
7      // вывод связанного с RenderView слоя
8      debugDumpLayerTree();
9    });
10  }
11};

До обновления:

1TransformLayer#a92f6
2
3  ├─child 1: OffsetLayer#9ef20
4
5  │ └─child 1: PictureLayer#69c8a
6
7  └─child 2: OffsetLayer#c3b9e
8
9    └─child 1: PictureLayer#76c2d

После обновления:

1TransformLayer#a92f6
2
3  ├─child 1: OffsetLayer#ab491
4
5  │ └─child 1: PictureLayer#95d19
6
7  └─child 2: OffsetLayer#c3b9e
8
9    └─child 1: PictureLayer#76c2d

Если сравнить исходное дерево слоёв и дерево после обновления, то можно обнаружить что слои TransformLayer и OffsetLayer—PictureLayer для второго RenderObject не изменяются, а для первого создаются новые. Таким образом, изображение из PictureLayer для второго (красного) прямоугольника извлекается из растрового кэша.

При изменении свойств слоя, например последовательности операций на canvas, в PictureLayer вызывается метод markNeedsAddToScene для пересоздания растрового представления слоя, иначе бы вместо addPicture в последовательности операций передавался бы addRetained.

Способ №2 — использовать стек операций PaintingContext

Альтернативный способ изоляции — использовать стек операций в PaintingContext. Операции push в PaintingContext позволяют добавить дополнительные слои преобразования и создать новый контекст для связывания canvas с вложенным PictureLayer.

Например, мы можем добавить операцию pushOpacity для отображения полупрозрачного прямоугольника:

1class ColoredRenderBox extends RenderBox {  
2  Color color;  
3  Offset shift;
4  int opacity;
5  
6  @override  
7  bool get isRepaintBoundary => true;  
8  
9  ColoredRenderBox({  
10    required this.color,  
11    required this.shift,
12    required this.opacity,
13  });  
14  
15  @override  
16  void performLayout() => size = constraints.biggest / 2;  
17  
18  @override  
19  void paint(PaintingContext context, Offset offset) {  
20    context.pushOpacity(  
21      Offset.zero,
22      opacity,
23      (context, offset) {  
24        context.canvas.drawRect(  
25            (offset + shift) & size,  
26            Paint()  
27              ..color = color  
28              ..style = PaintingStyle.fill);  
29      },  
30    );  
31  }  
32}

Теперь дерево слоев будет таким:

1TransformLayer < RenderView
2- OffsetLayer < ColoredRenderBox
3  - OpacityLayer
4    - Picture Layer
5- OffsetLayer < ColoredRenderBox
6  - OpacityLayer
7    - Picture Layer

flutter

Любой из контейнерных слоёв, а также слоёв содержания может быть преобразован в растровое изображение (методы toImage для асинхронного преобразования, которое будет завершено после выполнения растеризации, toImageSync — для синхронного извлечения текущего состояния растеризации).

Проверить возможность преобразования слоя в растровое изображение можно через вызов метода supportsRasterization(). Например, можно получить растеризованное изображение второго прямоугольника следующей операцией:

1final layer = secondRenderBox.debugLayer!;  
2if (layer.supportsRasterization()) {  
3  final image = (layer as OffsetLayer).toImageSync(Offset.zero & secondRenderBox.size);  
4}

Повторное использование слоя

Любой слой, в том числе PictureLayer, можно повторно использовать.

Создадим слой вручную и заполним его ссылкой на уже сформированное изображение из PictureLayer:

1class ClonedRenderBox extends RenderBox {  
2  Picture picture;  
3  
4  ClonedRenderBox({required this.picture});  
5  
6  @override  
7  void performLayout() => size = constraints.biggest;  
8  
9  @override  
10  void paint(PaintingContext context, Offset offset) {  
11    final pictureLayer = PictureLayer(offset & size);  
12    context.addLayer(pictureLayer);  
13    pictureLayer.picture = picture;  //программное переопределение содержимого слоя
14  }  
15}

Теперь мы можем добавить этот RenderBox и заполнить его изображением из PictureLayer:

1final renderStack = renderView.child as RenderStack;  
2final pictureLayer = ((secondRenderBox.debugLayer as OffsetLayer)  
3        .firstChild as OpacityLayer)  
4    .firstChild as PictureLayer;  
5renderStack.add(ClonedRenderBox(picture: pictureLayer.picture!));  
6renderStack.markNeedsPaint();


Итак, мы рассмотрели два подхода к изоляции содержания в дереве рендеринга:
Через isRepaintBoundary: позволяет изолировать перерисовку каждого RenderObject, создав для него отдельный OffsetLayer и, следовательно, отдельное растровое изображение. Это уменьшает область, которую нужно перерисовывать при изменениях, снижает нагрузку на GPU и делает анимации и переходы более плавными. Мы избежали ненужной перерисовки всех соседних объектов, что особенно критично для сложных и насыщенных интерфейсов.
Через стек операций в PaintingContext: позволяет гибко накладывать трансформации (например, прозрачность, фильтры, клипы) и изолировать эти изменения от других слоёв. Это важно, когда нужно применить визуальные эффекты только к части дерева, не затрагивая остальную часть сцены. Мы добились изолированной отрисовки без необходимости создания новых RenderObject, сохранив при этом возможность управлять композицией и кэшированием.
В результате мы получили оптимизированную систему, где изменения в одном объекте не затрагивают другие, ускоряя перерисовку, снижая энергопотребление и повышая отзывчивость интерфейса. Такие подходы особенно полезны при реализации анимаций, интерактивных компонентов и повторно используемых UI-элементов.
А в следующем параграфе мы сфокусируемся на слоях в PaintingContext.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.14. Painting Context: как происходит отрисовка приложения на экране
Следующий параграф3.16. Слои и PaintingContext