Halaman dari jaringan dan database

Berikan pengalaman pengguna yang lebih baik dengan memastikan bahwa aplikasi Anda dapat digunakan saat koneksi jaringan tidak dapat diandalkan atau saat pengguna sedang offline. Satu cara untuk melakukannya adalah dengan melakukan paging dari jaringan dan database lokal secara bersamaan. Dengan cara ini, aplikasi Anda akan menjalankan UI dari cache database lokal dan hanya membuat permintaan ke jaringan saat tidak ada lagi data dalam database.

Panduan ini mengasumsikan bahwa Anda sudah memahami library persistensi Room dan penggunaan dasar library Paging.

Mengoordinasikan pemuatan data

Library Paging menyediakan komponen RemoteMediator untuk kasus penggunaan ini. RemoteMediator bertindak sebagai sinyal dari library Paging saat aplikasi kehabisan data yang di-cache. Anda dapat menggunakan sinyal ini untuk memuat data tambahan dari jaringan dan menyimpannya di database lokal, tempat PagingSource dapat memuat dan memberikannya ke UI untuk ditampilkan.

Saat data tambahan diperlukan, library Paging akan memanggil metode load() dari implementasi RemoteMediator. Ini adalah fungsi yang ditangguhkan sehingga aman untuk melakukan pekerjaan yang berjalan lama. Fungsi ini biasanya mengambil data baru dari sumber jaringan dan menyimpannya ke penyimpanan lokal.

Proses ini berfungsi dengan data baru, tetapi seiring waktu data yang disimpan dalam database memerlukan pembatalan, seperti saat pengguna memicu refresh secara manual. Ini diwakili oleh properti LoadType yang diteruskan ke metode load(). LoadType memberi tahu RemoteMediator apakah perlu memuat ulang data yang ada atau mengambil data tambahan yang perlu ditambahkan atau ditambahkan di awal ke daftar yang ada.

Dengan cara ini, RemoteMediator memastikan bahwa aplikasi Anda memuat data yang ingin dilihat pengguna dalam urutan yang sesuai.

Siklus proses Paging

Gambar 1. Diagram siklus proses Paging dengan PagingSource dan PagingData.

Saat melakukan paging langsung dari jaringan, PagingSource akan memuat data dan menampilkan objek LoadResult. Implementasi PagingSource diteruskan ke Pager melalui parameter pagingSourceFactory.

Karena data baru diperlukan oleh UI, Pager memanggil metode load() dari PagingSource dan menampilkan aliran objek PagingData yang mengenkapsulasi data baru. Setiap objek PagingData biasanya di-cache dalam ViewModel sebelum dikirim ke UI untuk ditampilkan.

Gambar 2. Diagram siklus proses Paging dengan PagingSource dan RemoteMediator.

RemoteMediator mengubah alur data ini. PagingSource tetap memuat data; tetapi saat data yang di-page habis, library Paging akan memicu RemoteMediator untuk memuat data baru dari sumber jaringan. RemoteMediator menyimpan data baru di database lokal, sehingga cache dalam memori di ViewModel tidak diperlukan. Terakhir, PagingSource akan membatalkan validasinya sendiri, dan Pager akan membuat instance baru untuk memuat data baru dari database.

Penggunaan dasar

Jika Anda ingin aplikasi memuat halaman item User dari sumber data jaringan berkunci item ke dalam cache lokal yang tersimpan di database Room.

RemoteMediator memuat data dari jaringan ke database dan
    PagingSource memuat data dari database. Pager menggunakan
    RemoteMediator dan PagingSource untuk memuat data yang di-page.
Gambar 3. Diagram implementasi Paging yang menggunakan sumber data berlapis.

Implementasi RemoteMediator membantu memuat data yang di-page dari jaringan ke database, tetapi tidak memuat data secara langsung ke UI. Sebagai gantinya, aplikasi menggunakan database sebagai sumber ketepatan. Dengan kata lain, aplikasi hanya menampilkan data yang telah disimpan dalam cache di database. Implementasi PagingSource (misalnya, yang dihasilkan oleh Room) menangani pemuatan data yang disimpan dalam cache dari database ke UI.

