Эффективные стратегии рендеринга сложных интерактивных сцен с использованием Canvas»а Flutter, пакетной обработки на GPU и пространственного индексирования, а также продвинутые методы отладки.

Создание интерактивных и визуально насыщенных сцен во Flutter имеет тенденцию очень быстро становиться очень сложной задачей, особенно если речь идет о работе с множеством динамических объектов, сложной анимацией и взаимодействиями в реальном времени. Хотя встроенный в Flutter Canvas API предлагает мощный инструментарий, чтобы в полной мере раскрыть его потенциал, необходимо глубоко понимать эффективные методы рендеринга, способы оптимизации и знать правильные подходы к управлению пространственными данными.

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

Чему вы научитесь в этом Руководстве

  • Эффективная инициализация и управление Canvas:
    Как выбрать подходящую стратегию рендеринга (CustomPaint, кастомный RenderObject или LeafRenderObjectWidget) для вашего конкретного сценария, обеспечивая оптимальную производительность и поддерживаемость.

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

  • Пакетирование команд GPU и сокращение циклов отрисовки:
    Понимание стоимости циклов отрисовки и использование методов пакетного рендеринга Flutter (drawRawAtlas, drawRawPoints, drawVertices) для существенного повышения производительности.

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

  • GPU‑шейдеры и оптимизация Paint:
    Использование шейдеров с GPU‑ускорением, правильная настройка Paint‑объектов и понимание стратегий сглаживания и фильтрации для достижения как визуального качества, так и молниеносного рендеринга.

  • Эффективное кэширование с использованием Picture и растеризации:
    Использование PictureRecorder Flutter для кэширования статичных или редко меняющихся участков вашей сцены, существенно сокращая ненужные затраты на перерисовку.

  • Надежная отладка и мониторинг производительности:
    Создание слоя с комплексным оверлеем отладки, активируемого с помощью горячих клавиш (например, F2), который позволит нам получать важные показатели в реальном времени (FPS, информация о камере, состояния QuadTree, статистика базы данных). Это значительно упростит процесс разработки и поможет быстро находить и устранять возникающие проблемы.

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

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


Выбор правильного рендерера

При работе со сложными или высокодинамичными сценами во Flutter эффективный рендеринг имеет решающее значение для обеспечения наилучшей производительности. В то время как стандартного виджета CustomPaint может быть достаточно для более простых вариантов использования, по‑настоящему сложные или высокопроизводительные сценарии часто требуют более оптимизированного решения. Давайте рассмотрим несколько стратегий и соображений для создания эффективных и высокопроизводительных конфигураций рендеринга Canvas»а во Flutter.

Flutter предлагает несколько встроенных виджетов и механизмов для управления отрисовкой на канвасе (холсте). Давайте кратко рассмотрим доступные варианты:

Виджет / Подход

Сложность

Производительность

Применение

CustomPaint

Простая

Хорошая

До среднего уровня сложности

LeafRenderObjectWidget

Высокая

Великолепная

Сложные сцены, полный контроль

Пакет RePaint

Средняя

Великолепная

Оптимизированная пользовательская логика перерисовки

Использование CustomPaint

Самый простой и интуитивно понятный способ — это применение встроенного виджета CustomPaint:

dartCopyEditCustomPaint(
  painter: MyCanvasPainter(),
);

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

Использование пользовательского рендерера с помощью LeafRenderObjectWidget

Когда требования к сложности или производительности возрастают, вы можете воспользоваться возможностью создания пользовательского рендер‑объекта (render object), который обычно наследуется от LeafRenderObjectWidget. Этот пользовательский рендерер предоставляет вам низкоуровневый контроль над отрисовкой, компоновкой и управлением жизненным циклом:

class CustomRenderBoxWidget extends LeafRenderObjectWidget {
  // ...
  
  @override
  RenderObject createRenderObject(BuildContext context) {
    final box = CustomRenderBox();
    // ...
    return box;
  }
}

class CustomRenderBox extends RenderBox with WidgetsBindingObserver {

  // ...

  /// тикер цикла Vsync.
  Ticker? _ticker;
  
  @override
  bool get isRepaintBoundary => true;

  @override
  bool get alwaysNeedsCompositing => false;

  @override
  bool get sizedByParent => true;
  
  @override
  Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
  
  void _onTick(Duration elapsed) {
    // ...
    markNeedsPaint();
  }
  
  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    WidgetsBinding.instance.addObserver(this);
    _ticker = Ticker(_onTick, debugLabel: 'CustomRenderBox')..start();
    // ...
  }
  
  @override
  void detach() {
    _ticker?.dispose();
    WidgetsBinding.instance.removeObserver(this);
    super.detach();
    // ...
  }
  
  @override
  void paint(PaintingContext context, Offset offset) {
    // ...
  }
}

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

