Android 分頁的基本概念

1. 簡介

課程內容

  • 哪些是分頁程式庫的主要元件。
  • 如何在專案中新增分頁程式庫。

建構項目

在本程式碼研究室中,您會從已能顯示文章清單的範例應用程式開始操作。這份靜態清單包含 500 篇文章,且所有文章都儲存在手機記憶體中:

7d256d9c74e3b3f5.png

跟著本程式碼研究室的步驟操作,您將:

  • ...瞭解分頁的概念。
  • ...瞭解分頁程式庫的核心元件。
  • ...得知使用分頁程式庫實作分頁的方法。

完成課程後,您將建構一款符合以下條件的應用程式:

  • ...可成功實作分頁。
  • ...在擷取更多資料時,可有效地向使用者說明。

以下快速看一下我們將打造的使用者介面:

6277154193f7580.gif

軟硬體需求

建議條件

2. 設定環境

在這個步驟中,您將下載適用於整個程式碼研究室的程式碼,然後執行簡單的範例應用程式。

我們準備了範例專案,方便您盡快上手並進行建構。

如果您已安裝 Git,只要執行下列指令即可。如要檢查 Git 是否已安裝完成,請在終端機或指令列中輸入 git --version,並確認該指令可正確執行。

 git clone https://github.com/googlecodelabs/android-paging

如果您沒有 Git,可以按一下下方按鈕,下載本程式碼研究室的所有程式碼:

系統會將程式碼整理在兩個資料夾中:basicadvanced。但在這個程式碼研究室,我們只會用到 basic 資料夾。

basic 資料夾中還有兩個資料夾:startend。我們將開始處理 start 資料夾中的程式碼;在程式碼研究室課程最後,start 資料夾中的程式碼應該會與 end 資料夾中的程式碼相同。

  1. 在 Android Studio 的 basic/start 目錄中開啟專案。
  2. 在裝置或模擬器上執行 app 執行設定。

89af884fa2d4e709.png

現在應該會顯示文章清單!請捲動至頁面底部,確認這是靜態清單,也就是說,當我們捲動到清單結尾時,系統不會擷取更多項目。接著請捲動回頂端,確認所有項目仍完好如初。

3. 分頁簡介

向使用者顯示資訊的方法很多,最常見的是使用清單。不過有時候,這些清單只會透過小型視窗讓使用者瀏覽所有內容。當使用者捲動瀏覽可用的資訊時,往往會預期系統擷取更多資料來補充已顯示的資訊。因此每次擷取資料時都必須注重效率和流暢性,這樣逐步增量載入作業就不會對使用者體驗造成負面影響。另外,逐步增量載入功能也能提升效能,因為應用程式不需要一次將大量資料保存在記憶體中。

這個逐步擷取資訊的程序稱為「分頁」,其中每個「頁面」會對應要擷取的資料區塊。要求頁面時,需要分頁的資料來源通常必須使用「查詢」,藉此定義所需資訊。本程式碼研究室的其餘部分將介紹分頁程式庫,並示範這個程式庫如何協助您快速有效率地在應用程式中實作分頁。

分頁程式庫的核心元件

分頁程式庫的核心元件如下:

  • PagingSource - 為特定頁面查詢載入資料區塊的基礎類別。這是資料層的一部分,通常會透過 DataSource 類別揭露,且後續由 Repository 揭露,以用於 ViewModel 中。
  • PagingConfig - 這個類別用於定義決定分頁行為的參數,包括頁面大小、是否啟用預留位置等等。
  • Pager - 這個類別負責產生 PagingData 資料流。這項作業視 PagingSource 而定,且應在 ViewModel 中建立。
  • PagingData - 分頁資料的容器。每次重新整理資料時,系統都會產生獨立且對應的 PagingData 發射項目,並由其專屬的 PagingSource 做為支援。
  • PagingDataAdapter - RecyclerView.Adapter 子類別,會在 RecyclerView 中顯示 PagingDataPagingDataAdapter 可以使用 Factory 方法連結至 Kotlin FlowLiveData、RxJava Flowable、RxJava Observable,甚至是靜態清單。PagingDataAdapter 會監聽內部 PagingData 載入事件,並在頁面載入時有效更新使用者介面。

566d0f6506f39480.jpeg

在以下各節中,您將實作上述各項元件的範例。

4. 專案總覽

應用程式目前的形式會顯示一份靜態文章清單。每篇文章都有標題、說明和建立日期。靜態清單適用於少數項目的情況,但隨著資料集越來越大,這份清單便無法妥善調整規模。我們將透過分頁程式庫實作分頁,藉此修正這個問題,但首先回顧一下應用程式中現有的元件。

