Как я повысил производительность flutter приложения с помощью FragmentShader. Часть 2

Я flutter разработчик, в основном специализируюсь на разработке под iOS и Android. Нравится делать приложения для сферы развлечений и образования.

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

7d4c20ac742d2e673b74907fae68f51f.gif

А где про FragmentShader?

Итак, мы оптимизировали текущий вариант кода, как могли. Но что в этой реализации может быть еще не так?

Дерево виджетов

Дерево виджетов

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

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

Режим стирания линий

Режим стирания линий

class FingerPainterScreen extends StatefulWidget {
  const FingerPainterScreen({super.key});

  @override
  State createState() => _FingerPainterScreenState();
}

class _FingerPainterScreenState extends State {
  final List lines = [];

  void _onClearLines() {
    setState(() {
      lines.clear();
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      lines.last = lines.last.copyWith(
        points: [...lines.last.points, details.localPosition],
      );
    });
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      lines.add(
        LineObject(
          color: _currentColor,
          points: [details.localPosition],
        ),
      );
    });
  }

  Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
  bool _eraserModeEnabled = false;
  void _onToggleEraser(bool value) {
    setState(() {
      _eraserModeEnabled = value;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      bottomNavigationBar: SafeArea(
        child: ListTile(
          title: const Text('Eraser enabled'),
          trailing: Switch(
            value: _eraserModeEnabled,
            onChanged: _onToggleEraser,
          ),
        ),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        child: Stack(
          children: [
            for (int index = 0; index < lines.length; index++)
              RepaintBoundary(
                child: CustomPaint(
                  size: MediaQuery.sizeOf(context),
                  painter: FingerPainter(
                    line: lines[index],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

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

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

Пример с большим количеством линий

Пример с большим количеством линий

Скорость отрисовки фреймов при большом количестве линий

Скорость отрисовки фреймов при большом количестве линий

Пример с пустым холстом

Пример с пустым холстом

Скорость отрисовки фреймов при пустом холсте

Скорость отрисовки фреймов при пустом холсте

Чтобы решить проблему с отображением большого количества элементов на экране, мне пришла в голову идея не отображать большое количество элементов на экране :)

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

Чтобы реализовать подобную задумку, на помощь снова приходит виджет RepaintBoundary, но уже в связке с GlobalKey:

import 'dart:ui' as ui;
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class FingerPainterScreen extends StatefulWidget {
  const FingerPainterScreen({super.key});

  @override
  State createState() => _FingerPainterScreenState();
}

class _FingerPainterScreenState extends State {
  final List lines = [];

  void _onClearLines() {
    setState(() {
      lines.clear();
      _imageBytes = null;
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      lines.last = lines.last.copyWith(
        points: [...lines.last.points, details.localPosition],
      );
    });
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      lines.add(
        LineObject(
          color: _currentColor,
          points: [details.localPosition],
        ),
      );
    });
  }

  Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
  bool _eraserModeEnabled = false;
  void _onToggleEraser(bool value) {
    setState(() {
      _eraserModeEnabled = value;
    });
  }

  final _repaintKey = GlobalKey();
  Uint8List? _imageBytes;
  Future _capturePng() async {
    final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    final ui.Image image = boundary.toImageSync(pixelRatio: 1.0);
    final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    setState(() {
      _imageBytes = byteData!.buffer.asUint8List();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      bottomNavigationBar: SafeArea(
        child: ListTile(
          title: const Text('Eraser enabled'),
          trailing: Switch(
            value: _eraserModeEnabled,
            onChanged: _onToggleEraser,
          ),
        ),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        onPanEnd: (_) => _capturePng(),
        child: RepaintBoundary(
          key: _repaintKey,
          child: Stack(
            children: [
              // for (int index = 0; index < lines.length; index++)
              //   RepaintBoundary(
              //     child: CustomPaint(
              //       size: MediaQuery.sizeOf(context),
              //       painter: FingerPainter(
              //         line: lines[index],
              //       ),
              //     ),
              //   ),
              if (_imageBytes != null)
                Positioned.fill(
                  child: Image.memory(
                    _imageBytes!,
                    fit: BoxFit.cover,
                  ),
                ),
              if (lines.isNotEmpty)
                RepaintBoundary(
                  child: CustomPaint(
                    size: MediaQuery.sizeOf(context),
                    painter: FingerPainter(
                      line: lines.last,
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

1) Строки 83–85. Мы обернули виджет Stack в RepaintBoundary и пометили ключом _repaintKey
2) Строки 51–60. Далее по этому ключу мы получаем картинку из виджета и преобразуем в байты.

Параметр pixelRatio следует сделать равным 1, потому что нам нужны размеры 1:1. К слову, в иных случаях этот параметр можно делать больше 1, чтобы повысить разрешение полученной картинки.

В целом, этого достаточно, чтобы количество renderObject не росло и дерево виджетов всегда имело такой вид:

Дерево виджетов

Дерево виджетов

Мы хотим максимально увеличить производительность и уменьшить количество выполняемых операций, как это сделать?  

У CustomPainter в методе paint есть также метод рисования картинки. Напишем реализацию для него:

class ImagePainter extends CustomPainter {
  final ui.Image image;
  const ImagePainter({
    required this.image,
  });

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImage(image, Offset.zero, Paint());
  }

  @override
  bool shouldRepaint(ImagePainter oldDelegate) {
    return oldDelegate.image != image;
  }
}

Обновим код:

class FingerPainterScreen extends StatefulWidget {
  const FingerPainterScreen({super.key});

  @override
  State createState() => _FingerPainterScreenState();
}

class _FingerPainterScreenState extends State {
  final List lines = [];

  void _onClearLines() {
    setState(() {
      lines.clear();
      // _imageBytes = null;
      _image = null;
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      lines.last = lines.last.copyWith(
        points: [...lines.last.points, details.localPosition],
      );
    });
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      lines.add(
        LineObject(
          color: _currentColor,
          points: [details.localPosition],
        ),
      );
    });
  }

  Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
  bool _eraserModeEnabled = false;
  void _onToggleEraser(bool value) {
    setState(() {
      _eraserModeEnabled = value;
    });
  }

  final _repaintKey = GlobalKey();
  // Uint8List? _imageBytes;
  ui.Image? _image;
  Future _capturePng() async {
    final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    final image = boundary.toImageSync(pixelRatio: 1.0);
    // final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    setState(() {
      // _imageBytes = byteData!.buffer.asUint8List();
      _image = image;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      bottomNavigationBar: SafeArea(
        child: ListTile(
          title: const Text('Eraser enabled'),
          trailing: Switch(
            value: _eraserModeEnabled,
            onChanged: _onToggleEraser,
          ),
        ),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        onPanEnd: (_) => _capturePng(),
        child: RepaintBoundary(
          key: _repaintKey,
          child: Stack(
            children: [
              // for (int index = 0; index < lines.length; index++)
              //   RepaintBoundary(
              //     child: CustomPaint(
              //       size: MediaQuery.sizeOf(context),
              //       painter: FingerPainter(
              //         line: lines[index],
              //       ),
              //     ),
              //   ),
              if (_image != null)
                Positioned.fill(
                  child: CustomPaint(
                    painter: ImagePainter(
                      image: _image!,
                    ),
                  ),
                ),
              // if (_imageBytes != null)
              //   Positioned.fill(
              //     child: Image.memory(
              //       _imageBytes!,
              //       fit: BoxFit.cover,
              //     ),
              //   ),
              if (lines.isNotEmpty)
                RepaintBoundary(
                  child: CustomPaint(
                    size: MediaQuery.sizeOf(context),
                    painter: FingerPainter(
                      line: lines.last,
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

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

Я заметил, что качество при отрисовке картинок в CustomPainter может сильно теряться, они как бы становятся пиксельными. Заметно, когда переводишь обычную красивую картинку, например, из ассетов в ui.Image и рисуешь в пейнтере

Разница в качестве картинок (слева CustomPaint, справа оригинал)

Разница в качестве картинок (слева CustomPaint, справа оригинал)

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

bc806928a466debb163b820f4b6f31df.png

flutter:
  uses-material-design: true

  shaders:
    - assets/shaders/simple_shader.frag

  assets:
    - assets/images/
#include

uniform vec2 uResolution; // The resolution of the screen

uniform sampler2D uTexture; // The texture

out vec4 fragColor;

void main() {
    vec2 st=FlutterFragCoord().xy  / uResolution;

    fragColor = texture(uTexture, st);
}

Что тут происходит простыми словами:

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

uniform sampler2D uTexture — это по сути ui.Image. Этот параметр тоже нужно передать, чтобы воспользоваться шейдером.

out vec4 fragColor — переменная, у которой 4 параметра. Это цвет пикселя и как раз то, что пойдет на выход.

vec2 st — координата этого пикселя в относительных единицах. 

class ImagePainter extends CustomPainter {
  final ui.Image image;
  final ui.FragmentProgram program;
  const ImagePainter({
    required this.image,
    required this.program,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..shader = _getShader(size)
      ..style = PaintingStyle.fill;

    final rect = Rect.fromPoints(
      const Offset(0, 0),
      Offset(size.width, size.height),
    );
    canvas.drawRect(rect, paint);
  }

  ui.FragmentShader _getShader(Size size) {
    final shader = program.fragmentShader();

    // resolution
    shader.setFloat(0, size.width);
    shader.setFloat(1, size.height);

    // texture
    shader.setImageSampler(0, image!);

    return shader;
  }

  @override
  bool shouldRepaint(ImagePainter oldDelegate) {
    return oldDelegate.image != image;
  }
}

Здесь в методе _getShader мы задаем в шейдере необходимые параметры: размер виджета и саму картинку. 

import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class FingerPainterScreen extends StatefulWidget {
  const FingerPainterScreen({super.key});

  @override
  State createState() => _FingerPainterScreenState();
}

class _FingerPainterScreenState extends State {
  final List lines = [];

  void _onClearLines() {
    setState(() {
      lines.clear();
      _image = null;
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      lines.last = lines.last.copyWith(
        points: [...lines.last.points, details.localPosition],
      );
    });
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      lines.add(
        LineObject(
          color: _currentColor,
          points: [details.localPosition],
        ),
      );
    });
  }

  Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
  bool _eraserModeEnabled = false;
  void _onToggleEraser(bool value) {
    setState(() {
      _eraserModeEnabled = value;
    });
  }

  final _repaintKey = GlobalKey();
  ui.Image? _image;
  void _capturePng() {
    final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
    final image = boundary.toImageSync(pixelRatio: 1.0);
    setState(() {
      _image = image;
    });
  }

  ui.FragmentProgram? _program;
  Future _loadMyShader() async {
    final fragmentProgram = await ui.FragmentProgram.fromAsset('assets/shaders/simple_shader.frag');
    setState(() {
      _program = fragmentProgram;
    });
  }

  Future _onPanEnd(DragEndDetails details) async {
    if (_program == null) {
      await _loadMyShader();
    }
    _capturePng();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      bottomNavigationBar: SafeArea(
        child: ListTile(
          title: const Text('Eraser enabled'),
          trailing: Switch(
            value: _eraserModeEnabled,
            onChanged: _onToggleEraser,
          ),
        ),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        onPanEnd: _onPanEnd,
        child: RepaintBoundary(
          key: _repaintKey,
          child: Stack(
            children: [
              if (_image != null && _program != null)
                CustomPaint(
                  size: MediaQuery.sizeOf(context),
                  painter: ImagePainter(
                    image: _image!,
                    program: _program!,
                  ),
                ),
              if (lines.isNotEmpty)
                RepaintBoundary(
                  child: CustomPaint(
                    size: MediaQuery.sizeOf(context),
                    painter: FingerPainter(
                      line: lines.last,
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

class FingerPainter extends CustomPainter {
  final LineObject line;
  const FingerPainter({
    required this.line,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = line.color
      ..strokeWidth = line.strokeWidth
      ..strokeCap = StrokeCap.round;

    for (var i = 0; i < line.points.length - 1; i++) {
      canvas.drawLine(line.points[i], line.points[i + 1], paint);
    }
  }

  @override
  bool shouldRepaint(FingerPainter oldDelegate) {
    return oldDelegate.line.points.length != line.points.length;
  }
}

class LineObject {
  final List points;
  final Color color;
  final double strokeWidth;

  const LineObject({
    required this.points,
    this.color = Colors.red,
    this.strokeWidth = 20.0,
  });

  LineObject copyWith({
    List? points,
    Color? color,
    double? strokeWidth,
  }) {
    return LineObject(
      points: points ?? this.points,
      color: color ?? this.color,
      strokeWidth: strokeWidth ?? this.strokeWidth,
    );
  }
}

class ImagePainter extends CustomPainter {
  final ui.Image image;
  final ui.FragmentProgram program;
  const ImagePainter({
    required this.image,
    required this.program,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..shader = _getShader(size)
      ..style = PaintingStyle.fill;

    final rect = Rect.fromPoints(
      const Offset(0, 0),
      Offset(size.width, size.height),
    );
    canvas.drawRect(rect, paint);
  }

  ui.FragmentShader _getShader(Size size) {
    final shader = program.fragmentShader();

    // resolution
    shader.setFloat(0, size.width);
    shader.setFloat(1, size.height);

    // texture
    shader.setImageSampler(0, image);

    return shader;
  }

  @override
  bool shouldRepaint(ImagePainter oldDelegate) {
    return oldDelegate.image != image;
  }
}

1) Стр. 60–66 — Добавлена загрузка программы шейдера
2) Стр. 68–73 — Вынесен метод обновления картинки и загрузки шейдера по необходимости

Теперь после рисования новой линии будет выполняться скриншот, и далее он будет отображаться на бэкграунде как растровая картинка. Качество картинки при этом не потеряется.

Заключение

Мы достигли оптимизации за счет:
1) снижения количества ненужных перестроений
2) преобразования отрисованных объектов в растровую картинку.
Плюс воспользовались шейдером, чтобы не потерять в качестве.

Я надеюсь, что материал данной статьи поможет вам в ваших проектах и вдохновит на нетривиальное решение сложных задач.

© Habrahabr.ru