網路和資料庫的網頁

請確認你的應用程式可在網路連線不穩定或使用者離線時使用,以提供更優質的使用者體驗。要達到這個目的,其中一個做法是同時從網路和本機資料庫載入網頁。 這樣一來,你的應用程式會從本機資料庫快取驅動使用者介面,只有在資料庫中沒有其他資料時,才會向網路傳送要求。

本指南假設你已熟悉 Room 永久性程式庫分頁程式庫的基本用法

協調資料載入作業

分頁程式庫為此用途提供 RemoteMediator 元件。在應用程式快取資料不足時,RemoteMediator 可視為來自分頁程式庫的信號。你可以使用這個信號從網路載入其他資料,並將其儲存至本機資料庫。PagingSource 可以載入資料,並提供給使用者介面顯示。

如果你需要更多資料,分頁程式庫會從 RemoteMediator 實作呼叫 load() 方法。這是一個暫停函式,因此可以放心執行長時間的工作。這項函式通常會從網路來源擷取新資料,並儲存至本機儲存空間。

這項程序適用於新資料,但儲存在資料庫中的資料一段時間後必然會失效,例如使用者手動觸發重新整理作業時。這個情況會以傳遞至 load() 方法的 LoadType 屬性表示。LoadType 會通知 RemoteMediator 是否需要重新整理現有資料,或擷取需要附加到現有清單之前或之後執行的其他資料。

在這種情況下,RemoteMediator 可確保你的應用程式以適當的順序載入使用者想查看的資料。

分頁生命週期

圖 1. 包含 PagingSource 和 PagingData 的分頁生命週期圖表。

直接從網路分頁時,PagingSource 會載入資料並傳回 LoadResult 物件。PagingSource 實作會透過 pagingSourceFactory 參數傳遞至 Pager

因為使用者介面需要新資料,Pager 會從 PagingSource 呼叫 load() 方法並傳回一組封裝新資料的 PagingData 物件。一般來說,每個 PagingData 物件都會先從 ViewModel 快取,然後再傳送到使用者介面顯示。

圖 2. 包含 PagingSource 和 RemoteMediator 的分頁生命週期圖表。

RemoteMediator 會變更這個資料流程。PagingSource 仍會載入資料。不過,一旦分頁資料用盡,分頁程式庫就會觸發 RemoteMediator,從網路來源載入新資料。RemoteMediator 會將新資料儲存在本機資料庫中,因此不需要 ViewModel 中的記憶體內快取。最後,PagingSource 會自動失效,而 Pager 會建立新的執行個體,以從資料庫載入最新資料。

基本用法

假設你要應用程式將透過項目鍵分頁的網路資料來源 User 項目頁面載入到 Room 資料庫中儲存的本機快取。

RemoteMediator 會將網路中的資料載入到資料庫,而 PagingSource 會從資料庫載入資料。Pager 會同時使用 RemoteMediator 和 PagingSource 載入分頁資料。
圖 3. 使用分層資料來源的分頁實作圖表。

RemoteMediator 實作可將網路的分頁資料載入到資料庫,但不會將資料直接載入到使用者介面。不過,應用程式會使用資料庫做為真實資訊來源。換句話說,應用程式只會顯示資料庫中已快取的資料。PagingSource 實作 (例如 Room 產生的實作) 會負責將資料庫的快取資料載入到使用者介面。

建立 Room 實體

第一步是使用 Room 永久保存程式庫來定義資料庫,其中包含網路資料來源的分頁資料本機快取。首先請實作 RoomDatabase,詳情請參閱使用 Room 將資料儲存在本機資料庫中的說明。

接下來,請定義 Room 實體來代表清單項目的資料表,如使用 Room 實體定義資料一節所述。將 id 欄位設為主鍵,以及清單項目包含的任何其他資訊欄位。

Kotlin

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

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

你也必須按照使用 Room DAO 存取資料一節所述,定義這個 Room 實體的資料存取物件 (DAO)。清單項目實體的 DAO 必須包含以下方法:

  • 可將項目清單插入資料表中的 insertAll() 方法。
  • 可將查詢字串視為參數,並傳回結果清單 PagingSource 物件的方法。這樣一來,Pager 物件就可以使用這個資料表做為分頁資料來源。
  • 用於刪除資料表中所有資料的 clearAll() 方法。

Kotlin

@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()
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

實作 RemoteMediator

