【Flutter】RepaintBoundaryで不要な再描画を防ぐ

  • 2025年2月16日
  • 2025年2月16日
  • Widget

対象者

  • Flutterを使用してUIのパフォーマンスを最適化したい開発者
  • アニメーションやスクロールのスムーズさを向上させたいエンジニア
  • RepaintBoundaryの効果的な使用方法を理解し、適切に適用したい方

はじめに

FlutterでUIを構築する際、パフォーマンスの最適化は重要な要素です。特にアニメーションや頻繁に更新されるウィジェットを含む複雑なUIでは、不要な再描画によるパフォーマンスの低下が顕著になります。

Flutterの描画システムでは、ウィジェットツリーに変更があると、そのウィジェットと子孫ウィジェットが再描画されます。しかし、一部のウィジェットの変更だけでもツリー全体が再描画されると、無駄な処理が発生し、パフォーマンスに影響を与えます。

この問題を解決するためにFlutterには RepaintBoundary というウィジェットが用意されています。本記事では RepaintBoundary の仕組みと活用方法について解説します。

RepaintBoundaryとは

RepaintBoundary は、その子ウィジェットとそのサブツリーを、親ウィジェットの更新による不要な再描画から分離するウィジェットです。

仕組み

  • RepaintBoundary で囲まれたウィジェットは 独立したレイヤーとして扱われる
  • 内部のウィジェットが更新されても 外部には影響しない
  • 外部が更新されても 内部のウィジェットは再描画されない
  • 再描画範囲を限定することでパフォーマンス向上 が期待できる。

RepaintBoundaryのユースケース

RepaintBoundary は以下のような場合に特に効果的です。

1. 複雑なアニメーションの最適化

アニメーションを含むウィジェットを RepaintBoundary で囲むことで、アニメーションによる再描画を分離し、他のウィジェットへの影響を最小限にできます。

2. スクロールパフォーマンスの向上

ListViewGridView などのスクロール可能なリストでは、スクロール中に多くのウィジェットが再描画されます。RepaintBoundary を使用することで、リスト全体の不要な再描画を防ぐことができます。

3. インタラクティブなウィジェットの最適化

スライダーやボタン、チェックボックスなどの頻繁に更新されるウィジェットは RepaintBoundary で囲むことで、周囲のUIへの影響を減らせます。

4. 頻繁に更新されるウィジェット

時計やプログレスバーのような頻繁に更新されるウィジェットに RepaintBoundary を適用すると、再描画の負荷を軽減できます。

RepaintBoundaryの注意点

1. 過剰な使用は避ける

RepaintBoundary は、新しいレイヤーを作成するため、メモリ使用量が増加します。必要以上に使用すると、逆にパフォーマンスが低下する可能性があります。

2. パフォーマンスオーバーレイで確認

Flutterの Performance Overlay を有効にすることで、RepaintBoundary が適切に機能しているかどうかを可視化できます。

3. 適切な場所に配置

頻繁に更新されるウィジェットやアニメーションを含むウィジェットに RepaintBoundary を配置すると、再描画の負荷を最適化できます。

4. デバッグフラグ debugRepaintRainbowEnabled を活用

再描画の範囲を可視化するには、debugRepaintRainbowEnabledtrue に設定します。

void main() {
  debugRepaintRainbowEnabled = true;
  runApp(MyApp());
}

ベストプラクティス

RepaintBoundaryの使用は控えめに

  • RepaintBoundary 自体が若干のオーバーヘッドを追加するため、多用すると逆効果になる可能性があります。
  • 常にパフォーマンスオーバーレイと再描画レインボーを使用して、不要な再描画が発生している箇所を特定しましょう。

静的または独立したセクションをラップ

  • 頻繁に再描画する必要のないUIの部分を RepaintBoundary でラップすると効果的です。

RepaintBoundaryのコストに注意

  • RepaintBoundary はパフォーマンスを向上させる一方で、新たなレイヤーを作成するため、CPUとメモリのオーバーヘッドが発生します。
  • 複雑なアニメーションや頻繁に変更されるウィジェットの分離 など、明確なメリットがある場所に限定して使用しましょう。
  • 過剰に使用すると、境界が蓄積され、パフォーマンスの利点が相殺される 可能性があります。
  • Performance Overlay などのツールを活用し、影響を監視・テストすることを推奨します。

RepaintBoundaryの実装例

