以下の内容はhttps://developer.hatenastaff.com/entry/2025/12/26/000000より取得しました。


はてなブログのAndroidアプリのスムーズな無限スクロールを支える技術

こんにちは、Androidアプリケーションエンジニアのid:rokouchaです。

皆さんはリニューアルされたはてなブログのAndroidアプリをもう体験しましたか?まだの方はぜひダウンロードして、生まれ変わったはてなブログアプリを体験してみてください。

今回のリニューアルでは、目的の1つとして「Androidの最新標準技術に追従1」を掲げています。 スムーズな無限スクロールを実現するためのページングライブラリ、Pagingもその1つです。

しかしこのPaging、Android Jetpackライブラリスイートの一員にも関わらず、他のComposeRoomに比べると知名度が低く、ドキュメントやサンプルコードもあまり多くありません。

Pagingとは何なのか、はてなブログアプリではPagingをどのように活用しているのか、今回はスムーズな無限スクロールを支える技術について紹介します。

この記事ははてなエンジニア Advent Calendar 2025の26日目です。

Pagingとは何なのか

上述のとおり、PagingはAndroid Jetpackライブラリスイートの1つで、ページングライブラリです。

大量のデータを効率よく保持しつつ、画面表示に必要な分だけを表示コンポーネントに渡し、必要があればネットワークから追加のデータを取得するといった、表示部分以外のほぼ全てを提供しています。

Pagingは複数のコンポーネントで構成されていて、リポジトリ・ViewModel・UIの3つのレイヤーで生成・保持され、それぞれが連携することで、スムーズなページング、ひいてはスムーズな無限スクロールを実現しています。

ページング ライブラリがアプリのアーキテクチャにどのように適合するかを示す例 ページング ライブラリの概要  |  App architecture  |  Android Developers

またリポジトリ層とUI層のコンポーネントは、それぞれ様々なライブラリと連携できる作りになっています。

インターフェースを満たすようなPagingSourceであれば、裏側はRoomでもRealmでも何でも構いません。 もちろんUIも、Android ViewだけではなくJetpack Composeにも対応しています。

またRemoteMediatorコンポーネントを活用することで、適切なタイミングでネットワークから追加のデータを取得し、スムーズな無限スクロールを実現できます。

はてなブログアプリでのPaging

はてなブログアプリはブログというサービスの特性上、とくにエントリ一覧において数百件単位の大量のエントリを無限スクロールで表示したいというユースケースがあります。

もちろん大量のエントリを一回のAPI呼び出しで取得する訳にはいかないので、初期状態ではある程度の件数を取得しておき、スクロールに応じて追加で取得していかなければなりません。

様々な手法やライブラリを検討しましたが、Pagingが一番ユースケース的にもAndroidの最新標準技術に追従という観点からもベストであるとして、リニューアルの際に採用しました。

パフォーマンスや将来的な発展性の観点から、PagingSourceのバックエンドにはJetpack Roomを採用し、RemoteMediatorでAPIと連携して最新のデータの取得をしています。 また、RoomおよびUIライブラリであるJetpack Composeとの連携は、それぞれ公式で提供されている連携用ライブラリを採用しています。

PagingとRoomを組み合わせて使う場合の注意点とベストプラクティス

実際にはてなブログアプリにPagingを導入してみて、Roomと組み合わせて使う場合における注意点やドキュメントには記載されていないベストプラクティスを得られました。

PagingSourceのKeyをIntにする

PagingSourceのシグネチャはPagingSource<Key : Any, Value : Any>となっていて、Keyにページングするデータを一意に識別できる値を渡す必要があります。

ページングしたいデータにはたいていIDがついているのでそのIDをそのままPagingSourceのKeyに使いたくなりますが、Roomと組み合わせる場合は使うべきではありません。

というのも、RoomのPaging連携は自動でオフセットベースのページネーションのクエリを生成していて、そこでデータのインデックスをPagingSourceのKeyとして渡しています。

public suspend fun <Value : Any> queryDatabase(
    params: LoadParams<Int>,
    sourceQuery: RoomRawQuery,
    itemCount: Int,
    convertRows: suspend (RoomRawQuery, Int) -> List<Value>,
): LoadResult<Int, Value> {
    // 〜中略〜
    val limitOffsetQuery =
        RoomRawQuery(
            sql = "SELECT * FROM ( ${sourceQuery.sql} ) LIMIT $limit OFFSET $offset",
            onBindStatement = sourceQuery.getBindingFunction(),
        )

    val data: List<Value> = convertRows(limitOffsetQuery, rowsCount)
    // 〜中略〜
    return LoadResult.Page(
        data = data,
        prevKey = prevKey,
        nextKey = nextKey,
        itemsBefore = offset,
        itemsAfter = maxOf(0, itemCount - nextPosToLoad),
    )
}

