【问题标题】:Flutter - Reuse previously painted canvas in a CustomPainterFlutter - 在 CustomPainter 中重用以前绘制的画布
【发布时间】:2021-01-11 12:48:36
【问题描述】:

我有一个 CustomPainter,我想每隔几毫秒渲染一些项目。但我只想渲染自上次绘制以来已更改的项目。我计划手动清除将要更改的区域并仅在该区域内重新绘制。问题是每次调用paint() 时,Flutter 中的画布似乎都是全新的。我知道我可以跟踪整个状态并每次都重绘所有内容,但出于性能原因和特定用例的考虑,这是不可取的。以下是可能代表该问题的示例代码:

我了解当画布大小发生变化时,所有内容都需要重新绘制。

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';

class CanvasWidget extends StatefulWidget {
  CanvasWidget({Key key}) : super(key: key);

  @override
  _CanvasWidgetState createState() => _CanvasWidgetState();
}

class _CanvasWidgetState extends State<CanvasWidget> {
  final _repaint = ValueNotifier<int>(0);
  TestingPainter _wavePainter;

  @override
  void initState() {
    _wavePainter = TestingPainter(repaint: _repaint);
    Timer.periodic( Duration(milliseconds: 50), (Timer timer) {
      _repaint.value++;
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
       painter: _wavePainter,
    );
  }
}

class TestingPainter extends CustomPainter {
  static const double _numberPixelsToDraw = 3;
  final _rng = Random();

  double _currentX = 0;
  double _currentY = 0;

  TestingPainter({Listenable repaint}): super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint();
    paint.color = Colors.transparent;
    if(_currentX + _numberPixelsToDraw > size.width)
    {
      _currentX = 0;
    }

    // Clear previously drawn points
    var clearArea = Rect.fromLTWH(_currentX, 0, _numberPixelsToDraw, size.height);
    canvas.drawRect(clearArea, paint);

    Path path = Path();
    path.moveTo(_currentX, _currentY);
    for(int i = 0; i < _numberPixelsToDraw; i++)
    {
      _currentX++;
      _currentY = _rng.nextInt(size.height.toInt()).toDouble();
      path.lineTo(_currentX, _currentY);
    }

    // Draw new points in red    
    paint.color = Colors.red;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

【问题讨论】:

