3.14. Painting Context: как происходит отрисовка приложения на экране

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

После вы сможете:

  • Оптимизировать производительность анимаций, чтобы интерфейс оставался плавным даже при сложных переходах и движениях.
  • Глубоко понимать, как именно Flutter рендерит анимации покадрово, что позволит точно настраивать поведение и избегать распространённых ошибок.
  • Эффективно управлять кэшированием слоёв, снижая нагрузку на GPU и предотвращая лишние перерисовки, которые могут привести к «фризам».

Приступим!

Что такое PaintingContext

Начнём чуть издалека.

В приложениях на Flutter можно создавать плавный интерфейс произвольной сложности с поддержкой анимации. Часто при анимации изменяется только часть экрана, а изменения сводятся к движению уже готовых элементов интерфейса.

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

В этом случае можно сберечь ресурсы графического процессора и использовать заранее построенное растровое изображение виджета. Эта возможность реализуется во Flutter с помощью слоёв. Слои поддерживают оба движка — и первоначальный, но устаревающий движок Skia, и новый движок Impeller.

Слои создаются с помощью класса PaintingContext.

PaintingContext — это объект, предоставляющий методы для рисования (paint) и управления потомками в дереве рендеринга. Он используется в методе paint классов, наследующихся от RenderObject.

PaintingContext абстрагирует низкоуровневую отрисовку и упрощает реализацию paint() в кастомных RenderObject.

Но прежде чем поговорить о слоях, давайте сперва вспомним, как устроен процесс отрисовки приложения на экране:

  • Последовательно вызываются методы build, и из Stateless/Stateful-виджетов создаётся полное дерево виджетов, состоящих из наследников классов ProxyWidget и RenderObjectWidget.
  • Для RenderObjectWidget создаются объекты классов-наследников RenderObject, которые содержат код для определения собственных размеров, позиционирования дочерних RenderObject и отрисовки визуального представления RenderObject на экране.
  • В RenderObject отрисовка происходит на Canvas, при этом каждый RenderObject использует относительные координаты и размещается относительно родительского RenderObject или всей поверхности для рисования для устройства, если родителем является корневой RenderView.
  • Полученная последовательность операций отрисовки отправляется на GPU и с использованием фрагментных шейдеров, отвечающих за заливку и градиенты, преобразуется в пиксели на экране.

Давайте разберём каждый из этапов подробнее. Для удобства, первые два мы объединили вместе.

Этап №1: обработка дерева виджетов и создание RenderObject

Сделаем очень простое приложение с единственным корневым виджетом ColoredBox.

1import 'package:flutter/material.dart';  
2  
3void main() {  
4  runApp(RootWidget());  
5}  
6  
7class RootWidget extends StatelessWidget {  
8  @override  
9  Widget build(BuildContext context) => const ColoredBox(color: Colors.green);  
10}

В DevTools можно будет увидеть, что в финальном дереве виджетов во вкладке Widget Details Tree будут представлены и RootWidget, который не имеет визуального представления, и ColoredBox, который является наследником RenderObjectWidget и связан с RenderObject:

flutter

1class RootWidget extends StatelessWidget {  
2  @override  
3  Widget build(BuildContext context) {  
4    SchedulerBinding.instance.addPostFrameCallback((_) {  
5      debugDumpRenderTree();  
6    });  
7    return const ColoredBox(color: Colors.green);  
8  }
9}

Посмотрим на вывод этого кода (оставлены только наиболее важные строки):

1_ReusableRenderView#771c8
2 │ view size: Size(1080.0, 2337.0) (in physical pixels)
3 │ device pixel ratio: 2.6 (physical pixels per logical pixel)
4 └─child: _RenderColoredBox#613bc
5     creator: ColoredBox ← RootWidget ← _FocusInheritedScope ←

Можно видеть, что RenderView (класс _ReusableRenderView) отвечает за преобразование логических пикселей в физические (в этом случае коэффициент равен 2,6), а _RenderColoredBox — за заполнение экрана цветом.

Этап №2: отрисовка RenderObject на Canvas

Реализация _RenderColoredBox в методе paint вызывает единственную операцию для заливки сплошным цветом. Поверх неё выполняется отрисовка дочернего RenderObject, но пока этот момент здесь пропустим:

1canvas.drawRect(offset & size, Paint()..color = color);

Skia и Impeller работают с примитивными операциями для отрисовки геометрических фигур, список которых хорошо известен по интерфейсу Canvas, а также поддерживают матричные преобразования (масштабирование, поворот, сдвиг и другие) и пиксельные фильтры.

Операции на Canvas не отправляются на GPU напрямую. Вместо этого они складываются в специальную последовательность — Display List (о ней расскажем ниже), — которую движок преобразует в команды для GPU.

Этап №3: использование списка операций для растеризации на GPU

Display List внутри графического движка Skia/Impeller преобразуется в набор команд для соответствующей графической библиотеки в зависимости от платформы:

  • Android — EGL.
  • MacOS, iOS — Metal.
  • Linux — OpenGL.
  • Windows — Angle.

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

