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

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

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

26f81cba5f0502e1db4fd630edbd936f.gif

Над реализацией долго не думал, поэтому выполнил следующие шаги:

  • Создал класс, который будет в себе хранить основные характеристики линии (координаты точек, цвет и толщина)

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

  const LineObject({
    required this.points,
    this.color = Colors.red,
    this.strokeWidth = 20.0,
  });
}
class FingerPainter extends CustomPainter {
  final List lines;
  const FingerPainter({
    required this.lines,
  });

  @override
  void paint(Canvas canvas, Size size) {
    for (int index = 0; index < lines.length; index++) {
      final line = lines[index];
      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) => true;
}
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.points.add(details.localPosition);
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      body: GestureDetector(
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        child: CustomPaint(
          size: MediaQuery.sizeOf(context),
          painter: FingerPainter(
            lines: lines,
          ),
        ),
      ),
    );
  }
}

В целом, этого достаточно для начала. Далее можно потихоньку усложнять, кастомизировать и оптимизировать…

Какие существуют проблемы в текущей реализации?

Проведем небольшое код-ревью своего же кода. 

Во-первых, мы рисуем все линии сразу. В методе paint каждый раз запускается цикл, в котором по очереди рисуется каждая линия из списка. Давайте это поправим, оставив задачу FingerPainter отрисовать только одну конкретную линию:

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) => true;
}

Поправим также на самом экране:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        child: Stack(
          children: [
            for (int index = 0; index < lines.length; index++)
              CustomPaint(
                size: MediaQuery.sizeOf(context),
                painter: FingerPainter(
                  line: lines[index],
                ),
              ),
          ],
        ),
      ),
    );
  }

Теперь у нас цикл в виджете Stack. В GestureDetector изменено проперти behavior на HitTestBehavior.opaque. По умолчанию, GestureDetector реагирует на попадание в child, или же children в стеке. Если children нет, то и новые линии тоже создаваться не будут.
Почему лучше сделать цикл именно в стеке? Я вернусь к этому вопросу чуть позже.

Во-вторых, shouldRepaint всегда true. Этот параметр говорит нам, когда следует перерисовать виджет (то есть вызвать метод paint). Если он всегда true, то, соответственно, при любом чихе или, например, вызове build в родителе будет происходить прерисовка, и все N линий будут заново отрисованы. Добавим условие, чтобы перерисовка происходила только при изменении количества точек в линии.

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

Если делать пошагово, как в данной статье, то на этом шаге у нас опять перестанут рисоваться линии. Это потому что условие всегда возвращает false. 

Дело в том, что class LineObject не иммутабельный, и мы можем изменять проперти List points без каких-либо угрызений совести. Вроде, в этом ничего плохого нет до поры до времени.

Когда мы в первый раз создаем объект класса LineObject, мы создаем некую ссылку (референс) на участок в памяти. Всегда, когда мы изменяем проперти points, мы делаем это в одном и том же объекте, в том же участке памяти. 

Поскольку в конструкторе FingerPainter мы передаем именно референс на объект LineObject, то в любой момент времени, когда вызывается shouldRepaint, мы обращаемся к одному и тому же объекту. Соответственно, и проперти points имеет ту же длину.

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

В данной статье обойдемся созданием копии экземпляра, добавив метод copyWith:

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,
    );
  }
}

Скорректируем методы _onPanStart и _onPanUpdate

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

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

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

В-третьих, перестроение виджетов не изолировано. Есть очень полезный виджет, который называется RepaintBoundary. Он позволяет изолировать один виджет от других, как бы «защищая» от лишних перестроений и себя при изменении других виджетов, и другие виджеты при перестроении его самого.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onClearLines,
        child: const Icon(Icons.clear),
      ),
      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],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

Таким образом, перестроение происходит только в последнем виджете из списка.

Как я упоминал ранее, лучше вынести цикл в Stack, потому что это просто удобно, как показал пример выше.

Заключение

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

© Habrahabr.ru