Paging処理とFlutter(とpaging_viewパッケージ)
この記事はFlutter Advent Calendar 2025の12月9日分の記事です。
いわゆる無限スクロールについて書きます。
はじめに
アプリを作っていると、どこかで無限スクロールを実装する必要に迫られます。筆者の体感では、1アプリあたり1.5回程度の頻度です。
しかし、無限スクロールの実装はそこまで気楽に取り組めるものではありません。Material Componentsだけで実装を試みると、実装自体はできるものの、しっくりこない実装になってしまいがちです。また、動作確認をすると思ったように動かないなど、ハマりどころも多数存在します。
本記事では、いわゆる無限スクロールの実装のために、Paging処理を整理します。併せて、筆者が趣味で開発しているpaging_viewパッケージについて紹介することを通して、Flutterにおける柔軟なPaging処理の実装方法について説明します。
Paging API
十分に大きなデータ、もしくは無限に続くデータを扱う場合、全てのデータを一度に読み込むことは非効率的です。そのため、分割して読み込む必要があります。特にモバイル環境では、ネットワーク帯域やメモリの制約があるため、データを10〜20件ずつサーバーから取得することが一般的です。
このような処理では、分割されたデータのことを「ページ」と呼びます。各ページには、通常、固定数のアイテムと次のページを取得するための情報が含まれています。
Keyのタイプ
Paging APIのkeyは、いくつかのパターンがあります。
一番シンプルなのは、要素の順序をそのまま使うパターンです。1ページ目は要素を追加した順で最初の20、2ページ目は21〜40といった具合です。当然、keyはページ数であり整数値になります。メリットは実装が簡単な点ですが、リアルタイムで要素の追加や削除が発生するケースでは正しくページングできないデメリットがあります。とはいえ、Google検索のように、順序が変わったとしても許容されるケースでは有効です。
要素に対して一意な値をKeyとして使うケースもあります。読書記録のようなデータであれば、日時をKeyとしても良いでしょう。この場合、APIは「指定した日時よりも新しい/古いデータをN件取得する」といった形になります。一方、いわゆるSNSのタイムラインのようなケースでは、投稿日時をKeyに使うと、同じ日時に複数の投稿がある場合に正しくページングできないデメリットがあります。
これらのメリットとデメリットを整理すると、要素に対して一意なIDを与え、そのIDをKeyとして使うのが最も汎用的です。ページの取得時に、「指定したIDよりも新しい/古いデータをN件取得する」といった形とすることで、リアルタイムで要素の追加や削除が発生しても正しくページングできます。サーバー側で作成日時などの順序を保持した上で、クライアントから渡されたIDを基準に検索する必要があります。
追加取得のタイミング
Paging処理では、ユーザーがスクロールしてリストの末尾に近づいたとき、次のページを自動的に取得するケース。もしくは、末尾に次のページの取得をリクエストするボタンなどを設置し、ユーザーが明示的に次のページを取得するケースがあります。いわゆる無限スクロールは前者のケース、後者のケースは「もっと見る」ボタンのケースです。
それぞれのケースにはメリットとデメリットがあります。
自動的に次のページを取得するケースでは、ユーザーはシームレスにコンテンツを閲覧できますが、データ取得が余計に発生するかもしれません。明示的に次のページを取得するケースでは無駄なデータ通信を避けられますが、逐一ボタンをタップするのは不快かもしれません。アプリや機能の性質、想定されるユーザーの行動に応じて、適切な方法を選択することが重要です。
スクロールのパフォーマンス
ページング処理を行う必要があるのは、大量のデータがある場合です。そもそもページング処理は『大量のデータを限られたリソースの中でどう扱うか』という課題への対応そのものです。現代のモバイル端末では、数千件程度のデータであればメモリ上に保持しても大抵問題はないのですが、しかしどこかに限界があります。
大量のデータをメモリ上に保持すると、アプリケーションのパフォーマンスが低下し、最悪の場合クラッシュする可能性があります。また、無限に表示される全てのViewをメモリ上に保持すると、同様にパフォーマンスの低下やクラッシュの原因となります。これはデータの量に起因するため、いくらViewの再利用しても解決できません。
筆者の知る限り、この問題に対して最も洗練された解決策を提供しているのがAndroidのPaging 3ライブラリです。このライブラリはページごとにデータを読み込むだけでなく、メモリ上に保持する最大ページ数を制御し、それ以上のページをメモリから破棄する仕組みを持っています。さらにRemoteMediatorを使ってRoomと連携することで、破棄されたページをローカルデータベースから再取得できるようになります。この設計にすることで、ほぼ無限スクロールを実現します。[1]
また、Viewにおいてもメモリ枯渇を防ぐ必要があります。AndroidではLazyColumn、FlutterではSliverList.builderなど、大量の要素を描画できるWidgetが用意されています。これらのWidgetを適切に利用することで、スクロールパフォーマンスを維持しつつ、大量のデータを効率的に表示できます。Flutterにおける工夫は、次のzenn記事が参考になります。
なお、FlutterのListViewはCustomScrollViewとSliverListが組み合わさったWidgetです。複数のSliverを組み合わせたい場合にはCustomScrollView一択になりますが、単にリストを表示したい場合にはListViewを使う方がシンプルです。しかし、その実態に大きな違いはありません。本記事ではCustomScrollViewとSliverを中心に説明しますが、ListViewでも同様の考え方が適用できます。
paging_viewパッケージ
paging_viewパッケージは、AndroidのPaging 3っぽい実装を試してみたかった筆者が、趣味で開発しているパッケージです。基本的な考え方はAndroidのPaging 3と似ていますが、RemoteMediatorのようなローカルデータベースの実現には依存関係が増えてしまうため、あえて実装していません。v2.6.0現在、依存しているパッケージはflutterとcollectionのみです。collectionはFlutter SDKに含まれているため、実質的に外部依存はありません。
基本的な使い方
DataSourceクラスを継承し、APIに応じたページング処理を実装します。
loadメソッドには、UI操作によって発生するページングの要求が渡されます。LoadActionは次の継承クラスを持つsealed classです。
RefreshPrepend(key: Key key)Append(key: Key key)
アクションは「今回どのタイプのリクエストを行うか」と「前回のページングで取得した最後のKey」を含みます。Refreshは初回読み込みやリストの更新要求、Prependはリストの先頭方向への追加読み込み、Appendはリストの末尾方向への追加読み込みです。このDataSourceをPagingListやSliverPagingListといったWidgetに渡すことで、Widgetの状態に応じたページング処理が実行されます。
設計について
paging_viewはValueNotifierとValueListenableBuilder、SliverMainAxisGroup、そしてRenderSliverを活用したライブラリです。ここでは、特にRenderSliverを活用した部分について説明します。
SliverBoundsDetectorは「CustomScrollViewの中で描画されるようになる」タイミングでonVisibilityChangedコールバックを呼び出すSliverです。VisibilityDetectorと異なり、SliverBoundsDetectorはCustomScrollView内でcacheExtentを含む領域に入ったタイミングを検出します。このため「そろそろスクロールがリストの末端に届きそう」というタイミングでコールバックを呼び出すことができます。[2]
このSliverBoundsDetectorを活用することで、ページング処理の実装がシンプルになります。というのも、実態は単なるLeafRenderObjectWidgetとRenderSliverです。このため他のSliverと同じようにCustomScrollView内に配置でき、宣言的にスクロール時の動作を定義できます。
次のコードは、縦に1行リストを表示するSliverPagingListです。
SliverList.separatedの前後にSliverBoundsDetectorを配置しています。リストの末尾方向に向かってのリストであれば、このような実装で十分です。[3]また、発火させるかさせないかの切り替えも、配列に対するifで簡単に制御できます。
パフォーマンス
特にCustomScrollViewとSliverの組み合わせは、大量のデータを効率的に表示するための強力なツールです。paging_viewパッケージは、Flutterのこの特性を最大限に活用しています。Sliverが高速に描画できる状態であれば、paging_viewも高速に動作します。
一方、paging_viewではリストの要素をメモリ上に保持しています。AndroidのPaging 3のように、メモリ上に保持するページ数を制御し、不要なページを破棄する仕組みは現状実装されていません。大量のデータを扱う場合、この点がパフォーマンスのボトルネックになる可能性があります。
現時点の判断としては、DartのListで保持しているデータが多すぎるために問題が起きるケースは、相当稀だと考えています。また、AndroidのPaging 3のような実装をライブラリとして提供するためには、DBへの依存をpaging_viewに追加しなければなりません。ただWidgetを表示したいだけなのに、DBに関連する依存が増えるのは避けたいと考えています。[4]
現実的には、アプリケーション側で「データそのものはdriftに保存し、paging_viewのDataSourceではIDだけ保持する」といった工夫で回避できると考えています。もしもpaging_viewでメモリ上のデータ量が問題になるケースがあれば、気軽にGitHub Discussionsで相談してください。
CustomScrollView.centerプロパティの活用
下方向への読み込みに対して、上方向への読み込みは面倒さが数倍になります。特に、「読み込み前の位置にスクロール位置を固定したまま、その上に要素を追加する」動作を実装するのは大変です。
この動きはCustomScrollViewのcenterプロパティを活用することで実現できます。しかしcenterプロパティには、いくつかの制限があります。例えば、以下のような点です。
-
centerが指定されたSliverの上の要素の上下(左右)が反転する -
CustomScrollViewにcenterを指定すると同一のkeyを持つSliverが存在しなければならない -
centerプロパティと対になるSliverはCustomScrollViewの直下に存在しなければならない
paging_viewパッケージでは、CenterPagingListと名付けたWidgetで、これらの制限を吸収しています。
追加読み込みの制御については、先述のSliverBoundsDetectorを活用しています。このWidgetは任意のSliverの前後に配置するだけで、スクロール位置に応じたコールバックをセットできるため、複数のSliverListを組み合わせることができます。
centerプロパティを活用すると、slivers配列が変化したときに、centerプロパティに指定されたSliverの位置が維持されます。これを利用し、位置を固定したいSliver(Center用)とPrepend用とAppend用のSliverを分離します。あとはPrependの処理が実行されたら、Prepend用のSliverを更新すれば良いわけです。あらかじめPrependとCenter、Append用にList<Page>を用意することで、この処理はシンプルに実装できます。
工夫点としては、AppendやPrependの処理がリクエストされたときに、従来のPrependやAppend用のPageをCenter用のPageにマージする点です。これにより、複数回のAppendやPrependが連続で発生した場合でも、Center用のPageが最新の状態に保たれます。また、このPrependからCenterへのマージ処理と、新たに取得したPrepend用のPageの追加処理は、別のframeで実行される必要があります。現時点では読み込み処理をasync処理にしているため、ValueNotifierの更新を読み込み処理の前後に分けることで、この要件を吸収しています。
なお、現時点ではGridに対応していません。また、Groupedにも対応できていません。もしも気になる方がいれば、IssueやPull Requestを歓迎します。書いてみると、シンプルな実装になるものの、思った以上に手間がかかります。
おわりに
この記事がきっかけになり、Paging処理に興味を持ってもらえたら嬉しいです。というか、ぜひAndroidのPaging 3のコードを読んでみてほしいです。非常に洗練された設計がなされており、学ぶことが多いと思います。筆者はAndroid Jetpackの中でPagingが一番好きで、paging_viewパッケージを実装しましたが、Paging 3の凄さは全く模倣できていないと思っています。
今回、記事を書くのに合わせて複数の機能を追加しました。読み込み時にエラーが起きた時リストの状態を保ったままSnackBarを表示できたり、データの再取得なしに未読バッチを更新できたりします。READMEに実装例を追加したので、ぜひ参考にしてください。また、動作に興味を持った方は、サンプルアプリをGitHub Pagesで公開しているので、そちらもご覧ください。
デモはこちら。ブラウザで動きます。Flutter Web最高。
Discussion