effidevFlutter・Cloudflareエッジ・クラウドコスト最適化
日本語

Flutter CustomPainter と Canvas API で実現する 60fps 超高速 GPU グラフィック アニメーション構築

Flutter CustomPainter Canvas API GPU High Performance Animation

Flutter CustomPainter & Canvas API: 60fps 超高速 GPU グラフィックアニメーション完全攻略

Flutter でリアルタイムオーディオビジュアライザー、グラフィックチャート、パーティクルエフェクトなどを実装する際、大量の Widget を生成すると CPU ボトルネックによるフレームドロップ (Jank) が発生します。

この性能限界を打破する強力な手法が CustomPainterCanvas API です。Flutter の最新 Impeller GPU レンダリングエンジンと組み合わせることで、CPU 負荷を最小限に抑え、GPU レベルで 60fps / 120fps の滑らかなグラフィックを描画できます。

本ガイドでは、CustomPainter のパイプライン、RepaintBoundary によるレイヤー隔離、および実戦的な 60fps パーティクルアニメーションコードを解説します。


1. Widget トリー vs CustomPainter レンダリング比較

比較項目 標準 Widget 構成 CustomPainter & Canvas API
パイプライン Widget Tree ➡️ RenderObject ➡️ Layer 分割 単一 RenderCustomPaint ➡️ Direct Canvas 描画
1,000要素描画 1,000個の Element/RenderObject 生成 1つの Painter 内のループで GPU 描画
CPU / GPU 負荷 CPU のレイアウト計算・構築コスト大 高速 GPU ラスタライズ
適した用途 フォーム、リスト、静的 UI パーティクル、リアルタイムグラフ、ゲーム UI

2. 60fps アニメーション実現のための3大最適化原則

原則 1: shouldRepaint の厳密な判定

shouldRepaint(covariant CustomPainter oldDelegate) メソッドでプロパティの変化を正確にチェックし、不要な再描画を回避します。

原則 2: RepaintBoundary による描画隔離

CustomPaintRepaintBoundary で囲むことで、周囲の静的 UI レイヤーと描画層を分離し、全画面への再描画波及を防ぎます。

RepaintBoundary(
  child: CustomPaint(
    painter: ParticlePainter(particles: _particles),
    size: Size.infinite,
  ),
)

原則 3: AnimationControllerCustomPainterrepaint パラメータに渡す

setState() を呼び出してウィジェットツリー全体を再構築する代わりに、CustomPainter のコンストラクタが持つ repaint: Listenable パラメータに AnimationController を直接接続します。super(repaint: repaint) に渡した Listenable が値の変化を検知するたびに自動で paint() を呼び出してくれるため、リビルドなしでキャンバスレイヤーだけが毎フレーム再描画されます(なお CustomPaintwillChange はラスターキャッシュの有無を伝えるだけの単純な bool ヒントであり、Listenable を受け取るパラメータではありません)。


3. 実戦 60fps GPU パーティクルアニメーションコード

150個のパーティクルを GPU 描画する完全な Flutter サンプルコードです。

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

class Particle {
  double x, y, dx, dy, radius;
  Color color;

  Particle({
    required this.x,
    required this.y,
    required this.dx,
    required this.dy,
    required this.radius,
    required this.color,
  });

  void update(Size size) {
    x += dx;
    y += dy;
    if (x < 0 || x > size.width) dx = -dx;
    if (y < 0 || y > size.height) dy = -dy;
  }
}

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

  @override
  State<ParticleCanvasScreen> createState() => _ParticleCanvasScreenState();
}

class _ParticleCanvasScreenState extends State<ParticleCanvasScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<Particle> _particles = [];
  final Random _random = Random();

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

    for (int i = 0; i < 150; i++) {
      _particles.add(Particle(
        x: _random.nextDouble() * 400,
        y: _random.nextDouble() * 800,
        dx: (_random.nextDouble() - 0.5) * 3,
        dy: (_random.nextDouble() - 0.5) * 3,
        radius: _random.nextDouble() * 4 + 2,
        color: Colors.indigoAccent.withValues(alpha: _random.nextDouble() * 0.7 + 0.3),
      ));
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0F172A),
      body: RepaintBoundary(
        child: CustomPaint(
          painter: ParticlePainter(particles: _particles, repaint: _controller),
          size: Size.infinite,
        ),
      ),
    );
  }
}

class ParticlePainter extends CustomPainter {
  final List<Particle> particles;

  ParticlePainter({required this.particles, required Listenable repaint})
      : super(repaint: repaint);

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

    for (var particle in particles) {
      particle.update(size);
      paint.color = particle.color;
      canvas.drawCircle(Offset(particle.x, particle.y), particle.radius, paint);
    }
  }

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

4. まとめ

  1. 大量の動的グラフィック描画には CustomPainterCanvas API を採用する。
  2. 描画レイヤーを RepaintBoundary で分離し、不要な再描画を防ぐ。
  3. Flutter DevTools でフレーム描画時間が 16.6ms 以内に収まっているか確認する。