Использование пакета RePaint

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

Класс RePaint — библиотека repaint — Dart API

Документация API для класса RePaint из библиотеки repaint для языка программирования Dart.

final _painter = MyGamePainter();

@override
Widget build(BuildContext context) => RePaint(
    painter: _painter,
  );
  
// ...

class MyGamePainter extends RePainterBase {
  // ...
}

Оптимизация перерисовок с помощью границ

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

  • Оберните свой виджет CustomPaint в RepaintBoundary.

  • Или, если вы используете пользовательский RenderObject, установите для свойства isRepaintBoundary значение true.

RepaintBoundary(
  child: CustomPaint(
    painter: MyFrequentlyUpdatedPainter(),
  ),
)

Хотя такой подход может существенно улучшить производительность за счет изоляции операций рендеринга и уменьшения частоты и площади перерисовок, он также влечет за собой дополнительные накладные расходы из‑за использования памяти и GPU. Поэтому важно тщательно оценить производительность, чтобы убедиться, что этот компромисс будет оправдан.

⚠️ Не помещайте виджеты канваса в конструкторы или анимации

Распространенной ошибкой при работе с канвасом Flutter является размещение виджетов канваса (CustomPaint) внутрь конструкторов или анимационных виджетов, а также частые вызовы setState(). Хотя этот подход может показаться логичным или даже интуитивным, он приводит к ненужным перестройкам дерева виджетов, что значительно снижает производительность, особенно для динамичных или сложных сцен.

Вот чего ? не следует делать ?:

AnimatedBuilder(
  animation: animationController,
  builder: (context, _) =>
      CustomPaint(painter: MyPainter()),
);

или

BlocBuilder(
  bloc: bloc,
  builder: (context, state) =>
      CustomPaint(painter: MyPainter()),
);

или

context.watch<T>();
return CustomPaint(painter: MyPainter());

Вместо этого используйте встроенную оптимизацию Flutter, предоставляя Listenable (например, AnimationController, ChangeNotifier) напрямую в параметр repaint вашего CustomPainter.

Пример правильного решения:

final animationController = AnimationController(
  vsync: this,
  duration: const Duration(seconds: 1),
)..repeat();

CustomPaint(
  // Передаем в качестве параметра repaint
  painter: MyPainter(repaint: animationController), 
);

Внутри вашего отрисовщика:

class MyPainter extends CustomPainter {
  MyPainter({required super.repaint});

  @override
  void paint(Canvas canvas, Size size) {
    // Здесь располагается логика отрисовки
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) => false;
}
  • Flutter эффективно планирует перерисовку, избегая ненужных перестроек виджета.

  • Позволяет избежать накладных расходов, связанных с setState() и избыточными пересчетами дерева виджетов.

Если вы используете пользовательские реализации RenderObject вместо CustomPainter, старайтесь не вызывать setState() и не перестраивать виджеты без необходимости.

Вместо этого явно вызовите

RenderObject.markNeedsPaint();

после соответствующих изменений состояния, таких как движение камеры или обновление сцены. Это позволит вам инициировать перерисовку на уровне рендер‑объекта, не задействуя механизм перестройки виджетов Flutter.

? Избегайте этого...

✅ Предпочитайте это...

Частые вызовы setState() для обновлений canvas»а

Используйте параметр repaint Listenable.

Оборачивание canvas»а в AnimatedBuilder или билдеры

Напрямую вызывайте markNeedsPaint() или repaint.

Излишние обновления дерева виджетов

Эффективно прослушивайте события и инициируйте только перерисовку.

Отрисовка базовых фигур на канвасе

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

Самый простой способ начать отрисовку — это залить канвас каким‑нибудь фоновым цветом, а затем добавить простые геометрические фигуры, такие как прямоугольники и круги.

Вот наглядный пример, который это иллюстрирует:

final localBounds = Offset.zero & size;
final paint = Paint()..style = PaintingStyle.fill;

canvas
  // Цвет фона канваса
  ..drawPaint(paint..color = const ui.Color(0xFF00AAFF))
  // Прямоугольник
  ..drawRect(
    localBounds.deflate(16), 
    paint..color = const ui.Color(0xFF00FF1A),
  )
  // Круг
  ..drawCircle(
    localBounds.center, 
    localBounds.longestSide / 4, 
    paint..color = const ui.Color(0xFFFF0000),
  );

Объяснение:

  • drawPaint заполняет все пространство канваса указанным цветом.

  • drawRect рисует прямоугольник с небольшим уменьшением (deflate(16) уменьшает размеры одинаково со всех сторон).

  • drawCircle создает окружность в центре канваса (localBounds.center) с радиусом, равным четверти самой длинной стороны канваса.

