Paging Android

Yang akan Anda pelajari

  • Komponen utama dari Paging 3.0.
  • Cara menambahkan Paging 3.0 ke project.
  • Cara menambahkan header atau footer ke daftar menggunakan Paging 3.0 API.
  • Cara menambahkan pemisah daftar menggunakan Paging 3.0 API.
  • Cara melakukan paging dari jaringan dan database

Yang akan Anda buat

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:

e662a697dd078356.png

Yang akan Anda butuhkan

Untuk pengantar Komponen Arsitektur, lihat Room dengan codelab View. Untuk pengantar Flow, lihat Coroutine Lanjutan dengan Flow Kotlin dan codelab LiveData.

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 Paging 3.0 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_separator - Anda akan menemukan solusi untuk langkah 12, yaitu dengan menambahkan pemisah untuk aplikasi.
  • Di 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:

Download kode sumber

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

b3c0dfdb92dfed77.png

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

86fcb1b9b845c2f6.png

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.

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.

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.

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.

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

}

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.0, 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.
  • Kembalikan 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.0 melakukan banyak hal untuk kita:

  • 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 {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

Anda sekarang akan mengompilasi error di SearchRepositoriesViewModel. Mari kita lihat perubahan apa yang perlu dilakukan!

Dari SearchRepositoriesViewModel, kita menampilkan repoResult: LiveData<RepoSearchResult>. Peran repoResult akan menjadi cache di memori untuk penelusuran hasil yang mempertahankan perubahan konfigurasi. Dengan Paging 3.0, Flow tidak perlu dikonversi lagi menjadi LiveData. Sebaliknya, SearchRepositoriesViewModel akan memiliki anggota Flow<PagingData<Repo>> pribadi yang memiliki tujuan yang sama dengan repoResult.

Daripada menggunakan objek LiveData untuk setiap kueri baru, kita bisa menggunakan String. Hal ini akan membantu kita memastikan bahwa setiap kali mendapatkan kueri penelusuran baru yang sama dengan kueri saat ini, kita hanya akan menampilkan Flow yang ada. Kita hanya perlu memanggil repository.getSearchResultStream() jika kueri penelusuran baru berbeda.

Flow<PagingData> memiliki metode cachedIn() yang praktis yang memungkinkan kita meng-cache konten Flow<PagingData> di CoroutineScope. Karena berada di ViewModel, kita akan menggunakan androidx.lifecycle.viewModelScope.

Kita akan menulis ulang sebagian besar SearchRepositoriesViewModel memanfaatkan fungsi bawaan dari Paging 3.0. SearchRepositoriesViewModel akan terlihat seperti ini:

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

Sekarang, mari kita lihat perubahan yang telah kita lakukan pada SearchRepositoriesViewModel:

  • Menambahkan kueri String baru dan hasil penelusuran anggota Flow.
  • Mengupdate metode searchRepo() dengan fungsi yang dijelaskan sebelumnya.
  • Menghapus queryLiveData dan repoResult karena tujuannya dicakup oleh Paging 3.0 dan Flow.
  • Menghapus listScrolled() karena library Paging akan menangani ini untuk kita.
  • Menghapus companion object karena VISIBLE_THRESHOLD tidak lagi diperlukan.

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!

Mari kita update SearchRepositoriesActivity agar berfungsi dengan Paging 3.0. 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.

Kita juga ingin memastikan bahwa setiap kali pengguna menelusuri kueri baru, kueri sebelumnya akan dibatalkan. Untuk melakukannya, SearchRepositoriesActivity dapat menyimpan referensi ke Job baru yang akan dibatalkan setiap kali kita menelusuri kueri baru.

Mari buat fungsi penelusuran baru yang mendapatkan kueri sebagai parameternya. Fungsi ini harus melakukan hal berikut:

  • Membatalkan tugas penelusuran sebelumnya.
  • Meluncurkan tugas baru di lifecycleScope.
  • Memanggil viewModel.searchRepo.
  • Mengumpulkan hasil PagingData.
  • Meneruskan PagingData ke ReposAdapter dengan memanggil adapter.submitData(pagingData).
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

Fungsi penelusuran harus dipanggil di SearchRepositoriesActivity dalam metode onCreate(). Di updateRepoListFromInput(), ganti panggilan viewModel dan adapter dengan search():

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

Karena kita ingin memastikan bahwa posisi scroll direset pada setiap penelusuran baru, kita harus: binding.list.scrollToPosition(0). Namun, daripada mereset posisi di penelusuran baru, kita akan mereset posisi saat adaptor daftar diperbarui dengan hasil penelusuran baru. Untuk mencapai hal tersebut, kita bisa menggunakan PagingDataAdapter.loadStateFlow API. Flow ini muncul setiap kali terdapat perubahan dalam status pemuatan melalui objek CombinedLoadStates.

CombinedLoadStates memungkinkan kita mendapatkan status pemuatan untuk 3 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.

Mari kumpulkan dari alur ini saat kita menginisialisasi penelusuran. Dalam metode initSearch dan pada setiap kemunculan baru flow, mari scroll ke posisi 0.

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

Kita sekarang bisa menghapus binding.list.scrollToPosition(0) dari updateRepoListFromInput().

Saat ini, kita menggunakan OnScrollListener yang dilampirkan ke RecyclerView untuk mengetahui kapan harus memicu lebih banyak data. Kita bisa mengizinkan library Paging untuk menangani scrolling daftar. Hapus metode setupScrollListener() dan semua referensi yang merujuknya.

Mari hapus juga penggunaan repoResult. Aktivitas Anda akan terlihat seperti berikut:

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        viewModel = ViewModelProvider(this, Injection.provideViewModelFactory())
                .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

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.

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.0 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.0.

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

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 SearchRepositoriesActivity.initAdapter() dan panggil withLoadStateHeaderAndFooter() pada adaptor. Sebagai fungsi coba lagi, kita bisa memanggil adapter.retry().

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.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 aplikasinya.

Anda dapat menemukan kode lengkap untuk langkah-langkah yang telah dilakukan sejauh ini di cabang step10_loading_state_footer.

Anda mungkin mengetahui bahwa saat ini kita memiliki dua masalah:

  • Saat bermigrasi ke Paging 3.0, 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 dengan 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 metode PagingDataAdapter.addLoadStateListener(). Callback ini akan memberi tahu kita setiap kali ada 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 (selengkapnya tentang ini nanti).

Di SearchRepositoriesActivity.initAdapter(), kita memanggil addLoadStateListener. Daftar ini kosong bila status refresh CombinedLoadStates adalah NotLoading dan adapter.itemCount == 0. Kemudian kita memanggil showEmptyList:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(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

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

Berikutnya, mari bereaksi pada perubahan status pemuatan, di SearchRepositoriesActivity.initAdapter. Karena kita hanya ingin status progres ditampilkan saat memiliki kueri baru, kita harus mengandalkan pemuatan dari sumber paging, khususnya CombinedLoadStates.source.refresh dan pada LoadState: yang diandalkan 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 metode SearchRepositoriesActivity.initAdapter agar memiliki fungsi ini:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh.
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        binding.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,
                    "\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.

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.0 API memungkinkan penyisipan pemisah ke dalam PagingData.

170f5fa2945e7d95.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>>. Jadikan currentSearchResult sebagai jenis yang sama.

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    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():

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { 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
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

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.

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.

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.

Library Paging menggunakan database sebagai sumber kebenaran untuk data yang perlu ditampilkan di UI. Mintalah lebih banyak data dari jaringan setiap kali tidak ada data lagi yang tersedia dalam database. Untuk membantu hal ini, Paging 3.0 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.

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

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.

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 prevKey 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 prevKey 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 append.
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.
        // 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 prevKey is null, that means we've reached
        // the end of pagination for prepend.
        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
    }
}

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): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

Update metode SearchRepositoriesActivity.onCreate() dan teruskan konteks ke Injection.provideViewModelFactory():

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(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 aplikasinya.

Memperbarui sumber status pemuatan

Saat ini aplikasi memuat data dari jaringan dan menyimpannya dalam database, tetapi jika menampilkan indikator lingkaran berputar pemuatan saat pemuatan awal halaman berlangsung (dalam SearchRepositoriesActivity.initAdapter), aplikasi masih mengandalkan LoadState.source. Yang kita inginkan saat ini adalah menampilkan indikator lingkaran berputar pemuatan hanya untuk pemuatan dari RemoteMediator. Untuk itu, kita harus mengubah dari LoadState.source menjadi LoadState.mediator:

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

Anda dapat menemukan kode lengkap untuk langkah-langkah yang dilakukan sejauh ini di cabang step13-19_network_and_database.

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.