  • 你必须画所有的东西,对不起
  • 你可以尝试加快速度的唯一方法是使用Canvas.drawImage / Canvas.drawPicture 而不是几十个绘图原语

标签: flutter flutter-canvas


【解决方案1】:

目前唯一可用的解决方案是将进度捕获为图像,然后绘制图像,而不是执行整个画布代码。

要绘制图像,您可以使用canvas.drawImage,正如 pskink 在上述评论中提到的那样。

但我建议的解决方案是将CustomPaintRenderRepaint 包装在一起,以将该小部件转换为图像。详情请参阅 Creating raw image from Widget or Canvas 和(https://medium.com/flutter-community/export-your-widget-to-image-with-flutter-dc7ecfa6bafb 用于简要实现),并且有条件检查您是否是第一次构建。

class _CanvasWidgetState extends State<CanvasWidget> {
  /// Just to track if its the first frame or not.
  var _flag = false;

  /// Will be used for generating png image.
  final _globalKey = new GlobalKey();

  /// Stores the image bytes
  Uint8List _imageBytes;

  /// No need for this actually;
  /// final _repaint = ValueNotifier<int>(0);
  TestingPainter _wavePainter;

  Future<Uint8List> _capturePng() async {
    try {
      final boundary = _globalKey
         .currentContext.findRenderObject();
      ui.Image image = await boundary.toImage();
      ByteData byteData =
          await image.toByteData(format: ui.ImageByteFormat.png);
      var pngBytes = byteData.buffer.asUint8List();
      var bs64 = base64Encode(pngBytes);
      print(pngBytes);
      print(bs64);
      setState(() {});
      return pngBytes;
    } catch (e) {
      print(e);
    }
  }

  @override
  void initState() {
    _wavePainter = TestingPainter();
    Timer.periodic( Duration(milliseconds: 50), (Timer timer) {
      if (!flag) flag = true;

      /// Save your image before each redraw.
      _imageBytes = _capturePng();   

      /// You don't need a listener if you are using a stful widget.
      /// It will do just fine.
      setState(() {});
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: _globalkey,
      child: Container(
        /// Use this if this is not the first frame.
        decoration: _flag ? BoxDecoration(
          image: DecorationImage(
            image: MemoryImage(_imageBytes)
          )
        ) : null,
        child: CustomPainter(
          painter: _wavePainter
        )
      )
    );
  }
}

这样图像将不会成为您自定义画家的一部分,让我告诉您,我尝试使用画布绘制图像,但效率不高,flutter 提供的MemoryImage 以更好的方式渲染图像。

【讨论】:

  • 我得到了这个工作,但性能方面(至少使用这个简单的示例代码)更糟。渲染大量此类列表时出现奇怪的崩溃。
【解决方案2】:

重绘整个画布,甚至在每一帧,都是非常有效的。尝试重用前一帧通常不会更有效率。

查看您发布的代码,有些地方有改进的余地,但尝试保留画布的某些部分不应该是其中之一。

您遇到的真正性能问题是每 50 毫秒重复从 Timer.periodic 事件更改 ValueNotifier。处理每一帧重绘的更好方法是将AnimatedBuildervsync 一起使用,因此将在每一帧上调用CustomPainterpaint 方法。这类似于网络浏览器世界中的Window.requestAnimationFrame,如果您熟悉的话。如果您熟悉计算机图形的工作原理,这里vsync 代表“垂直同步”。本质上,您的 paint 方法将在具有 60 Hz 屏幕的设备上每秒调用 60 次,并且它会在 120 Hz 屏幕上每秒绘制 120 次。这是在不同类型的设备上实现黄油般流畅动画的正确且可扩展的方式。

在考虑保留部分画布之前,还有其他值得优化的区域。例如,只是简单地看一下你的代码,你有这行:

_currentY = _rng.nextInt(size.height.toInt()).toDouble();

在这里,我假设您希望在 0size.height 之间有一个随机小数,如果是这样,您可以简单地写 _rng.nextDouble() * size.height,而不是将 double 转换为 int 并再次返回,并且(可能是无意的)四舍五入在那个过程中。但是这些东西带来的性能提升是微不足道的。

想一想,如果 3D 视频游戏可以在手机上流畅运行,并且每一帧都与前一帧大不相同,那么您的动画应该可以流畅运行,而不必担心手动清除部分画布。尝试手动优化画布可能会导致性能损失。

因此,您真正应该关注的是使用AnimatedBuilder 而不是Timer 来触发项目中的画布重绘,作为起点。

例如,这是我使用 AnimatedBuilder 和 CustomPaint 制作的一个小演示:

完整源代码:

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  List<SnowFlake> snowflakes = List.generate(100, (index) => SnowFlake());
  AnimationController _controller;

  @override
  void initState() {
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat();
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        width: double.infinity,
        height: double.infinity,
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [Colors.blue, Colors.lightBlue, Colors.white],
            stops: [0, 0.7, 0.95],
          ),
        ),
        child: AnimatedBuilder(
          animation: _controller,
          builder: (_, __) {
            snowflakes.forEach((snow) => snow.fall());
            return CustomPaint(
              painter: MyPainter(snowflakes),
            );
          },
        ),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  final List<SnowFlake> snowflakes;

  MyPainter(this.snowflakes);

  @override
  void paint(Canvas canvas, Size size) {
    final w = size.width;
    final h = size.height;
    final c = size.center(Offset.zero);

    final whitePaint = Paint()..color = Colors.white;

    canvas.drawCircle(c - Offset(0, -h * 0.165), w / 6, whitePaint);
    canvas.drawOval(
        Rect.fromCenter(
          center: c - Offset(0, -h * 0.35),
          width: w * 0.5,
          height: w * 0.6,
        ),
        whitePaint);

    snowflakes.forEach((snow) =>
        canvas.drawCircle(Offset(snow.x, snow.y), snow.radius, whitePaint));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

class SnowFlake {
  double x = Random().nextDouble() * 400;
  double y = Random().nextDouble() * 800;
  double radius = Random().nextDouble() * 2 + 2;
  double velocity = Random().nextDouble() * 4 + 2;

  SnowFlake();

  fall() {
    y += velocity;
    if (y > 800) {
      x = Random().nextDouble() * 400;
      y = 10;
      radius = Random().nextDouble() * 2 + 2;
      velocity = Random().nextDouble() * 4 + 2;
    }
  }
}

在这里,我生成 100 个雪花,每帧重绘整个屏幕。您可以轻松地将雪花的数量更改为 1000 或更高,并且它仍然会非常流畅地运行。在这里,我也没有尽可能多地使用设备屏幕尺寸,正如你所看到的,有一些硬编码的值,比如 400 或 800。无论如何,希望这个演示能让你对 Flutter 的图形引擎有一些信心。 :)

这是另一个(较小的)示例,向您展示了在 Flutter 中使用 Canvas 和 Animations 所需的一切。可能更容易理解:

import 'package:flutter/material.dart';

void main() {
  runApp(DemoWidget());
}

class DemoWidget extends StatefulWidget {
  @override
  _DemoWidgetState createState() => _DemoWidgetState();
}

class _DemoWidgetState extends State<DemoWidget>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;

  @override
  void initState() {
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat(reverse: true);
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (_, __) => CustomPaint(
        painter: MyPainter(_controller.value),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  final double value;

  MyPainter(this.value);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      value * size.shortestSide,
      Paint()..color = Colors.blue,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

【讨论】:

  • 谢谢。在尝试了一些保存图像和恢复最终效率较低的图像场景后,我几乎得出了相同的结论。代码只是展示场景的示例代码,完全理解它可以有改进。一个重要的方法是构建 Path 并一次绘制所有内容,而不是每次都使用 lineTo。但是感谢您的详细回答和 AnimatedBuilder 建议!肯定也会采纳您的建议!
  • @Alex:感谢您的接受。而且,是的,肯定的,就性能而言,没有什么能比得上直接在画布上绘制东西,因为所有其他小部件和诸如此类的东西,最终仍然需要绘制。所以你在正确的轨道上。使用 AnimatedBuilder 和 CustomPaint 是 Fl​​utter 团队推荐的方式,当人们通过其他动画方式遇到性能问题时,你一定要看看这个组合。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-12-25
  • 1970-01-01
  • 1970-01-01
  • 2019-10-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多