こんにちは。 AndroidとiOSといろいろやっている若宮(id:D_R_1009)です。
西日本では記録的な早い梅雨入りな一方、会社のある関東ではだいぶ遅れての梅雨入りとなりました。 部屋にこもってAppleやGoogle、Microsoftのカンファレンスを見ることで時間は潰せるのですが、ちょっと英語に圧倒されつつあります。
さて、Studyplus Androidアプリでは5月17日リリースの v7.0.10
においてPaging 3に更新したアプリをリリースしました。
リリースから1月以上経ったので、Paging 3の紹介と知見を書きます。
Pagingライブラリとは
Pagingライブラリは大量のデータを扱う際に、メモリ使用量の管理や追加読み込み処理を手助けしてくれるライブラリです。 RecyclerViewと相性が良く、タイムラインのようなリストで力を発揮します。
2021年5月にv3がリリースされました。
v2はJavaでしたが、v3から全てKotlinで書かれています。
このためAndroid Jetpackでは珍しく、 -ktx
ライブラリがありません。
StudyplusアプリとPagingライブラリ
Studyplusアプリでは、バージョン v4.14.3
にてPagingライブラリ v1.0.1
を導入し始めました。
v4.14.3
は2018年11月のリリースとなるため、既に2年半以上利用していることになります。
導入後はタイムラインだけではなく、ページネーションされているAPIに利用しているため、現時点では10を超える箇所で利用するようになっています。
ページネーションされたAPIの場合、リストの終端を表示したイベントをキーにして、データの追加読み込みを行わないといけません。 構造上、この処理はUIのスクロールイベントをキーとして、APIリクエストを行う必要があります。 Pagingライブラリを導入することで、「リストの終端判定」をライブラリが適切に隠蔽かつ処理するようになります。 UIとバックグランド処理を適切に隠蔽してくれるため、導入すればするほど利用したくなるライブラリです。
Paging 2からPaging 3への更新
先ほども紹介しましたが、2021年の5月にPagingライブラリのv3がリリースされました。 v2の最終リリースは2020年3月なので、1年ぶりの大更新です。
変わることと変わらないこと
v3の大きな特徴の1つに、Kotlin Coroutinesの採用があります。
class ExamplePagingSource( val backend: ExampleBackendService, val query: String, ) : PagingSource<Int, User>() { override suspend fun load( params: LoadParams<Int>, ): LoadResult<Int, User> { try { // Start refresh at page 1 if undefined. val nextPageNumber = params.key ?: 1 val response = backend.searchUsers(query, nextPageNumber) return LoadResult.Page( data = response.users, prevKey = null, // Only paging forward. nextKey = response.nextPageNumber, ) } catch (e: Exception) { // Handle errors in this block and return LoadResult.Error if it is an // expected error (such as a network failure). } } ~~ }
suspend fun load
であるため、このメソッドの中からは suspend
を付けた関数を呼び出すことができます。
このため、RetrofitやApolloを利用している場合、APIへのリクエストをCoroutinesで書きやすくなりました。
ライブラリの中を追っていくと、処理しているのは(デフォルトでは) Dispatchers.IO
のため、スイッチングコストも気になりませんね。
一方で、View側には影響が出ないように工夫されています。
androidx.paging.PagedListAdapter
は androidx.paging.PagingDataAdapter
に、import元を更新すればほぼ問題ありません。
変更が必要なのは、Adapterに対してデータを追加するActivityやFragment、そしてViewModelです。
LiveDataで更新する
Pagingライブラリから取得されるデータは、Flowになります。 このFlowをViewModelでLiveDataに変更すると、UI層の処理はほぼ変更がありません。
例えば、次のように TimelineRepository
から任意のタイムラインを取得するケースがあるとします。
feed()
にて Flow<List<Feed>>
を取得できるとします。
この時、 asLiveData()
を利用すると、下記のような処理になります。
class TimelineViewModel @Inject constructor( repository: TimelineRepository, ): ViewModel() { val timeline: LiveData<PagingData<Feed>> = repository.feed().cachedIn(viewModelScope).asLiveData() }
class TimelineFragment: DaggerFragment(R.layout.timeline) { @Inject lateinit var factory: ViewModelFactory<TimelineViewModel> private val viewModel by viewModels<TimelineViewModel> { factory } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = FragmentTimelineBinding.bind(view) val adapter = TimelineAdapter() binding.recyclerView.adapter = adapter viewModel.timeline.observe(viewLifecycleOwner) { // lifecycleが引数になるため、Fragmentの場合はviewLifecycleOwnerのlifecycle adapter.submitData(viewLifecycleOwner.lifecycle, it) } } }
submitData
に Lifecycle
を引数にする箇所は増えますが、それ以外はほぼ変更なく移行できます。
この対応にするとLiveDataが残るものの、コード上の差分を抑えてチェックできます。
Flowで更新する
次に、ViewModelでLiveDataに変換しないケースです。
class TimelineViewModel @Inject constructor( repository: TimelineRepository, ): ViewModel() { val timeline: Flow<PagingData<Feed>> = repository.feed().cachedIn(viewModelScope) }
class TimelineFragment: DaggerFragment(R.layout.timeline) { @Inject lateinit var factory: ViewModelFactory<TimelineViewModel> private val viewModel by viewModels<TimelineViewModel> { factory } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = FragmentTimelineBinding.bind(view) val adapter = TimelineAdapter() binding.recyclerView.adapter = adapter // Fragmentで呼び出す場合はviewLifecycleOwnerを利用する viewLifecycleOwner.lifecycleScope.launch { viewModel.timeline.collectLatest { pagingData -> adapter.submitData(pagingData) } } } }
Fragmentの中で viewLifecycleOwner.lifecycleScope.launch
を呼び出している以外は、LiveDataの実装と変わりません。
ActivityやFragmentの中でKotlin Coroutinesをどのように利用するか、既に議論が済んでいるチームであれば、採用できますね。
Room DBを利用している場合
v2において PagedList.BoundaryCallback
を利用している場合は、変更点が大きくなります。
実際にマイグレーションを行った際、気になったのは下記3点です。
- DB操作時に利用するExecutorを明示的に指定しなくて良くなる
- 実装量が大幅に削減される
ExperimentalPagingApi
アノテーションが必要になる
1はKotlin Coroutinesを利用した恩恵です。 このため、トランザクション処理を記述する際に複数のメソッドを余計に定義しなければならない状況を解決できます。 また、Kotlin Coroutines上で処理が完結するため、処理が高速になることが期待されます。
実装は PagedList.BoundaryCallback
で分割されていたメソッドが RemoteMediator
では1つにまとめられました。
初回のデータ読み込みとリストの最初に追加する読み込み、最後に追加する読み込みを最低限の分岐だけで記述できるようになります。
2021年6月のサンプルコードを一部省略の上で抜粋してみます。
LoadType
により処理を分岐することで、シンプルに処理をまとめているのがわかりますね。
@OptIn(ExperimentalPagingApi::class) class ExampleRemoteMediator( private val query: String, private val database: RoomDb, private val networkService: ExampleBackendService, ) : RemoteMediator<Int, User>() { val userDao = database.userDao() val remoteKeyDao = database.remoteKeyDao() override suspend fun load( loadType: LoadType, state: PagingState<Int, User>, ): MediatorResult { return try { val loadKey = when (loadType) { LoadType.REFRESH -> null LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) // Query remoteKeyDao for the next RemoteKey. LoadType.APPEND -> { val remoteKey = database.withTransaction { remoteKeyDao.remoteKeyByQuery(query) } if (remoteKey.nextKey == null) { return MediatorResult.Success(endOfPaginationReached = true) } remoteKey.nextKey } } // Suspending network load via Retrofit. This doesn't need to be wrapped in a // withContext(Dispatcher.IO) { ... } block since Retrofit's Coroutine CallAdapter // dispatches on a worker thread. val response = networkService.searchUsers(query, loadKey) // Store loaded data, and next key in transaction, so that they're always consistent database.withTransaction { if (loadType == LoadType.REFRESH) { remoteKeyDao.deleteByQuery(query) userDao.deleteByQuery(query) } // Update RemoteKey for this query. remoteKeyDao.insertOrReplace(RemoteKey(query, response.nextKey)) // Insert new users into database, which invalidates the current // PagingData, allowing Paging to present the updates in the DB. userDao.insertAll(response.users) } MediatorResult.Success(endOfPaginationReached = response.nextKey == null) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { MediatorResult.Error(e) } } }
おわりに
Paging 3に更新したことで、プロダクトとして嬉しかったことをまとめます。
Kotlin 100%
Paging 3ライブラリに更新することで PagingRequestHelper
クラスを削除できます。
PagingRequestHelper
は PagedList.BoundaryCallback
のヘルパークラスです。
// THIS class is likely to be moved into the library in a future release. Feel free to copy it // from this sample.
と記載されていたように、Paging 2で PagedList.BoundaryCallback
の利用を助けるクラスです。
ご覧の通りJavaで書かれており、それなりに複雑な処理を行っているため、Kotlinに書き換えることが難しい状態にありました。
Studyplus Androidアプリでは、2020年6月ごろにKotlin化をほぼ完了し、残りは PagingRequestHelper
だけとなっていました。
上記ブログのように2019年9月末に86%程度だったKotlin率は、2020年6月頭に99%となり、その後はKotlinのみ増減している状態になります。 1ファイルのみ、Paging 2のutilクラスをJavaで利用しているので、Paging 3のリリースと同時に100%となる見込みです。 リリースが待ち遠しい……!
今回、Paging 3がリリースされたことにより、満を持してアプリのコードからJavaファイルを削除し切ることができました。 2018年の3月からKotlinへの置き換えを始めていたので、まる3年かかった計算になります。 Paging 3がKotlinで書かれたことで、導入するアプリ側もKotlinにしやすくなっています。
Jetpack Compose
Paging 3にはJetpack Compose用の拡張が用意されています。 Pagingライブラリを利用するような大量のデータを表示する仕組みと、宣言的なUIは、待ち望んでいた組み合わせと言えます。
またPagingライブラリを利用すると、Repository層からUIに向けてデータが一方的に流れてくる仕組みとなります。 このためPagingライブラリを利用しておくと、Composeを導入する前からUIとデータの更新処理を分離しておくことができます。
既存のViewの仕組みをそのままComposeに移行できるので、移行の予定があってもPagingライブラリに対して懸念はありません。
サンプルコードも既に用意されているため、機会があれば導入する予定です。
まとめ
Paging 3ライブラリは、個人的に強く推奨しているJetpackのライブラリです。 これまではUIとロジックの分離に、これからはComposeへのデータソースとして。 アプリのアーキテクチャに合わせて、色々なところで活躍してくれるでしょう。 これからもバンバン使っていきたいですね!