PayPayフリマは、C to Cのフリーマーケットサービスです。キャッシュレス決済の「PayPay」で売上金を受け取ることができます。 PayPayフリマのAndroidアプリでは、おすすめ商品の表示画面といった「大量のデータを読み込む画面」でPagingライブラリを使用しています。
大きなデータを小さなデータに分割して、必要な分だけ取得する「ページング処理」を簡単に実装できる公式ライブラリが「Paging」です。そのメジャーバージョン3系が「Paging 3」と呼ばれています。これを活用すると、ユーザーは一気に大量のデータを取得する必要がなくなるため、帯域が圧迫されずに済むメリットがあります。
PayPayフリマがPaging 2からPaging 3に移行した要因は、大きく分けて3つあります。
1つ目は、Paging 3のリリースに伴いPaging 2がdeprecatedになったことです。アップデートされなくなったため、最新機能のリリースやバグフィックスといった恩恵を受けられなくなりました。
2つ目は、Paging 3からCoroutines Flowを使用できるようになったことで、コルーチンフローを用いて実装しているアプリとの親和性がとても高くなりました。PayPayフリマでもLiveDataをCoroutines Flowに移行しているため、親和性が生まれることが見込まれます。
3つ目は、データの読み込み状態をPaging 3が提供していることです。個人的に一番注目しているポイントです。これによってRepositoryやViewModelなどで読み込み状態を、独自実装により定義したり管理する必要がなくなります。それによりロジックに集中してコーディングできるようになりました。
Paging 3へ移行していなかった理由
Paging 3は2021年5月にリリースされました。すぐに移行しなかったのには、大きく分けて3つの理由があります。
1つ目は、Paging 3への移行に関する調査が十分にできていなかった点です。PayPayフリマにPaging 3をどう導入するのか、導入するとどのような影響を受けるのかの調査ができていませんでした。
2つ目は、動作が安定しているか不明であった点です。Stableかどうかではなく、Pagingのライブラリアップデート後の挙動や、仕様変更後の動作の安定性を調査できていませんでした。
3つ目は、社内の他サービスでPaging 3に移行した事例がなかったことでノウハウが少なかった点です。
移行の流れ
それではどのようにPaging 3に移行したのでしょうか。
以下の順番で進めました。
- データ取得のロジックとなるBoundaryCallbackをRemoteMediatorに移行
- データを表示させるPagedListAdapterをPagingDataAdapterに移行
- State周りの処理をloadStateFlowに移行
BoundaryCallbackをRemoteMediatorに移行する際は、継承する型を変更したり、データ取得の関数を移行するなど行います。さらにLivePagedListBuilderをPagerに置き換えます。 PagedListAdapterからPagingDataAdapterへの移行は、基本的に継承するクラスを変更するだけで済みます。
State周りの処理のloadStateFlowへの移行ですが、下図の左が今までの実装で、右が新しい実装です。
移行後はPagingDataAdapterが提供する、読み込みのloadStateFlowを使用して出し分けられるため、Stateを独自実装したり管理したりする必要がなくなりました。
移行の際に気を付けたポイント
移行の際に気をつけた点は、主に3つあります。
1つ目は、Paging 3への移行によって不具合が発生する可能性を想定し、サービスに影響が少ない画面から移行した点です。もちろんデグレードに配慮し、リリース前にチーム全員で確認するステップは経ていますが、移行やライブラリに関するノウハウの不足が原因で不具合が発生することも少なくありません。そのため、PayPayフリマでは主要な機能に影響のない画面から順次移行を進めていきました。
2つ目は、Paging 3への移行方法を共有した点です。移行の際に抜け漏れが発生したり、移行方法やPaging 3の実装が属人化しないように配慮し、既存実装と移行後の実装を対比させドキュメントにまとめました。
3つ目は、移行と並行してデータベースやStateに関する独自実装のリファクタリングを行った点です。PayPayフリマのAndroidチームでは、リリース前にチーム全体で動作確認をしています。Paging 3への移行とデータベースやStateの移行は、挙動を確認するべき範囲が同一だったので、別々にリファクタリングして挙動確認に工数をかけるよりも、効率的に負債の返済と移行を進められました。
移行の際に得られた知見
移行プロジェクトによって得た知見を3つ紹介します。
1つ目は、API無限呼び出しが発生するコードについてです。これはRemoteMediatorのload関数で起こる問題で、下方向にのみスクロールする場合に注意する必要があります。API無限呼び出しが発生するコードがこちらです。
下図は、API無限呼び出しが発生しないように、コードのハイライト部分を修正したものです。
上方向の読み込みであるLoadTypeがPREPENDである場合、これ以上読み込みをしないようにMediatorResultのSuccessを、全てのデータを回収しきったことを表すtrueで返却しています。
このコードが必要な理由は、データの読み込み順にあります。RemoteMediatorの読み込み順を見ていきます。
RemoteMediatorのload関数にログを仕込んで、API無限呼び出しが発生するコードと修正した後のコードで、どのような違いあるかを見比べます。
下図は、API無限呼び出しが発生しないよう修正したコードのログです。初期読み込みであるREFRESHが走った後、上方向への読み込みであるPREPENDが走り、その後は下方向の読み込みであるAPPENDが走っていることが分かると思います。上方向の読み込みであるPREPENDの際には、先ほど修正したコードによってAPIを呼び出すことなくリターンしているため、初期読み込みと下方向の読み込みが行われています。つまり、実装者が意図したように、内部でも初期読み込みと下方向への読み込みが行われています。
下図は、API無限呼び出しが発生するコードのログです。初期読み込みの後、上方向の読み込みであるPREPENDが行われており、下方向の読み込みであるAPPENDが行われていないことがログから分かります。API無限呼び出しが発生しないように修正したコードのログとは異なり、内部では上方向の読み込みを行っています。 さらに、スクロールしていない初期状態の判定が上方向の末端判定となっているため、RemoteMediatorがデータを見た際に末端まで到達した、つまり「データがない」と判断してAPIコールを行います。
API無限呼び出しが発生する仕組み
初期状態では、上方向の末端判定になるため、データが足りないと認識してRemoteMediatorがAPIを呼び出します。内部では「上方向の読み込み」、つまり上方向にデータが追加されていると認識しています。しかし、実装者は下方向のスクロールのみ使用する想定で組んでいるため、データは下に追加されていきます。
ここでユーザーがスクロールをしていないと、上付きのままリストが止まってしまうため、再度末端判定が行われてしまいます。
それらが循環することで、APIが無限に呼び出され、データを全て回収するまで続きます。ユーザーの帯域を圧迫するなどの悪影響を及ぼしますし、多くのユーザーが実行すると、バックエンドがダウンするなど大きな負荷を与えてしまいます。API無限呼び出しを防止するために、上記で紹介した上方向の読み込みの場合は処理をしないようにするコードを追加する必要があります。
データベース周りの独自実装
得られた知見の2つ目は、移行する上で問題になったデータベース周りの独自実装についてです。これまでAndroid版PayPayフリマの古いコードでは、Stateに関する情報と商品の情報を一緒にデータベースに保存していました。
ところが、Paging 3からはloadStateFlowを使用してStateに関する情報を取得できるため、データベースに保存する必要がなくなりました。そのため、同じ画面のStateをデータベースとloadStateFlow両方に残してしまうなど、不完全な移行をしないように対策を講じる必要があります。
これまでは、表示するデータがないことを示すかどうかのフラグをデータベースに持たせていました。データがないことを示すオブジェクトに変換し、Adapterで商品を表示するのか、もしくはデータがないことを表示するのかを出し分けていました。
こうした独自実装を残さず移行するべく、PayPayフリマでは次のように取り組みを進めました。まずは、Stateなどの表示に関わる処理をデータベースから切り離します。今回の例でいうとval empty
といった、データがないことを示すかどうかのフラグをデータベースから削除します。
次に、StateをFragmentに集約しました。下図の左側が今までの実装で、右側が新しい実装です。左側では、ItemViewHolderとStateのZeroMatchViewHolderが共存しています。そこで、他のStateのみ表示するように分離する移行を行いました。
次に実施したのは、BoundaryCallbackなどのデータ取得処理の移行です。こちらは前半でも触れましたが、表示に使うAdapterやデータを取得するBoundaryCallbackなどを移行しました。
最後に、PagingDataAdapterのloadStateFlowをもとにStateを管理する点ですが、下図ようにloadStateFlowを使用して、表示すべきStateの場合にLoadingを出す処理を記述します。
今回の例では、ItemAdapterが提供しているloadStateFlowの最新の値を取得。StateがloadStateFlowのLoadingである場合に、Loadingを見せる処理を追加しています。
このような手順であれば、誰でもデータベースにStateの処理を残すことなく、loadStateFlowに移行できます。
loadStateFlowにStateを移行するメリットと考慮すべき点
移行する上で得た知見の3つ目が、loadStateFlowに移行するメリットと考慮すべき点についてです。
メリットとしては、State周りのロジックをFragmentに集約できること、ライブラリから読み込みの状態が提供されるため、独自実装する必要がなく、ロジックに集中できることが挙げられます。
考慮すべき点は2つです。
1つ目は、複数APIの読み込み情報をもとに表示を出し分ける場合、複雑になることです。これは、2つのAdapterのStateをcombineするなどした上に、Stateをこと細かく考慮すべき必要があることに起因します。
loadStateFlowには初期読み込み、上方向の読み込み、下方向の読み込みを「CombinedLoadStates」として提供しています。読み込み状態であるloadStateには3種類あり、「NotLoading」「Loading」「Error」が提供されています。
仮に初期読み込みがLoadingであると判定するには、loadStateFlowのREFRESHがloadState.loadingであるか判別する必要があります。今回の例では、ItemAdapterのloadStateFlowのデータを取得し、初期読み込みがloadStateのLoadingである場合、というのがLoadingを表示する条件です。
今のloadStateFlowの話を踏まえた上で、1つ目の複数APIの読み込み情報を元に表示する場合、複雑になることについて解説します。複数組み合わせたロジックが必要となるため、既存のリポジトリで管理する方が分かりやすいかなど、チームごとに考慮する必要があります。
今回の例では、ItemAdapterのloadStateFlowと他のAdapterのloadStateFlowをcombineした上でItemAdapterの読み込み中である、かつ他のAdapterも読み込み中である場合にLoadingを表示するなど、こと細かな条件を定義しなければなりません。
実際には、loadStateFlowが大量にデータを流すことも考慮する必要があり、より複雑になります。
2つ目は、loadStateが大量に流れるため絞り込む工夫が必要となることです。ログを仕込むと、下図のようにLoadingの段階だけでも大量のデータが流れることが分かります。このように大量のデータが流れると、実装方法によってはStateのちらつきの原因になり得ます。
そのため、LoadTypeで「あらかじめ初期読み込みのStateのみ流れてくるように絞る」、distinctUntilChangedで「前回の値から変更がなければ値を流さないようする」といった工夫も必要です。このようにするとStateのちらつきを考慮する必要がなくなり、Stateごとの表示条件に集中してコーディングができます。
修正した後のログを見てみると、必要な情報が適切に流れてくるようになり、大量にデータが流れることによる表示のちらつきを気にする必要がなくなりました。
今回は、Android版PayPayフリマでPaging 3へ移行した方法、気をつけた点、移行する中で得られた知見を紹介しました。移行によって新機能やCoroutines Flowとの親和性、LoadStateの提供など、さまざまな恩恵を享受できています。この内容が、皆さまのプロジェクトに少しでもお役に立てれば幸いです。
アーカイブ動画
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました