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

المفاهيم والتنفيذ في Jetpack Compose

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

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

تتمثل الخطوة الأولى في تحديد تنفيذ PagingSource لتحديد مصدر البيانات. يتضمّن فئة واجهة برمجة التطبيقات PagingSource طريقة load، التي يمكنك إلغاء تعريفها لتحديد كيفية استرداد البيانات المقسّمة إلى صفحات من مصدر البيانات المناسب.

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

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

تحتوي 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 (Coroutines)

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 (Coroutines)

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 (Coroutines)

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