Flutter - Reuse previously painted canvas in a CustomPainter

3.6k views Asked by At

I have a CustomPainter that I want to render some items every few milliseconds. But I only want to render the items that have changed since the last draw. I plan on manually clearing the area that will be changing and redrawing just in the area. The problem is that the canvas in Flutter seems to be completely new every time paint() is called. I understand that I can keep track of the entire state and redraw everything every time, but for performance reasons and the specific use case that is not preferable. Below is sample code that could represent the issue:

I understand that everything will need to be redrawn when the canvas size changes.

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

There are 2 answers

2
WSBT On BEST ANSWER

Redrawing the whole canvas, even on every frame, is completely efficient. Trying to reuse the previous frame will often not be more efficient.

Looking at the code you posted, there are certain areas with rooms for improvement, but trying to preserve parts of the canvas should not be one of them.

The real performance issue you are having, is from repeatedly changing a ValueNotifier from a Timer.periodic event, every 50 ms. A much better way to handle redrawing on every frame, is to use AnimatedBuilder with a vsync, so the paint method of the CustomPainter will be called on every frame. This is similar to Window.requestAnimationFrame in the web browser world, if you are familiar with that. Here vsync stands for "vertical sync", if you are familiar with how computer graphics work. Essentially, your paint method will be called 60 times per second, on a device with 60 Hz screen, and it'll paint 120 times per second on a 120 Hz screen. This is the correct and scalable way to achieve buttery smooth animation across different kind of devices.

There are other areas worth optimizing, before thinking about preserving parts of the canvas. For example, just briefly looking at your code, you have this line:

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

Here I assume you want to have a random decimal between 0 and size.height, if so, you can simply write _rng.nextDouble() * size.height, instead of casting a double to int and back again, and (probably unintentionally) rounding it during that process. But the performance gain from stuff like these is negligible.

Think about it, if a 3D video game can run smoothly on a phone, with each frame being dramatically different from the previous one, your animation should run smoothly, without having to worry about manually clearing parts of the canvas. Trying to manually optimize the canvas will probably lead to performance loss instead.

So, what you really should be focusing, is to use AnimatedBuilder instead of Timer to trigger the canvas redraw in your project, as a starting point.

For example, here's a small demo I made using AnimatedBuilder and CustomPaint:

demo snowman

Full source code:

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

Here I'm generating 100 snowflakes, redrawing the whole screen every frame. You can easily change the number of snowflakes to 1000 or higher, and it would still run very smoothly. Here I'm also not using the device screen size as much as I should be, as you can see, there are some hardcoded values like 400 or 800. Anyway, hopefully this demo would give you some faith in Flutter's graphics engine. :)

Here is another (smaller) example, showing you everything you need to get going with Canvas and Animations in Flutter. It might be easier to follow:

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
Arish Khan On

The only solution currently available is to capture the progress as image and then draw the image instead of executing the whole canvas code.

for drawing the image you can use canvas.drawImage as mentioned by pskink in the above comment.

but the solution i would recommend is to wrap the CustomPaint with RenderRepaint to convert that widget to image. for detials refer to Creating raw image from Widget or Canvas and (https://medium.com/flutter-community/export-your-widget-to-image-with-flutter-dc7ecfa6bafb for brief implementation), and have a condition to check if you are building for the first time or not.

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

This way the image will not be a part of your custom painter and let me tell you, i tried drawing image using canvas but it was not that efficient, the MemoryImage provided by flutter renders image in a much better way.