dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

【クラシルAndroid】 ページング基盤を実装する

f:id:meilcli:20220323165527j:plain

こんにちは、クラシルAndroidエンジニアの@MeilCliです。先日ページングの基盤を実装したので紹介します

なぜページングの基盤を実装することになったのか

クラシルAndroidにはもともとFeedListContainerというページングに関する実装がありました。インターフェースとして表現するとUI Layerからは以下のような見た目です

interface FeedListContainer<TId, TValue> {

    fun getUpdateFlowable(): Flowable<FeedState<TId, TValue>>

    fun getErrorFlowable(): Flowable<Throwable>

    fun requestNext()

    fun requestRefresh()

    fun restore(state: FeedState<TId, TValue>)
}

ページングリスト本体となるインスタンスはUI Layer側のStateとして保持し、Domain Layer側が提供するFeedListContainerがページング後のStateをUI Layerに提供するという形式です

ここで、先日投稿したクラシルAndroidの次世代アーキテクチャー設計を御覧ください

tech.dely.jp

先日紹介したクラシルAndroidのこれからのアーキテクチャ設計では、データの加工などの処理はDomain LayerのUseCaseが担当することになっています。現状のFeedListContainerではDomain Layerが公開したFeedListContainerをUI Layerが参照し、FeedStateの更新をUI Layer側で観測するという形になっています。そのため、FeedStateの加工をしたい場合にはUI Layerで行うのが慣習化していました

そこで、Data LayerやDomain Layerといったレイヤー定義に従ったページングを実装する運びになりました

新ページング基盤の設計

新しいページング基盤を作成するにあたって以下のような使い勝手にしようと考えました

  • request関数の返り値にresponseを付ける
  • そのため、呼び出し順によって問題が発生しないようにresponseは最新のrequestの結果によるものを返す*1
  • 基盤側でDBにページングリストを保存するが、利用者側はページングAPIを用意すればページングを実装できるようにする
  • ページングを始めた時刻(SessionStart)を保持する
  • DomainLayerでのページングリストの加工ができる形にする

上述のことを実現するにあたってこれからのアーキテクチャー設計に照らし合わせると以下のような構造になります

f:id:meilcli:20220310191809p:plain
新ページング基盤の簡略図

また、PagingCollectionProviderは以下のようなinterfaceを持つことになります

sealed class PagingLink {
    // offsetベースやcursorベースのページング用にそれぞれ、次のリクエストのための情報を保持する
    abstract val hasNext: Boolean
    abstract val total: Int?
}

interface PagingCollectionProvider<TParameter, TLink, TElement> where TLink : PagingLink {

    fun request(request: PagingRequest<TParameter>): Single<PagingCollection<TElement>>
}

新ページング基盤の実装

すべてのコードをお見せしようとするととても長くなるため、一部抜粋で紹介させていただきます

まず基本となるクラスを用意します

class PagingCollection<T>(
    val metaData: PagingMetaData,
    val session: PagingSession,
    val latestRequestSegment: List<T>,
    private val source: List<T>
) : List<T> by source

class PagingCollectionSegment<TLink : PagingLink, TElement>(
    val link: TLink, 
    val source: List<TElement>
) : List<TElement> by source

PagingCollectionがページングリストの本体を表します。また、それぞれのページング時にAPIなどに問い合わせた結果ををページングリストの一部=Segmentとして用意します

また、APIとの通信部分は各利用者のRestClientが提供することになります。その通信部分をPagingCollectionProviderに渡しやすくするために関数として提供する形式にしました

typealias PagingApi<TParameter, TLink, TElement> = (parameter: TParameter, nextLink: TLink) -> Single<PagingCollectionSegment<TLink, TElement>>

