以下の内容はhttps://tech.stmn.co.jp/entry/2025/08/06/172530より取得しました。


Paging3 でのリストのアイテム更新にハマった話

はじめに

株式会社スタメンでモバイルアプリの開発をしているカーキ(X: @khaki_ngy)です。

これまでJetpack Composeを採用した画面で、大量のアイテムをページング表示したい場合は、多くのケースで AndroidX Paging ライブラリ(以下、Paging3)を採用してきました。 直近で担当したタイムライン機能のリプレイスに際しても、投稿一覧を表示するために当初 Paging3 を採用しましたが、開発を進める中で、Paging3 が持つ特性と、我々の要件との間にギャップがあり、Paging3 の利用を見送る必要がある状況になりました。

今回のブログでは、Paging3 の基本的な使い方を振り返りつつ、我々が直面した課題と、それをどのように乗り越えたかについて紹介をします。

Paging3×Composeの基本的な使い方

Paging3とは、大規模なデータセットを効率的に、少量ずつページ単位で読み込み、表示するためのGoogle公式のライブラリです。ユーザーのスクロール操作に応じて次のページを自動で読み込むことで、メモリ使用量を抑え、スムーズなユーザー体験を提供します。

ここでは、Paging3 を利用する上で必須となる登場人物と、その基本的な接続方法に絞って、シンプルなコード例を紹介します。

1. データソースを定義する (PagingSource)

まず、データをどのように取得するかを定義する PagingSource を実装します。この例では、API通信などを模した上で、20個の文字列アイテムを擬似的に生成しています。

// PagingSourceの実装。キーはページ番号(Int)、値は取得するデータ(String)
class MyPagingSource : PagingSource<Int, String>() {

    // ページングされたデータを読み込むための中心的な関数
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
        val page = params.key ?: 1 // 読み込むページ番号(初回は1)

        // 実際にはここでAPIリクエストなどを行う
        // この例では、ページ番号に基づいて擬似的なデータを生成
        val data = List(20) { index -> "アイテム ${index + (page - 1) * 20}" }

        // 読み込み結果をLoadResult.Pageとして返す
        return LoadResult.Page(
            data = data,
            prevKey = if (page == 1) null else page - 1, // 前のページ
            nextKey = if (page < 5) page + 1 else null   // 次のページ(5ページで終わり)
        )
    }

    // リストが更新されたときに、新しいPagingSourceをどこから読み込み始めるかを定義する
    // この例では簡単のため実装を省略
    override fun getRefreshKey(state: PagingState<Int, String>): Int? {
        return null
    }
}

2. ViewModelでPagerをセットアップする

次に、ViewModelPagingSource を利用して Pager を構築し、UIに公開するための Flow<PagingData<String>> を作成します。

class MyViewModel : ViewModel() {

    val items: Flow<PagingData<String>> = Pager(
        // PagingConfigで、ページサイズなどの設定を行う
        config = PagingConfig(pageSize = 20),
        // 新しいPagingSourceのインスタンスを生成するファクトリを渡す
        pagingSourceFactory = { MyPagingSource() }
    ).flow
     .cachedIn(viewModelScope) // Flowをキャッシュし、画面回転などの構成変更後もデータを保持
}

3. Composableでデータを表示する

最後に、Composable関数で ViewModel から Flow を受け取り、LazyColumn で表示します。paging-compose ライブラリが提供する専用の items 拡張関数を利用すると、より簡潔に記述できます。

@Composable
fun MyPagingScreen(viewModel: MyViewModel) {
    // ViewModelのFlowを、Composeで扱えるLazyPagingItemsに変換
    val lazyPagingItems = viewModel.items.collectAsLazyPagingItems()

    LazyColumn {
        // Paging3ライブラリ専用のitems拡張関数
        // これを使うと、アイテムの取得やキーの管理などを自動で行ってくれる
        items(
            items = lazyPagingItems,
            key = { it } // 各アイテムの安定したキーを指定 (今回はString自体をキーに)
        ) { item ->
            // itemはnull許容型なので、nullでないことを確認
            if (item != null) {
                Text(
                    text = item,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                )
            }
        }
    }
}

lazyPagingItems.loadState を監視することで、リストの末尾にローディングインジケーターやエラーメッセージを表示することも可能ですが、ここでは基本的なアイテムの表示に絞って紹介しました。

以上がPaging3をJetpack Composeで利用する際の、最もシンプルで基本的な実装の流れです。

Paging3 の「ツラミ」との遭遇

タイムラインリプレイスでのユースケースと課題

今回リプレイスしたタイムライン機能では、ユーザーが投稿に対して「リアクション」や「コメント」といったリアクションを行えます。これらの操作はAPIを通じてサーバーのデータを更新しますが、ユーザー体験を考慮し、操作後は画面全体を再読み込みするのではなく、対象の投稿アイテムの「リアクション」や「コメント数」といった情報をローカルで即時反映させたい、という要件がありました。

タイムライン上に表示される投稿の例

しかし、ここで大きな課題に直面しました。Paging3で表示しているリスト内の特定のアイテムの状態を、ローカルで部分的に更新することは原則サポートされていませんでした。

なぜPaging3の要素は(簡単には)更新できないのか?

この課題の根本原因は、Paging3の設計思想にあります。Paging3が提供する PagingData は、その時点でのデータの不変なスナップショットとして扱われます。これは、宣言的にUIを構築するJetpack Composeの考え方とも一致しています。

身近なもので例えるなら、PagingData は「印刷済みの写真アルバム」のようなものです。アルバムに印刷された写真の一部分だけを後から修正するのは困難であり、もし修正したい場合は、元のネガフィルム(データソース)から、修正を加えた新しい写真を印刷し直し、アルバムのページごと差し替えるのが正しい手順です。