1import 'dart:ui';  
2  
3void main() {  
4  final view = PlatformDispatcher.instance.implicitView;  
5  final builder = SceneBuilder();  
6  final pictureRecorder = PictureRecorder();  
7  final canvas = Canvas(pictureRecorder);  
8  canvas.drawRect(  
9      Offset.zero & view!.display.size,  
10      Paint()  
11        ..color = const Color(0xFFFF0000)  
12        ..style = PaintingStyle.fill);  
13  builder.addPicture(Offset.zero, pictureRecorder.endRecording());  
14  view.render(builder.build());  
15}
Результат

flutter

SceneBuilder и PictureRecorder могут быть созданы непосредственно как объекты или с использованием методов RendererBinding.instance.createSceneBuilder() и RendererBinding.instance.createPictureRecorder() соответственно.

Передача последовательности действий выполняется в объекте view (экземпляр FlutterView), который содержит информацию об активном дисплее и поддерживает пересылку сообщений в GPU через вызов view.render.

Сама последовательность операций представлена в сцене (интерфейс Scene, нативная реализация во Flutter Engine представлена в виде класса _NativeScene). Сцена создаётся в дополнительном объекте класса SceneBuilder (нативная реализация _NativeSceneBuilder), который поддерживает несколько действий:

Операция Назначение
pushTransform применение матричного преобразования
pushOffset добавление операции смещения на указанное количество логических пикселей
pushClipRect и множество других Clip-операций применение операции обрезки видимой части
pushOpacity добавление полупрозрачности
pushColorFilter применение фильтра смешивания цветов
pushImageFilter применение растровой операции, требует предварительной растеризации
pushBackdropFilter применение операции ко всей вложенной операции (например, размытия), использует предварительную растеризацию
pushShaderMask смешивание растеризованного изображения и результата вычисления фрагментного шейдера
pop завершение применения операции, добавленной через push
addRetained добавление ранее сгенерированного (и кэшированного) изображения, принимает ссылку на объект EngineLayer
addPerformanceOverlay добавление графика времени выполнения: UI - dart-часть кода, Raster: CPU во Flutter Engine до передачи в GPU
addTexture добавляет платформенную текстуру по идентификатору, может быть зарегистрирована и обновлена через embedder или в нативном коде
addPlatformView добавляет обновляемое представление нативного view (View для Android, UIView для iOS) по зарегистрированному идентификатору
addPicture добавляет растеризированное изображение

Эти действия составляют единую последовательность операций — Display List (мы её упоминали выше).

Все операции push/pop, как следует из названия, работают со стеком операций, однако на этом уровне понятие слоя не имеет смысла и сцена создаётся просто как последовательность операций, где операции add — терминальные. Они добавляют содержание, на которое применяется созданный к этому моменту стек преобразований.

Например, добавим смещение и цветовой фильтр к существующему изображению:

1import 'dart:ui';  
2  
3void main() {  
4  final view = PlatformDispatcher.instance.implicitView;  
5  final builder = SceneBuilder();  
6  final pictureRecorder = PictureRecorder();  
7  final canvas = Canvas(pictureRecorder);  
8  canvas.drawRect(  
9      Offset.zero & view!.display.size / 2,  
10      Paint()  
11        ..color = const Color(0xFFFF0000)  
12        ..style = PaintingStyle.fill);  
13  final picture = pictureRecorder.endRecording();  
14  //генерация матрицы 5x4 для преобразования исходного RGBA в инверсный  
15  final invert = List.generate(  
16      20,  
17      (i) => (i % 5 == 4)  
18          ? (i == 19)  
19              ? 0.0  
20              : 255.0       //значение прозрачности  
21          : (i == 18)  
22              ? 1.0  
23              : (i % 5 == i ~/ 5)  
24                  ? -1.0    //инверсия компонентов RGB  
25                  : 0.0);  
26  builder.pushColorFilter(ColorFilter.matrix(invert));  
27  builder.pushOffset(128, 128);             //смещение для первого голубого прямоугольника  
28  builder.addPicture(Offset.zero, picture); //голубой прямоугольник со смещением  
29  builder.pop();                            //снимаем со стека смещение  
30  builder.addPicture(Offset.zero, picture); //голубой прямоугольник без смещения  
31  builder.pop();                            //снимаем со стека ColorFilter  
32  builder.pushOffset(256, 256);             //смещение для красного прямоугольника  
33  builder.addPicture(Offset.zero, picture); //красный прямоугольник со смещением  
34  builder.pop();                            //снимаем смещение Offset(256,256)  
35  view.render(builder.build());  
36}

Результат будет таким:

flutter

Обратите внимание, что для обработки визуального представления из Flutter-виджетов используется только одна операция addPicture (или операция addRetained в случае, если переиспользуется ранее построенное изображение слоя).

То есть любые виджеты, включая изображения, CustomPaint и даже тексты представляются в виде растеризованного изображения, которое может быть кэшировано или использовано в пиксельных преобразованиях, например при применении фильтра размытия.

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

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

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

Слои можно создавать как на уровне RenderObject, так и через PaintingContext. В следующих параграфах мы последовательно рассмотрим оба подхода: сначала — работу с RenderObject, затем — работу с PaintingContext и управление иерархией слоёв вручную.

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

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

Предыдущий параграф3.13. RenderObject: продвинутые концепции
Следующий параграф3.15. Слои и RenderObject