Membuat entity Room

Langkah pertama adalah menggunakan library persistensi Room untuk menentukan database yang menyimpan cache lokal data yang di-page dari sumber data jaringan. Mulailah dengan implementasi RoomDatabase seperti yang dijelaskan dalam Menyimpan data di database lokal menggunakan Room.

Selanjutnya, tentukan entity Room untuk merepresentasikan tabel item daftar seperti yang dijelaskan dalam Menentukan data menggunakan entity Room. Tambahkan kolom id sebagai kunci utama, serta kolom untuk informasi lainnya yang terdapat dalam item daftar Anda.

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

Anda juga harus menentukan objek akses data (DAO) untuk entity Room ini, seperti yang dijelaskan dalam Mengakses data menggunakan DAO Room. DAO untuk entity item daftar harus menyertakan metode berikut:

  • Metode insertAll() yang menyisipkan daftar item ke dalam tabel.
  • Metode yang mengambil string kueri sebagai parameter dan menampilkan objek PagingSource untuk daftar hasil. Dengan cara ini, objek Pager dapat menggunakan tabel ini sebagai sumber data yang di-page.
  • Metode clearAll() yang menghapus semua data tabel.

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

Mengimplementasikan RemoteMediator

Peran utama RemoteMediator adalah memuat lebih banyak data dari jaringan saat Pager kehabisan data atau data yang ada tidak valid. Ini menyertakan metode load() yang harus Anda ganti untuk menentukan perilaku pemuatan.

Implementasi RemoteMediator standar mencakup parameter berikut:

  • query: String kueri yang menentukan data yang akan diambil dari layanan backend.
  • database: Database Room yang berfungsi sebagai cache lokal.
  • networkService: Instance API untuk layanan backend.

Membuat implementasi RemoteMediator<Key, Value>. Jenis Key dan jenis Value harus sama seperti jika Anda menentukan PagingSource terhadap sumber data jaringan yang sama. Untuk informasi lebih lanjut tentang cara memilih parameter jenis, lihat Memilih jenis kunci dan nilai.

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

Metode load() bertanggung jawab untuk memperbarui set data cadangan dan membatalkan validasi PagingSource. Beberapa library yang mendukung paging (seperti Room) akan otomatis menangani pembatalan validasi objek PagingSource yang diterapkan.

Metode load() menggunakan dua parameter:

  • PagingState, yang berisi informasi tentang halaman yang dimuat sejauh ini, indeks yang terakhir diakses, dan objek PagingConfig yang Anda gunakan untuk menginisialisasi aliran paging.
  • LoadType, yang menunjukkan jenis pemuatan: REFRESH, APPEND, atau PREPEND.

Nilai hasil dari metode load() adalah objek MediatorResult. MediatorResult dapat berupa MediatorResult.Error (yang menyertakan deskripsi error) atau MediatorResult.Success (yang menyertakan sinyal yang menyatakan apakah ada lebih banyak data atau tidak yang akan dimuat).

Metode load() harus menjalankan langkah-langkah berikut:

  1. Menentukan halaman yang akan dimuat dari jaringan bergantung pada jenis pemuatan dan data yang telah dimuat sejauh ini.
  2. Memicu permintaan jaringan.
  3. Melakukan tindakan yang bergantung pada hasil operasi pemuatan:
    • Jika pemuatan berhasil dan daftar item yang diterima tidak kosong, simpan item daftar dalam database dan tampilkan MediatorResult.Success(endOfPaginationReached = false). Setelah data disimpan, hapus sumber data untuk memberi tahu library Paging tentang data baru.
    • Jika pemuatan berhasil dan daftar item yang diterima kosong atau merupakan indeks halaman terakhir, kembalikan MediatorResult.Success(endOfPaginationReached = true). Setelah data disimpan, batalkan sumber data untuk memberi tahu library Paging data baru.
    • Jika permintaan menyebabkan error, tampilkan 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
  );
}

Menentukan metode inisialisasi