應用程式採用的是《應用程式架構指南》中建議採用的架構。以下是您會在各套件中看到的內容:

資料層:

  • ArticleRepository:負責提供文章清單並將其儲存在記憶體中。
  • Article:這個類別代表「資料模型」,表示從資料層中擷取的資訊。

使用者介面層

  • ActivityRecyclerView.AdapterRecyclerView.ViewHolder:這些類別負責在使用者介面中顯示清單。
  • ViewModel:這個狀態容器負責建立使用者介面要顯示的狀態。

存放區會在具有 articleStream 欄位的 Flow 中公開顯示所有相關文章,這會依次由使用者介面層中的 ArticleViewModel 讀取,並讓其就緒,以便於 ArticleActivity 中的使用者介面透過 state 欄位 (亦即 StateFlow) 使用。

將文章當做來自存放區的 Flow 公開顯示後,存放區就能在已顯示的文章有所變動時加以更新。舉例來說,假如文章標題有所更動,系統可以輕鬆將這項更動傳達給 articleStream 的收集器。而針對 ViewModel 中的使用者介面狀態使用 StateFlow,可確保即使系統停止收集使用者介面狀態 (例如在更改設定期間重新建立 Activity 時),還是可以在再次開始收集時,從中斷處繼續。

如前文所述,存放區中目前的 articleStream 只會顯示當天的新聞。雖然這可能足以符合部分使用者的需求,但其他使用者或許會希望先瀏覽當天提供的所有文章,之後再瀏覽較舊的文章。正因為他們有這種期望,顯示文章時才更適合使用分頁。應該透過文章來探索分頁的其他原因包括:

  • ViewModel 會將所有載入到記憶體中的項目保留在 items StateFlow 中。當資料集日趨龐大時,這會是首要考量,因為這樣會影響效能。
  • 當清單中的一或多篇文章出現變動,我們就必須更新,但隨著文章清單越來越大,更新的成本就越來越高。

分頁程式庫能協助解決所有這些問題,同時提供一致的 API,在應用程式中以逐步增量的方式 (分頁) 擷取資料。

5. 定義資料來源

實作分頁時,我們希望確保同時滿足下列條件:

  • 妥善處理來自使用者介面的資料要求,確保不會同時對同一查詢觸發多項要求。
  • 在記憶體中保留一定限度的擷取資料。
  • 觸發擷取更多資料的要求,以補充已擷取的資料。

我們可以透過 PagingSource 滿足所有這些條件。PagingSource 會指定如何透過逐步增量的區塊擷取資料,定義資料來源。接著,PagingData 物件會擷取 PagingSource 中的資料,以便對使用者在 RecyclerView 中捲動時產生的載入提示做出回應。

PagingSource 將載入文章。在 data/Article.kt 中,您會發現模型的定義如下::

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

如要建構 PagingSource,您必須定義下列項目:

  • 分頁金鑰的類型 - 這會定義我們用來要求更多資料的頁面查詢類型。在這個範例中,ID 必然會按遞增順序排序,因此我們會擷取特定文章 ID 前後的文章。
  • 載入的資料類型 - 每個頁面都會傳回文章的 List,因此類型為 Article
  • 資料擷取來源 - 這通常是資料庫、網路資源或任何其他分頁式資料的來源。不過,在本程式碼研究室中,我們使用的是本機產生的資料。

現在要在 data 套件中,透過名為 ArticlePagingSource.kt 的新檔案建立 PagingSource 實作項目:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

PagingSource 要求我們實作 load()getRefreshKey() 這兩個函式。

分頁程式庫會呼叫 load() 函式,以非同步方式擷取更多資料,以便在使用者捲動畫面時顯示。LoadParams 物件則保留與載入作業相關的資訊,包括:

  • 要載入頁面的金鑰 - 如果這是系統第一次呼叫 load()LoadParams.key 會是 null。在本範例中,您必須定義初始頁面金鑰。就我們的專案而言,我們會使用文章 ID 做為金鑰。同時,請在 ArticlePagingSource 檔案的頂端新增 STARTING_KEY 常數 0,做為初始頁面金鑰。
  • 載入大小 - 要求載入的項目數。

load() 函式會傳回 LoadResultLoadResult 可以是以下其中一種類型:

  • LoadResult.Page (如果結果成功)。
  • LoadResult.Error (發生錯誤時)。
  • LoadResult.Invalid (如果 PagingSource 因無法再保證其結果的完整性而失效)。