Paging3も同様に、表示されているリスト(写真)を直接操作することは推奨されていません。データの整合性を保つため、変更は常に単一の信頼できる情報源(Single Source of Truth)、すなわち大元のデータソース(ネガフィルム)に対して行われるべきだとされています。データソースが更新されると、Paging3はそれを検知し、新しい PagingData のスナップショットを生成してUIにストリームします。この単一方向のデータの流れにより、複雑なページング処理や非同期なデータ読み込みの中でも、データの整合性が保たれるのです。

したがって、リスト内のアイテムをローカルで更新したい場合、PagingData が参照している大元のデータソース(データベースなど)を更新し、PagingSource の無効化(invalidation)をトリガーして、Paging3に新しいスナップショットを再生成させるのが正規のフローとなります。

最終的な回避方法

PagingDataAdapter.refresh() を呼び出してリスト全体を再取得する方法も検討しましたが、ユーザーのスクロール位置がリセットされるなど、UXの観点から今回の要件には合致しませんでした。 そこで、更新が必要な要素のページングに関しては、Paging3を利用しないという方針を決定しました。

その代替として、LazyColumn のスクロール状態を LazyListState で監視し、ユーザーがリストの末尾近くまで表示したことを検知して、次のページを読み込むコールバックをトリガーするという方法を実装しました。

【ViewModel】

class TimelineViewModel(private val repository: TimelineRepository) : ViewModel() {

    private val _items = MutableStateFlow<List<TimelineItem>>(emptyList())
    val items: StateFlow<List<TimelineItem>> = _items

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    private var currentPage = 1
    private var canLoadMore = true // これ以上読み込むページがないことを示すフラグ

    init {
        loadMoreItems() // 最初のページを読み込む
    }

    fun loadMoreItems() {
        // ローディング中、またはこれ以上読み込むページがない場合は処理を中断
        if (isLoading.value || !canLoadMore) return

        viewModelScope.launch {
            _isLoading.value = true
            try {
                // Repositoryから次のページのデータを取得
                val newItems = repository.fetchTimeline(page = currentPage)
                if (newItems.isNotEmpty()) {
                    // 既存のリストに新しいアイテムを追加
                    _items.value += newItems
                    currentPage++
                } else {
                    // 新しいアイテムがなければ、それ以上読み込まないようにフラグを更新
                    canLoadMore = false
                }
            } catch (e: Exception) {
                // 実際にはエラー状態をUIに通知するなどの処理が必要
            } finally {
                _isLoading.value = false
            }
        }
    }
}

【Composable】

@Composable
fun TimelineScreen(viewModel: TimelineViewModel) {
    val items by viewModel.items.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val listState = rememberLazyListState()

    LazyColumn(state = listState) {
        items(items) { item ->
            TimelineItem(item = item)
        }

        // ローディング中であれば、リストの末尾にインジケーターを表示
        if (isLoading) {
            item {
                Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                }
            }
        }
    }

    // LazyColumnのスクロール状態を監視
    val isScrolledToEnd by remember {
        derivedStateOf {
            val layoutInfo = listState.layoutInfo
            val visibleItemsInfo = layoutInfo.visibleItemsInfo
            if (layoutInfo.totalItemsCount == 0) {
                false
            } else {
                // 最後に表示されているアイテムが、リスト全体の最後のアイテムであるか
                val lastVisibleItem = visibleItemsInfo.lastOrNull()
                lastVisibleItem?.index == layoutInfo.totalItemsCount - 1
            }
        }
    }

    // リストの末尾までスクロールされたら、次のページを読み込む
    LaunchedEffect(isScrolledToEnd) {
        if (isScrolledToEnd) {
            viewModel.loadMoreItems()
        }
    }
}

この実装により、Paging3を使わずにページング機能を実現しつつ、ViewModel が保持する StateFlow のリストを直接更新することで、ローカルでのデータ更新にも柔軟に対応できるようになりました。

取り得た別の選択肢

もしPaging3を選択した上で今回のケースを対応するとしたら、ローカルデータベースを管理するライブラリであるRoomを用いて、ローカルデータベースをデータソースとして扱ってページングする方法もありました。 Paging3 では、API から取得したデータを Room と同期をしながらページングする実装RemoteMediatorが用意されています。この記事では詳しくは述べませんが、これを利用すれば、ローカルで持っている Roomに対して更新をかけることで、表示状態を更新することができます。

こちらの公式ドキュメントがよくまとまっています。 ネットワークとデータベースからページングする

今回のトレードオフ

先述の通り、Room のようなローカルデータベースを信頼できる情報源(Single Source of Truth)として利用し、Paging3にその変更を監視させるのが、今回の要件を満たす上での最も望ましいアーキテクチャであったと認識しています。タイムラインの投稿は、ローカルにキャッシュすることのメリットも享受することができます。

しかし、今回はリリースまでの期間が短く設定されていたこと、そしてオフライン対応なども見据えたローカルデータベースのスキーマ設計には、慎重に時間をかけたかったことから、この方法は見送りました。当時の開発の状況としては、致し方ない判断だったと思っています。

まとめ:Paging3 の注意点

今回のPaging3の一連の取り組みから、 Paging3で取得したデータは、不変なスナップショットとして扱われるため、リスト内の一要素をローカルで簡単に更新することはできない。 ということを学びました。チームとして Paging3 に今まで頼ってきていた経緯はありましたが、これを機に Paging3との付き合い方を考え直すきっかけになりました。

スタメンでは、Androidエンジニアを含めて、モバイルアプリ開発者を募集しています!

herp.careers




以上の内容はhttps://tech.stmn.co.jp/entry/2025/08/06/172530より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

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