صفحة من الشبكة وقاعدة البيانات

يمكنك تقديم تجربة محسّنة للمستخدم من خلال التأكّد من إمكانية استخدام التطبيق عندما تكون اتصالات الشبكة غير موثوقة أو عندما يكون المستخدِم غير متصل بالإنترنت. تتمثل إحدى طرق القيام بذلك في الانتقال إلى صفحة من الشبكة ومن قاعدة بيانات محلية في نفس الوقت. وبهذه الطريقة، ينقل تطبيقك واجهة المستخدم من ذاكرة التخزين المؤقت لقاعدة البيانات المحلية ولا يُجري الطلبات إلا على الشبكة عندما لا يكون هناك مزيد من البيانات في قاعدة البيانات.

يفترض هذا الدليل أنك على دراية بـ مكتبة استمرارية الغرف بالإضافة إلى الاستخدام الأساسي لمكتبة "تسجيل الصفحات".

تنسيق عمليات تحميل البيانات

توفّر مكتبة تسجيل الصفحات المكوِّن RemoteMediator لحالة الاستخدام هذه. يعمل RemoteMediator كإشارة من مكتبة تسجيل الصفحات عند نفاد البيانات المخزَّنة مؤقتًا في التطبيق. يمكنك استخدام هذه الإشارة لتحميل بيانات إضافية من الشبكة وتخزينها في قاعدة البيانات المحلية، حيث يمكن لـ PagingSource تحميلها وتقديمها إلى واجهة المستخدم لعرضها.

عند الحاجة إلى بيانات إضافية، تستدعي "مكتبة تسجيل الصفحات" الطريقة load() من عملية تنفيذ RemoteMediator. هذه دالة تعليق، لذا فهي آمنة لأداء العمل الطويل الأمد. عادةً ما تجلب هذه الدالة البيانات الجديدة من مصدر شبكة وتحفظها في التخزين المحلي.

تعمل هذه العملية مع البيانات الجديدة، ولكن بمرور الوقت، تتطلّب البيانات المخزّنة في قاعدة البيانات إبطالًا، مثل ما يحدث عندما يشغّل المستخدم عملية التحديث يدويًا. ويتم تمثيل ذلك من خلال السمة LoadType التي يتم تمريرها إلى طريقة load(). وتخبر السمة LoadType RemoteMediator ما إذا كان يجب إعادة تحميل البيانات الحالية أو جلب بيانات إضافية يجب إلحاقها بالقائمة الحالية أو إلحاقها بها.

بهذه الطريقة، تضمن RemoteMediator تحميل تطبيقك للبيانات التي يريد المستخدمون الاطّلاع عليها بالترتيب المناسب.

مراحل النشاط

الشكل 1. رسم بياني لدورة حياة عملية وضع الصفحات باستخدام PagingSource وPagingData.

عند نقل البيانات مباشرةً من الشبكة، تُحمِّل PagingSource البيانات وتعرض كائن LoadResult. يتم تمرير عملية تنفيذ PagingSource إلى Pager من خلال المَعلمة pagingSourceFactory.

بما أنّ واجهة المستخدم تطلب البيانات الجديدة، تستدعي Pager الطريقة load() من PagingSource وتعرض تدفق عناصر PagingData التي تضم البيانات الجديدة. يتم عادةً تخزين كل عنصر PagingData مؤقتًا في ViewModel قبل إرساله إلى واجهة المستخدم لعرضه.

الشكل 2. رسم تخطيطي لدورة حياة نظام الترحيل بين الصفحات باستخدام PagingSource و RemoteMediator.

يؤدي RemoteMediator إلى تغيير تدفق البيانات. تواصل PagingSource تحميل البيانات، ولكن عند نفاد البيانات المقسّمة على صفحات، تشغِّل "مكتبة الصفحات" RemoteMediator لتحميل بيانات جديدة من مصدر الشبكة. تخزِّن السمة RemoteMediator البيانات الجديدة في قاعدة البيانات المحلية، وبالتالي لا حاجة إلى استخدام ذاكرة تخزين مؤقت في الذاكرة في ViewModel. وأخيرًا، تُبطل PagingSource نفسها، وتنشئ Pager مثيلاً جديدًا لتحميل البيانات الجديدة من قاعدة البيانات.

الاستخدام الأساسي

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

تحمِّل خدمة RemoteMediator البيانات من الشبكة إلى قاعدة البيانات ويحمِّل PagingSource البيانات من قاعدة البيانات. ويستخدم جهاز النداء كل من RemoteMediator وPagingSource لتحميل البيانات المقسّمة إلى صفحات.
الشكل 3. رسم بياني لتنفيذ التقسيم على صفحات يستخدم مصدر بيانات متعدّد الطبقات.

