ネットワークとデータベースからページングする

ネットワーク接続の信頼性が低い場合やユーザーがオフラインの場合でもアプリを使用できるようにすることで、ユーザー エクスペリエンスを向上させることができます。これを行うための方法として、ネットワークとローカル データベースから同時にページングすることが挙げられます。この方法では、アプリはローカル データベース キャッシュのデータで UI を動作させ、データベース内にデータがなくなった場合にのみネットワークにリクエストを行います。

このガイドは、読者が Room 永続ライブラリページング ライブラリの基本的な使用方法を熟知していることを前提としています。

データの読み込みをコーディネートする

ページング ライブラリには、このユースケース向けの RemoteMediator コンポーネントが用意されています。RemoteMediator は、アプリがキャッシュ データを使い切った際に、ページング ライブラリからのシグナルとして機能します。このシグナルを使用して、追加のデータをネットワークから読み込み、ローカル データベースに保存することができます。その場合、PagingSource でデータの読み込みと表示する UI への提供を行えます。

追加のデータが必要な場合、ページング ライブラリは RemoteMediator の実装から load() メソッドを呼び出します。これは suspend 関数であるため、長時間に及ぶ処理でも問題なく実行できます。この関数は通常、ネットワーク ソースから新しいデータを取得してローカル ストレージに保存します。

このプロセスが取り扱うのは新しいデータですが、時間とともにデータベースに保存したデータの無効化が必要となります(ユーザーが手動で更新をトリガーした場合など)。これは、load() メソッドに渡される LoadType プロパティで示されます。LoadTypeRemoteMediator に対して、既存のデータを更新するべきか、データを取得して既存のリストの先頭または末尾に追加するべきかを指定します。

このように、RemoteMediator はアプリがユーザーに表示するデータを適切な順序で読み込めるようにします。

ページングのライフサイクル

ネットワークから直接ページングする場合、PagingSource はデータを読み込んで LoadResult オブジェクトを返します。PagingSource の実装は、pagingSourceFactory パラメータで Pager に渡されます。

UI で新しいデータが必要になると、PagerPagingSource から load() メソッドを呼び出して、新しいデータをカプセル化した PagingData オブジェクトのストリームを返します。各 PagingData オブジェクトは通常、ViewModel にキャッシュされてから UI に送信されて表示されます。

図 1. PagingSource と RemoteMediator を使用したページングのライフサイクル図。

RemoteMediator を使用する場合は、このデータフローが変わります。PagingSource がデータを読み込むことは変わりませんが、ページングされたデータが使い果たされるとページング ライブラリが RemoteMediator をトリガーして、ネットワーク ソースから新しいデータを読み込みます。RemoteMediator は新しいデータをローカル データベースに保存するため、ViewModel のメモリ内キャッシュは不要です。最後に、PagingSource は自身を無効化し、Pager は新しいインスタンスを作成してデータベースから新しいデータを読み込みます。

基本的な使用法

アプリで、アイテムのキー付きネットワーク データソースから Room データベースに格納されているローカル キャッシュに User アイテムのページを読み込むとします。

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

Room エンティティを作成する

まず、Room 永続ライブラリを使用して、ネットワーク データソースからのページング データのローカル キャッシュを保持するデータベースを定義します。Room を使用してローカル データベースにデータを保存するの説明に従い、RoomDatabase を実装することから始めます。

次に、Room エンティティを使用してデータを定義するの説明に従い、リストアイテムのテーブルを表す Room エンティティを定義します。主キーとして id フィールドを指定し、リストアイテムに含まれるその他の情報のフィールドも指定します。

@Entity(tableName = "users")
data class User(val id: String, val label: String)

また、Room DAO を使用してデータにアクセスするの説明に従い、この Room エンティティのデータ アクセス オブジェクト(DAO)も定義する必要があります。リストアイテム エンティティの DAO には、次のメソッドを含める必要があります。

  • アイテムのリストをテーブルに挿入する insertAll() メソッド。
  • クエリ文字列をパラメータとして受け取り、結果のリストに対して PagingSource オブジェクトを返すメソッド。これにより、Pager オブジェクトはこのテーブルをページング データのソースとして使用できます。
  • テーブルのすべてのデータを削除する clearAll() メソッド。
@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertAll(users: List<User>)

  @Query("SELECT * FROM users WHERE label LIKE :query")
  fun pagingSource(query: String): PagingSource<Int, User>

  @Query("DELETE FROM users")
  suspend fun clearAll()
}

