載入並顯示分頁資料 (檢視區塊)

概念和 Jetpack Compose 實作

分頁程式庫提供強大的功能,可載入並顯示大型資料集內的分頁資料。本指南說明如何使用分頁程式庫,設定來自網路資料來源的分頁資料串流,並顯示在 RecyclerView 中。

定義資料來源

首先,您必須定義 PagingSource 實作來識別資料來源。PagingSource API 類別包含 load 方法,您必須覆寫該方法,指出如何從對應的資料來源擷取分頁資料。

請直接透過 PagingSource 類別,使用 Kotlin 協同程式執行非同步載入。分頁程式庫也提供支援其他非同步架構的類別:

選取鍵和值類型

PagingSource<Key, Value> 有兩個類型參數:KeyValue。鍵會定義用於載入資料的 ID,值則是資料本身的類型。舉例來說,假設您將 Int 頁碼傳遞至 Retrofit,從網路載入 User 物件的網頁,就可選取 Int 做為 Key 的類型,並將 User 設為 Value 的類型。

定義 PagingSource

以下範例會實作 PagingSource,依據頁碼載入項目頁面。Key 類型為 IntValue 類型為 User

Java (RxJava)

class ExamplePagingSource extends RxPagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;

  ExamplePagingSource(@NonNull ExampleBackendService backend,
    @NonNull String query) {
    mBackend = backend;
    mQuery = query;
  }

  @NotNull
  @Override
  public Single<LoadResult<Integer, User>> loadSingle(
    @NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    return mBackend.searchUsers(mQuery, nextPageNumber)
      .subscribeOn(Schedulers.io())
      .map(this::toLoadResult)
      .onErrorReturn(LoadResult.Error::new);
  }

  private LoadResult<Integer, User> toLoadResult(
    @NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(
      response.getUsers(),
      null, // Only paging forward.
      response.getNextPageNumber(),
      LoadResult.Page.COUNT_UNDEFINED,
      LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

Java (Guava/LiveData)

class ExamplePagingSource extends ListenableFuturePagingSource<Integer, User> {
  @NonNull
  private ExampleBackendService mBackend;
  @NonNull
  private String mQuery;
  @NonNull
  private Executor mBgExecutor;

  ExamplePagingSource(
    @NonNull ExampleBackendService backend,
    @NonNull String query, @NonNull Executor bgExecutor) {
    mBackend = backend;
    mQuery = query;
    mBgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<LoadResult<Integer, User>> loadFuture(@NotNull LoadParams<Integer> params) {
    // Start refresh at page 1 if undefined.
    Integer nextPageNumber = params.getKey();
    if (nextPageNumber == null) {
      nextPageNumber = 1;
    }

    ListenableFuture<LoadResult<Integer, User>> pageFuture =
      Futures.transform(mBackend.searchUsers(mQuery, nextPageNumber),
      this::toLoadResult, mBgExecutor);

    ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture =
      Futures.catching(pageFuture, HttpException.class,
      LoadResult.Error::new, mBgExecutor);

    return Futures.catching(partialLoadResultFuture,
      IOException.class, LoadResult.Error::new, mBgExecutor);
  }

  private LoadResult<Integer, User> toLoadResult(@NonNull SearchUserResponse response) {
    return new LoadResult.Page<>(response.getUsers(),
    null, // Only paging forward.
    response.getNextPageNumber(),
    LoadResult.Page.COUNT_UNDEFINED,
    LoadResult.Page.COUNT_UNDEFINED);
  }

  @Nullable
  @Override
  public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
    // Try to find the page key of the closest page to anchorPosition from
    // either the prevKey or the nextKey; you need to handle nullability
    // here.
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey are null -> anchorPage is the
    //    initial page, so return null.
    Integer anchorPosition = state.getAnchorPosition();
    if (anchorPosition == null) {
      return null;
    }

    LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
    if (anchorPage == null) {
      return null;
    }

    Integer prevKey = anchorPage.getPrevKey();
    if (prevKey != null) {
      return prevKey + 1;
    }

    Integer nextKey = anchorPage.getNextKey();
    if (nextKey != null) {
      return nextKey - 1;
    }

    return null;
  }
}

一般 PagingSource 實作會將建構函式中的參數傳遞至 load 方法,為查詢載入適合的資料。在上述範例中,相關的參數如下:

  • backend:提供資料的後端服務例項
  • query:要傳送到 backend 指定服務的搜尋查詢

LoadParams 物件包含要執行的載入作業相關資訊。其中包括要載入的鍵和要載入的項目數量。

LoadResult 物件包含載入作業的結果。 LoadResult 是密封類別;視 load 呼叫是否成功而定,可採用下列其中一個形式:

  • 如果載入成功,就傳回 LoadResult.Page 物件。
  • 如果載入失敗,則傳回 LoadResult.Error 物件。

下圖說明這個範例中的 load 函式如何接收每個載入作業的鍵,並提供後續載入作業的鍵。

在每次 load 呼叫中,ExamplePagingSource 會接受目前的鍵,並傳回下一個要載入的鍵。
圖 1. 圖表說明 load 如何使用及更新鍵。

PagingSource 實作項目也必須實作 getRefreshKey 方法,後者採用 PagingState 物件做為參數。當資料重新整理或在初始載入作業完成後失效時,這個方法會傳回要傳入 load 方法的鍵。在後續重新整理資料時,分頁程式庫會自動呼叫這個方法。

處理錯誤

有很多原因會造成資料載入要求失敗,尤其是透過網路載入時。從 load 方法傳回 LoadResult.Error 物件,即可回報載入期間發生的錯誤。

舉例來說,只要將下方程式碼加入 load 方法,你就能擷取及回報前一個範例 ExamplePagingSource 中的載入錯誤:

Java (RxJava)

return backend.searchUsers(searchTerm, nextPageNumber)
  .subscribeOn(Schedulers.io())
  .map(this::toLoadResult)
  .onErrorReturn(LoadResult.Error::new);

Java (Guava/LiveData)

ListenableFuture<LoadResult<Integer, User>> pageFuture = Futures.transform(
  backend.searchUsers(query, nextPageNumber), this::toLoadResult,
  bgExecutor);

ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture = Futures.catching(
  pageFuture, HttpException.class, LoadResult.Error::new,
  bgExecutor);

return Futures.catching(partialLoadResultFuture,
  IOException.class, LoadResult.Error::new, bgExecutor);

如要進一步瞭解如何處理 Retrofit 錯誤,請參閱 PagingSource API 參考資料中的範例。

PagingSource 會收集 LoadResult.Error 物件並傳送至 UI,方便您執行操作。如要進一步瞭解如何在 UI 中顯示載入狀態,請參閱「管理及顯示載入狀態」一文。

設定 PagingData 串流

接下來,您需要用到來自 PagingSource 實作的串流分頁資料。請在 ViewModel 中設定資料串流。Pager 類別提供的方法,可對來自 PagingSourcePagingData 物件顯示回應式串流。分頁程式庫支援使用多種串流類型,包括 Flow, LiveData,以及來自 RxJava 的 FlowableObservable 類型。

建立 Pager 執行個體以設定回應式串流時,你必須為執行個體提供 PagingConfig 設定物件和一個用於通知 Pager 如何取得 PagingSource 實作執行個體的函式:

Java (RxJava)

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

Flowable<PagingData<User>> flowable = PagingRx.getFlowable(pager);
PagingRx.cachedIn(flowable, viewModelScope);

Java (Guava/LiveData)

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
  new PagingConfig(/* pageSize = */ 20),
  () -> ExamplePagingSource(backend, query));

PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope);