يساعد تنفيذ RemoteMediator في تحميل البيانات المقسّمة إلى صفحات من الشبكة إلى قاعدة البيانات، ولكن لا يحمِّل البيانات مباشرةً إلى واجهة المستخدم. بدلاً من ذلك، يستخدم التطبيق قاعدة البيانات باعتبارها مصدر الحقيقة. بمعنى آخر، لا يعرض التطبيق سوى البيانات التي تم تخزينها مؤقتًا في قاعدة البيانات. يعالج تنفيذ PagingSource (على سبيل المثال، الذي تم إنشاؤه بواسطة Room) تحميل البيانات المخزّنة مؤقتًا من قاعدة البيانات إلى واجهة المستخدم.

إنشاء عناصر الغرفة

الخطوة الأولى هي استخدام مكتبة استمرارية الغرفة لتحديد قاعدة بيانات تحتوي على ذاكرة تخزين مؤقت محلية للبيانات المقسّمة على صفحات من مصدر بيانات الشبكة. يمكنك البدء بتنفيذ RoomDatabase كما هو موضّح في حفظ البيانات في قاعدة بيانات محلية باستخدام 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;
}

عليك أيضًا تعريف عنصر الوصول إلى البيانات (DAO) لكيان الغرفة هذا على النحو الموضَّح في الوصول إلى البيانات باستخدام عناصر 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: قاعدة بيانات الغرفة التي يتم استخدامها كذاكرة تخزين مؤقت محلية.
  • networkService: مثيل واجهة برمجة التطبيقات لخدمة الخلفية

أنشِئ عملية تنفيذ 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() معلَمتين:

  • PagingState، الذي يحتوي على معلومات حول الصفحات التي تمّ تحميلها حتى الآن، وأحدث فهرس تم الوصول إليه، وكائن PagingConfig الذي استخدمته لإعداد ساحة مشاركات الصفحات.
  • LoadType، التي تشير إلى نوع التحميل: REFRESH أو APPEND أو PREPEND.

والقيمة المعروضة لطريقة 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
  );
}

تحديد طريقة الإعداد

يمكن لعمليات تنفيذ RemoteMediator أيضًا إلغاء طريقة initialize() للتحقق مما إذا كانت البيانات المخزَّنة مؤقتًا قديمة وتحديد ما إذا كان سيتم تفعيل إعادة التحميل عن بُعد. وتعمل هذه الطريقة قبل إجراء أي تحميل، لذلك يمكنك معالجة قاعدة البيانات (على سبيل المثال، لمحو البيانات القديمة) قبل تشغيل أي عمليات تحميل محلية أو بعيدة.

بما أنّ initialize() هي دالة غير متزامنة، يمكنك تحميل البيانات لتحديد مدى صلة البيانات الحالية في قاعدة البيانات بموضوع البحث. تتمثل الحالة الأكثر شيوعًا في أن البيانات المخزنة مؤقتًا تكون صالحة فقط لفترة زمنية معينة. يمكن للRemoteMediator التحقُّق ممّا إذا كان وقت انتهاء الصلاحية هذا قد انقضى، وفي هذه الحالة تحتاج مكتبة تسجيل الصفحات إلى إعادة تحميل البيانات بالكامل. ينبغي أن تعرض عمليات تنفيذ initialize() عنصر InitializeAction على النحو التالي:

  • وفي الحالات التي يجب فيها إعادة تحميل البيانات المحلية بالكامل، من المفترض أن تعرض السمة initialize() InitializeAction.LAUNCH_INITIAL_REFRESH. ويؤدي ذلك إلى إجراء RemoteMediator إعادة تحميل عن بُعد لإعادة تحميل البيانات بالكامل. أي عمليات تحميل APPEND أو PREPEND عن بُعد تنتظر نجاح تحميل 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 من مصدر بيانات بسيط للشبكة، ولكن هناك إجراءان عليك تنفيذهما بشكل مختلف:

  • بدلاً من تمرير الدالة الإنشائية PagingSource مباشرةً، عليك توفير طريقة الاستعلام التي تعرض كائن PagingSource من DAO.
  • يجب توفير مثيل من تنفيذ 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));

التعامل مع حالات السباق

إن أحد الحالات التي يحتاج تطبيقك إلى التعامل معها عند تحميل البيانات من مصادر متعددة هو الحالة التي تصبح فيها البيانات المخزنة مؤقتًا المحلية غير متزامنة مع مصدر البيانات البعيد.

