本文へスキップ
すべての記事

Room と Paging 3:2026 年、Android で肥大化する取引リストをスムーズに保つ

Jetpack Paging 3 を使って Room ベースの取引リストをページングし、何年分ものデータでも Compose でスムーズに動かす方法 — RemoteMediator 不要、ネットワーク不要、ローカルデータベースを正しく扱うだけ。

MFKAPPS 2 分で読めます

家計簿アプリの取引リストは、30 行程度のデモではまったく問題なく見える。実際のユーザーが 2 年使い込んで 4000 件のデータを抱え、履歴画面のスクロールがカクつき始めた途端、その見た目は崩れる。解決策は LIMIT を大きくすることでも、「下部にローディングスピナーを足すだけ」でもない — ネットワーク層をまったく介さず、Room に直結させた Jetpack Paging 3 だ。ここでは Granyn のためにそれをどう実装したか、そして正しく動くようになるまでにそれぞれ丸一日を費やした 2 つの失敗について説明する。

LazyColumn だけでは問題は解決しない理由

素朴な実装は、あらゆるチュートリアルのあらゆるリストと同じように動く。ひとつの Room クエリ SELECT * FROM transactions ORDER BY date DESCFlow<List<Transaction>> にマッピングし、それを LazyColumn で収集する。Compose の lazy レイアウトは、コンポジションとレンダリングに関して本当に「遅延」している — 画面外の行を再コンポーズすることはない。だからカクつきは Compose の問題ではない。問題はその上流にある。Room は、テーブル内のどこか 1 件の取引が変わるたびに、発行のたびに、4000 行すべてをオブジェクトとして具現化しなければならない。支出をひとつ追加するだけで、リスト全体が再構築される。これが実際のコストであり、リストレンダリングの最適化ではどうにもならない部分だ。

Paging 3 が実際にもたらすもの

Paging 3 は通常、ネットワークのユースケース — API からページを取得してローカルにキャッシュする RemoteMediator — を通じて説明される。だがここで扱うのはそれではない。ローカルファーストなアプリでは、パイプライン全体が Room の中で完結する。メディエーターもなければ、ネットワーク状態もリトライロジックもない。Flow<List<Transaction>> の代わりに PagingSource<Int, Transaction> を返す @Query を書けば、Room が PagingSource を自動生成してくれる。

@Dao
interface TransactionDao {
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun pagingSource(): PagingSource<Int, Transaction>
}

この型をひとつ変えるだけで、統合作業のほとんどは終わる。Room はすでに、基盤となるテーブルが変化したときに PagingSource を無効化する方法を知っている — Flow クエリを支えているのと同じ監視の仕組みだ。だから書き込みが発生すると、余計な配線をしなくてもページは自動的に更新される。

ストリームを組み立てる

PagingSourcePager でラップされる。Pager はページングの設定 — 1 ページあたりの行数や、どれだけ先まで先読みするか — を保持する。

class TransactionRepository(private val dao: TransactionDao) {
    fun pagedTransactions(): Flow<PagingData<Transaction>> =
        Pager(
            config = PagingConfig(
                pageSize = 40,
                prefetchDistance = 20,
                enablePlaceholders = true,
            ),
            pagingSourceFactory = { dao.pagingSource() },
        ).flow
    }

enablePlaceholders = true は特筆に値する。Room はローカルテーブルに対して SELECT COUNT(*) を安価に実行できるため、Paging 3 はスクロールバーのサイズを正確に算出でき、まだ読み込んでいないコンテンツにはプレースホルダー行を表示できる — 4000 行ある履歴の一番下まで素早くスクロールしても、先に 4000 行すべてを読み込む必要はない。これはネットワークに依存したページングでは得られない贅沢であり、ローカルなページングが通常の無限スクロールフィードとは違って瞬時に感じられる主な理由でもある。

Compose 側の実装

collectAsLazyPagingItems()Flow<PagingData<Transaction>> を、LazyColumn が直接インデックスできる形に変換する。

@Composable
fun TransactionList(viewModel: TransactionViewModel) {
    val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = transactions.itemCount,
            key = transactions.itemKey { it.id },
        ) { index ->
            val transaction = transactions[index]
            if (transaction != null) {
                TransactionRow(transaction)
            } else {
                TransactionRowPlaceholder()
            }
        }
    }
}

itemKey は、通常のリストよりもここでこそ重要になる。安定したキーがなければ、Compose は行をリスト内の位置でキー付けする。そして新しい取引を先頭に挿入する操作 — 新規エントリは日付順で先頭にソートされるため、家計簿アプリでは絶えず発生する — は、すべての行の識別子をずらし、不要な再コンポジションの波を引き起こしてしまう。

丸一日を失った失敗:フィルタリング

履歴画面はカテゴリによるフィルタリングにも対応している。最初に思いついたのは、Pager をひとつだけ維持しておき、各 PagingData の発行に対して下流で .filter { } を適用し PagingData ストリームをフィルタする方法だった。これは間違いで、しかもよくある罠なのではっきり名指しする価値がある。後からフィルタしても、ページングは依然としてフィルタされていないクエリを対象に行われる。そのため 4000 行の中で 5 件しか一致しないカテゴリでも、5 件の結果を表示するまでにテーブルのほとんどをページングすることになり、プレースホルダーの件数もフィルタ後ではなくテーブル全体の行数を反映してしまうため誤ったものになる。

正しい修正は、フィルタを後処理ではなくクエリ自体の一部にし、フィルタが変わるたびに Pager を作り直すことだ。

@Query("""
    SELECT * FROM transactions
    WHERE (:categoryId IS NULL OR category_id = :categoryId)
    ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
    .flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
    .cachedIn(viewModelScope)

flatMapLatest は古い Pager をキャンセルし、新しいフィルタに対応した新しい Pager を開始する。これにより、プレースホルダー、件数、先読みのすべてが、ユーザーが実際に見ているクエリに対して正しい状態になる。cachedIn(viewModelScope) は設定変更を乗り越えるために必要なものだ — これがないと、画面を回転させるだけでページングが最初からやり直しになってしまう。

まとめ

Room 上の Paging 3 には、通常セットで教えられるネットワーク関連の仕組みが一切いらない — RemoteMediator も、LoadState のリトライ UI も、整合を取るべきオフラインキャッシュもない。残るのは本当にシンプルなものだけだ。クエリから生まれる PagingSource、先読み設定を持つ Pager、そして Compose 側の collectAsLazyPagingItems()。最初に正しくやっておく価値がある唯一のことは、フィルタを下流ではなくクエリの内側に留めておくことであり、それさえ押さえれば残りは自然についてくる。4000 行になってもレスポンシブであり続ける取引リストは、後から付け足すパフォーマンス機能ではない — デモでしか動かないアプリと、実運用 2 年後もなお動き続けるアプリとの違いそのものだ。