LoadResult.Page 含有三個必要的引數:

  • data:擷取到的項目 List
  • prevKeyload() 方法在需要擷取當前頁面「背後」的項目時使用的金鑰。
  • nextKeyload() 方法在需要擷取當前頁面「之後」的項目時使用的金鑰。

...另外還有兩個選用引數:

  • itemsBefore:載入資料前方顯示的預留位置數量。
  • itemsAfter:載入資料後方顯示的預留位置數量。

載入金鑰為 Article.id 欄位。我們可以將這個欄位當做金鑰使用的原因在於,每篇文章的 Article ID 都會加一;也就是說,文章 ID 是連續遞增的整數。

如果對應方向沒有其他資料可供載入,nextKeyprevKey 就會是 null。在本例中,prevKey 會有以下情況:

  • 如果 startKeySTARTING_KEY 相同,系統會傳回空值,原因是我們無法在這個金鑰背後載入更多項目。
  • 否則,我們會擷取清單中的第一個項目,並在其背後載入 LoadParams.loadSize,以確保傳回的金鑰絕不會小於 STARTING_KEY。只要定義 ensureValidKey() 方法,即可完成上述操作。

新增以下函式來檢查分頁金鑰是否有效:

class ArticlePagingSource : PagingSource<Int, Article>() {
   ... 
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

nextKey 而言:

  • 由於我們支援載入無限量的項目,因此會在 range.last + 1 中傳遞。

此外,由於每篇文章都有 created 欄位,我們還需要為該欄位產生一個值。請將下列程式碼新增到檔案頂端:

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
}

備妥上述所有程式碼後,我們現在就可以實作 load() 函式:

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        // Start paging with the STARTING_KEY if this is the first load
        val start = params.key ?: STARTING_KEY
        // Load as many items as hinted by params.loadSize
        val range = start.until(start + params.loadSize)

        return LoadResult.Page(
            data = range.map { number ->
                Article(
                    // Generate consecutive increasing numbers as the article id
                    id = number,
                    title = "Article $number",
                    description = "This describes article $number",
                    created = firstArticleCreatedTime.minusDays(number.toLong())
                )
            },

            // Make sure we don't try to load items behind the STARTING_KEY
            prevKey = when (start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1
        )
    }

    ...
}

接下來,我們需要實作 getRefreshKey()。當分頁程式庫因幕後 PagingSource 中的資料發生變動而必須重新載入 UI 所用的項目時,就需要呼叫這個方法。在此情況下,PagingSource 的基本資料已有所變動,而且需要在使用者介面中更新,這就是所謂的「失效」。失效情形發生時,分頁程式庫會建立新的 PagingSource 來重新載入資料,並發出新的 PagingData 來通知使用者介面。如果想進一步瞭解「失效」,請參閱稍後的章節。

從新的 PagingSource 載入時,系統會呼叫 getRefreshKey() 來提供新的 PagingSource 開始載入時要使用的金鑰,以確保在重新整理畫面後,使用者還是能找到他們在清單中的目前位置。

分頁程式庫失效的原因有兩種:

  • 您在 PagingAdapter 上呼叫了 refresh()
  • 您在 PagingSource 上呼叫了 invalidate()

傳回的金鑰 (在本範例中為 Int) 會透過 LoadParams 引數傳送給新 PagingSourceload() 方法的下一次呼叫。為防止項目在失效後跳轉,我們需要確保傳回的金鑰能載入足夠的項目來填滿畫面。這會造成新項目集較有可能出現無效資料中的項目,有助於維持目前的捲動位置。現在我們來看看如何在應用程式中實作:

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

在上方的程式碼片段中,我們使用的是 PagingState.anchorPosition。如果您不清楚分頁程式庫如何知道要擷取更多項目,這是一個線索!當使用者介面嘗試讀取 PagingData 中的項目時,會在特定索引進行讀取。如果資料已讀取完畢,系統便會在使用者介面中顯示該資料。但在沒有資料的情況下,Paging 程式庫會知道自身必須擷取資料,才能執行失敗的讀取要求。上次在讀取時成功擷取資料的索引是 anchorPosition

重新整理畫面時,我們會找出最靠近 anchorPositionArticle,並拿取其中的金鑰做為載入金鑰。如此一來,當我們從新的 PagingSource 重新開始載入時,已擷取的項目集就會包含已載入的項目,以確保能提供流暢且一致的使用者體驗。

完成上述步驟表示您已充分定義了 PagingSource,下一步是將其連結至使用者介面。

6. 為 UI 產生 PagingData