Implementasi RemoteMediator juga dapat menggantikan metode initialize() untuk memeriksa apakah data yang di-cache sudah kedaluwarsa dan memutuskan apakah akan memicu refresh jarak jauh. Metode ini berjalan sebelum pemuatan apa pun dilakukan, sehingga Anda dapat memanipulasi database (misalnya, untuk menghapus data yang lama) sebelum memicu pemuatan lokal atau jarak jauh.

Karena initialize() adalah fungsi asinkron, Anda dapat memuat data untuk menentukan relevansi data yang ada dalam database. Kasus yang paling umum adalah data yang disimpan dalam cache hanya valid untuk jangka waktu tertentu. RemoteMediator dapat memeriksa apakah waktu habis masa berlaku ini telah berlalu, dalam hal ini library Paging perlu me-refresh data sepenuhnya. Implementasi initialize() harus menampilkan InitializeAction sebagai berikut:

  • Jika data lokal perlu di-refresh sepenuhnya, initialize() harus menampilkan InitializeAction.LAUNCH_INITIAL_REFRESH. Hal ini menyebabkan RemoteMediator melakukan refresh jarak jauh untuk sepenuhnya memuat ulang data. Semua pemuatan APPEND atau PREPEND jarak jauh akan menunggu pemuatan REFRESH berhasil sebelum melanjutkan.
  • Jika data lokal tidak perlu direfresh, initialize() harus menampilkan InitializeAction.SKIP_INITIAL_REFRESH. Hal ini menyebabkan RemoteMediator melewati refresh jarak jauh dan memuat data yang di-cache.

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

Membuat Pager

Terakhir, Anda harus membuat instance Pager untuk menyiapkan aliran data yang di-page. Tindakan ini mirip seperti membuat Pager dari sumber data jaringan sederhana, tetapi ada dua hal yang harus Anda lakukan secara berbeda:

  • Anda harus menyediakan metode kueri yang mengembalikan objek PagingSource dari DAO, bukan meneruskan konstruktor PagingSource secara langsung.
  • Anda harus menyediakan instance penerapan RemoteMediator sebagai parameter 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));

Menangani kondisi race

Satu situasi yang perlu ditangani aplikasi Anda saat memuat data dari beberapa sumber adalah kasus saat data lokal yang di-cache menjadi tidak sinkron dengan sumber data jarak jauh.

Saat metode initialize() dari implementasi RemoteMediator menampilkan LAUNCH_INITIAL_REFRESH, data sudah usang dan harus diganti dengan data baru. Setiap permintaan pemuatan PREPEND atau APPEND dipaksa untuk menunggu pemuatan REFRESH jarak jauh berhasil. Karena permintaan PREPEND atau APPEND sudah dimasukkan dalam antrean sebelum permintaan REFRESH, mungkin PagingState yang diteruskan ke panggilan pemuatan tersebut akan habis masa berlakunya pada waktu tertentu saat dijalankan.

Tergantung pada cara data disimpan secara lokal, aplikasi dapat mengabaikan permintaan ganda jika perubahan pada data yang di-cache menyebabkan pembatalan validasi dan pengambilan data baru. Misalnya, Room membatalkan kueri pada penyisipan data apa pun. Artinya, objek PagingSource baru dengan data yang di-refresh akan diberikan ke permintaan pemuatan yang tertunda saat data baru dimasukkan ke dalam database.

Mengatasi masalah sinkronisasi data ini sangat penting untuk memastikan bahwa pengguna melihat data yang paling relevan dan terbaru. Solusi terbaik sebagian besar bergantung pada cara sumber data jaringan memilah data. Dalam situasi apa pun, kunci jarak jauh memungkinkan Anda menyimpan informasi tentang halaman terbaru yang diminta dari server. Aplikasi Anda dapat menggunakan informasi ini untuk mengidentifikasi dan meminta halaman data yang benar untuk dimuat berikutnya.

Mengelola kunci jarak jauh

Kunci jarak jauh adalah kunci yang digunakan oleh penerapan RemoteMediator untuk memberi tahu layanan backend tentang data yang akan dimuat berikutnya. Dalam kasus yang paling sederhana, setiap item data yang di-page menyertakan kunci jarak jauh yang dapat dirujuk dengan mudah. Namun, jika kunci jarak jauh tidak sesuai dengan item individual, Anda harus menyimpannya secara terpisah dan mengelolanya di metode load().