RemoteMediator 的主要角色是在 Pager 的資料用盡或現有資料失效時,從網路載入更多資料。其中包含一個必須覆寫以定義載入行為的 load() 方法。

常見的 RemoteMediator 導入方式包含下列參數:

  • query:這個查詢字串會定義要從後端服務擷取的資料。
  • database:做為本機快取的 Room 資料庫。
  • networkService:後端服務的 API 執行個體。

建立 RemoteMediator<Key, Value> 實作。Key 類型和 Value 類型應與定義該網路資料來源的 PagingSource 時相同。如要進一步瞭解如何選取類型參數,請參閱選取鍵和值類型

Kotlin

@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 {
    // ...
  }
}

Java

@UseExperimental(markerClass = ExperimentalPagingApi.class)
class ExampleRemoteMediator extends RxRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService, RoomDb database
  ) {
    query = query;
    networkService = networkService;
    database = database;
    userDao = database.userDao();
  }

  @NotNull
  @Override
  public Single<MediatorResult> loadSingle(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

Java

class ExampleRemoteMediator extends ListenableFutureRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;
  private Executor bgExecutor;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService,
    RoomDb database,
    Executor bgExecutor
  ) {
    this.query = query;
    this.networkService = networkService;
    this.database = database;
    this.userDao = database.userDao();
    this.bgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<MediatorResult> loadFuture(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

load() 方法會負責更新備份資料集並使 PagingSource 失效。部分支援分頁的程式庫 (例如 Room) 會自動使已實作的 PagingSource 物件失效。

load() 方法可接受兩個參數:

load() 方法的傳回值為 MediatorResult 物件。MediatorResult 可以是 MediatorResult.Error (含錯誤說明) 或 MediatorResult.Success (當中包含信號,指出是否要載入更多資料)。

load() 方法必須執行下列步驟:

  1. 根據載入類型和目前載入的資料,判斷要從網路載入哪些網頁。
  2. 觸發網路要求。
  3. 根據載入作業的結果執行動作:
    • 如果載入成功且收到的項目清單並非空白,則將清單項目儲存在資料庫中,然後傳回 MediatorResult.Success(endOfPaginationReached = false)。資料儲存完畢後,讓資料來源失效以通知新資料的分頁程式庫。
    • 如果載入成功且收到的項目清單為空白或是最後一頁索引,則傳回 MediatorResult.Success(endOfPaginationReached = true)。資料儲存完畢後,讓資料來源失效以通知新資料的分頁程式庫。
    • 如果要求導致錯誤發生,則傳回 MediatorResult.Error

Kotlin

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)
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // 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.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // 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.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User 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 Single.just(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  return networkService.searchUsers(query, loadKey)
    .subscribeOn(Schedulers.io())
    .map((Function<SearchUserResponse, MediatorResult>) response -> {
      database.runInTransaction(() -> {
        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.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // 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.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // 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.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User 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 Futures.immediateFuture(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  ListenableFuture<MediatorResult> networkResult = Futures.transform(
    networkService.searchUsers(query, loadKey),
    response -> {
      database.runInTransaction(() -> {
        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.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

  ListenableFuture<MediatorResult> ioCatchingNetworkResult =
    Futures.catching(
      networkResult,
      IOException.class,
      MediatorResult.Error::new,
      bgExecutor
    );

  return Futures.catching(
    ioCatchingNetworkResult,
    HttpException.class,
    MediatorResult.Error::new,
    bgExecutor
  );
}

定義 initialize 方法

RemoteMediator 實作也可以覆寫 initialize() 方法,檢查快取資料是否過時,並決定是否觸發遠端重新整理。這個方法會在所有載入作業之前執行,因此你可以在觸發任何本機或遠端載入之前,先操控資料庫 (例如清除舊資料)。

由於 initialize() 是非同步函式,因此你可以載入資料來確定資料庫中現有資料的關聯性。最常見的情況是,快取資料僅在一段時間內有效。RemoteMediator 可以檢查這個到期時間是否已過期;在這個情況下,分頁程式庫必須全面重新整理資料,initialize() 的實作應會傳回 InitializeAction,如下所示:

  • 如果本機資料需要完全重新整理,initialize() 會傳回 InitializeAction.LAUNCH_INITIAL_REFRESH。這會導致 RemoteMediator 執行遠端重新整理以完全重新載入資料。任何遠端 APPENDPREPEND 載入都會等待 REFRESH 載入成功,再進行下一步。
  • 如果本機資料不需要重新整理,initialize() 會傳回 InitializeAction.SKIP_INITIAL_REFRESH。這會導致 RemoteMediator 略過遠端重新整理並載入快取的資料。

Kotlin

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
  }
}

Java

@NotNull
@Override
public Single<InitializeAction> initializeSingle() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return mUserDao.lastUpdatedSingle()
    .map(lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return 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.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    });
}

Java

@NotNull
@Override
public ListenableFuture<InitializeAction> initializeFuture() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return Futures.transform(
    mUserDao.lastUpdated(),
    lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return 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.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    },
    mBgExecutor);
}

建立呼叫器

最後,你必須建立 Pager 執行個體,才能設定分頁資料串流。方法與從簡單的網路資料來源建立 Pager 類似,但有兩件事必須採取不同做法:

  • 你必須提供直接從 DAO 傳回 PagingSource 物件的查詢方法,而不是直接傳送 PagingSource 建構函式。
  • 你必須提供 RemoteMediator 實作執行個體做為 remoteMediator 參數。

Kotlin

val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey,
  new ExampleRemoteMediator(query, database, networkService)
  () -> userDao.pagingSource(query));

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey
  new ExampleRemoteMediator(query, database, networkService, bgExecutor),
  () -> userDao.pagingSource(query));

