В этом параграфе мы начнём увязывать 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
для виджета Stack
— RenderStack
. Добавим поддержку смещения и цвета в 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);
Результат выполнения будет таким

Однако если мы посмотрим на дерево слоёв, то обнаружим, что дерево не изменилось и оба прямоугольника находятся в одном слое 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

Любой из контейнерных слоёв, а также слоёв содержания может быть преобразован в растровое изображение (методы 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.