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
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.
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.
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, objekPager
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 objekPagingConfig
yang Anda gunakan untuk melakukan inisialisasi aliran paging.LoadType
, yang menunjukkan jenis pemuatan:REFRESH
,APPEND
, atauPREPEND
.
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:
- Menentukan halaman yang akan dimuat dari jaringan bergantung pada jenis pemuatan dan data yang telah dimuat sejauh ini.
- Memicu permintaan jaringan.
- Menjalankan 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
.
- Jika pemuatan berhasil dan daftar item yang diterima tidak kosong,
simpan item daftar dalam database dan tampilkan
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 menampilkanInitializeAction.LAUNCH_INITIAL_REFRESH
. Hal ini menyebabkanRemoteMediator
melakukan refresh jarak jauh untuk sepenuhnya memuat ulang data. Semua pemuatanAPPEND
atauPREPEND
jarak jauh akan menunggu pemuatanREFRESH
berhasil sebelum melanjutkan. - Jika data lokal tidak perlu direfresh,
initialize()
harus menampilkanInitializeAction.SKIP_INITIAL_REFRESH
. Hal ini menyebabkanRemoteMediator
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 konstruktorPagingSource
secara langsung. - Anda harus menyediakan instance penerapan
RemoteMediator
sebagai parameterremoteMediator
.
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.
Referensi lainnya
Untuk mempelajari library Paging lebih lanjut, lihat referensi tambahan berikut:
Codelab
Contoh
Direkomendasikan untuk Anda
- Catatan: teks link ditampilkan saat JavaScript nonaktif
- Memuat dan menampilkan data yang dibagi-bagi
- Menguji implementasi Paging
- Bermigrasi ke Paging 3