BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Paging 3を使ったお気に入り変更機能の実装

はじめに

こんにちは!

BASE 株式会社 Customer Product Dev で Android エンジニアをしている小林です。
ショッピングアプリ「BASE」のAndroid版アプリの開発を担当しています。

最近、フォロー中タブ追加というアプリのトップ画面を大きく変えるリリースを行いました。

f:id:eijenson:20210831140416p:plain

その際、RecyclerViewの実装でPagingライブラリの3.0.0を導入してページング処理を実装してみたのでその話をしていきたいと思います。

Pagingライブラリとは?

データの追加読み込みやメモリに保持するデータ量の管理をやりやすくしてくれるライブラリです。
2021年9月時点ではPagingライブラリの最新バージョンは3.0.1になっています。

https://developer.android.com/topic/libraries/architecture/paging/v3-overview

2021年5月にメジャーバージョンが上がり、v2の時とは色々と実装方法が変わっています。

Paging 3ライブラリを使うと、以下の機能がサポートされます

  • RecyclerView Adapterと組み合わせることで、自動で次のデータを取得してくれるページング機能
  • Kotlin コルーチンやFlowを使ってのデータの取得や反映
  • リフレッシュやローディング中、エラー時のUIの切り替え

今のアプリ内のPaging処理の現状と問題点

今のアプリはScrollListenerを使って独自にページングを実装しています。
スクロール時に何件まで商品を表示したかをチェックして、ある程度読み込んだら次のページを読み込み始める形です。

この実装はJavaで書かれていてKotlin コルーチンとの相性が悪く、今のアプリの構成に合わない実装になっていました。

Pagingを使おうとした理由

上記の問題点を解消するために、現状のページング処理のKotlin化&アーキテクチャ適応も検討しました。

しかし、以下のことを考慮した結果、今回実装する新規画面にPaging 3ライブラリを導入することにしました。

  • 機能的にPaging 3ライブラリが優れていること
  • 特に、次のデータを自動で取得してくれるページング機能はコード量を大きく減らせると期待できる

実装

公式ドキュメントを参考に、ViewModelにFlowを定義して、それをFragmentで購読する形で実装していきました。

https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data?hl=ja#pagingdata-stream

ViewModel

MVVMにおけるViewModelです。AACのViewModelを継承しています。

Pagerクラスを生成して、remoteMediatorを設定します。

class ItemsViewModel(
    //...
) :ViewModel() {

    // 商品情報を取得するRepository
    @Inject
    lateinit var itemsRepository: ItemsRepository
    // お気に入り情報を取得するRepository
    @Inject
    lateinit var favoriteItemsAllRepository: FavoriteItemsRepository
    // APIで取得したリストの情報をキャッシュするDatabase
    @Inject
    lateinit var itemDatabase: ItemDatabase


    // 1画面に表示される商品数
    // pagingの先読み商品数の設定のために定義している
    private val viewIngItemNum = 15

    @OptIn(ExperimentalPagingApi::class)
    val flow = Pager(
        config = PagingConfig(pageSize = viewIngItemNum, initialLoadSize = viewIngItemNum),
        remoteMediator = ItemRemoteMediator(
            itemDatabase, itemsRepository, favoriteItemsRepository
        )
    ) {
        ItemDatabase.ItemDao().pagingSource()
    }.flow.map { pagingData ->
        pagingData.map { it.convert() }
    }.cachedIn(viewModelScope)

    //...
}

RemoteMediator

Repository経由でAPIから商品情報を取得しています。
後述するお気に入り情報の更新のために、ローカルキャッシュとしてRoomのテーブルクラスに格納しています。

色々書いていますが、やっていることは以下のとおりです

  • APIから読み込むページ数を決める
  • リフレッシュならRoomのテーブルを削除する
  • APIから取得する
  • 取得した情報をRoomのテーブルに格納する

Roomのテーブルに情報が更新されたことをViewModelのFlowが検知して、Adapterに反映されます。

@OptIn(ExperimentalPagingApi::class)
class ItemRemoteMediator(
    private val database: ItemDatabase,
    private val itemsRepository: ItemsRepository,
    private val favoriteItemsRepository: FavoriteItemsRepository
) : RemoteMediator<Int, ItemEntity>() {
    override suspend fun load(loadType: LoadType, state: PagingState<Int,ItemEntity>): MediatorResult {
        val page: Int = // 略 リフレッシュ時には1,追加読み込み時はnextKeys等の読み込むページを指定している

        try {
            // itemRepository.itemsはRxJavaのSingleで返ってくるので、await()を使ってKotlin コルーチンに変換しています。
            val result = itemsRepository.items(page).subscribeOn(Schedulers.io()).await().items
            // APIからの値をローカルDBに格納できるクラスへ変換する
            val entities = result.convertToEntity()
            entities.forEach {
                // 取得した商品がお気に入りしているかどうかを取得して設定する
                it.isFav = favoriteItemsRepository.isFavorite(it.itemId)
            }
            val endOfPaginationReached = result.isEmpty()
            database.withTransaction {
                if (loadType === LoadType.REFRESH) {
                    // PullToRefresh等で、最初から読み込む場合はRoomのテーブルの情報を消す
                    database.remoteKeysDao().clearRemoteKeys()
                    database.itemDao().clearAll()
                }
                val prevKey = if (page == 1) null else page - 1
                val nextKey = if (endOfPaginationReached) null else page + 1
                val keys = entities.map { entity ->
                    ItemRemoteKeys(repoId = entity.itemId, prevKey = prevKey, nextKey = nextKey)
                }
                // Roomのテーブルに取得した商品情報とページ数を格納する
                database.remoteKeysDao().insertAll(keys)
                database.itemDao().insertAll(entities)
            }
            return MediatorResult.Success(endOfPaginationReached)
        } catch (e: RetrofitException) {
            return MediatorResult.Error(e)
        }
    }
}

