Codelab Paging Android Lanjutan

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:

23643514cb9cf43e.png

Yang Anda butuhkan

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:

  1. Buka zip kode, lalu buka project di Android Studio.
  2. Jalankan app yang menjalankan konfigurasi di perangkat atau emulator.

89af884fa2d4e709.png

Aplikasi akan berjalan dan menampilkan daftar repositori GitHub yang serupa dengan berikut:

50d1d2aa6e79e473.png

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; dan RepoSearchResult, class yang digunakan oleh UI untuk mengamati data hasil penelusuran dan error jaringan.
  • ui - class terkait untuk menampilkan Activity dengan RecyclerView.

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:

  1. LiveData<UiState>
  2. (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 menjadi LiveData 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 dengan LiveData 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 atau filter di daftar yang akan ditampilkan, terlepas dari apakah Anda sedang menggunakan Flow, LiveData, atau RxJava Flowable atau Observable.
  • 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 memiliki PagingData yang sesuai secara terpisah.
  • PagingSource - PagingSource adalah class dasar untuk memuat ringkasan data ke dalam aliran PagingData.
  • Pager.flow - membuat Flow<PagingData>, berdasarkan PagingConfig dan fungsi yang menentukan cara membuat PagingSource yang diimplementasikan.
  • PagingDataAdapter - RecyclerView.Adapter yang menyajikan PagingData di RecyclerView. PagingDataAdapter dapat dihubungkan ke Flow Kotlin, LiveData, Flowable RxJava, atau Observable RxJava. PagingDataAdapter akan memproses peristiwa pemuatan PagingData internal saat halaman dimuat dan menggunakan DiffUtil di thread latar belakang untuk mengomputasi update terperinci saat konten yang diupdate diterima dalam bentuk objek PagingData 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 ke GithubService.

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 menjadi null. Pada kasus ini, Anda harus menentukan kunci halaman awal. Untuk project kita, Anda diharuskan memindahkan konstanta GITHUB_STARTING_PAGE_INDEX dari GithubRepository ke implementasi PagingSource 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 - gunakan Pager.flow.
  • LiveData - gunakan Pager.liveData.
  • Flowable RxJava - gunakan Pager.flowable.
  • Observable RxJava - gunakan Pager.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 dari PagingSource 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 parameter maxSize di PagingConfig. Jika Paging dapat menghitung item yang tidak dimuat dan jika flag konfigurasi enablePlaceholders 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 meneruskan enablePlaceholders = false.
  • Fungsi yang menentukan cara membuat PagingSource. Dalam kasus ini, kita akan membuat GithubPagingSource 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:

  1. UiAction.Search untuk setiap kali pengguna memasukkan kueri tertentu.
  2. 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:

  1. shareIn: Ini diperlukan karena saat Flow digunakan, pemakaiannya menggunakan operator flatmapLatest. Setiap kali upstream muncul, flatmapLatest akan membatalkan Flow 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 operator Flow dengan nilai replay 1 untuk meng-cache nilai terakhir sehingga tidak hilang saat kueri baru masuk.
  2. 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 mengimplementasikan ListAdapter. Mari kita buat kode tersebut mengimplementasikan PagingDataAdapter. 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 memuat PagingData 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.

3f6f2cd47b55de92.png 661da51b58c32b8c.png

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 berdasarkan LoadState Paging
  • File adaptor menentukan cara membuat dan mengikat ViewHolder. Daripada memperluas RecyclerView.Adapter, kita akan memperluas LoadStateAdapter 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.

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

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.

573969750b4c719c.png

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 dari Repo menjadi UiModel.
  • Implementasikan komparator UiModel dan ganti REPO_COMPARATOR dengannya.
  • Buat SeparatorViewHolder dan ikat dengan deskripsi UiModel.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:

  1. Buat database Room, tabel untuk menyimpan objek Repo di, dan DAO yang akan kita gunakan untuk menangani objek Repo.
  2. Tentukan cara memuat data dari jaringan jika telah kita mencapai akhir data dalam database dengan mengimplementasikan RemoteMediator.
  3. Buat Pager berdasarkan tabel Repositori sebagai sumber data dan RemoteMediator 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 objek Repo 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>, tampilkan PagingSource<Int, Repo>. Dengan begitu, tabel repos 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 memperluas RoomDatabase.
  • Anotasikan class dengan @Database, tetapkan daftar entity agar berisi class Repo, dan tetapkan versi database ke 1. Untuk tujuan codelab ini, kita tidak perlu mengekspor skema.
  • Tentukan fungsi abstrak yang menampilkan ReposDao.
  • Buat fungsi getInstance() di companion object yang membuat objek RepoDatabase 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, dan PagingConfig 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 mendapatkan Repos dari jaringan, kita akan membuat kunci jarak jauh untuk itu.
  • Mendapatkan **RemoteKey** berdasarkan Repo 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():

  1. Cari tahu halaman yang perlu dimuat dari jaringan, berdasarkan LoadType.
  2. Picu permintaan jaringan.
  3. Setelah permintaan jaringan selesai, jika daftar repositori yang diterima tidak kosong, lakukan hal berikut:
  4. Kita akan mengomputasi RemoteKeys untuk setiap Repo.
  5. Jika ini adalah kueri baru (loadType = REFRESH), hapus database.
  6. Simpan RemoteKeys dan Repos dalam database.
  7. Tampilkan MediatorResult.Success(endOfPaginationReached = false).
  8. Jika daftar repositori kosong, maka tampilkan MediatorResult.Success(endOfPaginationReached = true). Jika terjadi error saat meminta data, tampilkan MediatorResult.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.

  1. 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)
                }
    }
  1. Jika remoteKeys null, berarti hasil pemuatan ulang belum ada dalam database. Kita dapat menampilkan Success dengan endOfPaginationReached = false karena Paging akan memanggil metode ini lagi jika RemoteKeys menjadi non-null. Jika remoteKeys bukan null tetapi nextKey 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.

  1. 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)
            }
}
  1. Jika remoteKeys null, berarti hasil pemuatan ulang belum ada dalam database. Kita dapat menampilkan Success dengan endOfPaginationReached = false karena Paging akan memanggil metode ini lagi jika RemoteKeys menjadi non-null. Jika remoteKeys bukan null tetapi prevKey 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.

  1. Berdasarkan anchorPosition dari state, kita bisa mendapatkan item Repo yang paling dekat dengan posisi tersebut dengan memanggil state.closestItemToPosition().
  2. Berdasarkan item Repo, kita bisa mendapatkan RemoteKeys 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)
        }
    }
}
  1. 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 dari remoteKey.nextKey.
  2. Jika RemoteKey adalah null (karena anchorPosition adalah null), 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 dalam GithubRepository tempat konstruktor memanggil RepoDatabase.getInstance.
  • Metode provideViewModelFactory harus mendapatkan konteks sebagai parameter dan meneruskannya ke provideGithubRepository.
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 membuat Flow<PagingData> berdasarkan konfigurasi dan fungsi yang menentukan cara membuat instance PagingSource.
  • Flow memunculkan PagingData baru setiap kali data baru dimuat oleh PagingSource.
  • UI mengamati perubahan PagingData dan menggunakan PagingDataAdapter untuk mengupdate RecyclerView yang menyajikan data.
  • Untuk mencoba kembali pemuatan yang gagal dari UI, gunakan metode PagingDataAdapter.retry. Di balik layar, library Paging akan memicu metode PagingSource.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 implementasikan LoadStateAdapter. Jika Anda ingin menjalankan tindakan lainnya berdasarkan status pemuatan, gunakan callback PagingDataAdapter.addLoadStateListener().
  • Untuk menangani jaringan dan database, implementasikan RemoteMediator.
  • Menambahkan RemoteMediator akan menyebabkan pembaruan pada kolom mediator di LoadStatesFlow.