Flutter - 在 CustomPainter 中重复利用之前绘制的画布

8
我有一个CustomPainter,想要每几毫秒渲染一些项。但我只想渲染自上次绘制以来发生了变化的项。我计划手动清除将要更改的区域并重新绘制该区域。问题是,在Flutter中,每次调用paint()时似乎都会创建完全新的画布。我知道可以跟踪整个状态并每次都重新绘制所有内容,但考虑到性能原因和具体的使用情况,这并不理想。以下是可能表示此问题的示例代码:
我知道在画布大小改变时需要重新绘制所有内容。
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;
  }
}

1
你必须画出所有的东西,抱歉。 - pskink
1
你可以尝试加速它的唯一方法是使用“Canvas.drawImage”/“Canvas.drawPicture”,而不是使用数十个绘图原语。 - pskink
2个回答

13

对于每一帧都重新绘制整个画布,甚至是重新绘制上一帧的尝试通常不会更有效率。

从你发布的代码来看,有一些需要改进的地方,但试图保留画布的某些部分不应该是其中之一。

你正在遇到的真正性能问题是,每50毫秒从Timer.periodic事件中重复更改ValueNotifier。更好的处理每帧重绘的方法是,使用带有vsyncAnimatedBuilder,这样CustomPainterpaint方法将在每一帧被调用。如果您熟悉Web浏览器世界中的Window.requestAnimationFrame,那么这与其类似。这里的vsync代表“垂直同步”,如果您了解计算机图形工作原理的话。基本上,在具有60 Hz屏幕的设备上,您的paint方法将每秒调用60次,并且在具有120 Hz屏幕的设备上,它将每秒绘制120次。这是实现流畅动画的正确可扩展方式,可适用于各种设备。

在考虑保留画布的某些部分之前,还有其他需要优化的地方。例如,只是简要查看您的代码,您有这行:

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

在这里,我假设你想要一个介于0size.height之间的随机小数,如果是这样,你可以简单地写_rng.nextDouble() * size.height,而不是把一个double强制转换成int再转回来,并且(可能无意中)在这个过程中四舍五入。但是像这样的性能提升是微不足道的。

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

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

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

demo snowman

完整源代码:

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

1
谢谢。我在尝试保存和恢复图像的情况后,基本上得出了相同的结论,这些情况效率较低。代码只是示例代码,用于展示场景,完全理解它可以有改进。一个重要的改进是一次性构建路径并绘制所有内容,而不是每次使用lineTo。但感谢您详细的答案和AnimatedBuilder建议!我一定会采纳您的建议! - Alex
@Alex:谢谢您的接受。确实,在性能方面,没有任何东西可以与直接在画布上绘制相媲美,因为所有其他小部件和诸如此类的东西仍然需要最终绘制。所以你走在了正确的轨道上。Flutter团队推荐使用AnimatedBuilder与CustomPaint组合来解决通过其他动画手段时遇到的性能问题,所以你应该一定要研究这个组合。 - WSBT

2
目前唯一的解决方案是捕获进度图像,然后绘制该图像,而不是执行整个画布代码。
要绘制图像,您可以使用pskink在上面评论中提到的canvas.drawImage
但我推荐的解决方案是将CustomPaint包装在RenderRepaint中,以将该小部件转换为图像。有关详细信息,请参见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以更好的方式呈现图像。


我已经让它工作了,但是性能方面(至少在这个简单的示例代码中)表现得更差。当渲染大量这些内容时,会出现奇怪的崩溃。 - Alex

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接