🦆

【Flutter】大量のAnimationを実装する時の負荷について

2023/12/07に公開

経緯

Flutter で複数の Animation を実装する際に、どのくらいの数が限度なのか気になった為、確認してみた。

実装する Animation

シンプルに数字のテキストを左右に動かします。

final controller = AnimationController(
    duration: const Duration(seconds: 3),
    vsync: this,// with TickerProviderStateMixin
)..repeat(reverse: true);
final animation = Tween<double>(begin: 0.0, end: 100.0).animate(controller);

AnimatedBuilder(
    animation: _animations,
    builder: (context, child) {
        return Transform.translate(
            offset: Offset(_animations.value, 0),
            child: Text('1')
        );
    },
);

大量の Animation を配置する

ここで 2 種類の手法を使います。
この 2 種類ではレンダリング手法が違うため、違う挙動が見られると思われるため。

  1. ListView による実装
  2. Column による実装

まずは共通の AnimationController,Animation が必要なので、
for 文で count 個の controller,animation をリストに入れます。

※今回は同一の Animation を使っていますが、想定は異なる Animation です。

共通
final List<AnimationController> _controllers = [];
final List<Animation<double>> _animations = [];


void initState() {
    super.initState();
    for (int i = 0; i < count; i++) {
        final controller = AnimationController(
        duration: const Duration(seconds: 3),
        vsync: this,
        )..repeat(reverse: true);
        final animation = Tween<double>(begin: 0.0, end: 100.0).animate(controller);
        _controllers.add(controller);
        _animations.add(animation);
    }
}

1. ListView による実装

ListView では、現在画面上に表示されているアイテムのみがレンダリングされる。

ListView
ListView.builder(
    itemCount: _animations.length,
    itemBuilder: (context, index) {
        return AnimatedBuilder(
            animation: _animations[index],
            builder: (context, child) {
                return Transform.translate(
                    offset: Offset(_animations[index].value, 0),
                    child: Text('${index + 1}')
                );
            },
        );
    },
),

2. Column による実装

Column,SingleChildScrollView で実装すると、すべてのアイテム(画面外も含む)が一度にレンダリングされる。

List.generate()でリストを作成し、Column に配置

Colum
SingleChildScrollView(
    child: Column(
        children: List.generate(_animations.length, (index) {
            return Row(
                children: [
                    Expanded(
                        child: AnimatedBuilder(
                            animation: _animations[index],
                            builder: (context, child) {
                                return Transform.translate(
                                    offset: Offset(_animations[index].value, 0),
                                    child: Text('${index + 1}'),
                                );
                            },
                        ),
                    ),
                ],
            );
        }),
    ),
),

動作結果

ListView を使った場合は、画面内しかレンダリングされないので、表示される Animation 数により負荷が変動する。
一方、Column の場合は、一度にすべてレンダリングされるので設定した Animation 数により負荷が増す。
私の環境、M2 MacBook air では ListView ではカクつきはなく、Column の 3000 で少々カクつきました。
また、スマートフォン Pixel 6a では Column の 1000 で少々、3000 で大きなカクつきが見られました。
結論的には、多様な実行環境を踏まえて一度にレンダリングする Animation 数 の限度は 500 ぐらいではないでしょうか。

検証サンプル

実行例

↓ 下記リンクより ListView と Column で Animation 数による負荷の差を確認できます。

Many Animations Demo

追記 2023/12/9

テキストではなく画像を動かした場合の負荷も検証が必要と思い、追加で検証した。
結果的にはテキストも画像も視覚的には同様な結果となった。画像は約 500KB
また、上記の検証ページに column image を追加した。

コードサンプル

list_view_page.dart
import 'package:flutter/material.dart';
class ListViewPage extends StatefulWidget {
  final int count; // 100 or 1000 or 3000

  const ListViewPage({super.key, required this.count});
  
  ListViewPageState createState() => ListViewPageState();
}

class ListViewPageState extends State<ListViewPage> with TickerProviderStateMixin {
  final List<AnimationController> _controllers = [];
  final List<Animation<double>> _animations = [];

  
  void initState() {
    super.initState();
    for (int i = 0; i < widget.count; i++) {
      final controller = AnimationController(
        duration: const Duration(seconds: 3),
        vsync: this,
      )..repeat(reverse: true);
      final animation = Tween<double>(begin: 0.0, end: 100.0).animate(controller);
      _controllers.add(controller);
      _animations.add(animation);
    }
  }

  
  void dispose() {
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(
            'ListView:${widget.count}',
          )),
      body: ListView.builder(
        itemCount: _animations.length,
        itemBuilder: (context, index) {
          return AnimatedBuilder(
            animation: _animations[index],
            builder: (context, child) {
              return Transform.translate(
                offset: Offset(_animations[index].value, 0),
                child: Text('${index + 1}'),
              );
            },
          );
        },
      ),
    );
  }
}
column_page.dart
import 'package:flutter/material.dart';

class ColumnPage extends StatefulWidget {
  final int count; // 100 or 1000 or 3000

  const ColumnPage({super.key, required this.count});
  
  ColumnPageState createState() => ColumnPageState();
}

class ColumnPageState extends State<ColumnPage> with TickerProviderStateMixin {
  final List<AnimationController> _controllers = [];
  final List<Animation<double>> _animations = [];

  
  void initState() {
    super.initState();
    for (int i = 0; i < widget.count; i++) {
      final controller = AnimationController(
        duration: const Duration(seconds: 3),
        vsync: this,
      )..repeat(reverse: true);
      final animation = Tween<double>(begin: 0.0, end: 100.0).animate(controller);
      _controllers.add(controller);
      _animations.add(animation);
    }
  }

  
  void dispose() {
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(
            'column:${widget.count}',
          )),
      body: SingleChildScrollView(
        child: Column(
          children: List.generate(_animations.length, (index) {
            return Row(
              children: [
                Expanded(
                  child: AnimatedBuilder(
                    animation: _animations[index],
                    builder: (context, child) {
                      return Transform.translate(
                        offset: Offset(_animations[index].value, 0),
                        child: Text('${index + 1}'),
                      );
                    },
                  ),
                ),
              ],
            );
          }),
        ),
      ),
    );
  }
}

Discussion