cachedIn 運算子將使資料串流變得可共用,並透過提供的 CoroutineScope 快取已載入的資料。這個範例會用到生命週期 lifecycle-viewmodel-ktx 構件提供的 viewModelScope

Pager 物件會呼叫來自 PagingSource 物件的 load 方法,為其提供 LoadParams 物件,並接收傳回的 LoadResult 物件。

定義 RecyclerView 轉換介面

你也必須設定轉換介面將資料納入 RecyclerView 清單中。為了達成這個目的,分頁程式庫提供了 PagingDataAdapter 類別。

定義擴充 PagingDataAdapter 的類別。在範例中,UserAdapter 擴充 PagingDataAdapter 以提供 User 類型的 RecyclerView 轉換介面清單項目,並使用 UserViewHolder 做為檢視畫面預留位置

Kotlin (協同程式)

class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
  PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): UserViewHolder {
    return UserViewHolder(parent)
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val item = getItem(position)
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item)
  }
}

Java (RxJava)

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

Java (Guava/LiveData)

class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
  UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
    super(diffCallback);
  }

  @NonNull
  @Override
  public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    return new UserViewHolder(parent);
  }

  @Override
  public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
    User item = getItem(position);
    // Note that item can be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item);
  }
}

轉換介面也必須定義 onCreateViewHolderonBindViewHolder 方法,並指定 DiffUtil.ItemCallback。其運作方式與定義 RecyclerView 清單轉換介面時相同:

Kotlin (協同程式)

object UserComparator : DiffUtil.ItemCallback<User>() {
  override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
    // Id is unique.
    return oldItem.id == newItem.id
  }

  override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
    return oldItem == newItem
  }
}

Java (RxJava)

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

Java (Guava/LiveData)

class UserComparator extends DiffUtil.ItemCallback<User> {
  @Override
  public boolean areItemsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    // Id is unique.
    return oldItem.id.equals(newItem.id);
  }

  @Override
  public boolean areContentsTheSame(@NonNull User oldItem,
    @NonNull User newItem) {
    return oldItem.equals(newItem);
  }
}

在使用者介面中顯示分頁資料

現在你已定義 PagingSource、建立一個方式讓應用程式產生 PagingData 串流,並定義 PagingDataAdapter,你已經準備好將這些元素串連在一起,並在你的活動中顯示頁面資料。

在活動的 onCreate 或片段的 onViewCreated 方法中執行下列步驟:

  1. 建立 PagingDataAdapter 類別的執行個體。
  2. PagingDataAdapter 例項傳遞至要顯示分頁資料的 RecyclerView 清單。
  3. 觀察 PagingData 串流,並將每個產生的值傳遞至轉接程式的 submitData() 方法。

Kotlin (協同程式)

val viewModel by viewModels<ExampleViewModel>()

val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

// Activities can use lifecycleScope directly; fragments use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

Java (RxJava)

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

viewModel.flowable
  // Using AutoDispose to handle subscription lifecycle.
  // See: https://github.com/uber/AutoDispose.
  .to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
  .subscribe(pagingData -> pagingAdapter.submitData(lifecycle, pagingData));

Java (Guava/LiveData)

ExampleViewModel viewModel = new ViewModelProvider(this)
  .get(ExampleViewModel.class);

UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
  R.id.recycler_view);
recyclerView.adapter = pagingAdapter

// Activities can use getLifecycle() directly; fragments use
// getViewLifecycleOwner().getLifecycle().
viewModel.liveData.observe(this, pagingData ->
  pagingAdapter.submitData(getLifecycle(), pagingData));

RecyclerView 清單現在會顯示資料來源的分頁資料,並在必要時自動載入另一個頁面。