處理競爭狀況

從多個來源載入資料時,應用程式必須處理的一個情況是本機快取資料與遠端資料來源不同步。

RemoteMediator 實作的 initialize() 方法傳回 LAUNCH_INITIAL_REFRESH 時,資料就會過時且必須替換成新資料。系統會強制要求任何 PREPENDAPPEND 載入要求等待遠端 REFRESH 載入成功。由於 PREPENDAPPEND 要求已在 REFRESH 要求之前加入佇列,因此傳遞給這些載入呼叫的 PagingState 在執行時可能已經過舊。

視資料在本機的儲存方式而定,如果快取資料的變更導致失效並擷取新的資料,你的應用程式可能會忽略多餘的要求。例如,Room 會讓任何資料插入查詢失效。也就是說,當新資料插入資料庫時,系統會為待處理的載入要求提供具有重新整理資料的新 PagingSource 物件。

解決這項資料同步處理問題至關重要,如此才能確保使用者可查看最相關且最新的資料。要找出最佳解決方案,主要參考依據是網路資料來源將資料分頁的方式。在任何情況下,遠端鍵都可讓你儲存伺服器要求的最新網頁相關資訊。您的應用程式可運用這項資訊,來辨識及要求接下來要載入的正確資料頁面。

管理遠端鍵

RemoteMediator 實作項目會使用「遠端鍵」,告知後端服務接下來要載入哪些資料。在最簡單的情況下,頁面資料的每個項目都包含一個可輕鬆參照的遠端鍵。不過,如果遠端鍵並未對應至個別項目,就必須分開儲存並在你的 load() 方法中加以管理。

本節說明如何收集、儲存及更新未儲存在個別項目中的遠端鍵。

項目鍵

本節說明如何使用與個別項目對應的遠端鍵。一般而言,當 API 金鑰從個別項目中移除時,項目 ID 會以查詢參數的形式傳遞。參數名稱會指出伺服器應在提供的指定 ID 之前或之後運用項目來回應。在 User 模型類別的範例中,來自伺服器的 id 欄位會當做遠端鍵,用於要求其他資料。

load() 方法需要管理項目專屬的遠端鍵時,這些鍵通常是從伺服器擷取的資料 ID。重新整理作業不需要載入鍵,因為這類作業只會擷取最新的資料。同樣地,前附作業不需要擷取任何其他資料,因為重新整理功能一律會從伺服器提取最新資料。

但是,附加作業就必須使用 ID。你必須從資料庫載入最後一個項目,並使用該項目的 ID 載入下一頁的資料。如果資料庫中沒有任何項目,則 endOfPaginationReached 會設為 true,表示需要重新整理資料。

Kotlin

@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)
    }
  }
}

Java