// 提供側
class ExampleRestClient(
    private val exampleApi: ExampleApi // Retrofit側のinterface
) {

    // この関数をPagingCollectionProviderに渡す
    fun fetchExample(
        parameter: ExampleRequestParameter,
        nextLink: PagingLink.KeyBase
    ): Single<PagingCollectionSegment<PagingLink.KeyBase, Example>> {
        return exampleApi.fetchExample
            .map {
                PagingCollectionSegment(
                    PagingLink.KeyBase(it.meta.nextPageKey != null, it.meta.nextPageKey, it.meta.totalCount),
                    it.data
                )
            }
    }

取得したPagingCollection/PagingCollectionSegmentをローカルに保存しておく必要もあります。AndroidにおいてIn-memoryなオブジェクトはアプリケーションのライフサイクルより短いスパンで開放される可能性があります。アプリケーションのライフサイクルに合わせるにはBundle以上の寿命をもたせる必要があるのですが、今回はDBに保存しました

class PagingState<TLink, TElement>(
    val elements: List<TElement>,
    val nextLink: TLink
) where TLink : PagingLink

interface PagingStateDb<TLink, TElement> where TLink : PagingLink {

    fun update(componentPath: String, state: PagingState<TLink, TElement>): Completable

    fun get(componentPath: String): Maybe<PagingState<TLink, TElement>>
}

上記のようなインターフェースを実装していくという感じです。ページングリストの要素についてですが、ばか正直にDBのTableを用意すると変更がしづらいため、Jsonの文字列として保存するようにしました。一定のパフォーマンスペナルティが出ますが、In-memory cacheである程度の場面でDBを参照しなくてよいこと、RecyclerViewの構築は非同期スレッドで要素の差分を計算したり、もともとのページング処理が非同期であること、実際に動作させたところ大きな遅延が見られなかったことなどからページングリストの要素はそれぞれJsonの文字列に変換して保存することにしました

あとはPagingCollectionProviderが各DataSourceを結びつけてページング処理を実行するだけです

class PagingCollectionProvider<TParameter, TLink, TElement>(
    private val currentDateTime: CurrentDateTime,
    private val api: PagingApi<TParameter, TLink, TElement>,
    private val stateCache: PagingStateCache<TLink, TElement>,
    private val stateDb: PagingStateDb<TLink, TElement>,
    private val sessionCache: PagingSessionCache,
    private val sessionDb: PagingSessionDb,
    private val linkProvider: PagingLinkProvider<TLink>,
    private val applicationExecutors: ApplicationExecutors
) where TLink : PagingLink {

    private val resultPublisher = PublishProcessor.create<Pair<String, Result<PagingCollection<TElement>>>>()
    private var requestDisposable: Disposable? = null
    private var latestRequests: MutableMap<String, PagingRequest<TParameter>> = mutableMapOf()
    private val requestLock = ReentrantReadWriteLock()

    fun request(request: PagingRequest<TParameter>): Single<PagingCollection<TElement>> {
        return resultPublisher
            .filter { it.first == request.componentPath }
            // 最初に通知される要素のみを返り値とする
            .take(1)
            .singleOrError()
            .flatMap { (_, result) ->
                val value = result.getOrNull()
                val error = result.exceptionOrNull()
                when {
                    value != null -> {
                        Single.just(value)
                    }
                    error != null -> {
                        Single.error(error)
                    }
                    else -> {
                        Single.error(Exception())
                    }
                }
            }
            .doAfterSubscribe {
                applicationExecutors.background()
                    .submit {
                        // requestの各処理内でthread lockをしているのでcurrent threadをlockさせないためにbackground threadに投げている
                        when (request) {
                            is PagingRequest.Refresh -> {
                                requestRefresh(request)
                            }
                            is PagingRequest.LoadFirst -> {
                                requestLoadFirst(request)
                            }
                            is PagingRequest.LoadMore -> {
                                requestLoadMore(request)
                            }
                            is PagingRequest.GetCollection -> {
                                requestGetCollection(request)
                            }
                        }
                    }
            }
    }

    private fun requestApi(
        request: PagingRequest<TParameter>,
        session: PagingSession,
        nextLink: TLink,
        newStateCreator: (PagingCollectionSegment<TLink, TElement>) -> PagingState<TLink, TElement>
    ): Disposable {
        return api.invoke(request.parameter, nextLink)
            .flatMapCompletable { segment ->
                val newState = newStateCreator(segment)
                stateCache.update(request.componentPath, newState)
                stateDb.update(request.componentPath, newState)
                    .doOnComplete {
                        resultPublisher.offer(
                            Pair(
                                request.componentPath,
                                Result.success(
                                    PagingCollection(
                                        metaData = newState.nextLink.toMetaData(),
                                        session = session,
                                        latestRequestSegment = segment,
                                        source = newState.elements
                                    )
                                )
                            )
                        )
                    }
            }
            .andThen(sessionDb.update(request.componentPath, session))
            .doOnComplete {
                sessionCache.update(request.componentPath, session)
            }
            .doOnError {
                resultPublisher.offer(Pair(request.componentPath, Result.failure(it)))
            }
            .doFinally {
                requestLock.write { latestRequests.remove(request.componentPath) }
            }
            .subscribe()
    }

    private fun requestRefresh(request: PagingRequest.Refresh<TParameter>) {
        requestLock.read {
            val latestRequest = latestRequests[request.componentPath]
            if (latestRequest is PagingRequest.Refresh) {
                return
            }
        }

        requestLock.write {
            requestDisposable?.dispose()
            requestDisposable = null
            latestRequests[request.componentPath] = request

            val nextLink = linkProvider.initialLink
            val session = PagingSession(currentDateTime.nowUnixLong())

            requestDisposable = requestApi(request, session, nextLink) { PagingState(it, it.link) }
        }
    }

    // requestLoadFirstやrequestLoadMoreも同様にDataSourceを使い分けて処理します
    // ただしrequestLoadMoreやrequestLoadFirstよりもrequestRefreshを優先的に処理するようにしたりなどの場合分けが必要になります
}

ちなみにですが途中に出てくるSingle<T>に対するdoAfterSubscribe拡張関数は以下のような実装です

fun <T> Single<T>.doAfterSubscribe(action: () -> Unit): Single<T> {
    return SingleDoAfterSubscribe(this, action)
}

class SingleDoAfterSubscribe<T>(
    private val source: SingleSource<T>,
    private val action: () -> Unit
) : Single<T>() {

    override fun subscribeActual(observer: SingleObserver<in T>) {
        source.subscribe(observer)
        action()
    }
} 

注意点

クラシルAndroidのDomain LayerやData LayerのUseCaseやDataSourceはSingletonになっています。ページングリストは画面ごとに必要になるオブジェクトですので、画面ごとのページングリストを判別する識別子が必要になります。今回はUI LayerのComponentIdが各画面ごとにユニークな値として存在しているのでそれを活用しました

終わりに

クラシルAndroidの基盤に乗っける形式でページングの基盤を作成しました。そのため、他のアプリケーションでは一部噛み合わない箇所があるかもしれません。ページングについてはPaging Libraryを利用するなどの方法がありますが、アプリケーションの理想的な挙動を実現するには最終的には自作しかないんじゃないかなと思います。そのための助けになれば幸いです

もしクラシルAndroidのページング基盤の実装について聞きたいことがあればカジュアル面談で話をすることもできるので気軽にお声がけくださいm( )m

meety.net

*1:ここについては異論が出るところだと思う。なぜならrequestした通りの結果が返ってくるのではなく、ページングリストとしての最新の値が返ってくるのだから。ただ、そのような違和感のある動作をすることによる弊害はなく、むしろエラーハンドリングやローディングハンドリングがしやすくなるというメリットがあるのでこの方針にしました