在目前的實作項目中,我們是透過在 ArticleRepository 中使用 Flow<List<Article>>,向 ViewModel 揭露已載入的資料。反過來,ViewModel 會透過 stateIn 運算子,將資料維持在隨時可用的狀態,以便顯示在使用者介面中。

透過分頁程式庫,我們會改為從 ViewModel 公開 Flow<PagingData<Article>>PagingData 這種類型能納入我們已載入的資料,協助分頁程式庫判定要在什麼時候擷取更多資料,同時確保我們不會對同一頁面提出兩次要求。

為了建構 PagingData,我們會根據要使用哪個 API 將 PagingData 傳遞至應用程式的其他層,決定採用 Pager 類別的其中一個建構工具方法:

  • Kotlin Flow - 使用 Pager.flow
  • LiveData - 使用 Pager.liveData
  • RxJava Flowable - 使用 Pager.flowable
  • RxJava Observable - 使用 Pager.observable

由於我們已在應用程式中採用 Flow,因此將繼續採用這種做法;但我們會改用 Flow<PagingData<Article>>,而不是 Flow<List<Article>>

無論您使用哪一種 PagingData 建構工具,都必須傳遞下列參數:

  • PagingConfig。此類別用於設定如何從 PagingSource 載入內容的相關選項,例如要提早多久進行載入、初始載入的大小要求等。您必須定義的唯一必要參數是頁面大小,也就是每個頁面應載入的項目數量。根據預設,分頁會將所有載入的頁面保存在記憶體中。如要確保在使用者捲動頁面時不浪費記憶體,請在 PagingConfig 中設定 maxSize 參數。根據預設,如果分頁可以計算已卸載的項目,且 enablePlaceholders 設定標記為 true,則分頁將針對尚未載入的內容傳回空值做為預留位置。這樣一來,您就可以在轉接器中顯示預留位置檢視畫面。為了簡化本程式碼研究室的工作,讓我們先傳遞 enablePlaceholders = false,藉此停用預留位置。
  • 可定義如何建立 PagingSource 的函式。在本範例中,我們會建立 ArticlePagingSource,因此需要透過函式來告知分頁程式庫該如何執行此操作。

現在我們可以來修改 ArticleRepository 了!

更新 ArticleRepository

  • 刪除 articlesStream 欄位。
  • 新增名為 articlePagingSource() 的方法,用於傳回剛剛建立的 ArticlePagingSource
class ArticleRepository {

    fun articlePagingSource() = ArticlePagingSource()
}

清除 ArticleRepository

分頁程式庫為我們處理了許多事情:

  • 處理記憶體內的快取。
  • 在使用者滑動到清單末端時提出資料要求。

這表示除了 articlePagingSource() 外,可以移除 ArticleRepository 中的所有其他項目。您的 ArticleRepository 檔案現在看起來應像這樣:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
    fun articlePagingSource() = ArticlePagingSource()
}

現在 ArticleViewModel 中應會出現編譯錯誤。我們來看看其中需要進行哪些變更!

7. 在 ViewModel 中要求及快取 PagingData

在處理編譯錯誤之前,我們來檢查一下 ViewModel

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

為了在 ViewModel 中整合分頁程式庫,我們會將 items 的傳回類型從 StateFlow<List<Article>> 變更為 Flow<PagingData<Article>>。為此,請先在檔案頂端新增名為 ITEMS_PER_PAGE 的私有常數:

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

接著,我們將 items 更新為 Pager 執行個體的輸出結果,方法是將以下兩個參數傳遞給 Pager

  • pageSizeITEMS_PER_PAGE 且預留位置已停用的 PagingConfig
  • PagingSourceFactory,用於提供剛剛建立的 ArticlePagingSource 的執行個體。
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

接著,為維持經過設定或瀏覽變更後的分頁狀態,我們會使用 cachedIn() 方法,將 androidx.lifecycle.viewModelScope 傳遞給它。

完成上述變更後,ViewModel 看起來會像這樣:

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

關於 PagingData 還有一點要注意,它屬於獨立類型,包含可變動的更新資料串流,而這些資料會顯示在 RecyclerView 中。每次發出的 PagingData 均完全獨立,但如果幕後的 PagingSource 因基礎資料集的變動而失效,則可能會針對單一查詢發出多個 PagingData 執行個體。因此,公開 PagingDataFlows 時,應與其他 Flows 分開進行。

大功告成!ViewModel 現在可以支援分頁功能了!

8. 將轉接器與 PagingData 搭配使用

如要將 PagingData 繫結至 RecyclerView,請使用 PagingDataAdapter。每當載入 PagingData 內容時,PagingDataAdapter 都會收到通知,接著向 RecyclerView 發出更新信號。