Fragment

ViewModelで設定したflowで、変更された値をAdapterへ通知します。

class ItemsFragment : Fragment(){
        lifecycleScope.launch {
            binding.viewModel?.flow?.collectLatest { pagingData ->
                //...
                // PagingDataAdapterのメソッド
                adapter.submitData(pagingData)
            }
        }
}

Adapter

PagingDataAdapterを継承していること以外は通常のRecyclerView.Adapterと実装方法は変わらないです。

ViewModel
class ItemsAdapter(
     //...
    diffCallback: DiffUtil.ItemCallback<ItemViewData>
) :
    PagingDataAdapter<ItemViewData, ItemsAdapter.ViewHolder>(diffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(ViewItemBinding.inflate(LayoutInflater.from(parent.context)), listener)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)
        holder.setData(item, position)
    }

    class ViewHolder(
        //...
    ) : RecyclerView.ViewHolder(binding.root) {
        fun setData(data: ItemViewData?, index: Int) {
            //...
        }
    }
}

// Paging 3ライブラリが、Adapter内の商品のチェックを行うための処理
object ItemComposer : DiffUtil.ItemCallback<ItemViewData>() {
    override fun areItemsTheSame(oldItem: ItemViewData, newItem: ItemViewData): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: ItemViewData, newItem: ItemViewData): Boolean {
        return oldItem == newItem
    }
}

実装してみての詰まったところ

アプリでは、ユーザが商品をお気に入りする機能があります。
他の画面でお気に入りが変更された場合、フォロー中画面の商品リストに反映する必要があります。

f:id:eijenson:20210831140458p:plain

しかし、Paging 3ライブラリでFlowを使って商品リストの反映を行っていたため、すでに取得/反映している商品の情報をAdapterやViewModelでは更新することができませんでした。
これはPaging 3ライブラリの仕様となっています。
一度リストの情報をPagingDataとして取得した後は、個々の情報の中身を取得したり変更することができません。

現在更新を可能にするissueが上がっている状態です。
https://issuetracker.google.com/issues/160232968

解決策

Roomを使ったローカルキャッシュを実装して解決をしました。

Paging 3ライブラリは中間にRoomのテーブルを挟んで実装した場合、Roomのテーブルを唯一の情報源として動作します
https://developer.android.com/topic/libraries/architecture/paging/v3-network-db?hl=ja#basic-usage

RemoteMediator 実装は、ページング データをネットワークからデータベースに読み込む際には有用ですが、データを直接 UI に読み込むためには使用できません。代わりに、アプリはデータベースを信頼できる唯一の情報源として使用します。つまり、アプリはデータベース内にキャッシュされたデータのみを表示します。PagingSource 実装(Room によって生成されたものなど)は、データベースから UI へのキャッシュ データの読み込みを処理します。

これにより、お気に入りが変更された場合に、API通信を行うこと無く情報の反映ができるようになりました。
実装では、RxBusなどで他画面からのお気に入りが変更されたことの通知を受け、changeFavを実行してテーブルの更新を行っています。

class ItemsViewModel{
    // APIで取得したリストの情報をキャッシュするDatabase
    @Inject
    lateinit var itemDatabase: ItemDatabase

    val flow = // 略

    // map = お気に入り状態を変更した商品ID=Stringと、変更後状態=Booleanが格納されている
    private fun changeFav(map: Map<String, Boolean>) {
        viewModelScope.launch {
            itemDatabase.withTransaction {
                val table = itemDatabase.itemDao()
                // 商品IDでキャッシュの情報を取得する
                table.selectByItemId(map.keys.toList())
                    .forEach {
                        val beforeFav = it.isFav
                        it.isFav = map[it.itemId] ?: false
                        if (it.isFav != beforeFav) {
                            // リスト表示用のキャッシュに、お気に入り変更を更新している
                            table.update(it)
                        }
                    }
            }
        }
    }

まとめ

Paging 3ライブラリでのリスト表示処理の実装と、他画面からの通知を受け取ったお気に入り変更処理の実装について書いてみました。

Paging 3ライブラリはリスト表示についてどんなシチュエーションでもカバーできるようになっていて、一見難しいです。
でも、使いこなせるようになれば自分で実装するよりも手間もコード量も少なく済むのでとても便利なライブラリだと思います。

おわりに

最後になりますが、BASEではAndroid/iOSのエンジニアを募集しています。
カジュアル面談もありますので、興味のある方は気軽にご連絡ください!
Android:採用情報/カジュアル面談
iOS:採用情報/カジュアル面談