Jetpack Compose でページングによるアイテムの取得・表示を行う場合、Paging3 を利用すれば、容易に実現することができます。ただそのページングの性質によってはクラッシュの発生原因になる危険性も秘めています。本記事では注意したい使い方について紹介をします。
Paging3とLazyColumnを使っている実装でIllegalArgumentException
が発生
Paging3で取得したデータをLazyColumn
で表示する画面にてjava.lang.IllegalArgumentException: Key was already used on another item
というエラーによるクラッシュが報告されました。
原因は、、、
エラーメッセージからLazyColumn
に設定しているキーが重複していることが原因だとすぐにわかりました。
しかし、LazyColumn
のキーにはアイテムのユニークなIDを指定しており、データソース上にも重複したデータは存在していませんでした。
ではなぜキーの重複が発生したのでしょうか。原因をさらに探ると、APIのページングの仕組みと、データの更新頻度の高さが関係していることがわかりました。
LazyColumnのキーの指定について
LazyColumn
やLazyRow
といった遅延読み込みを行うコンポーネントでは、items
の引数にkey
を指定することができます。
LazyColumn {
items(
items = items,
key = { item -> item.id } // itemのidをキーとして指定
) { item ->
ItemRow(item)
}
}
キーを指定すると、Composeは各アイテムを識別し、スクロール時のパフォーマンスを最適化したり、アイテムの追加や削除、並び替えがあった場合に賢く再コンポーズを行ったりすることができます。しかし、このキーに重複した値が渡されると、どのアイテムに対応するコンポーザブルなのかを正確に判断できなくなり、IllegalArgumentException
が発生します。
API側のページングの指定方法
今回のAPIは、リクエストパラメータとして「ページの番号」と「1ページあたりのアイテム数」を指定する、一般的なオフセットベースのページング方式を採用していました。
例えば、「2ページ目の20件」を取得する場合、page=2&limit=20
のようなリクエストを送ります。
この方式はシンプルですが、頻繁に新しいデータが追加されるリストの場合に問題が発生します。
ユーザーが1ページ目を読み込み、次に2ページ目をリクエストするまでの間に新しいアイテムがリストの先頭に追加されたとします。すると、元々1ページ目の末尾にあったアイテムが2ページ目の先頭にずれてしまい、結果としてクライアントは同じアイテムを2回受け取ることになります。
これが、LazyColumn
でキーの重複が発生していた真の原因でした。
回避策
原因がわかったところで、いくつかの解決策を検討しました。
filterで重複したアイテムを取り除く❌
最初に思いついたのは、PagingData
をmap
やfilter
で加工して重複を排除することでした。
しかし、Paging3のPagingData
は、それ自体がデータのスナップショットであり、ページ単位で独立しています。そのため、ページをまたいで重複をチェックし、データストリーム全体から動的にアイテムをフィルタリングすることは困難です。PagingData
の変換処理はサポートされていますが、このようなケースでの重複排除には向いていません。
LazyColumnのキーの指定をなくす🤔
次に考えられるのは、LazyColumn
のkey
の指定を単純にやめることです。
LazyColumn {
items(
items = items
// key = { item -> item.id } // キーの指定をやめる
) { item ->
ItemRow(item)
}
}
これによりクラッシュは回避できます。しかし、キーを指定することによるパフォーマンス上のメリットを失うことになり、特にリストのアイテムが複雑な場合や、スクロール位置を正確に復元したい場合には望ましくありません。あくまで一時的な回避策であり、根本的な解決とは言えません。
API側の修正😃
最も根本的な解決策は、APIのページング方式を修正することです。
オフセットベースではなく、カーソルベース(またはキーセット)のページングを導入します。これは、最後に取得したアイテムのID(やタイムスタンプなど)を次のリクエストのパラメータに含める方式です。
例えば、「item_id=123以降の20件」を取得する場合、since_id=123&limit=20
のようなリクエストを送ります。
この方式であれば、リクエストの合間に新しいアイテムが追加されたとしても、取得するデータの範囲が明確であるため、アイテムの重複や欠落が発生しません。
まとめ
Paging3とLazyColumn
の組み合わせは非常に強力ですが、扱うデータの性質によっては思わぬ落とし穴があります。
特に、SNSのタイムラインのようにリアルタイム性が高く、頻繁にデータが更新されるリストを扱う場合は注意が必要です。
-
LazyColumn
のキー重複はIllegalArgumentException
を引き起こす。 - オフセットベースのページングは、データが頻繁に更新される場合にアイテムの重複を発生させる可能性がある。
- 根本的な解決策は、APIをカーソルベースのページングに変更すること。
Paging3を導入する際は、利用するAPIのページング方式が、扱うデータの特性に適しているかを事前に確認することが重要です。