عندما تعرض طريقة initialize() من تنفيذ RemoteMediator الخطأ LAUNCH_INITIAL_REFRESH، تكون البيانات قديمة ويجب استبدالها ببيانات جديدة. يتم فرض الانتظار على أي طلبات تحميل PREPEND أو APPEND إلى أن يتم نجاح تحميل REFRESH عن بُعد. بما أنّه تم وضع طلبات PREPEND أو APPEND في "قائمة المحتوى التالي" قبل طلب REFRESH، من المحتمل أن تصبح طلبات PagingState التي تم تمريرها إلى عمليات التحميل هذه قديمة بحلول وقت تنفيذها.

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

يعد حل مشكلة مزامنة البيانات هذه أمرًا ضروريًا لضمان رؤية المستخدمين للبيانات الأكثر صلة وحداثة. يعتمد أفضل حل في الغالب على الطريقة التي تربط بها مصدر بيانات الشبكة البيانات. في جميع الأحوال، تسمح لك المفاتيح عن بُعد بحفظ معلومات عن أحدث صفحة تم طلبها من الخادم. يمكنك للتطبيق استخدام هذه المعلومات لتحديد صفحة البيانات الصحيحة وطلب تحميلها بعد ذلك.

إدارة المفاتيح عن بُعد

المفاتيح عن بُعد هي مفاتيح يستخدمها تطبيق RemoteMediator لإبلاغ خدمة الخلفية بالبيانات المطلوب تحميلها بعد ذلك. في أبسط الحالات، يتضمن كل عنصر من البيانات المقسمة على صفحات مفتاح بعيد يمكنك الرجوع إليه بسهولة. ومع ذلك، إذا كانت المفاتيح عن بُعد لا تتوافق مع عناصر فردية، يجب تخزينها بشكل منفصل وإدارتها في طريقة load().

يوضّح هذا القسم طريقة جمع وتخزين وتعديل مفاتيح التحكّم عن بُعد غير المخزّنة في عناصر فردية.

مفاتيح العناصر

يصف هذا القسم طريقة العمل باستخدام المفاتيح عن بُعد التي تتوافق مع عناصر فردية. وعادةً، عند إيقاف عناصر واجهة برمجة التطبيقات عن عناصر فردية، يتم تمرير معرّف العنصر كمَعلمة طلب بحث. يشير اسم المعلمة إلى ما إذا كان يجب أن يستجيب الخادم بعناصر قبل المعرّف المقدّم أو بعده. في مثال فئة نموذج User، يُستخدم الحقل id من الخادم كمفتاح عن بُعد عند طلب بيانات إضافية.

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

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

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

مفاتيح الصفحة

يصف هذا القسم طريقة العمل باستخدام مفاتيح التحكم عن بُعد التي لا تتوافق مع عناصر فردية.

إضافة جدول مفتاح التحكّم عن بُعد

عندما لا تكون المفاتيح البعيدة مرتبطة مباشرة بعناصر القائمة، فمن الأفضل تخزينها في جدول منفصل في قاعدة البيانات المحلية. حدد كيان غرفة يمثل جدول مفاتيح عن بُعد:

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

عليك أيضًا تعريف DAO للكيان RemoteKey:

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 إلى تحديد سلوك التحميل في البداية.

ومع ذلك، إذا كان تطبيقك يحتاج إلى إتاحة التحميل التدريجي من الشبكة إلى قاعدة البيانات المحلية، عليك تقديم دعم لاستئناف عملية التقسيم على صفحات بدءًا من نقطة الارتساء، وهي موضع تمرير المستخدِم. تعالج عملية تنفيذ PagingSource في الغرفة هذا الإجراء نيابةً عنك، ولكن في حال عدم استخدام Room، يمكنك إجراء ذلك من خلال تجاوز PagingSource.getRefreshKey(). للاطّلاع على مثال لتنفيذ دالة getRefreshKey()، راجِع تحديد مصدر الصفحات.

يوضح الشكل 4 عملية تحميل البيانات أولاً من قاعدة البيانات المحلية، ثم من الشبكة بمجرد خروج قاعدة البيانات من البيانات.

يتم تحميل PagingSource من قاعدة البيانات إلى واجهة المستخدم حتى نفاد البيانات من قاعدة البيانات. بعد ذلك، يتم تحميل RemoteMediator من الشبكة إلى قاعدة البيانات، وبعد ذلك يستمر PagingSource في التحميل.
الشكل 4. رسم بياني يوضّح طريقة عمل PagingSource و RemoteMediator معًا لتحميل البيانات.

مراجع إضافية

لمعرفة المزيد من المعلومات حول مكتبة تسجيل الصفحات، اطّلِع على المراجع الإضافية التالية:

الدروس التطبيقية حول الترميز

عيّنات