@NotNull
@Override
public Single>MediatorResult< loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState>Integer, User< state
) {
  // 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.
  Single>String< remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(null);
      break;
    case PREPEND:
      // 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.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User 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 Single.just(new MediatorResult.Success(true));
      }
      remoteKeySingle = Single.just(lastItem.getId());
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<String, Single<MediatorResult>>) remoteKey -> {
      return networkService.searchUsers(query, remoteKey)
        .map(response -> {
          database.runInTransaction(() -> {
            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.getUsers());
          });

          return new MediatorResult.Success(response.getUsers().isEmpty());
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // 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.
  ResolvableFuture<String> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(null);
      break;
    case PREPEND:
      // 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.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User 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 Futures.immediateFuture(new MediatorResult.Success(true));
      }

      remoteKeyFuture.set(lastItem.getId());
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey),
      response -> {
        database.runInTransaction(() -> {
        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.getUsers());
      });

      return new MediatorResult.Success(response.getUsers().isEmpty());
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

頁面鍵

本節說明如何使用不需對應至個別項目的遠端鍵。

新增遠端鍵資料表

當遠端鍵沒有與清單項目直接關聯時,最好將鍵儲存在本機資料庫的不同資料表中。定義代表遠端鍵資料表的 Room 實體:

Kotlin

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

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

你也必須定義 RemoteKey 實體的 DAO:

Kotlin

@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)
}

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  Single<RemoteKey> remoteKeyByQuerySingle(String query);

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

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  ListenableFuture<RemoteKey> remoteKeyByQueryFuture(String query);

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

使用遠端鍵載入

load() 方法需要管理遠端頁面鍵時,你必須採取下列與 RemoteMediator 基本用法不同的方式加以定義:

  • 納入另一個屬性,其中包含遠端鍵資料表的 DAO 參照。
  • 透過查詢遠端鍵資料表 (而非使用 PagingState) 來決定要載入哪些鍵。
  • 除了分頁資料本身之外,同時也插入或儲存網路資料來源傳回的遠端鍵。

Kotlin

@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)
    }
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // 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.
  Single<RemoteKey> remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(new RemoteKey(mQuery, null));
      break;
    case PREPEND:
      // 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.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      // Query remoteKeyDao for the next RemoteKey.
      remoteKeySingle = mRemoteKeyDao.remoteKeyByQuerySingle(mQuery);
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<RemoteKey, Single<MediatorResult>>) remoteKey -> {
      // 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 (loadType != REFRESH && remoteKey.getNextKey() == null) {
        return Single.just(new MediatorResult.Success(true));
      }

      return networkService.searchUsers(query, remoteKey.getNextKey())
        .map(response -> {
          database.runInTransaction(() -> {
            if (loadType == LoadType.REFRESH) {
              userDao.deleteByQuery(query);
              remoteKeyDao.deleteByQuery(query);
            }

            // Update RemoteKey for this query.
            remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

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

          return new MediatorResult.Success(response.getNextKey() == null);
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // 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.
  ResolvableFuture<RemoteKey> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(new RemoteKey(query, null));
      break;
    case PREPEND:
      // 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.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User 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 Futures.immediateFuture(new MediatorResult.Success(true));
      }

      // Query remoteKeyDao for the next RemoteKey.
      remoteKeyFuture.setFuture(
        remoteKeyDao.remoteKeyByQueryFuture(query));
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {
    // 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 (loadType != LoadType.REFRESH && remoteKey.getNextKey() == null) {
      return Futures.immediateFuture(new MediatorResult.Success(true));
    }

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey.getNextKey()),
      response -> {
        database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
          remoteKeyDao.deleteByQuery(query);
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

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

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

就地重新整理

如果應用程式只需要如上述範例所述,支援從清單最上方依序重新整理網路,則你的 RemoteMediator 不需要定義前附載入行為。

不過,如果應用程式需要支援從網路逐步將資料載入到本機資料庫的功能,則您必須提供從錨點 (使用者的捲動位置) 開始恢復分頁程序的支援。Room 的 PagingSource 實作會為您處理這項作業,但如果未使用 Room,則可覆寫 PagingSource.getRefreshKey()。如需 getRefreshKey() 的導入範例,請參閱定義 PagingSource

圖 4 說明先從本機資料庫載入資料,然後當資料庫資料用盡之後,再從網路載入資料的程序。

PagingSource 將資料從資料庫載入到使用者介面,直到資料庫資料用盡。接著,RemoteMediator 會將資料從網路載入到資料庫,之後 PagingSource 會繼續載入。
圖 4. 顯示 PagingSource 和 RemoteMediator 如何合作載入資料的圖表。

其他資源

如要進一步瞭解 Paging 程式庫,請參閱以下資源:

程式碼研究室

範例