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:

import 'dart:ui';  
  
import 'package:flutter/cupertino.dart';  
import 'package:flutter/rendering.dart';  
import 'package:flutter/scheduler.dart';  
  
void main() {  
  // инициализация связи Binding и Flutter Engine  
  WidgetsFlutterBinding.ensureInitialized();  
  // получение основного view (поверхность для рисования)  
  final view = PlatformDispatcher.instance.implicitView!;  
  // создание корневого RenderObject (RenderView)
  final renderView = RenderView(view: view, configuration: ViewConfiguration(  
    devicePixelRatio: view.devicePixelRatio,  
    physicalConstraints: BoxConstraints.fromViewConstraints(  
        view.physicalConstraints),  
    logicalConstraints: BoxConstraints.fromViewConstraints(  
        view.physicalConstraints / view.devicePixelRatio),  
  ));  
  // связывание RenderView и экземпляра RendererBinding  
  final rendererBinding = RendererBinding.instance;  
  rendererBinding.addRenderView(renderView);  
  renderView.attach(rendererBinding.rootPipelineOwner);  
  
  // подготовка первого кадра  
  renderView.prepareInitialFrame();  
  rendererBinding.scheduleWarmUpFrame();  
  SchedulerBinding.instance.addPostFrameCallback((_) {  
    // вывод связанного с RenderView слоя  
    print(renderView.debugLayer);  
  });
}

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

TransformLayer#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 можно прочитать в этом статье):

class ColoredRenderBox extends RenderBox {  
  @override  
  void performLayout() => size = constraints.biggest/2;  
  
  @override  
  void paint(PaintingContext context, Offset offset) {  
    context.canvas.drawRect(  
        offset & size,  
        Paint()  
          ..color = const Color(0xFFFF0000)  
          ..style = PaintingStyle.fill);  
    super.paint(context, offset);  
  }  
}

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

void main() {  
  // инициализация связи Binding и Flutter Engine  
  WidgetsFlutterBinding.ensureInitialized();  
  // получение основного view (поверхность для рисования)  
  final view = PlatformDispatcher.instance.implicitView!;  
  // создание корневого RenderObject (RenderView) и ColoredRenderBox  
  final renderView = RenderView(  
    child: ColoredRenderBox(),  
    view: view,  
  );  
  // связывание RenderView и экземпляра RendererBinding  
  final rendererBinding = RendererBinding.instance;  
  rendererBinding.addRenderView(renderView);  
  renderView.attach(rendererBinding.rootPipelineOwner);  
  renderView.configuration = ViewConfiguration(  
    devicePixelRatio: view.devicePixelRatio,  
    physicalConstraints: BoxConstraints.fromViewConstraints(view.physicalConstraints).loosen(),  
    logicalConstraints: BoxConstraints.fromViewConstraints(  
            view.physicalConstraints / view.devicePixelRatio)  
        .loosen(),  
  );
  // здесь для logincalConstraints установлено минимальный размер (0,0)
  // через вызов loosen, чтобы можно было сделать прямоугольник,
  // с размером меньше, чем размер экрана
  // подготовка первого кадра (первоначальный layout - paint)  
  renderView.prepareInitialFrame();  
  rendererBinding.scheduleWarmUpFrame();  
  
  SchedulerBinding.instance.addPostFrameCallback((_) {  
    // вывод связанного с RenderView слоя  
    debugDumpLayerTree();  
  });
}

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

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

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

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

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

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

class ColoredRenderBox extends RenderBox {  
  Color color;  
  Offset shift;  
  
  ColoredRenderBox({  
    required this.color,  
    required this.shift,  
  });  
  
  @override  
  void performLayout() => size = constraints.biggest / 2;  
  
  @override  
  void paint(PaintingContext context, Offset offset) {  
    context.canvas.drawRect(  
        (offset + shift) & size,  
        Paint()  
          ..color = color  
          ..style = PaintingStyle.fill);  
    super.paint(context, offset);  
  }  
}

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

final firstRenderBox = ColoredRenderBox(  
  color: const Color(0xFFFF00FF),  
  shift: Offset.zero,  
);  
final secondRenderBox = ColoredRenderBox(  
  color: const Color(0xFFFF0000),  
  shift: const Offset(32, 32),  
);  
final renderView = RenderView(  
  view: view,  
  child: RenderStack(  
    children: [  
      firstRenderBox,  
      secondRenderBox,  
    ],  
    textDirection: TextDirection.ltr,  
  ),  
);
Результат выполнения будет таким

flutter_3_15.1

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

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

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

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

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

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

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

@override
bool get isRepaintBoundary => true;

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

TransformLayer
  - OffsetLayer
    - PictureLayer
  - OffsetLayer
    - PictureLayer

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

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

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

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

PlatformDispatcher.instance.onPointerDataPacket = (pointer) {
  if (pointer.data.first.change == PointerChange.down) {
    firstRenderBox.color = const Color(0xFF00FFFF);
    firstRenderBox.markNeedsPaint();
    rendererBinding.scheduleFrame();
    SchedulerBinding.instance.addPostFrameCallback((_) {
      // вывод связанного с RenderView слоя
      debugDumpLayerTree();
    });
  }
};

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

TransformLayer#a92f6

  ├─child 1: OffsetLayer#9ef20

  │ └─child 1: PictureLayer#69c8a

  └─child 2: OffsetLayer#c3b9e

    └─child 1: PictureLayer#76c2d

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

TransformLayer#a92f6

  ├─child 1: OffsetLayer#ab491

  │ └─child 1: PictureLayer#95d19

  └─child 2: OffsetLayer#c3b9e

    └─child 1: PictureLayer#76c2d

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

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

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

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

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

class ColoredRenderBox extends RenderBox {  
  Color color;  
  Offset shift;
  int opacity;
  
  @override  
  bool get isRepaintBoundary => true;  
  
  ColoredRenderBox({  
    required this.color,  
    required this.shift,
    required this.opacity,
  });  
  
  @override  
  void performLayout() => size = constraints.biggest / 2;  
  
  @override  
  void paint(PaintingContext context, Offset offset) {  
    context.pushOpacity(  
      Offset.zero,
      opacity,
      (context, offset) {  
        context.canvas.drawRect(  
            (offset + shift) & size,  
            Paint()  
              ..color = color  
              ..style = PaintingStyle.fill);  
      },  
    );  
  }  
}

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

TransformLayer < RenderView
- OffsetLayer < ColoredRenderBox
  - OpacityLayer
    - Picture Layer
- OffsetLayer < ColoredRenderBox
  - OpacityLayer
    - Picture Layer

flutter_3_15.2

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

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

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

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

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

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

class ClonedRenderBox extends RenderBox {  
  Picture picture;  
  
  ClonedRenderBox({required this.picture});  
  
  @override  
  void performLayout() => size = constraints.biggest;  
  
  @override  
  void paint(PaintingContext context, Offset offset) {  
    final pictureLayer = PictureLayer(offset & size);  
    context.addLayer(pictureLayer);  
    pictureLayer.picture = picture;  //программное переопределение содержимого слоя
  }  
}

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

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

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