このコードでは、RepaintBoundary を使用することで アニメーションのパフォーマンスをどのように最適化できるか を確認できます。
具体的には、以下の点を検証できます:

  • RepaintBoundary なし の場合、アニメーションの影響が親ウィジェット (Scaffold) に及び、不要な再描画が発生すること。
  • RepaintBoundary あり の場合、アニメーションが個別のレイヤーで処理され、親ウィジェットの不要な再描画を防ぐことができること。
  • debugRepaintRainbowEnabled を利用し、どの部分が再描画されているのかを視覚的に確認 できること。

この実験を通じて、Flutter における RepaintBoundary の実際の効果と適用すべき場面を理解できる ようになります。

RepaintBoundary の適用箇所

このコードでは RollingDash (回転するアイコン) に RepaintBoundary を適用しています。

final isAvailable = _counter % 2 == 1;
return isAvailable
    ? RepaintBoundary(
        child: RollingDash(
          angele: angle,
        ),
      )
    : RollingDash(angele: angle);
  • _counter の値によって RepaintBoundary を適用するかどうかを切り替える
  • isAvailable == true のときは RepaintBoundaryRollingDash を囲む
  • isAvailable == false のときは RollingDash をそのまま描画

目的:

  • RepaintBoundary を適用した場合としなかった場合の違いを確認できるようにする
  • RepaintBoundary を適用することで、親 (Scaffold) の再描画を防ぐことができる

RollingDash のアニメーション**

class RollingDash extends StatelessWidget {
  const RollingDash({
    super.key,
    required this.angele,
  });

  final double angele;

  @override
  Widget build(BuildContext context) {
    return Transform.rotate(
      angle: angele,
      child: Icon(
        Icons.flutter_dash,
        size: 128,
      ),
    );
  }
}
  • _controller.value に基づいて Icons.flutter_dash を回転
  • Transform.rotate(angle: angele) を使用し、angele の値に応じて回転角度を変更

ポイント:

  • RollingDashAnimatedBuilderbuilder 内で回転する
  • この回転が原因で再描画が発生する
  • そのため、RepaintBoundary でラップすることで、影響範囲を限定できる

debugRepaintRainbowEnabled を活用

このコードでは、Flutter の 再描画デバッグ機能 を利用しています。

void main() {
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}
  • 再描画されるウィジェットを可視化 できる
  • RepaintBoundary の効果を検証可能
  • RollingDash のアニメーション中に、 どの部分が再描画されているかが色付きで表示される

デバッグ方法

  1. debugRepaintRainbowEnabled = true; を有効化
  2. RepaintBoundarychildRollingDash を設定
  3. isAvailable を切り替えて、 再描画範囲の違い を確認

RepaintBoundary の効果を検証**

RepaintBoundary を適用しない場合

  • RollingDash のアニメーションにより、 親ウィジェット (Scaffold) まで再描画される
  • 不要な再描画が発生し、フレームレートが低下する可能性あり

RepaintBoundary を適用した場合

  • RollingDash のアニメーションが 個別のレイヤーで処理される
  • 親 (Scaffold) や他の UI 部分には影響を与えない
  • パフォーマンスが向上し、スムーズなアニメーションを実現

まとめ

RepaintBoundary は、Flutterアプリのパフォーマンス最適化に役立つ強力なツールです。ただし、過剰な使用はメモリ消費を増やすため、適切な場所に適用することが重要です。パフォーマンスオーバーレイを使用して最適な適用箇所を特定し、よりスムーズなUIを実現しましょう!

参考

ソース(main.dartにコピペして動作確認用)

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

import 'package:flutter/rendering.dart';

void main() {
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      showPerformanceOverlay: true,
      checkerboardRasterCacheImages: true,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  late final _controller = AnimationController(
      vsync: this,
      duration: const Duration(
        milliseconds: 1000,
      ))
    ..repeat();

  @override
  Widget build(BuildContext context) {
    final isAvailable = _counter % 2 == 1;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            final angle = _controller.value * 2 * math.pi;
            return Center(
              child: isAvailable
                  ? RepaintBoundary(
                      child: RollingDash(
                      angele: angle,
                    ))
                  : RollingDash(angele: angle),
            );
          }),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: const Icon(Icons.add),
      ),
    );
  }
}

class RollingDash extends StatelessWidget {
  const RollingDash({
    super.key,
    required this.angele,
  });

  final double angele;

  @override
  Widget build(BuildContext context) {
    return Transform.rotate(
      angle: angele,
      child: Icon(
        Icons.flutter_dash,
        size: 128,
      ),
    );
  }
}