Обрезка канваса для предотвращения отрисовки за его пределами

Нарисованные вами объекты могут иногда выходить за пределы канваса, что может привести к неожиданным визуальным эффектам. Чтобы избежать этого, вы можете явно ограничить область отрисовки.

Самый простой способ обрезки — использование прямоугольной границы:

context.canvas
  ..save() // Сохранить текущее состояние
  ..clipRect(Offset.zero & size); // Определить границы обрезки

// Ваша логика отрисовки

context.canvas.restore(); // Восстановить в исходное состояние
  • Важно: Всегда используйте canvas.save() и canvas.restore(), чтобы обрезка влияла только на необходимые операции отрисовки.

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

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

final localBounds = Offset.zero & size;

canvas
  ..save()
  ..clipPath(Path()..addOval(localBounds)); // Обрезка с использованием овального контура

// Ваша логика отрисовки

canvas.restore();

Система камеры для Canvas

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

Давайте рассмотрим эффективный способ реализации надежного и гибкого объекта‑камеры в вашем Flutter‑приложении.

Первым шагом мы создадим интуитивно понятный и расширяемый интерфейс для нашего объекта‑камеры. С помощью ChangeNotifier Flutter мы сможем легко уведомлять виджеты об изменении состояния камеры, обеспечивая плавную и эффективную перерисовку.

Вот предлагаемый интерфейс (CameraView):

/// Интерфейс камеры.
abstract interface class CameraView implements Listenable {
  /// Центр положения камеры.
  ui.Offset get position;

  /// Размер вьюпорта.
  ui.Size get viewportSize;

  /// Размер вьюпорта.
  ui.Rect get bound;

  /// Зум (масштабирование) камеры.
  /// Масштабирование находится в диапазоне от 0.1 до 1.
  double get zoomDouble;

  /// Уровень масштабирования.
  /// Уровень масштабирования находится в диапазоне от 1 до 10.
  int get zoomLevel;

  /// Преобразование глобальной позицию в локальную.
  ui.Offset globalToLocal(double x, double y);

  /// Преобразование глобального смещения в локальное.
  ui.Offset globalToLocalOffset(ui.Offset offset);

  /// Преобразование прямоугольника в глобальных координатах в локальные.
  ui.Rect globalToLocalRect(ui.Rect rect);

  /// Преобразование локальной позицию в глобальную.
  ui.Offset localToGlobal(double x, double y);

  /// Преобразование локального смещения в глобальное.
  ui.Offset localToGlobalOffset(ui.Offset offset);

  /// Преобразование прямоугольника в локальных координатах в глобальные.
  ui.Rect localToGlobalRect(ui.Rect rect);
}

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

/// Реализация камеры.
class Camera with ChangeNotifier implements CameraView {
  /// Создаем новую камеру с указанными параметрами.
  Camera({ui.Size viewportSize = ui.Size.zero, ui.Offset position = ui.Offset.zero, int zoom = 5, ui.Rect? boundary})
    : _position = position,
      _viewportSize = viewportSize,
      _halfViewportSize = viewportSize / 2,
      _bound = ui.Rect.zero,
      _zoomLevel = zoom.clamp(1, 10),
      _zoom = zoom.clamp(1, 10) / 10,
      _boundary = boundary {
    _calculateBound();
  }

  @override
  ui.Size get viewportSize => _viewportSize;
  ui.Size _viewportSize;
  ui.Size _halfViewportSize;

  @override
  ui.Offset get position => _position;
  ui.Offset _position;

  @override
  ui.Rect get bound => _bound;
  ui.Rect _bound;

  @override
  double get zoomDouble => _zoom;
  double _zoom;

  @override
  int get zoomLevel => _zoomLevel;
  int _zoomLevel = 5; // 1..10

  /// Ограничение перемещения камеры указанной границей.
  final ui.Rect? _boundary;

  @override
  @pragma('vm:prefer-inline')
  ui.Offset globalToLocal(double x, double y) =>
      _zoom == 1
          ? ui.Offset(x - _bound.left, y - _bound.top)
          : ui.Offset((x - _bound.left) * _zoom, (y - _bound.top) * _zoom);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset localToGlobal(double x, double y) =>
      _zoom == 1
          ? ui.Offset(x + _bound.left, y + _bound.top)
          : ui.Offset(x / _zoom + _bound.left, y / _zoom + _bound.top);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset globalToLocalOffset(ui.Offset offset) =>
      ui.Offset((offset.dx - _bound.left) * _zoom, (offset.dy - _bound.top) * _zoom);

