1. Pengantar
Yang akan Anda pelajari
- Komponen utama dari Paging 3.
- Cara menambahkan Paging 3 ke project.
- Cara menambahkan header atau footer ke daftar menggunakan Paging 3 API.
- Cara menambahkan pemisah daftar menggunakan Paging 3 API.
- Cara melakukan paging dari jaringan dan database.
Yang akan Anda build
Di codelab ini, Anda akan memulai dengan aplikasi contoh yang sudah menampilkan daftar repositori GitHub. Permintaan jaringan baru akan terpicu setiap kali pengguna men-scroll sampai akhir daftar yang ditampilkan, lalu hasilnya akan ditampilkan di layar.
Anda akan menambahkan kode melalui serangkaian langkah untuk mencapai hal berikut:
- Migrasi ke komponen Library Paging.
- Menambahkan status pemuatan header dan footer ke daftar.
- Menampilkan progres pemuatan di antara setiap penelusuran repositori baru.
- Menambahkan pemisah di daftar.
- Menambahkan dukungan database untuk melakukan paging dari jaringan dan database.
Tampilan aplikasi saat selesai akan seperti berikut:
Yang Anda butuhkan
- Android Studio Arctic Fox.
- Pemahaman tentang Komponen Arsitektur berikut: LiveData, ViewModel, View Binding, dan dengan arsitektur yang disarankan di bagian "Panduan Arsitektur aplikasi".
- Pemahaman tentang coroutine dan Flow Kotlin.
Untuk pengantar Komponen Arsitektur, lihat Room dengan codelab View. Untuk pengantar Flow, lihat Coroutine Lanjutan dengan Flow Kotlin dan codelab LiveData.
2. Menyiapkan Lingkungan Anda
Pada langkah ini, Anda akan mendownload kode untuk seluruh codelab kemudian menjalankan aplikasi contoh sederhana.
Untuk memulainya secepat mungkin, kami telah menyiapkan project awal untuk Anda kembangkan.
Jika sudah menginstal git, Anda cukup menjalankan perintah di bawah ini. (Anda dapat memeriksanya dengan mengetikkan git --version
di baris perintah/terminal, lalu pastikan git dijalankan dengan benar.)
git clone https://github.com/googlecodelabs/android-paging
Status awal berada di cabang master. Berikut solusi yang dapat Anda temukan untuk langkah-langkah tertentu:
- Cabang step5-9_paging_3.0 - Anda akan menemukan solusi untuk langkah 5 - 9, yaitu dengan menambahkan versi terbaru Paging ke project.
- Cabang step10_loading_state_footer - Anda akan menemukan solusi untuk langkah 10, yaitu dengan menambahkan footer yang menampilkan status pemuatan.
- Cabang step11_loading_state - Anda akan menemukan solusi untuk langkah 11, yaitu dengan menambahkan tampilan untuk status pemuatan di antara kueri.
- Cabang step12_separators - Anda akan menemukan solusi untuk langkah 12, yaitu dengan menambahkan pemisah untuk aplikasi.
- Cabang step13-19_network_and_database - Anda akan menemukan solusi untuk langkah 13 - 19, yaitu dengan menambahkan dukungan offline ke aplikasi.
Jika tidak memiliki git, Anda dapat mengklik tombol berikut untuk mendownload semua kode untuk codelab ini:
- Buka zip kode, lalu buka project di Android Studio.
- Jalankan
app
yang menjalankan konfigurasi di perangkat atau emulator.
Aplikasi akan berjalan dan menampilkan daftar repositori GitHub yang serupa dengan berikut:
3 Ringkasan project
Aplikasi ini memungkinkan Anda menelusuri GitHub untuk repositori dengan nama atau deskripsi yang berisi kata tertentu. Daftar repositori akan ditampilkan dalam urutan menurun menurut jumlah bintang, lalu menurut nama sesuai abjad.
Aplikasi mengikuti arsitektur yang direkomendasikan di "Panduan arsitektur aplikasi". Anda akan menemukan item berikut di setiap paket:
- api - Panggilan API GitHub, menggunakan Retrofit.
- data - class repositori yang bertanggung jawab untuk memicu permintaan API dan meng-cache respons di memori.
- model - model data
Repo
, yang juga merupakan tabel di database Room; danRepoSearchResult
, class yang digunakan oleh UI untuk mengamati data hasil penelusuran dan error jaringan. - ui - class terkait untuk menampilkan
Activity
denganRecyclerView
.
Class GithubRepository
mengambil daftar nama repositori dari jaringan setiap kali pengguna men-scroll ke akhir daftar, atau saat pengguna menelusuri repositori baru. Daftar hasil kueri disimpan di memori GithubRepository
dalam ConflatedBroadcastChannel
dan ditampilkan sebagai Flow
.
SearchRepositoriesViewModel
meminta data dari GithubRepository
dan menampilkannya ke SearchRepositoriesActivity
. Karena kita ingin memastikan bahwa kita tidak meminta data beberapa kali saat perubahan konfigurasi (misalnya rotasi), kita mengonversi Flow
ke LiveData
di ViewModel
menggunakan metode builder liveData()
. Dengan begitu, LiveData
meng-cache daftar hasil terbaru di memori, dan saat SearchRepositoriesActivity
dibuat ulang, konten LiveData
akan ditampilkan di layar. ViewModel
menampilkan:
LiveData<UiState>
(UiAction) -> Unit
fungsi
UiState
adalah representasi dari semua hal yang diperlukan untuk merender UI aplikasi, dengan berbagai kolom yang sesuai dengan berbagai komponen UI. Ini adalah objek tetap, yang berarti tidak dapat diubah. Namun, versi barunya dapat dihasilkan dan diamati oleh UI. Dalam kasus kita, versi barunya diproduksi sebagai hasil dari tindakan pengguna: baik menelusuri kueri baru, maupun men-scroll daftar untuk mengambil lainnya.
Tindakan pengguna diwakilkan dengan tepat oleh jenis UiAction
. Menyertakan API untuk interaksi ke ViewModel
dalam satu jenis memiliki manfaat berikut:
- Platform API kecil: Tindakan dapat ditambahkan, dihapus, atau diubah, tetapi tanda tangan metode
ViewModel
tidak pernah berubah. Hal ini membuat pemfaktoran ulang secara lokal dan cenderung tidak membocorkan abstraksi atau implementasi antarmuka. - Manajemen serentak yang lebih mudah: Seperti yang akan Anda lihat nanti di codelab, penting untuk dapat menjamin urutan eksekusi permintaan tertentu. Dengan mengetik API menggunakan
UiAction
, kita dapat menulis kode dengan persyaratan ketat tentang apa yang dapat terjadi, dan kapan hal itu dapat terjadi.
Dari perspektif kegunaan, terdapat masalah berikut:
- Pengguna tidak memiliki informasi status pemuatan daftar: mereka melihat layar kosong saat menelusuri repositori baru atau hanya melihat daftar yang tiba-tiba terhenti sedangkan hasil lainnya di kueri yang sama sedang dimuat.
- Pengguna tidak dapat mencoba lagi kueri yang gagal.
- Daftar selalu di-scroll ke atas setelah orientasi berubah atau setelah penghentian proses.
Dari perspektif implementasi, terdapat masalah berikut:
- Daftar tersebut menjadi tidak terbatas di memori, sehingga membuang memori saat pengguna men-scroll.
- Kita harus mengonversi hasil dari
Flow
menjadiLiveData
untuk meng-cachenya, sehingga menambah kerumitan kode. - Jika aplikasi perlu menampilkan beberapa daftar, kita akan melihat bahwa ada banyak boilerplate yang harus ditulis untuk setiap daftar.
Mari kita bahas cara library Paging membantu mengatasi masalah ini dan komponen apa saja yang disertakan.
4. Komponen library paging
Library Paging memudahkan Anda memuat data secara bertahap dan tanpa adanya masalah di UI aplikasi. Paging API memberikan dukungan untuk banyak fungsi yang seharusnya perlu diimplementasikan secara manual saat Anda perlu memuat data di halaman:
- Selalu melacak kunci yang akan digunakan untuk mengambil halaman berikutnya dan sebelumnya.
- Otomatis meminta halaman yang benar saat pengguna men-scroll ke akhir daftar.
- Memastikan beberapa permintaan tidak terpicu pada saat yang sama.
- Mengizinkan Anda meng-cache data: dilakukan di
CoroutineScope
jika Anda menggunakan Kotlin; dilakukan denganLiveData
jika Anda menggunakan Java. - Melacak status pemuatan dan memungkinkan Anda menampilkannya di item daftar
RecyclerView
atau di tempat lain di UI, dan mencoba kembali pemuatan yang gagal dengan mudah. - Memungkinkan Anda menjalankan operasi umum seperti
map
ataufilter
di daftar yang akan ditampilkan, terlepas dari apakah Anda sedang menggunakanFlow
,LiveData
, atau RxJavaFlowable
atauObservable
. - Menyediakan cara yang mudah untuk mengimplementasikan pemisah daftar.
Panduan arsitektur aplikasi menyarankan arsitektur dengan komponen utama berikut:
- Database lokal yang berfungsi sebagai sumber kebenaran tunggal untuk data yang disajikan kepada pengguna dan dimanipulasi oleh pengguna.
- Layanan API web.
- Repositori yang berfungsi dengan database dan layanan API web, yang menyediakan antarmuka data terpadu.
ViewModel
yang menyediakan data khusus untuk UI.- UI, yang menampilkan representasi visual dari data di
ViewModel
.
Library Paging berfungsi dengan semua komponen ini dan dapat mengoordinasikan interaksi antarkomponen tersebut, sehingga dapat memuat "halaman" konten dari sumber data dan menampilkan konten tersebut di UI.
Codelab ini akan memperkenalkan library Paging dan komponen utamanya:
PagingData
- penampung untuk data yang telah dipaginasi. Setiap pemuatan ulang data akan memilikiPagingData
yang sesuai secara terpisah.PagingSource
-PagingSource
adalah class dasar untuk memuat ringkasan data ke dalam aliranPagingData
.Pager.flow
- membuatFlow<PagingData>
, berdasarkanPagingConfig
dan fungsi yang menentukan cara membuatPagingSource
yang diimplementasikan.PagingDataAdapter
-RecyclerView.Adapter
yang menyajikanPagingData
diRecyclerView
.PagingDataAdapter
dapat dihubungkan keFlow
Kotlin,LiveData
,Flowable
RxJava, atauObservable
RxJava.PagingDataAdapter
akan memproses peristiwa pemuatanPagingData
internal saat halaman dimuat dan menggunakanDiffUtil
di thread latar belakang untuk mengomputasi update terperinci saat konten yang diupdate diterima dalam bentuk objekPagingData
yang baru.RemoteMediator
- membantu mengimplementasikan paging dari jaringan dan database.
Di codelab ini, Anda akan mengimplementasikan contoh setiap komponen yang telah dijelaskan di atas.
5. Menentukan sumber data
Implementasi PagingSource
menentukan sumber data dan cara mengambil data dari sumber tersebut. Objek PagingData
membuat kueri data dari PagingSource
sebagai respons terhadap petunjuk pemuatan yang dibuat saat pengguna men-scroll di RecyclerView
.
Saat ini, GithubRepository
memiliki banyak tanggung jawab sumber data yang akan ditangani oleh library Paging setelah selesai ditambahkan:
- Memuat data dari
GithubService
, yang memastikan beberapa permintaan tidak terpicu pada saat yang sama. - Menyimpan cache di memori dari data yang diambil.
- Selalu melacak halaman yang akan diminta.
Untuk membuat PagingSource
, Anda harus menentukan hal berikut:
- Jenis kunci paging - dalam kasus kita, GitHub API menggunakan nomor indeks berbasis 1 untuk halaman, sehingga jenisnya adalah
Int
. - Jenis data yang dimuat - saat ini kita akan memuat item
Repo
. - Dari mana data diambil - kita mendapatkan data dari
GithubService
. Sumber data dikhususkan untuk kueri tertentu, sehingga perlu dipastikan bahwa informasi kueri diteruskan keGithubService
.
Jadi, dalam paket data
, mari kita buat implementasi PagingSource
yang disebut GithubPagingSource
:
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
TODO("Not yet implemented")
}
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
TODO("Not yet implemented")
}
}
Kita akan melihat bahwa PagingSource
mengharuskan kita mengimplementasikan dua fungsi: load()
dan getRefreshKey()
.
Fungsi load()
akan dipanggil oleh library Paging untuk secara asinkron mengambil lebih banyak data yang akan ditampilkan saat pengguna melakukan scroll. Objek LoadParams
menyimpan informasi terkait operasi pemuatan, termasuk hal berikut:
- Kunci halaman yang akan dimuat. Jika ini merupakan pertama kalinya pemuatan dipanggil,
LoadParams.key
akan menjadinull
. Pada kasus ini, Anda harus menentukan kunci halaman awal. Untuk project kita, Anda diharuskan memindahkan konstantaGITHUB_STARTING_PAGE_INDEX
dariGithubRepository
ke implementasiPagingSource
karena ini merupakan kunci halaman awal. - Ukuran pemuatan - jumlah item yang diminta untuk dimuat.
Fungsi pemuatan menampilkan LoadResult
. Hasil ini akan menggantikan penggunaan RepoSearchResult
di aplikasi, karena LoadResult
dapat menggunakan salah satu jenis berikut:
LoadResult.Page
, jika hasilnya berhasil.LoadResult.Error
, jika terjadi error.
Ketika menyusun LoadResult.Page
, teruskan null
untuk nextKey
atau prevKey
jika daftar tidak dapat dimuat ke arah yang sesuai. Misalnya, dalam kasus ini, kita dapat mempertimbangkan bahwa saat respons jaringan berhasil tetapi daftar kosong, tidak ada data tersisa untuk dimuat; sehingga nextKey
bisa menjadi null
.
Berdasarkan semua informasi ini, kita dapat mengimplementasikan fungsi load()
.
Berikutnya, kita harus mengimplementasikan getRefreshKey()
. Tombol pemuatan ulang digunakan untuk panggilan pemuatan ulang berikutnya ke PagingSource.load()
(panggilan pertama adalah pemuatan awal yang menggunakan initialKey
yang disediakan oleh Pager
). Pemuatan ulang terjadi setiap kali library Paging ingin memuat data baru untuk menggantikan daftar saat ini, misalnya, menggeser untuk memuat ulang atau pembatalan validasi karena pembaruan database, perubahan konfigurasi, penghentian proses, dsb. Biasanya, panggilan pemuatan ulang berikutnya ingin memulai ulang pemuatan data yang berpusat di sekitar PagingState.anchorPosition
, yang mewakili indeks yang terakhir diakses.
Implementasi GithubPagingSource
terlihat seperti ini:
// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query + IN_QUALIFIER
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)
val repos = response.items
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
// The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
// We need to get the previous key (or next key if previous is null) of the page
// that was closest to the most recently accessed index.
// Anchor position is the most recently accessed index
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
6. Membuat dan mengonfigurasi PagingData
Dalam implementasi saat ini, Flow<RepoSearchResult>
digunakan di GitHubRepository
untuk mendapatkan data dari jaringan dan meneruskannya ke ViewModel
. ViewModel
kemudian mengubahnya menjadi LiveData
dan menampilkannya ke UI. Setiap kali kita sampai di akhir daftar yang ditampilkan dan lebih banyak data dimuat dari jaringan, Flow<RepoSearchResult>
akan berisi seluruh daftar data yang diambil sebelumnya untuk kueri tersebut selain data terbaru.
RepoSearchResult
mengenkapsulasi kasus keberhasilan dan error. Kasus keberhasilan akan menahan data repositori. Kasus error berisi alasan Exception
. Dengan Paging 3, RepoSearchResult
tidak diperlukan lagi karena kasus keberhasilan dan error dimodelkan oleh library dengan LoadResult
. Anda dapat menghapus RepoSearchResult
, karena kode tersebut akan digantikan dalam beberapa langkah berikutnya.
Untuk membuat PagingData
, terlebih dahulu kita harus memutuskan API yang ingin digunakan untuk meneruskan PagingData
ke lapisan lain aplikasi:
Flow
Kotlin - gunakanPager.flow
.LiveData
- gunakanPager.liveData
.Flowable
RxJava - gunakanPager.flowable
.Observable
RxJava - gunakanPager.observable
.
Karena Flow
sudah digunakan dalam aplikasi, pendekatan ini akan tetap dilanjutkan. Namun, kita akan menggunakan Flow<PagingData<Repo>>
, dan bukan Flow<RepoSearchResult>
.
Apa pun builder PagingData
yang akan digunakan, Anda harus meneruskan parameter berikut:
PagingConfig
. Class ini menyetel opsi terkait cara memuat konten dariPagingSource
seperti seberapa lama lagi untuk dimuat, permintaan ukuran untuk pemuatan awal, dan lainnya. Satu-satunya parameter wajib yang harus ditentukan adalah ukuran halaman — berapa banyak item yang harus dimuat di setiap halaman. Secara default, Paging akan mengingat semua halaman yang dimuat. Untuk memastikan agar tidak menghapus memori saat pengguna men-scroll, setel parametermaxSize
diPagingConfig
. Jika Paging dapat menghitung item yang tidak dimuat dan jika flag konfigurasienablePlaceholders
adalah benar (true), Paging secara default akan menampilkan item null sebagai placeholder untuk konten yang belum dimuat. Dengan begini, Anda akan dapat menampilkan placeholder di adaptor. Untuk menyederhanakan tugas di codelab ini, nonaktifkan placeholder dengan meneruskanenablePlaceholders = false
.- Fungsi yang menentukan cara membuat
PagingSource
. Dalam kasus ini, kita akan membuatGithubPagingSource
baru untuk setiap kueri baru.
Mari kita ubah GithubRepository
.
Memperbarui GithubRepository.getSearchResultStream
- Hapus pengubah
suspend
. - Tampilkan
Flow<PagingData<Repo>>
. - Buat
Pager
.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
Membersihkan GithubRepository
Paging 3 memiliki banyak fungsi:
- Menangani cache dalam memori.
- Meminta data saat pengguna mendekati akhir daftar.
Artinya, semua hal lain di GithubRepository
dapat dihapus, kecuali getSearchResultStream
dan objek pendamping tempat kita menentukan NETWORK_PAGE_SIZE
. GithubRepository
sekarang akan terlihat seperti ini:
class GithubRepository(private val service: GithubService) {
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
companion object {
const val NETWORK_PAGE_SIZE = 50
}
}
Anda sekarang akan mengompilasi error di SearchRepositoriesViewModel
. Mari kita lihat perubahan apa yang perlu dilakukan!
7. Meminta dan meng-cache PagingData di ViewModel
Sebelum mengatasi error kompilasi, mari kita tinjau jenis di ViewModel
:
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(
val visibleItemCount: Int,
val lastVisibleItemPosition: Int,
val totalItemCount: Int
) : UiAction()
}
data class UiState(
val query: String,
val searchResult: RepoSearchResult
)
Di UiState
, kita menampilkan searchResult
; peran searchResult
akan menjadi cache dalam memori untuk penelusuran hasil yang bertahan dari perubahan konfigurasi. Dengan Paging 3, Flow
tidak perlu dikonversi lagi menjadi LiveData
. Sebagai gantinya, SearchRepositoriesViewModel
sekarang akan menampilkan StateFlow<UiState>
. Selain itu, kita menghapus val searchResult
sepenuhnya, memilih untuk menampilkan Flow<PagingData<Repo>>
terpisah yang memiliki fungsi yang sama dengan searchResult
.
PagingData
adalah jenis mandiri yang berisi aliran update yang dapat diubah ke data yang akan ditampilkan di RecyclerView
. Setiap emisi PagingData
sepenuhnya bersifat independen, dan beberapa PagingData
dapat ditampilkan untuk satu kueri. Dengan demikian, Flows
dari PagingData
harus ditampilkan secara independen dari Flows
lainnya.
Selain itu, sebagai keuntungan pengalaman pengguna, untuk setiap kueri baru yang dimasukkan, kita ingin men-scroll ke bagian atas daftar untuk menampilkan hasil penelusuran pertama. Namun, karena data paging dapat ditampilkan beberapa kali, kita hanya ingin men-scroll ke bagian atas daftar jika pengguna belum mulai men-scroll.
Untuk melakukan ini, perbarui UiState
dan tambahkan kolom untuk lastQueryScrolled
dan hasNotScrolledForCurrentSearch
. Flag ini akan mencegah kita men-scroll ke bagian atas daftar ketika seharusnya tidak dilakukan:
data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
)
Mari kita bahas kembali arsitektur kita. Karena semua permintaan ke ViewModel
melewati satu titik entri - kolom accept
yang ditentukan sebagai (UiAction) -> Unit
- kita harus melakukan hal berikut:
- Konversikan titik entri tersebut menjadi aliran yang berisi jenis yang kita minati.
- Transformasikan aliran tersebut.
- Gabungkan aliran data kembali ke
StateFlow<UiState>
.
Dalam istilah yang lebih fungsional, kita akan mengubah emisi reduce
dari UiAction
menjadi UiState
. Ini seperti jalur perakitan: jenis UiAction
adalah bahan mentah yang masuk, jenis ini menimbulkan efek (terkadang disebut mutasi), dan UiState
adalah output akhir yang siap diikat ke UI. Ini terkadang disebut menjadikan UI sebagai fungsi dari UiState
.
Mari kita menulis ulang ViewModel
untuk menangani setiap jenis UiAction
dalam dua aliran yang berbeda, lalu mengubahnya menjadi StateFlow<UiState>
menggunakan beberapa operator Kotlin Flow
.
Pertama, kita akan memperbarui definisi state
di ViewModel
agar menggunakan StateFlow
, bukan LiveData
, sekaligus menambahkan kolom untuk menampilkan Flow
dari PagingData
:
/**
* Stream of immutable states representative of the UI.
*/
val state: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<Repo>>
Selanjutnya, kita akan memperbarui definisi untuk subclass UiAction.Scroll
:
sealed class UiAction {
...
data class Scroll(val currentQuery: String) : UiAction()
}
Perhatikan bahwa kita menghapus semua kolom di class data UiAction.Scroll
dan menggantinya dengan satu string currentQuery
. Hal ini memungkinkan kita mengaitkan tindakan scroll dengan kueri tertentu. Kita juga akan menghapus ekstensi shouldFetchMore
karena tidak digunakan lagi. Ini juga merupakan hal yang perlu dipulihkan setelah penghentian proses, jadi pastikan kita memperbarui metode onCleared()
di SearchRepositoriesViewModel
:
class SearchRepositoriesViewModel{
...
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
super.onCleared()
}
}
// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
Ini juga saat yang tepat untuk memperkenalkan metode yang akan membuat pagingData
Flow
dari GithubRepository
:
class SearchRepositoriesViewModel(
...
) : ViewModel() {
override fun onCleared() {
...
}
private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
repository.getSearchResultStream(queryString)
}
Flow<PagingData>
memiliki metode cachedIn()
praktis yang memungkinkan kita meng-cache konten Flow<PagingData>
di CoroutineScope
. Karena berada di ViewModel
, kita akan menggunakan androidx.lifecycle.viewModelScope
.
Sekarang, kita dapat mulai mengonversi kolom accept
di ViewModel menjadi aliran UiAction
. Ganti blok init
dari SearchRepositoriesViewModel
dengan blok berikut:
class SearchRepositoriesViewModel(
...
) : ViewModel() {
...
init {
val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
}
}
Mari kita bahas cuplikan kode di atas. Kita mulai dengan dua item, initialQuery
String
, yang diambil dari status tersimpan atau default, beserta lastQueryScrolled
, String
yang mewakili istilah penelusuran terakhir tempat pengguna berinteraksi dengan daftar. Selanjutnya, kita mulai membagi Flow
menjadi jenis UiAction
tertentu:
UiAction.Search
untuk setiap kali pengguna memasukkan kueri tertentu.UiAction.Scroll
untuk setiap kali pengguna men-scroll daftar dengan fokus pada kueri tertentu.
UiAction.Scroll Flow
memiliki beberapa transformasi tambahan yang diterapkan padanya. Mari kita bahas:
shareIn
: Ini diperlukan karena saatFlow
digunakan, pemakaiannya menggunakan operatorflatmapLatest
. Setiap kali upstream muncul,flatmapLatest
akan membatalkanFlow
terakhir yang dioperasikannya, dan mulai bekerja berdasarkan flow baru yang diberikan. Dalam kasus kita, hal ini akan membuat kita kehilangan nilai kueri terakhir yang telah di-scroll pengguna. Jadi, kita menggunakan operatorFlow
dengan nilaireplay
1 untuk meng-cache nilai terakhir sehingga tidak hilang saat kueri baru masuk.onStart
: Juga digunakan untuk meng-cache. Jika aplikasi dihentikan, tetapi pengguna sudah men-scroll kueri, kita tidak ingin men-scroll daftar ke atas yang menyebabkan pengguna kehilangan tempat lagi.
Seharusnya masih ada error kompilasi karena kita belum menentukan kolom state
, pagingDataFlow
, dan accept
. Ayo perbaiki. Dengan transformasi yang diterapkan ke setiap UiAction
, sekarang kita dapat menggunakannya untuk membuat flow untuk PagingData
dan UiState
.
init {
...
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
state = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
}
Kita menggunakan operator flatmapLatest
pada flow searches
karena setiap kueri penelusuran baru perlu membuat Pager
baru. Selanjutnya, kita menerapkan operator cachedIn
ke flow PagingData
agar tetap aktif dalam viewModelScope
dan menetapkan hasilnya ke kolom pagingDataFlow
. Dari segi UiState
, kita menggunakan operator kombinasi untuk mengisi kolom UiState
yang wajib diisi dan menetapkan Flow
yang dihasilkan ke kolom state
yang ditampilkan. Kita juga menentukan accept
sebagai lambda yang meluncurkan fungsi penangguhan yang memberi feed mesin status kita.
Selesai. Sekarang kita memiliki ViewModel
yang fungsional dari sudut pandang pemrograman literal dan reaktif.
8. Membuat Adaptor berfungsi dengan PagingData
Untuk mengikat PagingData
ke RecyclerView
, gunakan PagingDataAdapter
. PagingDataAdapter
akan diberi tahu setiap kali konten PagingData
dimuat, lalu memberi sinyal kepada RecyclerView
untuk melakukan pembaruan.
Memperbarui ui.ReposAdapter
agar berfungsi dengan aliran PagingData
:
- Saat ini,
ReposAdapter
mengimplementasikanListAdapter
. Mari kita buat kode tersebut mengimplementasikanPagingDataAdapter
. Isi class lainnya tetap tidak berubah:
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}
Sejauh ini kita telah melakukan banyak perubahan, tetapi sekarang kita hanya selangkah lagi untuk dapat menjalankan aplikasi—hanya perlu menghubungkan UI!
9. Memicu pembaruan jaringan
Mengganti LiveData dengan Flow
Mari kita update SearchRepositoriesActivity
agar berfungsi dengan Paging 3. Agar dapat berfungsi dengan Flow<PagingData>
, kita perlu meluncurkan coroutine baru. Kita akan melakukannya di lifecycleScope
, yang bertanggung jawab untuk membatalkan permintaan saat aktivitas dibuat ulang.
Untungnya, kita tidak perlu melakukan banyak perubahan. Daripada observe()
LiveData
, kita akan menerapkan launch()
pada coroutine
dan menerapkan collect()
pada Flow
. UiState
akan digabungkan dengan PagingAdapter
LoadState
Flow
untuk memberikan jaminan bahwa kami tidak akan men-scroll daftar kembali ke atas dengan emisi PagingData
baru jika pengguna sudah men-scroll.
Pertama, karena sekarang kita menampilkan status sebagai StateFlow
, bukan LiveData
, semua referensi dalam Activity
ke LiveData
harus diganti dengan StateFlow
, untuk memastikan menambahkan argumen untuk pagingData
Flow
juga. Tempat pertama berada di metode bindState
:
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
...
}
Perubahan ini memiliki efek bergulir, karena kita sekarang harus memperbarui bindSearch()
dan bindList()
. bindSearch()
memiliki perubahan terkecil, jadi mari kita mulai dari sana:
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener {...}
searchRepo.setOnKeyListener {...}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
Perubahan utama di sini adalah kebutuhan untuk meluncurkan coroutine, dan mengumpulkan perubahan kueri dari UiState
Flow
.
Mengatasi masalah scroll dan mengikat data
Sekarang untuk bagian scroll. Pertama, seperti dua perubahan terakhir, kita mengganti LiveData
dengan StateFlow
dan menambahkan argumen untuk pagingData
Flow
. Setelah itu, kita dapat melanjutkan ke pemroses scroll. Perhatikan bahwa sebelumnya, kita menggunakan OnScrollListener
yang dilampirkan ke RecyclerView
untuk mengetahui kapan harus memicu lebih banyak data. Library Paging menangani scroll daftar untuk kita, tetapi kita masih memerlukan OnScrollListener
sebagai sinyal jika pengguna telah men-scroll daftar untuk kueri saat ini. Dalam metode bindList()
, mari kita ganti setupScrollListener()
dengan RecyclerView.OnScrollListener
inline. Kita juga akan menghapus metode setupScrollListener()
sepenuhnya.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
// the rest of the code is unchanged
}
Selanjutnya, kita akan menyiapkan pipeline untuk membuat flag boolean shouldScrollToTop
. Setelah itu, kita memiliki dua flow yang dapat menerapkan collect
dari: PagingData
Flow
dan shouldScrollToTop
Flow
.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(...)
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
Di atas, kita menggunakan collectLatest
pada pagingData
Flow
sehingga kita dapat membatalkan pengumpulan pada emisi pagingData
sebelumnya pada emisi pagingData
baru. Untuk flag shouldScrollToTop
, emisi PagingDataAdapter.loadStateFlow
sinkron dengan apa yang ditampilkan di UI, sehingga aman untuk segera memanggil list.scrollToPosition(0)
tepat setelah flag boolean yang ditampilkan benar (true).
Jenis di LoadStateFlow adalah objek CombinedLoadStates
.
CombinedLoadStates
memungkinkan kita mendapatkan status pemuatan untuk tiga jenis operasi pemuatan:
CombinedLoadStates.refresh
- merepresentasikan status pemuatan untuk memuatPagingData
untuk kali pertama.CombinedLoadStates.prepend
- merepresentasikan status pemuatan untuk memuat data di awal daftar.CombinedLoadStates.append
- merepresentasikan status pemuatan untuk memuat data di akhir daftar.
Kali ini, kita ingin mereset posisi scroll hanya jika pemuatan ulang selesai, yaitu LoadState
di-refresh
, NotLoading
.
Kita sekarang bisa menghapus binding.list.scrollToPosition(0)
dari updateRepoListFromInput()
.
Setelah itu, aktivitas Anda akan terlihat seperti:
class SearchRepositoriesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
// get the view model
val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
.get(SearchRepositoriesViewModel::class.java)
// add dividers between RecyclerView's row items
val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.list.addItemDecoration(decoration)
// bind the state
binding.bindState(
uiState = viewModel.state,
pagingData = viewModel.pagingDataFlow,
uiActions = viewModel.accept
)
}
/**
* Binds the [UiState] provided by the [SearchRepositoriesViewModel] to the UI,
* and allows the UI to feed back user actions to it.
*/
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter
bindSearch(
uiState = uiState,
onQueryChanged = uiActions
)
bindList(
repoAdapter = repoAdapter,
uiState = uiState,
pagingData = pagingData,
onScrollChanged = uiActions
)
}
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
searchRepo.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
searchRepo.text.trim().let {
if (it.isNotEmpty()) {
list.scrollToPosition(0)
onQueryChanged(UiAction.Search(query = it.toString()))
}
}
}
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
}
Aplikasi akan mengompilasi dan berjalan, tetapi tanpa footer status pemuatan dan Toast
yang menampilkan error. Pada langkah berikutnya, kita akan melihat cara menampilkan footer status pemuatan.
Anda dapat menemukan kode lengkap untuk langkah-langkah yang telah dilakukan sejauh ini di cabang step5-9_paging_3.0.
10. Menampilkan status pemuatan dalam footer
Di aplikasi, kita ingin dapat menampilkan footer berdasarkan status pemuatan: saat daftar sedang dimuat, kita ingin menampilkan indikator lingkaran berputar progres. Jika terjadi error, kita ingin menampilkan error dan tombol coba lagi.
Header/footer yang perlu kita buat akan mengikuti ide daftar yang perlu ditambahkan di awal (sebagai header) atau di akhir (sebagai footer) dari daftar item sesungguhnya yang akan kita tampilkan. Header/footer adalah daftar yang hanya berisi satu elemen: tampilan yang menampilkan status progres atau error dengan tombol coba lagi, berdasarkan LoadState
Paging.
Karena menampilkan header/footer berdasarkan status pemuatan dan mengimplementasikan mekanisme coba lagi merupakan tugas umum, Paging 3 API akan membantu kita melakukan kedua hal tersebut.
Kita akan menggunakan LoadStateAdapter
untuk implementasi header/footer. Implementasi RecyclerView.Adapter
ini otomatis memberitahukan perubahan status pemuatan. Ini memastikan bahwa hanya status Loading
dan Error
yang menyebabkan item ditampilkan dan memberi tahu RecyclerView
saat item dihapus, dimasukkan, atau diubah, bergantung pada LoadState
.
Untuk mekanisme coba lagi, kita menggunakan adapter.retry()
. Di balik layar, metode ini pada akhirnya memanggil implementasi PagingSource
Anda untuk halaman yang benar. Respons akan otomatis disebarkan melalui Flow<PagingData>
.
Mari kita lihat seperti apa implementasi header/footer-nya!
Seperti daftar lainnya, ada 3 file yang akan dibuat:
- File tata letak berisi elemen UI untuk menampilkan progres, error, dan tombol coba lagi
- **File** **
ViewHolder
** membuat item UI terlihat berdasarkanLoadState
Paging - File adaptor menentukan cara membuat dan mengikat
ViewHolder
. Daripada memperluasRecyclerView.Adapter
, kita akan memperluasLoadStateAdapter
dari Paging 3.
Membuat tata letak tampilan
Buat tata letak repos_load_state_footer_view_item
untuk status pemuatan repositori. Tata letak ini harus memiliki ProgressBar
, TextView
(untuk menampilkan error), dan Button
coba lagi. String dan dimensi yang diperlukan sudah dideklarasikan dalam project.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/error_text_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/retry"/>
</LinearLayout>
Membuat ViewHolder
Buat ViewHolder
baru bernama ReposLoadStateViewHolder
dalam folder ui
**.** Ini akan menerima fungsi coba lagi sebagai parameter, yang akan dipanggil saat tombol coba lagi ditekan. Buat fungsi bind()
yang menerima LoadState
sebagai parameter dan menyetel visibilitas setiap tampilan sesuai dengan LoadState
. Implementasi ReposLoadStateViewHolder
menggunakan ViewBinding
akan terlihat seperti ini:
class ReposLoadStateViewHolder(
private val binding: ReposLoadStateFooterViewItemBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.errorMsg.text = loadState.error.localizedMessage
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorMsg.isVisible = loadState is LoadState.Error
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.repos_load_state_footer_view_item, parent, false)
val binding = ReposLoadStateFooterViewItemBinding.bind(view)
return ReposLoadStateViewHolder(binding, retry)
}
}
}
Membuat LoadStateAdapter
Buat ReposLoadStateAdapter
yang memperluas LoadStateAdapter
di folder ui
juga. Adaptor akan menerima fungsi coba lagi sebagai parameter karena fungsi coba lagi akan diteruskan ke ViewHolder
saat dibuat.
Seperti halnya Adapter
, kita perlu mengimplementasikan metode onBind()
dan onCreate()
. LoadStateAdapter
membuatnya lebih mudah saat meneruskan LoadState
di kedua fungsi ini. Di onBindViewHolder()
, ikat ViewHolder
Anda. Di onCreateViewHolder()
, tentukan cara membuat ReposLoadStateViewHolder
berdasarkan ViewGroup
induk dan fungsi coba lagi:
class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
return ReposLoadStateViewHolder.create(parent, retry)
}
}
Mengikat adaptor footer dengan daftar
Sekarang setelah memiliki semua elemen footer, mari kita ikat elemen tersebut ke daftar. Untuk melakukannya, PagingDataAdapter
memiliki 3 metode yang bermanfaat:
withLoadStateHeader
- jika hanya ingin menampilkan header—ini harus digunakan saat daftar hanya mendukung penambahan item di awal daftar.withLoadStateFooter
- jika hanya ingin menampilkan footer—ini harus digunakan saat daftar hanya mendukung penambahan item di akhir daftar.withLoadStateHeaderAndFooter
- jika ingin menampilkan header dan footer - jika daftar dapat di-paging di kedua arah.
Update metode ActivitySearchRepositoriesBinding.bindState()
dan panggil withLoadStateHeaderAndFooter()
pada adaptor. Sebagai fungsi coba lagi, kita bisa memanggil adapter.retry()
.
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)
...
}
Karena daftar scrolling tanpa batas berhasil diperoleh, satu cara mudah untuk melihat footer adalah dengan menyetel ponsel atau emulator ke mode pesawat dan men-scroll hingga bagian akhir daftar.
Mari kita jalankan aplikasi!
Anda dapat menemukan kode lengkap untuk langkah-langkah yang telah dilakukan sejauh ini di cabang step10_loading_state_footer.
11. Menampilkan status pemuatan di Activity
Anda mungkin mengetahui bahwa saat ini kita memiliki dua masalah:
- Saat bermigrasi ke Paging 3, kita tidak lagi dapat menampilkan pesan saat daftar hasil kosong.
- Setiap kali Anda menelusuri kueri baru, hasil kueri saat ini tetap berada di layar hingga kita mendapatkan respons jaringan. Ini adalah pengalaman pengguna yang buruk. Sebagai gantinya, kita harus menampilkan status progres atau tombol coba lagi.
Solusi untuk kedua masalah ini adalah bereaksi terhadap perubahan status pemuatan dalam SearchRepositoriesActivity
.
Menampilkan pesan daftar kosong
Pertama, mari kita kembalikan pesan daftar kosong. Pesan ini hanya akan ditampilkan setelah daftar dimuat dan item dalam daftar ini berjumlah 0. Untuk mengetahui waktu daftar dimuat, kita akan menggunakan properti PagingDataAdapter.loadStateFlow
. Flow
ini muncul setiap kali terdapat perubahan dalam status pemuatan melalui objek CombinedLoadStates
.
CombinedLoadStates
memberikan status pemuatan untuk PageSource
yang telah kita tentukan atau untuk RemoteMediator
yang diperlukan bagi kasus jaringan dan database (baca lebih lanjut nanti).
Di SearchRepositoriesActivity.bindList()
, kita mengumpulkan data dari loadStateFlow
secara langsung. Daftar ini kosong bila status refresh
CombinedLoadStates
adalah NotLoading
dan adapter.itemCount == 0
. Kemudian, kita akan mengalihkan visibilitas emptyList
dan list
:
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
}
}
}
}
Menampilkan status pemuatan
Mari kita perbarui activity_search_repositories.xml
agar menyertakan tombol coba lagi dan elemen UI status progres:
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.SearchRepositoriesActivity">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/search_repo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_hint"
android:imeOptions="actionSearch"
android:inputType="textNoSuggestions"
android:selectAllOnFocus="true"
tools:text="Android"/>
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingVertical="@dimen/row_item_margin_vertical"
android:scrollbars="vertical"
app:layoutManager="LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/input_layout"
tools:ignore="UnusedAttribute"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView android:id="@+id/emptyList"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/no_results"
android:textSize="@dimen/repo_name_size"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Tombol coba lagi akan memicu pemuatan ulang PagingData
. Untuk melakukannya, kita memanggil adapter.retry()
dalam implementasi onClickListener
, seperti yang juga dilakukan untuk header/footer:
// SearchRepositoriesActivity.kt
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
retryButton.setOnClickListener { repoAdapter.retry() }
...
}
Berikutnya, mari bereaksi pada perubahan status pemuatan di SearchRepositoriesActivity.bindList
. Karena kita hanya ingin status progres yang ditampilkan saat memiliki kueri baru, kita harus mengandalkan jenis pemuatan dari sumber paging, khususnya CombinedLoadStates.source.refresh
dan di LoadState
: Loading
atau Error
. Selain itu, satu fungsi yang telah kita jadikan sebagai komentar pada langkah sebelumnya menampilkan Toast
saat terjadi error. Jadi, pastikan Anda juga menyertakannya. Untuk menampilkan pesan error, kita harus memeriksa apakah CombinedLoadStates.prepend
atau CombinedLoadStates.append
adalah instance LoadState.Error
dan mengambil pesan error dari error tersebut.
Mari kita perbarui ActivitySearchRepositoriesBinding.bindList
di metode SearchRepositoriesActivity
agar memiliki fungsi ini:
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.source.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.source.refresh is LoadState.Error
// Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
val errorState = loadState.source.append as? LoadState.Error
?: loadState.source.prepend as? LoadState.Error
?: loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
errorState?.let {
Toast.makeText(
this@SearchRepositoriesActivity,
"\uD83D\uDE28 Wooops ${it.error}",
Toast.LENGTH_LONG
).show()
}
}
}
}
Sekarang, mari kita jalankan aplikasi dan periksa cara kerjanya!
Selesai. Dengan penyiapan saat ini, komponen library Paging adalah komponen yang memicu permintaan API pada waktu yang tepat, menangani cache dalam memori, dan menampilkan datanya. Jalankan aplikasi dan coba telusuri repositori.
Anda dapat menemukan kode lengkap untuk langkah-langkah yang telah dilakukan sejauh ini di cabang step11_loading_state.
12. Menambahkan pemisah daftar
Satu cara untuk meningkatkan keterbacaan daftar adalah dengan menambahkan pemisah. Misalnya, dalam aplikasi, karena repositori diurutkan menurun menurut jumlah bintang, pemisah dapat tersedia untuk setiap 10 ribu bintang. Untuk membantu mengimplementasikan fungsi ini, Paging 3 API memungkinkan penyisipan pemisah ke dalam PagingData
.
Menambahkan pemisah di PagingData
akan menyebabkan modifikasi daftar yang ditampilkan di layar. Kita tidak lagi menampilkan objek Repo
saja, tetapi juga objek pemisah. Oleh karena itu, kita harus mengubah model UI yang ditampilkan dari ViewModel
, dari Repo
menjadi jenis lain yang dapat mengenkapsulasi kedua jenis: RepoItem
dan SeparatorItem
. Selanjutnya, kita harus mengupdate UI untuk mendukung pemisah:
- Tambahkan tata letak dan
ViewHolder
untuk pemisah. - Update
RepoAdapter
untuk mendukung pembuatan serta pengikatan pemisah dan repositori.
Mari kita lakukan langkah demi langkah ini dan lihat seperti apa implementasinya.
Mengubah model UI
Saat ini, SearchRepositoriesViewModel.searchRepo()
menampilkan Flow<PagingData<Repo>>
. Untuk mendukung repositori dan pemisah, kita akan membuat class UiModel
tertutup di file yang sama dengan SearchRepositoriesViewModel
. Kita bisa memiliki 2 jenis objek UiModel
: RepoItem
dan SeparatorItem
.
sealed class UiModel {
data class RepoItem(val repo: Repo) : UiModel()
data class SeparatorItem(val description: String) : UiModel()
}
Karena kita ingin memisahkan repositori berdasarkan 10 ribu bintang, mari kita mulai membuat properti ekstensi di RepoItem
yang akan membulatkan jumlah bintang:
private val UiModel.RepoItem.roundedStarCount: Int
get() = this.repo.stars / 10_000
Menyisipkan pemisah
SearchRepositoriesViewModel.searchRepo()
sekarang akan menampilkan Flow<PagingData<UiModel>>
.
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
...
fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
...
}
}
Mari lihat perubahan implementasinya! Saat ini, repository.getSearchResultStream(queryString)
menampilkan Flow<PagingData<Repo>>
. Jadi, operasi pertama yang perlu ditambahkan adalah mengubah setiap Repo
menjadi UiModel.RepoItem
. Untuk melakukannya, gunakan operator Flow.map
dan petakan setiap PagingData
untuk membuat UiModel.Repo
baru dari item Repo
saat ini sehingga menghasilkan Flow<PagingData<UiModel.RepoItem>>
:
...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...
Sekarang kita dapat menyisipkan pemisah! Kita akan memanggil PagingData.insertSeparators()
untuk setiap kemunculan Flow
. Metode ini menampilkan PagingData
yang berisi setiap elemen asli, dengan pemisah opsional yang akan Anda buat, yang telah diberi elemen sebelum dan sesudahnya. Dalam kondisi batas (di awal atau akhir daftar), elemen sebelum atau sesudahnya masing-masing adalah null
. Jika pemisah tidak perlu dibuat, tampilkan null
.
Karena kita mengubah jenis elemen PagingData
dari UiModel.Repo
menjadi UiModel
, pastikan Anda menetapkan argumen jenis metode insertSeparators()
secara eksplisit.
Berikut adalah tampilan metode searchRepo()
:
private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
.map {
it.insertSeparators { before, after ->
if (after == null) {
// we're at the end of the list
return@insertSeparators null
}
if (before == null) {
// we're at the beginning of the list
return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
}
// check between 2 items
if (before.roundedStarCount > after.roundedStarCount) {
if (after.roundedStarCount >= 1) {
UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
} else {
UiModel.SeparatorItem("< 10.000+ stars")
}
} else {
// no separator
null
}
}
}
Mendukung beberapa jenis tampilan
Objek SeparatorItem
harus ditampilkan di RecyclerView
. Kita hanya menampilkan string di sini, jadi mari mulai membuat tata letak separator_view_item
dengan TextView
di folder res/layout
:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/separatorBackground">
<TextView
android:id="@+id/separator_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/row_item_margin_horizontal"
android:textColor="@color/separatorText"
android:textSize="@dimen/repo_name_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>
Mari kita membuat SeparatorViewHolder
di folder ui
, tempat kita mengikat string ke TextView
:
class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val description: TextView = view.findViewById(R.id.separator_description)
fun bind(separatorText: String) {
description.text = separatorText
}
companion object {
fun create(parent: ViewGroup): SeparatorViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.separator_view_item, parent, false)
return SeparatorViewHolder(view)
}
}
}
Update ReposAdapter
untuk mendukung UiModel
, bukan Repo
:
- Update parameter
PagingDataAdapter
dariRepo
menjadiUiModel
. - Implementasikan komparator
UiModel
dan gantiREPO_COMPARATOR
dengannya. - Buat
SeparatorViewHolder
dan ikat dengan deskripsiUiModel.SeparatorItem
.
Karena sekarang kita harus menampilkan 2 ViewHolders yang berbeda, ganti RepoViewHolder dengan ViewHolder:
- Update parameter
PagingDataAdapter
- Update jenis nilai
onCreateViewHolder
yang ditampilkan - Update parameter
onBindViewHolder
holder
Berikut tampilan ReposAdapter
saat selesai:
class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return if (viewType == R.layout.repo_view_item) {
RepoViewHolder.create(parent)
} else {
SeparatorViewHolder.create(parent)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is UiModel.RepoItem -> R.layout.repo_view_item
is UiModel.SeparatorItem -> R.layout.separator_view_item
null -> throw UnsupportedOperationException("Unknown view")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
when (uiModel) {
is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
}
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
oldItem.repo.fullName == newItem.repo.fullName) ||
(oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
oldItem.description == newItem.description)
}
override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
oldItem == newItem
}
}
}
Selesai. Saat menjalankan aplikasi, Anda seharusnya bisa melihat pemisahnya!
Anda dapat menemukan kode lengkap untuk langkah-langkah yang telah dilakukan sejauh ini di cabang step12_separators.
13. Melakukan paging dari jaringan dan database
Selanjutnya, mari tambahkan dukungan offline ke aplikasi dengan menyimpan data di database lokal. Dengan begitu, database akan menjadi sumber kebenaran aplikasi dan kita akan selalu memuat data dari sana. Mintalah lebih banyak data dari jaringan setiap kali tidak ada data lagi yang tersedia, lalu simpan di database. Karena database adalah sumber kebenaran, UI akan otomatis diupdate saat ada lebih banyak data yang disimpan.
Berikut yang perlu dilakukan untuk menambahkan dukungan offline:
- Buat database Room, tabel untuk menyimpan objek
Repo
di, dan DAO yang akan kita gunakan untuk menangani objekRepo
. - Tentukan cara memuat data dari jaringan jika telah kita mencapai akhir data dalam database dengan mengimplementasikan
RemoteMediator
. - Buat
Pager
berdasarkan tabel Repositori sebagai sumber data danRemoteMediator
untuk memuat dan menyimpan data.
Mari lakukan langkah-langkah berikut!
14. Menentukan database Room, tabel, dan DAO
Objek Repo
harus disimpan dalam database. Jadi, mari kita mulai dengan membuat class Repo
sebagai entity, menggunakan tableName = "repos"
, dengan Repo.id
sebagai kunci utama. Untuk melakukannya, anotasikan class Repo
dengan @Entity(tableName = "repos")
dan tambahkan anotasi @PrimaryKey
ke id
. Seperti inilah tampilan class Repo
Anda sekarang:
@Entity(tableName = "repos")
data class Repo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String,
@field:SerializedName("full_name") val fullName: String,
@field:SerializedName("description") val description: String?,
@field:SerializedName("html_url") val url: String,
@field:SerializedName("stargazers_count") val stars: Int,
@field:SerializedName("forks_count") val forks: Int,
@field:SerializedName("language") val language: String?
)
Buat paket db
baru. Di sinilah kita akan mengimplementasikan class yang mengakses data dalam database dan class yang mendefinisikan database.
Implementasikan objek akses data (DAO) untuk mengakses tabel repos
dengan membuat antarmuka RepoDao
, yang dianotasi dengan @Dao
. Kita memerlukan tindakan berikut di Repo
:
- Sisipkan daftar objek
Repo
. Jika objekRepo
sudah ada dalam tabel, gantilah objek tersebut.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
- Buat kueri untuk repositori yang berisi string kueri dalam nama atau dalam deskripsi, dan urutkan menurun hasil tersebut menurut jumlah bintang, lalu menurut nama sesuai abjad. Sebagai ganti
List<Repo>
, tampilkanPagingSource<Int, Repo>
. Dengan begitu, tabelrepos
menjadi sumber data untuk Paging.
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
- Hapus semua data di tabel
Repos
.
@Query("DELETE FROM repos")
suspend fun clearRepos()
Berikut ini tampilan RepoDao
Anda:
@Dao
interface RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
@Query("DELETE FROM repos")
suspend fun clearRepos()
}
Implementasikan database Repositori:
- Buat class abstrak
RepoDatabase
yang memperluasRoomDatabase
. - Anotasikan class dengan
@Database
, tetapkan daftar entity agar berisi classRepo
, dan tetapkan versi database ke 1. Untuk tujuan codelab ini, kita tidak perlu mengekspor skema. - Tentukan fungsi abstrak yang menampilkan
ReposDao
. - Buat fungsi
getInstance()
dicompanion object
yang membuat objekRepoDatabase
jika belum ada.
Seperti inilah tampilan RepoDatabase
Anda:
@Database(
entities = [Repo::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
companion object {
@Volatile
private var INSTANCE: RepoDatabase? = null
fun getInstance(context: Context): RepoDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE
?: buildDatabase(context).also { INSTANCE = it }
}
private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.applicationContext,
RepoDatabase::class.java, "Github.db")
.build()
}
}
Setelah menyiapkan database, mari kita lihat cara meminta data dari jaringan dan menyimpannya dalam database.
15. Meminta dan menyimpan data - ringkasan
Library Paging menggunakan database sebagai sumber kebenaran untuk data yang perlu ditampilkan di UI. Mintalah lebih banyak data dari jaringan setiap kali tidak ada lagi data yang tersedia dalam database. Untuk membantu hal ini, Paging 3 akan menentukan class abstrak RemoteMediator
, dengan satu metode yang perlu diimplementasikan: load()
. Metode ini akan dipanggil setiap kali kita perlu memuat lebih banyak data dari jaringan. Class ini menampilkan objek MediatorResult
yang dapat berupa:
Error
- jika mengalami error saat meminta data dari jaringan.Success
- Jika berhasil mendapatkan data dari jaringan. Di sini, kita juga perlu meneruskan sinyal yang memberitahukan apakah data yang lebih banyak dapat dimuat atau tidak. Misalnya, jika respons jaringan berhasil, tetapi yang berhasil diperoleh adalah daftar repositori kosong, artinya tidak ada lagi data yang dapat dimuat.
Dalam paket data
, mari kita buat class baru bernama GithubRemoteMediator
yang memperluas RemoteMediator
. Class ini akan dibuat ulang untuk setiap kueri baru sehingga akan menerima string berikut sebagai parameter:
String
kueri.GithubService
- sehingga kita dapat membuat permintaan jaringan.RepoDatabase
- sehingga kita dapat menyimpan data yang diperoleh dari permintaan jaringan.
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
private val query: String,
private val service: GithubService,
private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
}
}
Untuk dapat membuat permintaan jaringan, metode pemuatan memiliki 2 parameter yang akan memberikan semua informasi yang kita butuhkan:
PagingState
- ini memberikan informasi tentang halaman yang dimuat sebelumnya, indeks dalam daftar yang terakhir diakses, danPagingConfig
yang ditentukan saat menginisialisasi aliran paging.LoadType
- ini memberitahukan apakah kita perlu memuat data di akhir (LoadType.APPEND
) atau di awal data (LoadType.PREPEND
) yang sebelumnya telah dimuat, atau apakah ini kali pertama kita memuat data (LoadType.REFRESH
).
Misalnya, jika jenis pemuatan adalah LoadType.APPEND
, kita akan mengambil item terakhir yang dimuat dari PagingState
. Berdasarkan hal tersebut, kita harus bisa menemukan cara memuat batch objek Repo
selanjutnya, dengan mengomputasi halaman berikutnya yang akan dimuat.
Di bagian berikutnya, Anda akan mengetahui cara mengomputasi kunci untuk halaman berikutnya dan sebelumnya yang akan dimuat.
16. Mengomputasi dan menyimpan kunci halaman jarak jauh
Untuk tujuan API GitHub, kunci halaman yang kita gunakan untuk meminta halaman repositori hanya berupa indeks halaman yang ditambahkan saat mendapatkan halaman berikutnya. Artinya, dengan objek Repo
, batch objek Repo
berikutnya dapat diminta berdasarkan indeks halaman + 1. Batch objek Repo
sebelumnya dapat diminta berdasarkan indeks halaman - 1. Semua objek Repo
yang diterima di respons halaman tertentu akan memiliki kunci berikutnya dan sebelumnya yang sama.
Setelah item terakhir berhasil dimuat dari PagingState
, tidak ada cara untuk mengetahui indeks halamannya. Untuk mengatasi masalah ini, kita dapat menambahkan tabel lain yang menyimpan kunci halaman berikutnya dan sebelumnya untuk setiap Repo
; dan itu disebut remote_keys
. Meskipun cara ini dapat dilakukan dalam tabel Repo
, membuat tabel baru untuk kunci jarak jauh berikutnya dan sebelumnya yang terkait dengan Repo
memungkinkan kita memiliki pemisahan masalah yang lebih baik.
Dalam paket db
, mari membuat class data baru bernama RemoteKeys
, menganotasikannya dengan @Entity
, dan menambahkan 3 properti: id
repositori (yang juga merupakan kunci utama), serta kunci sebelumnya dan berikutnya (yang dapat berupa null
saat kita tidak dapat menambahkan data di awal dan di akhir).
@Entity(tableName = "remote_keys")
data class RemoteKeys(
@PrimaryKey
val repoId: Long,
val prevKey: Int?,
val nextKey: Int?
)
Mari membuat antarmuka RemoteKeysDao
. Kita akan memerlukan kemampuan berikut:
- Memasukkan daftar **
RemoteKeys
**, seperti setiap kali kita mendapatkanRepos
dari jaringan, kita akan membuat kunci jarak jauh untuk itu. - Mendapatkan **
RemoteKey
** berdasarkanRepo
id
. - Menghapus **
RemoteKeys
** yang akan kita gunakan setiap kali ada kueri baru.
@Dao
interface RemoteKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKey: List<RemoteKeys>)
@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?
@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}
Mari kita tambahkan tabel RemoteKeys
ke database dan sediakan akses ke RemoteKeysDao
. Untuk melakukannya, update RepoDatabase
sebagai berikut:
- Tambahkan RemoteKeys ke daftar entitas.
- Tampilkan
RemoteKeysDao
sebagai fungsi abstrak.
@Database(
entities = [Repo::class, RemoteKeys::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
abstract fun remoteKeysDao(): RemoteKeysDao
...
// rest of the class doesn't change
}
17. Meminta dan menyimpan data - implementasi
Setelah menyimpan kunci jarak jauh, mari kembali ke GithubRemoteMediator
dan lihat cara menggunakannya. Class ini akan menggantikan GithubPagingSource
. Mari kita salin deklarasi GITHUB_STARTING_PAGE_INDEX
dari GithubPagingSource
di GithubRemoteMediator
dan hapus class GithubPagingSource
.
Mari kita lihat cara mengimplementasikan metode GithubRemoteMediator.load()
:
- Cari tahu halaman yang perlu dimuat dari jaringan, berdasarkan
LoadType
. - Picu permintaan jaringan.
- Setelah permintaan jaringan selesai, jika daftar repositori yang diterima tidak kosong, lakukan hal berikut:
- Kita akan mengomputasi
RemoteKeys
untuk setiapRepo
. - Jika ini adalah kueri baru (
loadType = REFRESH
), hapus database. - Simpan
RemoteKeys
danRepos
dalam database. - Tampilkan
MediatorResult.Success(endOfPaginationReached = false)
. - Jika daftar repositori kosong, maka tampilkan
MediatorResult.Success(endOfPaginationReached = true)
. Jika terjadi error saat meminta data, tampilkanMediatorResult.Error
.
Berikut ini adalah tampilan kode secara keseluruhan. Kita akan mengganti TODO nanti.
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> {
// TODO
}
LoadType.PREPEND -> {
// TODO
}
LoadType.APPEND -> {
// TODO
}
}
val apiQuery = query + IN_QUALIFIER
try {
val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
val repos = apiResponse.items
val endOfPaginationReached = repos.isEmpty()
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
Mari kita lihat cara menemukan halaman untuk dimuat berdasarkan LoadType
.
18. Mendapatkan halaman berdasarkan LoadType
Setelah mengetahui apa yang terjadi dalam metode GithubRemoteMediator.load()
begitu memiliki kunci halaman, mari kita lihat cara mengomputasinya. Proses ini akan bergantung pada LoadType
.
LoadType.APPEND
Jika kita perlu memuat data di akhir set data yang saat ini dimuat, parameter pemuatannya adalah LoadType.APPEND
. Jadi, kita perlu mengomputasi kunci halaman jaringan berdasarkan item terakhir dalam database.
- Kita perlu mendapatkan kunci jarak jauh item
Repo
terakhir yang dimuat dari database. Mari kita pisahkan ini dalam fungsi:
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the last page that was retrieved, that contained items.
// From that last page, get the last item
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { repo ->
// Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
- Jika
remoteKeys
null, berarti hasil pemuatan ulang belum ada dalam database. Kita dapat menampilkan Success denganendOfPaginationReached = false
karena Paging akan memanggil metode ini lagi jika RemoteKeys menjadi non-null. Jika remoteKeys bukannull
tetapinextKey
null
, berarti kita telah mencapai akhir paging halaman untuk append.
val page = when (loadType) {
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
// We can return Success with endOfPaginationReached = false because Paging
// will call this method again if RemoteKeys becomes non-null.
// If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
// the end of pagination for append.
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
...
}
LoadType.PREPEND
Jika harus memuat data di awal set data yang saat ini dimuat, parameter pemuatannya adalah LoadType.PREPEND
. Kita perlu mengomputasi kunci halaman jaringan berdasarkan item pertama dalam database.
- Kita perlu mendapatkan kunci jarak jauh item
Repo
pertama yang dimuat dari database. Mari kita pisahkan ini dalam fungsi:
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the first page that was retrieved, that contained items.
// From that first page, get the first item
return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
?.let { repo ->
// Get the remote keys of the first items retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
- Jika
remoteKeys
null, berarti hasil pemuatan ulang belum ada dalam database. Kita dapat menampilkan Success denganendOfPaginationReached = false
karena Paging akan memanggil metode ini lagi jika RemoteKeys menjadi non-null. Jika remoteKeys bukannull
tetapiprevKey
null
, berarti kita telah mencapai akhir paging halaman untuk awalan.
val page = when (loadType) {
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
...
}
LoadType.REFRESH
LoadType.REFRESH
dipanggil saat kali pertama data dimuat, atau saat PagingDataAdapter.refresh()
dipanggil. Jadi, sekarang titik referensi untuk memuat data adalah state.anchorPosition
. Jika ini adalah pemuatan pertama, anchorPosition
adalah null
. Saat PagingDataAdapter.refresh()
dipanggil, anchorPosition
adalah posisi yang terlihat pertama dalam daftar yang ditampilkan, sehingga kita harus memuat halaman yang berisi item spesifik tersebut.
- Berdasarkan
anchorPosition
daristate
, kita bisa mendapatkan itemRepo
yang paling dekat dengan posisi tersebut dengan memanggilstate.closestItemToPosition()
. - Berdasarkan item
Repo
, kita bisa mendapatkanRemoteKeys
dari database.
private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int, Repo>
): RemoteKeys? {
// The paging library is trying to load data after the anchor position
// Get the item closest to the anchor position
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.id?.let { repoId ->
repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
}
}
}
- Jika
remoteKey
bukan null,nextKey
dapat diperoleh dari nilai tersebut. Di API GitHub, kunci halaman bertambah secara berurutan. Jadi, untuk mendapatkan halaman yang berisi item saat ini, cukup kurangkan 1 dariremoteKey.nextKey
. - Jika
RemoteKey
adalahnull
(karenaanchorPosition
adalahnull
), halaman yang perlu dimuat adalah halaman awal:GITHUB_STARTING_PAGE_INDEX
Sekarang, komputasi halaman penuh terlihat seperti ini:
val page = when (loadType) {
LoadType.REFRESH -> {
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
}
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
}
19. Mengupdate pembuatan Flow paging
Setelah mengimplementasikan GithubRemoteMediator
dan PagingSource
di ReposDao
, GithubRepository.getSearchResultStream
harus diupdate agar dapat digunakan.
Untuk melakukannya, GithubRepository
memerlukan akses ke database. Mari kita teruskan database sebagai parameter dalam konstruktor. Selain itu, karena class ini akan menggunakan GithubRemoteMediator
:
class GithubRepository(
private val service: GithubService,
private val database: RepoDatabase
) { ... }
Update file Injection
:
- Metode
provideGithubRepository
harus mendapatkan konteks sebagai parameter dan dalamGithubRepository
tempat konstruktor memanggilRepoDatabase.getInstance
. - Metode
provideViewModelFactory
harus mendapatkan konteks sebagai parameter dan meneruskannya keprovideGithubRepository
.
object Injection {
private fun provideGithubRepository(context: Context): GithubRepository {
return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
}
fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
return ViewModelFactory(owner, provideGithubRepository(context))
}
}
Update metode SearchRepositoriesActivity.onCreate()
dan teruskan konteks ke Injection.provideViewModelFactory()
:
// get the view model
val viewModel = ViewModelProvider(
this, Injection.provideViewModelFactory(
context = this,
owner = this
)
)
.get(SearchRepositoriesViewModel::class.java)
Mari kembali ke GithubRepository
. Pertama, agar dapat menelusuri repositori menurut nama, kita harus menambahkan %
awal dan akhir string kueri. Lalu, saat memanggil reposDao.reposByName
, kita akan mendapatkan PagingSource
. Karena PagingSource
menjadi tidak valid setiap kali kita melakukan perubahan dalam database, kita harus memberi tahu Paging cara mendapatkan instance baru PagingSource
. Untuk melakukannya, cukup buat fungsi yang akan memanggil kueri database:
// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery)}
Sekarang kita dapat mengubah builder Pager
untuk menggunakan GithubRemoteMediator
dan pagingSourceFactory
. Pager
adalah API eksperimental sehingga harus diberi anotasi dengan @OptIn
:
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(
query,
service,
database
),
pagingSourceFactory = pagingSourceFactory
).flow
Selesai. Mari kita jalankan aplikasi!
Bereaksi terhadap status pemuatan saat menggunakan RemoteMediator
Sampai sekarang, saat membaca dari CombinedLoadStates
, kita selalu membaca dari CombinedLoadStates.source
. Namun, saat menggunakan RemoteMediator
, informasi pemuatan yang akurat hanya dapat diperoleh dengan memeriksa CombinedLoadStates.source
dan CombinedLoadStates.mediator
. Secara khusus, saat ini kita memicu scroll ke bagian atas daftar di kueri baru jika source
LoadState
adalah NotLoading
. Kita juga harus memastikan bahwa RemoteMediator
yang baru ditambahkan memiliki LoadState
NotLoading
juga.
Untuk melakukannya, tentukan enum yang merangkum status presentasi daftar seperti yang diambil oleh Pager
:
enum class RemotePresentationState {
INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}
Dengan definisi di atas, kita dapat membandingkan emisi berturut-turut dari CombinedLoadStates
, dan menggunakannya untuk menentukan status yang sama persis dari item dalam daftar.
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
scan(RemotePresentationState.INITIAL) { state, loadState ->
when (state) {
RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
else -> state
}
RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
is LoadState.NotLoading -> RemotePresentationState.PRESENTED
else -> state
}
}
}
.distinctUntilChanged()
Hal di atas memungkinkan kita memperbarui definisi notLoading
Flow
yang digunakan untuk memeriksa apakah kita dapat men-scroll ke bagian atas daftar:
val notLoading = repoAdapter.loadStateFlow
.asRemotePresentationState()
.map { it == RemotePresentationState.PRESENTED }
Demikian pula, saat menampilkan indikator lingkaran berputar pemuatan saat pemuatan awal halaman berlangsung (dalam ekstensi bindList
di SearchRepositoriesActivity
) , aplikasi masih mengandalkan LoadState.source
. Yang kita inginkan saat ini adalah menampilkan indikator lingkaran berputar pemuatan hanya untuk pemuatan dari RemoteMediator
. Elemen UI lain yang visibilitasnya bergantung pada LoadStates
juga memiliki masalah ini. Oleh karena itu, kita memperbarui binding LoadStates
ke elemen UI sebagai berikut:
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
...
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds, either from the the local db or the remote.
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
}
}
}
}
Selain itu, karena kita memiliki database sebagai satu sumber kebenaran, aplikasi dapat diluncurkan ketika kita memiliki data di database, tetapi pemuatan ulang dengan RemoteMediator
gagal. Ini adalah kasus ekstrem yang menarik, tetapi kita dapat menanganinya dengan mudah. Untuk melakukannya, kita dapat menyimpan referensi ke header LoadStateAdapter
dan mengganti LoadState
menjadi milik RemoteMediator jika dan hanya jika status muat ulangnya mengalami error. Jika tidak, kita akan menggunakan nilai defaultnya.
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// Show a retry header if there was an error refreshing, and items were previously
// cached OR default to the default prepend state
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend
...
}
}
}
Anda dapat menemukan kode lengkap untuk langkah-langkah yang dilakukan sejauh ini di cabang step13-19_network_and_database.
20. Rangkuman
Setelah menambahkan semua komponen, sekarang saatnya merangkum semua yang sudah kita pelajari!
PagingSource
secara asinkron memuat data dari sumber yang Anda tentukan.Pager.flow
akan membuatFlow<PagingData>
berdasarkan konfigurasi dan fungsi yang menentukan cara membuat instancePagingSource
.Flow
memunculkanPagingData
baru setiap kali data baru dimuat olehPagingSource
.- UI mengamati perubahan
PagingData
dan menggunakanPagingDataAdapter
untuk mengupdateRecyclerView
yang menyajikan data. - Untuk mencoba kembali pemuatan yang gagal dari UI, gunakan metode
PagingDataAdapter.retry
. Di balik layar, library Paging akan memicu metodePagingSource.load()
. - Untuk menambahkan pemisah ke daftar Anda, buat jenis level tinggi dengan pemisah sebagai salah satu jenis yang didukung. Kemudian, gunakan metode
PagingData.insertSeparators()
untuk mengimplementasikan logika pembuatan pemisah. - Untuk menampilkan status pemuatan sebagai header atau footer, gunakan metode
PagingDataAdapter.withLoadStateHeaderAndFooter()
dan implementasikanLoadStateAdapter
. Jika Anda ingin menjalankan tindakan lainnya berdasarkan status pemuatan, gunakan callbackPagingDataAdapter.addLoadStateListener()
. - Untuk menangani jaringan dan database, implementasikan
RemoteMediator
. - Menambahkan
RemoteMediator
akan menyebabkan pembaruan pada kolommediator
diLoadStatesFlow
.