更新 ArticleAdapter 以使用 PagingData 資料流:

  • 目前,ArticleAdapter 已實作 ListAdapter,請改為實作 PagingDataAdapter。類別主體的其餘部分則維持不變:
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

到現在為止我們已進行了許多變更,現在只要再執行一個步驟就能開始執行應用程式了,這個步驟就是連結使用者介面!

9. 在 UI 中使用 PagingData

在目前的實作項目中,有一個名為 binding.setupScrollListener() 的方法,可在符合某些條件時呼叫 ViewModel,以載入更多資料。由於分頁程式庫會自動執行上述所有操作,因此我們可以刪除此方法及相關用例。

接著,因為 ArticleAdapter 不再是 ListAdapter,而是 PagingDataAdapter,因此我們進行兩項微幅更動:

  • Flow 上的終端機運算子從 ViewModel 切換為 collectLatest (而非 collect)。
  • 使用 submitData() (而非submitList()) 將變更內容告知 ArticleAdapter

我們會在 pagingData Flow 上使用 collectLatest,這樣當新的 pagingData 執行個體發射時,系統會針對先前的 pagingData 發射項目取消收集作業。

完成這些變更後,Activity 看起來應該會像這樣:

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

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

        val viewModel by viewModels<ArticleViewModel>(
            factoryProducer = { Injection.provideViewModelFactory(owner = this) }
        )

        val items = viewModel.items
        val articleAdapter = ArticleAdapter()

        binding.bindAdapter(articleAdapter = articleAdapter)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

private fun ActivityArticlesBinding.bindAdapter(
    articleAdapter: ArticleAdapter
) {
    list.adapter = articleAdapter
    list.layoutManager = LinearLayoutManager(list.context)
    val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
    list.addItemDecoration(decoration)
}

應用程式現在應可編譯並執行。您已成功將應用程式遷移至分頁程式庫!

f97136863cfa19a0.gif

10. 在 UI 中顯示載入狀態

當分頁程式庫擷取更多要在使用者介面中顯示的項目時,最佳做法就是告知使用者有更多資料正在傳送中。幸運的是,分頁程式庫方便您透過 CombinedLoadStates 類型存取其載入狀態。

CombinedLoadStates 執行個體會針對分頁程式庫中所有會載入資料的元件描述載入狀態。在本範例中,我們只關注 ArticlePagingSourceLoadState,因此主要處理 CombinedLoadStates.source 欄位中 LoadStates 的類型。您可以透過 PagingDataAdapter.loadStateFlow 經由 PagingDataAdapter 存取 CombinedLoadStates

CombinedLoadStates.sourceLoadStates 類型,包含三種不同 LoadState 的欄位:

  • LoadStates.append:用於在使用者目前位置之後擷取的項目 LoadState
  • LoadStates.prepend:用於在使用者目前位置之前擷取的項目 LoadState
  • LoadStates.refresh:用於初始載入的 LoadState

每項 LoadState 都可以設為下列其中一種狀態:

  • LoadState.Loading:正在載入項目。
  • LoadState.NotLoading:未載入項目。
  • LoadState.Error:載入時發生錯誤。

在我們的範例中,由於 ArticlePagingSource 不包含錯誤情況,因此我們只關心 LoadState 是否為 LoadState.Loading

首先,我們要在使用者介面頂端和底端新增進度列,以便表示任一方向的擷取作業載入狀態。

activity_articles.xml 中新增兩條 LinearProgressIndicator 列,如下所示:

<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.ArticleActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

接著,我們透過從 PagingDataAdapter 收集 LoadStatesFlow 來回應 CombinedLoadState。請收集 ArticleActivity.kt 中的狀態:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

最後,我們會在 ArticlePagingSource 中稍微延遲,以模擬負載:

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

讓應用程式再次執行,並捲動至清單底部。當分頁程式庫擷取更多項目時,您應該會看到底端的進度列,而作業完成後,此進度列就會消失!

6277154193f7580.gif

11. 總結

現在快速回顧一下本課程介紹的內容,我們...:

  • ...大致瞭解了分頁程序及其重要性。
  • ...透過建立 Pager、定義 PagingSource 及發出 PagingData,為應用程式新增了分頁。
  • ...使用 cachedIn 運算子在 ViewModel 中快取 PagingData
  • ...透過 PagingDataAdapter 在使用者介面中使用 PagingData
  • ...透過 PagingDataAdapter.loadStateFlow 回應 CombinedLoadStates

大功告成!如要瞭解更多進階分頁概念,請參閱「進階分頁」程式碼研究室