RemoteMediator を実装する

RemoteMediator の主な役割は、Pager がデータを使い切った、または既存のデータが無効化された場合に、ネットワークから追加のデータを読み込むことです。これには、読み込み動作を定義するためにオーバーライドする必要のある load() メソッドが含まれます。

一般的な RemoteMediator 実装では、次のパラメータを指定します。

  • query: バックエンド サービスから取得するデータを定義するクエリ文字列。
  • database: ローカル キャッシュとして機能する Room データベース。
  • networkService: バックエンド サービスの API インスタンス。

RemoteMediator<Key, Value> の実装例を以下に示します。同じネットワーク データソースに対しては、PagingSource を定義する際と同じ Key 型および Value 型を使用する必要があります。型パラメータの選択について詳しくは、キーと値の型を選択をご覧ください。

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    // ...
  }
}

load() メソッドが、バッキング データセットの更新と PagingSource の無効化を行います。ページングをサポートする一部のライブラリ(Room など)は、実装する PagingSource オブジェクトの無効化を自動的に処理します。

load() メソッドは、次の 2 つのパラメータを取ります。

  • PagingState。これまでに読み込まれたページ、最近アクセスしたインデックス、ページング ストリームの初期化に使用した PagingConfig オブジェクトに関する情報が含まれます。
  • LoadType。読み込みの種類(REFRESHAPPENDPREPEND)を示します。

load() メソッドの戻り値は MediatorResult オブジェクトです。MediatorResult は、MediatorResult.Error(エラーの説明を含む)か MediatorResult.Success(読み込むデータがまだあるかどうかを示すシグナルを含む)のいずれかになります。

load() メソッドでは、次の手順を行う必要があります。

  1. 読み込みの種類とこれまでに読み込まれたデータに基づき、ネットワークから読み込むページを決定します。
  2. ネットワーク リクエストをトリガーします。
  3. 読み込みオペレーションの結果に応じて、次のアクションを行います。
    • 読み込みが正常に行われ、受信したアイテムのリストが空でない場合、リストアイテムをデータベースに保存して MediatorResult.Success(endOfPaginationReached = false) を返します。データが保存されたら、データソースを無効化してページング ライブラリに新しいデータを通知します。
    • 読み込みが正常に行われ、受信したアイテムのリストが空、または最後のページ インデックスである場合、MediatorResult.Success(endOfPaginationReached = true) を返します。データが保存されたら、データソースを無効化してページング ライブラリに新しいデータを通知します。
    • リクエストでエラーが発生した場合は MediatorResult.Error を返します。
override suspend fun load(
  loadType: LoadType,
  state: PagingState<Int, User>
): MediatorResult {
  return try {
    // The network load method takes an optional after=<user.id>
    // parameter. For every page after the first, pass the last user
    // ID to let it continue from where it left off. For REFRESH,
    // pass null to load the first page.
    val loadKey = when (loadType) {
      LoadType.REFRESH -> null
      // In this example, you never need to prepend, since REFRESH
      // will always load the first page in the list. Immediately
      // return, reporting end of pagination.
      LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
      LoadType.APPEND -> {
        val lastItem = state.lastItemOrNull()

        // You must explicitly check if the last item is null when
        // appending, since passing null to networkService is only
        // valid for initial load. If lastItem is null it means no
        // items were loaded after the initial REFRESH and there are
        // no more items to load.
        if (lastItem == null) {
          return MediatorResult.Success(
            endOfPaginationReached = true
          )
        }

        lastItem.id
      }
    }

    // Suspending network load via Retrofit. This doesn't need to be
    // wrapped in a withContext(Dispatcher.IO) { ... } block since
    // Retrofit's Coroutine CallAdapter dispatches on a worker
    // thread.
    val response = networkService.searchUsers(
      query = query, after = loadKey
    )

    database.withTransaction {
      if (loadType == LoadType.REFRESH) {
        userDao.deleteByQuery(query)
      }

      // Insert new users into database, which invalidates the
      // current PagingData, allowing Paging to present the updates
      // in the DB.
      userDao.insertAll(response.users)
    }

    MediatorResult.Success(
      endOfPaginationReached = response.nextKey == null
    )
  } catch (e: IOException) {
    MediatorResult.Error(e)
  } catch (e: HttpException) {
    MediatorResult.Error(e)
  }
}