Bagian ini menjelaskan cara mengumpulkan, menyimpan, dan memperbarui kunci jarak jauh yang tidak disimpan dalam item individu.

Kunci item

Bagian ini menjelaskan cara bekerja dengan kunci jarak jauh yang sesuai dengan item individu. Biasanya, jika API menonaktifkan setiap item, ID item diteruskan sebagai parameter kueri. Nama parameter menunjukkan apakah server harus merespons dengan item sebelum atau setelah ID yang diberikan. Pada contoh kelas model User, kolom id dari server digunakan sebagai kunci jarak jauh saat meminta data tambahan.

Jika metode load() Anda perlu mengelola kunci jarak jauh khusus item, kunci tersebut biasanya merupakan ID data yang diambil dari server. Operasi refresh tidak memerlukan kunci pemuatan, karena hanya mengambil data terbaru. Demikian pula, operasi awalan tidak perlu mengambil data tambahan karena refresh selalu menarik data terbaru dari server.

Namun, operasi penambahan memerlukan ID. Hal ini mengharuskan Anda memuat item terakhir dari database dan menggunakan ID-nya untuk memuat halaman data berikutnya. Jika tidak ada item dalam database, endOfPaginationReached disetel ke true, yang menunjukkan bahwa refresh data diperlukan.

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

Kunci halaman

Bagian ini menjelaskan cara menggunakan kunci jarak jauh yang tidak sesuai dengan setiap item.

Menambahkan tabel kunci jarak jauh

Jika kunci jarak jauh tidak terkait langsung dengan item daftar, sebaiknya simpan di tabel terpisah dalam database lokal. Tentukan entity Room yang mewakili tabel kunci jarak jauh:

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

Anda juga harus menentukan DAO untuk entity 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);
}

Memuat dengan tombol jarak jauh

Jika metode load() perlu mengelola kunci halaman jarak jauh, Anda harus mendefinisikannya secara berbeda dengan cara berikut dibandingkan dengan penggunaan dasar RemoteMediator:

  • Sertakan properti tambahan yang menyimpan referensi ke DAO untuk tabel kunci jarak jauh Anda.
  • Tentukan kunci yang akan dimuat berikutnya dengan membuat kueri tabel kunci jarak jauh, bukan menggunakan PagingState.
  • Sisipkan atau simpan kunci jarak jauh yang dikembalikan dari sumber data jaringan selain data yang dibagi itu sendiri.

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

Me-refresh di tempat

Jika aplikasi Anda hanya perlu mendukung refresh jaringan dari bagian atas daftar seperti dalam contoh sebelumnya, RemoteMediator tidak perlu menentukan perilaku pemuatan awal.

Namun, jika aplikasi Anda perlu mendukung pemuatan secara bertahap dari jaringan ke dalam database lokal, Anda harus menyediakan dukungan untuk melanjutkan penomoran halaman yang dimulai dari anchor, yaitu posisi scroll pengguna. Implementasi PagingSource Room akan menangani hal ini untuk Anda, tetapi jika tidak menggunakan Room, Anda dapat melakukannya dengan mengganti PagingSource.getRefreshKey(). Untuk contoh implementasi getRefreshKey(), lihat Menentukan PagingSource.

Gambar 4 mengilustrasikan proses pemuatan data terlebih dahulu dari database lokal, kemudian dari jaringan setelah database kehabisan data.

PagingSource akan dimuat dari database ke dalam UI hingga database
    kehabisan data. Kemudian, RemoteMediator akan dimuat dari jaringan ke
    database, dan setelah itu PagingSource melanjutkan pemuatan.
Gambar 4. Diagram yang menunjukkan bagaimana PagingSource dan RemoteMediator bekerja sama untuk memuat data.

Referensi lainnya

Untuk mempelajari library Paging lebih lanjut, lihat referensi tambahan berikut:

Codelab

Contoh