room/room-paging/src/commonMain/kotlin/androidx/room/paging/util/RoomPagingUtil.kt

そのため、PagingSourceのKeyはIntにしておく必要があります。

ユニークなキーをソート順に含める

先述のとおり、RoomのPaging連携は自動でオフセットベースのページネーションクエリを生成します。

オフセットベースのページネーションは、結果の行が一意な順序に並んでいる必要があります。 もし一意な順序で並んでいない場合、行が重複したりして正常な結果を得られません。

SQLiteではORDER BYが指定されていない時のソート順は不定となっています2。 そのため、日時などでソートする時はもちろんのこと、日時などでソートして表示する必要がない場合でもユニークなキーでソートをしなければなりません。

@Query("SELECT * FROM entries ORDER BY created_at DESC, id")
fun getEntriesPagingSource(): PagingSource<Int, Entry>

マッパーを用意してエンティティをモデルに変換する

PagingではPagingSourceのValueの型がそのままPagerを経由してUI層まで露出します。 素朴にPagingSourceを実装するとRoomのエンティティがValueになるため、UI層にエンティティが露出してしまいます。

RoomのエンティティがUI層に露出するのは好ましい作りではありませんし、モジュール化のパターンによってはインポートのルールに抵触することもあり得ます。

次のようなマッパーを挟むことで、エンティティからモデルに変換してUI層への露出を防げます。

class MappingRoomPagingSource<Value : Any, MappedValue : Any>(
    private val source: PagingSource<Int, Value>,
    private val mapper: (Value) -> MappedValue,
) : PagingSource<Int, MappedValue>() {
    init {
        // source側でinvalidateされたら自分もinvalidateする
        source.registerInvalidatedCallback {
            invalidate()
        }
    }

    // room-pagingはjumpingSupportedがtrue
    override val jumpingSupported = source.jumpingSupported

    override fun getRefreshKey(state: PagingState<Int, MappedValue>): Int? = source.getRefreshKey(
            // 必要なのはanchorPositionとconfig.initialLoadSizeだけなので、それ以外は適当でよい
            PagingState(
                pages = emptyList(),
                anchorPosition = state.anchorPosition,
                config = PagingConfig(
                    pageSize = state.config.pageSize,
                    initialLoadSize = state.config.initialLoadSize,
                ),
                leadingPlaceholderCount = 0,
            ),
        )

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MappedValue> {
        val result = source.load(params)
        return when (result) {
            is LoadResult.Error -> LoadResult.Error(result.throwable)

            is LoadResult.Invalid -> LoadResult.Invalid()

            is LoadResult.Page -> LoadResult.Page(
                data = result.data.map(mapper),
                prevKey = result.prevKey,
                nextKey = result.nextKey,
                // room-pagingはjumpingSupportedなのでitemsAfterは必須
                itemsAfter = result.itemsAfter,
                // room-pagingはjumpingSupportedなのでitemsBeforeは必須
                itemsBefore = result.itemsBefore,
            )
        }
    }
}

重要な点として、invalidateを伝搬させるのとjumpingへの対応があります。

RemoteMediatorなどによるRoomのデータの更新は、PagingSourceのinvalidateを呼び出す事でPagerに反映されます3。 そのため、元々のPagingSourceがinvalidateされた場合はマッパー自身もinvalidateをしなければいけません。

また、RoomのPaging連携ではjumpingがサポートされています4。そのため、Pagerはページングしている要素をスクロール位置に合わせて動的に削除・挿入します5。 ページングのリストの適切な位置に要素を挿入したり削除するため、itemsAfteritemsBeforeは正しい値を返す必要があります。

おわりに

このように、はてなブログアプリでは最新標準技術を活用しつつ、スクロールの滑らかさや操作の手触りなどのパフォーマンスや体験についても妥協せず作り込んでいます。

無限スクロール以外にも、大画面を持つデバイスへの対応やウィジェットの提供など、従来のアプリから様々な点を改良しています。

これからも新しい技術を目的に沿って選び、パフォーマンスと使いやすさを両立させるアップデートを重ねていきますので、ぜひ今後の進化にご期待ください!




以上の内容はhttps://developer.hatenastaff.com/entry/2025/12/26/000000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14