初期化メソッドを定義する

RemoteMediator 実装では、initialize() メソッドをオーバーライドして、キャッシュ データが古くなっていないか確認し、リモート更新をトリガーするかどうかを決定することもできます。このメソッドは読み込みの前に実行されるため、ローカルまたはリモートの読み込みをトリガーする前に、データベースの操作(たとえば、古いデータの消去)を行えます。

initialize() は非同期関数であるため、データを読み込んで、データベース内の既存データの適合性を判別できます。通常、キャッシュ データには有効期限があります。RemoteMediator を使用して、この有効期限が過ぎているかどうかを確認できます。過ぎている場合は、ページング ライブラリでデータを完全に更新する必要があります。initialize() の実装は、次のように InitializeAction を返す必要があります。

  • ローカルデータを完全に更新する必要がある場合、initialize()InitializeAction.LAUNCH_INITIAL_REFRESH を返します。これにより、RemoteMediator はリモート更新を実行してデータを完全に再読み込みします。APPENDPREPEND のリモート読み込みは、REFRESH 読み込みが正常に終了してから行われます。
  • ローカルデータを更新する必要がない場合、initialize()InitializeAction.SKIP_INITIAL_REFRESH を返します。これにより、RemoteMediator はリモート更新をスキップし、キャッシュ データを読み込みます。
override suspend fun initialize(): InitializeAction {
  val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
  return if (System.currentTimeMillis() - db.lastUpdated() <= cacheTimeout)
  {
    // Cached data is up-to-date, so there is no need to re-fetch
    // from the network.
    InitializeAction.SKIP_INITIAL_REFRESH
  } else {
    // Need to refresh cached data from network; returning
    // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
    // APPEND and PREPEND from running until REFRESH succeeds.
    InitializeAction.LAUNCH_INITIAL_REFRESH
  }
}

ページャを作成する

最後に、Pager インスタンスを作成してページング データのストリームを設定します。単純なネットワーク データソースから Pager を作成する場合と似ていますが、次の 2 つの点で異なります。

  • PagingSource コンストラクタを直接渡す代わりに、DAO から PagingSource オブジェクトを返すクエリメソッドを指定する必要があります。
  • RemoteMediator 実装のインスタンスを remoteMediator パラメータとして指定する必要があります。
val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

競合状態を処理する

複数のソースからデータを読み込む際にアプリが処理しなければならない状況として、ローカルのキャッシュ データがリモートのデータソースと同期しなくなる場合が挙げられます。

RemoteMediator 実装の initialize() メソッドが LAUNCH_INITIAL_REFRESH を返した場合は、データが古くなっているため、新しいデータに置き換える必要があります。PREPENDAPPEND の読み込みリクエストは、リモート REFRESH 読み込みの正常終了を強制的に待たされます。PREPENDAPPEND のリクエストがキューに追加されたのは REFRESH リクエストより前のため、これらの読み込み呼び出しに渡された PagingState は、実行時には古くなっている可能性があります。

データのローカルでの保存方法によっては、キャッシュ データの変更によって無効化や新しいデータの取得が起こる場合、アプリは冗長なリクエストを無視できます。たとえば、Room はデータ挿入関連のクエリをすべて無効化します。つまり、データベースに新しいデータが挿入された場合、保留中の読み込みリクエストにはデータ更新済みの新しい PagingSource オブジェクトが返されます。

ユーザーに最適かつ最新のデータを表示するには、このデータ同期の問題を解決することが不可欠です。最善の解決策は、主にネットワーク データソースによるデータのページング方法によって異なります。いずれの場合も、リモートキーを使用することで、サーバーからリクエストされた最新のページに関する情報を保存できます。アプリはこの情報を使用して、次に読み込むべきデータのページを特定してリクエストできます。

リモートキーを管理する

リモートキーは、次に読み込むデータをバックエンド サービスに伝えるために RemoteMediator 実装で使用されるキーです。最も単純なケースでは、ページング データの各項目に、簡単に参照できるリモートキーが含まれます。ただし、リモートキーが個々のアイテムに対応していない場合は、個別に保存し、load() メソッドで管理する必要があります。

このセクションでは、個別のアイテムに保存されないリモートキーの収集、保存、更新の方法について説明します。

アイテムのキー