  @override
  @pragma('vm:prefer-inline')
  ui.Offset localToGlobalOffset(ui.Offset offset) =>
      ui.Offset(offset.dx / _zoom + _bound.left, offset.dy / _zoom + _bound.top);

  @override
  @pragma('vm:prefer-inline')
  ui.Rect globalToLocalRect(ui.Rect rect) => ui.Rect.fromLTRB(
    (rect.left - _bound.left) * _zoom,
    (rect.top - _bound.top) * _zoom,
    (rect.right - _bound.left) * _zoom,
    (rect.bottom - _bound.top) * _zoom,
  );

  @override
  @pragma('vm:prefer-inline')
  ui.Rect localToGlobalRect(ui.Rect rect) => ui.Rect.fromLTRB(
    rect.left / _zoom + _bound.left,
    rect.top / _zoom + _bound.top,
    rect.right / _zoom + _bound.left,
    rect.bottom / _zoom + _bound.top,
  );

  /// Перемещение в указанную глобальную позицию.
  bool moveTo(ui.Offset position) {
    if (_position == position) return false;
    _position = position;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Изменение размер вьюпорта.
  bool changeSize(ui.Size size) {
    if (_viewportSize == size) return false;
    _viewportSize = size;
    _halfViewportSize = size / 2;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Изменение масштаба (зума) камеры.
  /// Уровень зума находится в диапазоне от 1 до 10.
  bool changeZoom(int level) {
    final lvl = level.clamp(1, 10);
    if (_zoomLevel == lvl) return false;
    _zoomLevel = lvl;
    _zoom = lvl / 10;
    _calculateBound();
    notifyListeners();
    return true;
  }

  /// Увеличение зума камеры.
  void zoomIn() {
    changeZoom(_zoomLevel + 1);
  }

  /// Уменьшение зума камеры.
  void zoomOut() {
    changeZoom(_zoomLevel - 1);
  }

  /// Возврат зума к значению по умолчанию.
  void zoomReset() {
    changeZoom(5);
  }

  @pragma('vm:prefer-inline')
  void _calculateBound() {
    if (_boundary != null) {
      if (!_boundary.contains(_position)) {
        // Ограничение позиции границей.
        _position = ui.Offset(
          _position.dx.clamp(_boundary.left, _boundary.right),
          _position.dy.clamp(_boundary.top, _boundary.bottom),
        );
      }
    }
    if (_zoomLevel == 10) {
      _bound = ui.Rect.fromLTRB(
        _position.dx - _halfViewportSize.width,
        _position.dy - _halfViewportSize.height,
        _position.dx + _halfViewportSize.width,
        _position.dy + _halfViewportSize.height,
      );
    } else {
      _bound = ui.Rect.fromLTRB(
        _position.dx - _halfViewportSize.width / _zoom,
        _position.dy - _halfViewportSize.height / _zoom,
        _position.dx + _halfViewportSize.width / _zoom,
        _position.dy + _halfViewportSize.height / _zoom,
      );
    }
  }
}
  • Для удобного и интуитивно понятного управления обозначьте положение (position ) вашей камеры как центр вьюпорта.

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

  • Преобразование из глобальных в локальные координаты: необходимо для отображения глобальных объектов на вьюпорте канваса.

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

Интегрировать камеру в логику отрисовки можно следующим образом:

@override
void paint(ui.Canvas canvas, ui.Size size) {
  camera.changeSize(size);

  canvas.save();
  // Обрезка канваса по вьюпорту.
  canvas.clipRect(Offset.zero & size);

  // Отрисовка глобальных объектов, преобразованных в локальные координаты вьюпорта.
  final globalRect = ui.Rect.fromLTWH(500, 500, 100, 100);
  final localRect = camera.globalToLocalRect(globalRect);

  final paint = ui.Paint()..color = const ui.Color(0xFF00AAFF);
  canvas.drawRect(localRect, paint);

  canvas.restore();
}

Составные отрисовщики

Когда процесс рендеринга на вашем канвасе становится более сложным (например, в играх, детализированных визуализациях или интерактивных диаграммах), управление одним большим отрисовщиком (painter) может оказаться непрактичным. Вместо этого, разбиение вашей сцены на несколько составных классов‑отрисовщиков может принести значительные преимущества с точки зрения поддерживаемости, производительности и удобочитаемости.

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

Вместо того чтобы отрисовывать все в рамках одного класса, разделите обязанности на отдельные специализированные классы‑отрисовщики. Каждый отрисовщик отвечает за свои собственные задачи:

  • Логика компоновки

  • Процедуры отрисовки

  • Ввод (события указателя / клавиатуры)

  • Управление состоянием

Пример структуры:

abstract class ScenePainter {
  bool get needsPaint;

  bool onPointerEvent(PointerEvent event);
  bool onKeyboardEvent(KeyEvent event);
  void update(ui.Size size, double delta);
  void paint(ui.Size size, PaintingContext context);
}

Этот модульный подход позволяет эффективно снизить сложность, предоставляя каждому компоненту возможность развиваться независимо и упрощая процесс отладки.

Наши отрисовщики должны индивидуально обрабатывать события клавиатуры и указателя. Вот как можно эффективно реализовать пересылку событий:

Пересылайте события клавиатуры всем отрисовщикам, позволяя каждому из них отбирать свои события. Если хотя бы один отрисовщик обработает его, верните значение true:

@protected
bool onKeyboardEvent(KeyEvent event) {
  if (!_hasFocus) return false;
  // Пересылка события клавиатуры отрисовщикам, чтобы каждый из них мог его обработать.
  // Используйте | вместо || для вызова всех отрисовщиков, даже если один из них уже обработал событие.
  //final handled = _painters.fold(false, (prev, painter) => prev | painter.onKeyboardEvent(event));
  final handled = _painters.any((painter) => painter.onKeyboardEvent(event));
  return handled;
}

События указателя обрабатываются по аналогичной логике:

@override
void onPointerEvent(PointerEvent event) {
  if (!_hasFocus) return;
  // Пересылка события указателя отрисовщикам, чтобы каждый из них мог его обработать.
  // Используйте | вместо || для вызова всех отрисовщиков, даже если один из них уже обработал событие.
  //final _ = _painters.fold(false, (prev, painter) => prev | painter.onPointerEvent(event));
  final _ = _painters.any((painter) => painter.onPointerEvent(event));
}

Использование.any() позволяет избежать ненужной обработки события после его потребления.

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

/// Макет сцены (позиции, композиция) изменился и требует пересчета.
bool _needsRelayout = true;

/// Визуальный контент изменился и требует перерисовки.
/// Триггерит `markNeedsPaint()`
bool _needsPaint = true;

// Обновление отрисовщика только при необходимости:
@override
void update(ui.Size size, double delta) {
  if (_needsRelayout) {
    _calculateLayout();
    _needsRelayout = false;
    _needsPaint = true;
  }
}

В нашем RenderObject при каждом тике vsync мы можем проверять состояние needsPaint перед тем, как пометить сцену на перерисовку:

// Обновление отрисовщиков:
painter.update(size, delta);

// Если после обновления требуется перерисовка, помечаем сцену соответствующим образом:
if (painter.needsPaint) {
  markNeedsPaint();
}

Реализуйте логические флаги для контроля выполнения повторных вычислений и перерисовки. При обновлении отрисовщиков объединяйте их флаги перерисовки:

@override
void update(RePaintBox box, Duration elapsed, double delta) {
  final size = box.size;
  _camera.changeSize(size); // Обновление размеров камеры.

  for (final painter in _painters)
    painter.update(size, delta);

  // Если какому-либо отрисовщику нужно что-то перерисовать, пометьте всю сцену как требующую перерисовки.
  _needsPaint |= _painters.any((painter) => painter.needsPaint);
}

Порядок отрисовки играет важную роль в визуальной корректности вашей сцены. Важно четко определить этот порядок и неукоснительно следовать ему. Например:

@override
void paint(Size size, Canvas canvas) {
  canvas
    ..save()
    ..clipRect(Offset.zero & size);

  // Слои отрисовки в правильном порядке:
  _backgroundPainter.paint(size, canvas); // Фон
  _spritesPainter.paint(size, canvas); // Иконки и спрайты
  _tooltipsPainter.paint(size, canvas); // Интерактивные всплывающие подсказки
  _debugPainter.paint(size, canvas); // Отладочный оверлей (например, счетчик FPS)
  _miniMapPainter.paint(size, canvas); // HUD мини-карты
  _cameraPainter.paint(size, canvas); // Трансформации камеры

  canvas.restore();
}

Логичное именование отрисовщиков помогает улучшить читаемость кода и упрощает будущую работу над сценой.

Для достижения максимальной производительности:

  • Сведите к минимуму ненужные пересчеты:
    Убедитесь, что ваши отрисовщики выполняют дорогостоящие вычисления только тогда, когда это действительно необходимо.

  • Используйте эффективные флаги (_needsPaint, _needsRelayout), чтобы контролировать, когда происходят перерисовки.

  • Группируйте операции перерисовки: Пересчитывайте только то, что необходимо, чтобы избежать избыточных циклов перерисовки.

  • Модульность: Разделите вашу сцену на классы специализированных отрисовщиков.

  • Управляйте состоянием: Используйте флаги для управления пересчетами и перерисовкой.

  • Распространение событий: Пересылайте входящие события до тех пор, пока они не будут обработаны.

  • Определите порядок отрисовки: Четко обозначьте, в каком порядке будут отрисовываться слои, чтобы контролировать их наложение.

Оптимизация отрисовки на канвасе

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

Когда вы вызываете методы отрисовки на Canvas Flutter (такие как drawRect, drawCircle и т. д.), вы не записываете пиксели непосредственно в экранный буфер. Вместо этого вы передаете инструкции графическому процессору, который объединяет их и выполняет в рамках одной эффективной операции.

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

Рассмотрим следующий неэффективный пример:

// Неэффективный цикл отрисовки:
for (final sprite in sprites) {
  canvas.drawImageRect(sprite.image, sprite.srcRect, sprite.dstRect, paint);
}

Этот шаблон генерирует множество отдельных команд GPU, что приводит к значительным накладным расходам и снижению производительности.

Flutter предоставляет специализированные методы Canvas, разработанные специально для операций пакетной обработки:

Метод

Использование

Преимущество в производительности

drawAtlas

Эффективно отрисовывает несколько изображений (спрайтов) из одной текстуры.

Отличная пакетная обработка на GPU

drawRawAtlas

Рисует пакеты спрайтов или анимационных кадров с минимальными накладными расходами.

Высочайшая производительность на GPU

drawRawPoints

Эффективно отрисовывает большое количество точек (кругов, квадратов).

Быстрый рендеринг многочисленных примитивов

Эти пакетные методы должны быть вашими главными решениями для большинства интенсивных задач рендеринга.

drawRawAtlas позволяет отрисовывать несколько спрайтов из одной текстуры (атласа). Этот метод отличается высокой производительностью графического процессора и идеально подходит для игр на основе спрайтов или сложных визуализаций.

Важно помнить, что атласы спрайтов должны быть разумного размера — желательно не более 1024×1024 пикселей. Это позволит сбалансировать использование памяти графического процессора и обеспечить оптимальную производительность.

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

final visibleSkills = skills.length;
final skillsPos = Float32List(visibleSkills * 4);
final skillsSpr = Float32List(visibleSkills * 4);

// Макет и композиция
for (var i = 0; i < visibleSkills; i++) {
  final skill = skills[i];
  final sprite = skill.sprite ?? skill.tags.first.sprite;
  final rect = skill.boundary;
  final size = rect.longestSide;

  // Спрайты скилов
  skillsSpr
    ..[i * 4 + 0] = sprite.dx // слева
    ..[i * 4 + 1] = sprite.dy // вверху
    ..[i * 4 + 2] = sprite.dx + sprite.size // справа
    ..[i * 4 + 3] = sprite.dy + sprite.size; // внизу

  // Установите матрицу позиций скилов.
  final Offset(:dx, :dy) = _camera.globalToLocal(rect.left, rect.top);

  // Разместите скил из левого верхнего угла спрайта.
  skillsPos
    ..[i * 4 + 0] = size * _camera.zoomDouble / sprite.size
    ..[i * 4 + 1] = 0
    ..[i * 4 + 2] = dx
    ..[i * 4 + 3] = dy;
}

final Paint skillsAtlasPaint =
  Paint()
    ..style = ui.PaintingStyle.fill
    ..blendMode = BlendMode.srcOver
    // Отключите сглаживание для улучшения качества изображения в пикселях и повышения производительности.
    ..filterQuality = FilterQuality.none
    ..isAntiAlias = false;

// Отрисовывайте скилы из атласа по 500 элементов за итерацию.
// Используйте `visibleSkills` для перебора видимых буферов скилов.
for (var offset = 0; offset < visibleSkills; offset += 500) {
  final start = offset;
  final end = math.min(offset + 500, visibleSkills);
  final positionsView = Float32List.sublistView(skillsPos, start * 4, end * 4);
  final spritesView = Float32List.sublistView(skillsSpr, start * 4, end * 4);
  //final colorsView = Int32List.sublistView(skillsColors, start, end);
  canvas.drawRawAtlas(
    _atlas,
    positionsView,
    spritesView,
    null, // colorsView
    BlendMode.srcOver, // BlendMode.src
    null,
    skillsAtlasPaint,
  );
}
  • Значительно снижает нагрузку на процессор.

  • Обеспечивает максимальную загрузку графического процессора за счет пакетного выполнения команд рендеринга.

drawRawPoints эффективно выполняет пёакетное отрисовывание множества точек, таких как эффекты частиц или визуализация данных.

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

// Рисуем круги
final points = Float32List.fromList([
  100, 100, 200, 200, 300, 300, // x1,y1, x2,y2, x3,y3...
]);

final paint = Paint()
  ..color = const Color(0xFFFF0000)
  ..isAntiAlias = true
  ..blendMode = BlendMode.srcOver
  ..style = PaintingStyle.stroke
  ..strokeCap = StrokeCap.round
  ..strokeJoin = StrokeJoin.round
  ..strokeWidth = 6;

canvas.drawRawPoints(PointMode.points, points, paint);
  • Вы можете указать стиль фигуры (PointMode.points, PointMode.lines или PointMode.polygon) в зависимости от ваших потребностей.

  • При отображении кругов или квадратов с помощью этого метода накладные расходы минимальны.

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

  • Используйте: пакетные операции с помощью drawAtlas, drawRawAtlas, drawRawPoints.

  • Избегайте: циклов, вызывающих такие методы, как drawRect, drawCircle, drawImageRect повторно для каждого объекта.

Оптимизация производительности Flutter canvas не ограничивается отказом от циклов и пакетных вызовов рисования — использование оптимизации графического процессора и тщательное управление вашими объектами Paint может принести существенный выигрыш. Давайте рассмотрим, как эффективно достичь этого с помощью ускорения графического процессора (с использованием шейдеров) и повторного использования объектов smart paint.

Оптимизация производительности канваса Flutter не ограничивается только сокращением циклов и пакетной отрисовкой. Чтобы достичь значительного повышения производительности, необходимо обратить внимание на оптимизацию графического процессора и тщательное управление Paint‑объектами. Давайте рассмотрим, как эффективно реализовать эти подходы с помощью ускорения графического процессора (с использованием шейдеров) и умного повторного использования Paint‑объектов.

Пользовательские GPU‑шейдеры, написанные на языке GLSL и выполняемые непосредственно на графическом процессоре, обеспечивают непревзойденную эффективность рендеринга. Хорошо написанный шейдер значительно ускоряет сложные графические эффекты, анимацию и пиксельную визуализацию по сравнению с методами, управляемыми процессором.

Эффективная настройка и повторное использование Paint‑объектов являются ключевыми стратегиями для улучшения производительности канваса. Создание множества временных Paint‑объектов приводит к увеличению объема используемой памяти и дополнительным затратам на сборку мусора, что, в свою очередь, негативно сказывается на производительности.

Рекомендуемая практика: Создавайте и повторно используйте общие Paint‑объекты, настроенные для конкретных визуальных сценариев.

Например, для пиксельной графики (без сглаживания):

final pixelArtPaint = Paint()
  ..isAntiAlias = false
  ..filterQuality = FilterQuality.none
  ..style = PaintingStyle.fill;
  • Предотвращает нежелательное размытие, сохраняя четкие пиксели.

Сглаживание кривых и линий (сглаживание включено):

final smoothLinePaint = Paint()
  ..isAntiAlias = true
  ..strokeWidth = 2.0
  ..style = PaintingStyle.stroke;
  • Удаляет визуальные «неровности» (aliasing) на линиях и кривых.

Закругление:

final pointPaint = Paint()
  ..color = Colors.red
  ..strokeWidth = 8
  ..strokeCap = StrokeCap.round; // Закругление

canvas.drawPoints(PointMode.points, points, pointPaint);
  • Повторно используйте Paint‑объекты вместо регулярного создания новых экземпляров.

  • Явно настройте isAntiAlias и filterQuality в соответствии с вашими визуальными потребностями.

  • Используйте пользовательские шейдеры (vertex и fragment) для загрузки дорогостоящих визуальных вычислений на графический процессор.

  • Команды пакетной отрисовки с оптимизированными методами (drawRawAtlas, drawRawPoints) минимизируют накладные расходы.

Эффективное управление пространством 

Работая с интерактивными сценами на канвасе Flutter, вы неизбежно столкнетесь с необходимостью эффективного управления пространством. Вам нужно будет вычислять положения объектов, проверять попадания, обнаруживать коллизии и определять, какие объекты в данный момент видны во вьюпорте вашей камеры. Для небольших сцен, содержащих менее 100 элементов, линейная итерация по объектам может быть еще приемлемым решением. Однако для более сложных сцен требуются оптимизированные пространственные структуры данных.

Одной из таких широко используемых и эффективных структур является QuadTree.

Класс QuadTree — библиотека repaint — Dart API

Документация по API для класса QuadTree из библиотеки repaint для языка программирования Dart.

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

Преимущества QuadTree:

  • Быстрые пространственные запросы: Вы можете эффективно находить все объекты в пределах определенного региона.

  • Эффективное обнаружение коллизий: Быстрое выявление потенциальных коллизий.

  • Масштабируемость: Прекрасно работает даже с тысячами объектов.

Упрощенная реализация управления объектами с использованием QuadTree во Flutter:

// Определение пространственной границы (размер мира) вашего QuadTree.
final QuadTree qt = QuadTree(
  boundary: Rect.fromLTWH(0, 0, worldWidth, worldHeight),
  depth: 5,      // Максимальная глубина дерева
  capacity: 24,  // Максимальное количество объектов на узел до разделения
);

/// Сопоставление индекса QuadTree с фактическим объектом.
/// Поддерживайте единый список для сопоставления индексов QuadTree с реальными объектами.
List<Object?> _qt2object = List<Object?>.filled(64, null, growable: false);

/// Вставляем объект в QuadTree.
int put(HitBox obj) {
  final id = qt.insert(obj.rect);
  if (_qt2object.length <= id) {
    final prev = _qt2object;
    _qt2object = List<Object?>.filled(
      math.max(64, _qt2object.length << 1),
      null,
      growable: false,
    )..setAll(0, prev);
  }
  _qt2object[id] = obj;
  return id;
}

/// Запрос объектов в пределах заданной прямоугольной области.
Iterable<HitBox> query(Rect rect) =>
  qt.queryIds(rect).map((id) => _qt2object[id]).whereType<HitBox>();

При проверке коллизий или попаданий всегда начинайте с «дешевой» проверки перед дорогостоящими вычислениями:

  • Сначала сравните ограничивающие прямоугольники (bounding boxes).

  • Если ограничивающие прямоугольники пересекаются, выполните более точные тесты на коллизию.

bool checkCollision(HitBox obj1, HitBox obj2) {
  // Дешевая проверка ограничивающих прямоугольников
  if (!obj1.rect.overlaps(obj2.rect)) return false;
  // Проводите точную проверку на коллизию только при необходимости
  return detailedCollisionCheck(obj1, obj2);
}

Чтобы эффективно получать данные о видимых объектах во вьюпорте камеры, рекомендуется слегка увеличить прямоугольник вьюпорта. Это позволит включить частично видимые объекты или те, что находятся близко к краям.

final cameraBoundsInflated = camera.bound.inflate(32);
final visibleObjects = quadTree.query(cameraBoundsInflated);

Таким образом, вы сможете избежать «внезапных появлений», которые возникают, когда объекты попадают в границы вашего вьюпорта.

Старайтесь не выполнять пространственные запросы при каждом вызове функций paint или update, особенно если ваша сцена в основном статична. Вместо этого используйте реактивные шаблоны Flutter, такие как ChangeNotifier, чтобы запускать пересчеты только в случае необходимости.

Подпишитесь на изменения в камере и QuadTree:

void init() {
  camera.addListener(_onChange);
  quadTree.addListener(_onChange);
}

/// Пометка отрисовщиков на перестройку макета при изменениях.
void _onChange() {
  _skillsPainter.changed();
  _selectorPainter.changed();
  _miniMapPainter.changed();
}

class SkillsPainter {
  bool _needsRelayout = true;
  bool _needsPaint = true;
  bool get needsPaint => _needsPaint;

  void changed() => _needsRelayout = true;

  void update(Size size, double delta) {
    if (_needsRelayout) _relayout();
  }

  void _relayout() {
    _needsRelayout = false;
    final objects = quadTree.query(camera.bound.inflate(64));
    final skills = objects.whereType<RoadmapSkill>().toList(growable: false);
    
    // Выполнение логики перестройки макета...
    _needsPaint = true; // Пометка для перерисовки, если произошли изменения
  }
}

Убедитесь, что ваша сцена четко следует жизненному циклу:

  1. Обнаружение изменений: Помечайте компоненты как «невалидные» (_needsRelayout) при соответствующих событиях.

  2. Этап обновления: Пересчитывайте макет и состояния только

  3. Этап отрисовки: Перерисовывайте изображения только при необходимости (_needsPaint).

@override
bool get needsPaint => _needsPaint |= painters.any((painter) => painter.needsPaint);

Это значительно сокращает количество избыточных вычислений, повышая общую производительность.

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


Приглашаем вас на открытый урок курса «Flutter Mobile Developer» — «Flutter в Automotive & Embedded: создаём приложение для автомобиля (и не только)». Урок состоится 12 августа в 20:00. В ходе занятия вы познакомитесь с особенностями разработки приложений для встроенных систем и автомобильной электроники с использованием Flutter.

А также вы можете пройти вступительное тестирование, которое направлено на оценку ваших текущих знаний Flutter.

Комментарии (0)