تحميل البيانات المقسّمة إلى صفحات وعرضها (طرق العرض)

مفاهيم وتنفيذ باستخدام Jetpack Compose

توفّر مكتبة Paging إمكانيات فعّالة لتحميل البيانات المقسَّمة إلى صفحات وعرضها من مجموعة بيانات أكبر. يوضّح هذا الدليل كيفية استخدام مكتبة Paging لإعداد مصدر بيانات مقسَّمة إلى صفحات من مصدر بيانات شبكة وعرضها في RecyclerView.

تحديد مصدر بيانات

الخطوة الأولى هي تحديد عملية تنفيذ PagingSource لتحديد مصدر البيانات. يتضمّن صف PagingSource API الطريقة load التي يمكنك إلغاؤها للإشارة إلى كيفية استرداد البيانات المقسَّمة إلى صفحات من مصدر البيانات المقابل.

يمكنك استخدام صف PagingSource مباشرةً لاستخدام إجراءات Kotlin الفرعية للتحميل غير المتزامن. توفّر مكتبة Paging أيضًا صفوفًا لدعم أُطر العمل غير المتزامنة الأخرى:

  • لاستخدام RxJava، يمكنك تنفيذ RxPagingSource بدلاً من ذلك.
  • لاستخدام ListenableFuture من Guava، يمكنك تنفيذ ListenableFuturePagingSource بدلاً من ذلك.

اختيار أنواع المفاتيح والقيم

PagingSource<Key, Value> يتضمّن مَعلمتَين للنوع: Key وValue. يحدّد المفتاح المعرّف المستخدَم لتحميل البيانات، والقيمة هي نوع البيانات نفسها. على سبيل المثال، إذا كنت تحمّل صفحات من عناصر User من الشبكة عن طريق تمرير أرقام الصفحات Int إلى Retrofit، اختَر Int كنوع Key وUser كنوع Value.

تحديد PagingSource

ينفّذ المثال التالي PagingSource الذي يحمّل صفحات من العناصر حسب رقم الصفحة. نوع Key هو Int ونوع Value هو 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 في هذا المثال المفتاح لكل عملية تحميل وتوفّر المفتاح لعملية التحميل اللاحقة.

في كل طلب تحميل، يستقبل ExamplePagingSource المفتاح الحالي
    ويعرض المفتاح التالي الذي سيتم تحميله.
الشكل 1. مخطط يوضّح كيف تستخدم الدالة load المفتاح وتعدّله.

يجب أن تنفّذ عملية تنفيذ PagingSource أيضًا طريقة getRefreshKey التي تأخذ عنصر PagingState كمعلَمة. تعرض هذه الطريقة المفتاح الذي سيتم تمريره إلى الطريقة load عند إعادة تحميل البيانات أو إبطالها بعد التحميل الأولي. تستدعي مكتبة Paging هذه الطريقة تلقائيًا عند عمليات إعادة التحميل اللاحقة للبيانات.

التعامل مع الأخطاء

قد تفشل طلبات تحميل البيانات لعدد من الأسباب، خاصةً عند التحميل عبر الشبكة. يمكنك الإبلاغ عن الأخطاء التي تحدث أثناء التحميل عن طريق عرض عنصر LoadResult.Error من الطريقة load.

على سبيل المثال، يمكنك رصد أخطاء التحميل والإبلاغ عنها في ExamplePagingSource من المثال السابق عن طريق إضافة ما يلي إلى الطريقة load:

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.

تجمع PagingSource عناصر LoadResult.Error وتسلّمها إلى واجهة المستخدم حتى تتمكّن من اتّخاذ إجراء بشأنها. لمزيد من المعلومات حول عرض حالة التحميل في واجهة المستخدم، يُرجى الاطّلاع على إدارة حالات التحميل وعرضها.

إعداد مصدر بيانات PagingData

بعد ذلك، تحتاج إلى مصدر بيانات مقسَّمة إلى صفحات من عملية تنفيذ PagingSource. يمكنك إعداد مصدر البيانات في ViewModel. يوفّر صف Pager طرقًا تعرض مصدرًا تفاعليًا لعناصر PagingData من PagingSource. تتيح مكتبة Paging استخدام عدة أنواع من المصادر، بما في ذلك Flow, LiveData وأنواع Flowable وObservable من RxJava.

عند إنشاء مثيل 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 المقدَّم. يستخدم هذا المثال viewModelScope الذي يوفّره العنصر lifecycle-viewmodel-ktx في دورة الحياة.

يستدعي العنصر Pager الطريقة load من العنصر PagingSource، ويقدّم له العنصر LoadParams ويتلقّى العنصر LoadResult في المقابل.

تحديد محوّل RecyclerView

عليك أيضًا إعداد محوّل لتلقّي البيانات في قائمة RecyclerView. توفّر مكتبة Paging صف PagingDataAdapter لهذا الغرض.

يمكنك تحديد صف يوسّع PagingDataAdapter. في المثال، UserAdapter يوسّع PagingDataAdapter لتوفير محوّل RecyclerView لعناصر القائمة من النوع User واستخدام 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);
  }
}

يجب أن يحدّد المحوّل أيضًا الطريقتَين onCreateViewHolder و onBindViewHolder ويحدّد 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 الآن البيانات المقسَّمة إلى صفحات من مصدر البيانات وتحمّل تلقائيًا صفحة أخرى عند الضرورة.