このセクションでは、個々のアイテムに対応するリモートキーの取り扱い方法について説明します。通常、API が個々のアイテムを検出すると、アイテム ID がクエリ パラメータとして渡されます。パラメータ名は、渡された ID の前後どちらのアイテムをサーバーが返すべきかを示します。User モデルクラスの例では、サーバーからの id フィールドを、追加のデータをリクエストする際のリモートキーとして使用しています。

load() メソッドでアイテム固有のリモートキーを管理する必要がある場合、通常はサーバーから取得したデータの ID をリモートキーにします。更新オペレーションは最新のデータを取得するのみのため、読み込みキーを必要としません。同様に、PREPEND オペレーションは更新により常にサーバーから最新データを取得するため、追加のデータを取得する必要はありません。

一方、APPEND オペレーションには ID が必要です。データベースから最後のアイテムを読み込み、その ID を使用して次のデータのページを読み込む必要があります。データベースにアイテムがない場合は endOfPaginationReached を true に設定し、データ更新が必要であることを示します。

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Get the last User object id for the next RemoteKey.
        LoadType.APPEND -> {
          val lastItem = state.lastItemOrNull()

          // You must explicitly check if the last item is null when
          // appending, since passing null to networkService is only
          // valid for initial load. If lastItem is null it means no
          // items were loaded after the initial REFRESH and there are
          // no more items to load.
          if (lastItem == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          lastItem.id
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query)
        }

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      // End of pagination has been reached if no users are returned from the
      // service
      MediatorResult.Success(
        endOfPaginationReached = response.users.isEmpty()
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

ページキー

このセクションでは、個々のアイテムに対応しないリモートキーの取り扱い方法について説明します。

リモートキー テーブルを追加する

リモートキーがリストアイテムに直接関連付けられていない場合は、ローカル データベース内の別のテーブルに保存することをおすすめします。リモートキーのテーブルを表す Room エンティティを定義します。

@Entity(tableName = "remote_keys")
data class RemoteKey(val label: String, val nextKey: String?)

RemoteKey エンティティの DAO も定義する必要があります。

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertOrReplace(remoteKey: RemoteKey)

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  suspend fun remoteKeyByQuery(query: String): RemoteKey

  @Query("DELETE FROM remote_keys WHERE label = :query")
  suspend fun deleteByQuery(query: String)
}

リモートキーで読み込む

load() メソッドでリモート ページキーを管理する必要がある場合は、RemoteMediator基本的な使用方法と次の点が異なる方法で定義する必要があります。

  • リモートキー テーブルの DAO への参照を保持する追加プロパティを含めます。
  • PagingState を使用する代わりにリモートキー テーブルにクエリを実行して、次に読み込むキーを決定します。
  • ページング データ自体に加え、ネットワーク データソースから返されたリモートキーを挿入または保存します。
@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Query remoteKeyDao for the next RemoteKey.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            remoteKeyDao.remoteKeyByQuery(query)
          }

          // You must explicitly check if the page key is null when
          // appending, since null is only valid for initial load.
          // If you receive null for APPEND, that means you have
          // reached the end of pagination and there are no more
          // items to load.
          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          remoteKey.nextKey
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(
          RemoteKey(query, response.nextKey)
        )

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      MediatorResult.Success(
        endOfPaginationReached = response.nextKey == null
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

その場で更新する

上の例のように、アプリでリストの一番上からのネットワーク更新のみをサポートする必要がある場合、RemoteMediator で先頭での読み込み動作を定義する必要はありません。

ただし、アプリでネットワークからローカル データベースへの段階的な読み込みをサポートする必要がある場合は、アンカー(ユーザーのスクロール位置)からページ分けを再開できるようにする必要があります。Room の PagingSource 実装がこれを行いますが、Room を使用していない場合は PagingSource.getRefreshKey() をオーバーライドします。getRefreshKey() の実装例については、PagingSource を定義するをご覧ください。

図 2 は、最初にローカル データベースからデータを読み込み、データベースのデータがなくなり次第ネットワークからデータを読み込むプロセスを示しています。

PagingSource は、データベースのデータがなくなるまで、データベースから UI にデータを読み込みます。次に、RemoteMediator がネットワークからデータベースにデータを読み込みます。その後、PagingSource が読み込みを続行します。
図 2. PagingSource と RemoteMediator がどのように連携してデータを読み込むかを示す図。

参考情報

ページング ライブラリについて詳しくは、以下の参考情報をご覧ください。

コンテンツの閲覧