はじめに
正直に言うと、YOUTRUSTの画面は一覧表示がほとんどですよね。しかし、それは良くある事で、どんなアプリでもリスト表示は欠かせません。FlutterのListView.builder
は、アイテム数が多いときこそパフォーマンス:zap:を発揮する重要なWidgetです。多くのFlutterエンジニア🧑💻👩💻が毎日のように使っていますが、読者のみなさんはどこまでご存知でしょうか?
今回は、ほとんど知られていない変わった挙動の解説や、実践的な改善方法をご紹介します。まずは、チーズ牛丼を準備してから学びを深めましょう!🍚🧀🐮
美味しいリスト
このテーマをより分かりやすく伝えるため、今回は特に多数のサンプルを用意しました。以下のデモでは、ListView.builder
で牛丼アイテムを並べています🍜。各アイテムの状態を確認できるように、牛丼は2秒⏱️後にチーズ牛丼に変化し、🧀🐮その際にインデックス🔢番号が表示されます。🔍
牛丼アイテムコードこちらで見られます
class LoadingListItem extends StatefulWidget { final int index; const LoadingListItem({super.key, required this.index}); @override State<LoadingListItem> createState() => _LoadingListItemState(); } class _LoadingListItemState extends State<LoadingListItem> { String? _result; late Future<String> _loader; @override void initState() { super.initState(); _loader = Future.delayed( const Duration(seconds: 2), () => widget.index.toString(), )..then((value) { _result = value; }); } @override Widget build(BuildContext context) { return Center( child: SizedBox.square( dimension: 100, child: Center( child: FutureBuilder<String>( initialData: _result, future: _loader, builder: (BuildContext context, AsyncSnapshot<String> snapshot) => BowlItem( hasCheeseData: snapshot.hasData, label: snapshot.data ?? '', ), ), ), ), ); } }
その挙動を確かめるために、ここで実際にFlutterアプリを触ってみましょう!!
一見すると問題なさそうですが、アイテムが画面外へスクロールアウトし、再度表示域に戻ると、そのアイテムはリビルドされます。🔄そのため、すでにチーズ牛丼になっていたアイテムも再び牛丼→チーズ牛丼に遷移してしまいます。🧀🐮
ここまでは予想どおりかもしれませんが、実際にあるチーズ牛丼アイテムを削除してみると…💣 削除したアイテムの下にあるすべてのアイテムがまとめてリビルドされ、チーズ牛丼の状態がリセットされてしまいました?なぜでしょうか?この現象を解明するために、牛エンジニア🐮・カウさんをお招きしました。✨

お招きいただき、ありがとうございます。🙏それでは、この現象の原因を説明します。💡
Flutterはパフォーマンス向上のため、既存のWidgetを可能な限り再利用します。
しかし、リスト内の順序が入れ替わると、Flutterは「このWidgetがどのデータに対応しているのか」を判別できなくなります。
その結果、新しいWidgetを再構築し、以前の状態がリセットされてしまうのです。これがリビルドによる状態消失の原因です。💥

なるほど、少し複雑ですね…🤔 すべてのアイテムの状態を永続的に保持するようにしたらどうでしょうか?📂 その場合、順序が入れ替わっても状態は維持されるため、リビルドによる状態リセットを防げると思います。🚀
全てのアイテムの状態を保存する実験
Riverpodユーザーの皆さんは、KeepAliveをご存知かもしれませんが、Flutter標準の仕組みで同様の効果を得る方法があります。✨AutomaticKeepAliveClientMixinをStatefulWidgetに適用すると、Widgetがスクロールアウトしても破棄されず、常に生存状態を維持できます。🛡️

AutomaticKeepAliveClientMixinですね!実装してみます。まず、アイテムのコードを変更します
class MyItemWidget extends StatefulWidget { // … } class _MyItemWidgetState extends State<MyItemWidget> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; // これで状態を保持 @override Widget build(BuildContext context) { super.build(context); // mixin を使う場合は必須 // Widgetの中身 } }
結果

本当だ、スクロールアウトしても破棄されないですね!
findChildIndexCallback

どうして…?アイテムを削除すると、状態を変更していないのにリビルドされてしまいます。本当にどうすればいいですか??

困りましたね…😣 アイテムが自分の状態を持っていても、`ListView`がアイテムの順番を知らないので、リビルドされてしまっていますね。🔄 そこで、今回紹介するfindChildIndexCallbackで順番を制御してみましょう!🔑

それは何ですか?

このコールバックは、Widgetの順序が変更されたときに呼び出されます。🔄指定しない場合、`ListView`はWidgetと対応する RenderObject を正しく紐付けられず、状態が失われる可能性があります。⚠️コールバックには`Key`を渡し、そのキーを持つ子要素の新しいインデックスを返してください。該当する要素がない場合は`null`を返します。🚫
こちらの形で
ListView.builder( scrollDirection: Axis.vertical, findChildIndexCallback: (key) => key is ValueKey<int> ? _items.indexOf(key.value) : null, itemCount: _items.length, itemBuilder: (context, index) { return LoadingListItem( key: ValueKey(_items[index]), index: _items[index], onDelete: () { deleteItem(index); }, ); }, )
このような結果になりました。🎉比較のために、KeepAliveを使用した場合と使用しない場合の実装例をそれぞれご用意しました。🔧
実際のユースケース

やった!いいですね✨。これにより、長いリストでもパフォーマンスが向上し、すでにロードされたアイテムが再度リビルドされなくなります。👍
最後に、これはどういう時に使えますか?

色々試してみました! 基本的に、リストでアイテムを追加・削除・順序変更する場合には、findChildIndexCallbackを使うのが有効だと思います
最後に
これで本記事は終了です。🎉技術的な内容が中心でしたが、楽しみながらfindChildIndexCallback
について学んでいただけていれば幸いです。😊
個人開発でリスト周りの挙動に非常に困った経験があるため、本記事が皆さまのお役に立てれば嬉しいです。🙏
僕はこれからチーズ牛丼を食べに行きますが、YOUTRUSTではエンジニアを募集しています。興味のある方はぜひご応募ください!✨