重學Android Jetpack(八)之— Paging3基本使用

語言: CN / TW / HK

theme: geek-black

前言

谷歌在2020年已經開始推出Paging3版本,並且和之前的庫對比是有非常大的不同,甚至可以說是兩個庫了,官方文件對於Paging2的定義也很明確,就是舊的廢棄版Paging庫,所以我們這次只關注Paging3版本,以後要用也只是使用Paging3版本。

簡介

Paging庫是一個分頁庫,它的主要功能幫我們載入和顯示來自本地儲存或網路中更大的資料集中的資料頁面,可以讓應用更高效地利用網路頻寬和系統資源。Android開源社群有不少優秀的分頁功能庫,如:BaseRecyclerViewAdapterHelper,也有基於SwipeRefreshLayout實現。在Android上實現分頁功能並不困難,甚至還可以做得比較好,那麼谷歌為什麼還要推一個難於學習的Paging分頁庫呢?這是因為使用Paging分頁庫具有以下的優勢: - 分頁資料的記憶體中快取。該功能可確保您的應用在處理分頁資料時高效利用系統資源。 - 內建的請求重複資訊刪除功能,可確保您的應用高效利用網路頻寬和系統資源。 - 可配置的RecyclerView介面卡,會在使用者滾動到已載入資料的末尾時自動請求資料。 - 對Kotlin協程和Flow以及LiveDataRxJava的一流支援。 - 內建對錯誤處理功能的支援,包括重新整理和重試功能。

Paging庫的架構

Paging庫是推薦在MVVM架構的應用中使用,它在應用中主要分為三層:

  • 程式碼庫層 程式碼庫層中的主要Paging庫元件是PagingSourcePagingSource物件是用來定義資料來源以及從該資料來源檢索資料。PagingSource物件可以從任何單個數據源(包括網路來源和本地資料庫)載入資料。另外一個元件就是RemoteMediator,它是用來處理來自分層資料來源(例如具有本地資料庫快取的網路資料來源)的分頁。
  • ViewModelPager元件提供了一個公共 API,基於PagingSource物件和PagingConfig配置物件來構造在響應式流中公開的PagingData例項。將 ViewModel 層連線到介面的元件是PagingDataPagingData物件是用於存放分頁資料快照的容器。它會查詢PagingSource物件並存儲結果。
  • 介面層 介面層中的主要Paging庫元件是PagingDataAdapter,它是一種處理分頁資料的RecyclerView介面卡。

來自官方的Paging庫契合應用架構的邏輯圖:

paging3-library-architecture.svg

Paging庫的基本使用

下面我們通過使用Paging庫來實現一個列表分頁載入來學習Paging的使用流程。這裡使用Github公開的api來展示:https://api.github.com/search/repositories?sort=stars&q=Android&page=1&per_page=25

庫依賴

implementation 'androidx.paging:paging-runtime:3.1.1' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' //列印http請求日誌 implementation "com.squareup.okhttp3:logging-interceptor:4.9.3" //用到協程中的flow implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0" //下拉重新整理 implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"

編寫返回資料實體

1122.png

返回的資料是一個Object,其中包含了一個列表items,那麼列表的item實體如下:

data class RepositoryItem( @SerializedName("id") var id: Int, @SerializedName("name") var name: String, @SerializedName("html_url") var htmlUrl: String, @SerializedName("description") var description: String, @SerializedName("stargazers_count") var stargazersCount: Int, ) 只取其中一些資料就好了,接下來在一定一個RspRepository去包裹這個列表: ``` class RspRepository {

@SerializedName("total_count")
var totalCount: Int = 0
@SerializedName("incomplete_results")
var incompleteResults: Boolean = false
@SerializedName("items")
var items: List<RepositoryItem> = emptyList()

} ```

基於Retrofit網路介面定義

請求的介面: ``` interface GithubService {

companion object{
    const val BASE_URL = "https://api.github.com/"
    const val REPO_LIST = "search/repositories?sort=stars&q=Android"
}

@GET(REPO_LIST)
suspend fun getRpositories(@Query("per_page")per_page: Int,@Query("page")page: Int): RspRepository

} ```

Retrofit初始化配置: ``` object GithubApiManager {

val githubServieApi: GithubService by lazy {
    val retrofit = retrofit2.Retrofit.Builder()
        .client(OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
            .build())
        .baseUrl(GithubService.BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    retrofit.create(GithubService::class.java)
}

} ```

自定義PagingSource

PagingSourcePaging庫裡面的重要元件,在使用它是時需要新建一個類去繼承它,並重寫它的load()方法,並在這裡獲取當前頁數的相關資料。看下面程式碼:

``` class GithubPagingSource: PagingSource() {

override fun getRefreshKey(state: PagingState<Int, RepositoryItem>): Int? {
   return null
}

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RepositoryItem> {
    return try {
        val page = params.key ?: 1 
        val pageSize = params.loadSize
        val rspRepository = GithubApiManager.githubServieApi.getRpositories(page, pageSize)
        val items = rspRepository.items
        val preKey = if (page > 1) page - 1 else null
        val nextKey = if (items.isNotEmpty()) page + 1 else null
        LoadResult.Page(items, preKey, nextKey)
    } catch (e: Exception) {
        LoadResult.Error(e)
    }
}

} ```

PagingSource<Int,RepositoryItem>Int型別的泛型就是頁數的資料型別,一般都是整型啦,第二項看型別就知道是列表的每一項資料item。

load()方法是我們載入資料的地方,首先我們通過params得到了key的值,這個key就是當前的頁數,上面程式碼中我們做了判斷,如果key為null,我們則把當前頁數置為1,而params.loadSize代表的是每頁載入的數量。接下來就通過定義的GithubService介面呼叫getRpositories()方法獲取伺服器返回的對應資料。

最後呼叫的LoadResult.Page(items, preKey, nextKey),它返回的就是load(params: LoadParams<Int>)方法的返回值LoadResult物件,LoadResult.Page(items, preKey, nextKey)方法的三個引數分別是獲取到的資料列表,上一頁的頁數以及下一頁的頁數,並且在前面我們判斷了如果當前頁是第一頁或者最後一頁,那麼它的上一頁或者下一頁就為null

getRefreshKey()方法是代表在refresh時,從最後請求的頁面開始請求,預設返回null則是請求第一頁。實際開發場景中,如果請求出錯呼叫refresh方法重新整理資料時,當前已經請求到了前三頁的資料,則可以通過設定在refresh後從第四頁資料開始載入。如果getRefreshKey()返回null,呼叫refresh後會重新開始從第一頁開始載入,這裡我們直接返回null即可。

建立一個Repository類來管理資料來源GithubPagingSource: ``` object Repository {

private const val PAGE_SIZE = 25

fun getGithubPagingData(): Flow<PagingData<RepositoryItem>>{
    return Pager(
        config = PagingConfig(PAGE_SIZE),
        pagingSourceFactory = {GithubPagingSource()}
    ).flow
}

} ``Repository是一個單例,它裡面主要定義了一個getGithubPagingData()方法,返回值的是Flow>,這裡用到了協程中的Flow,也是官方推薦使用協程的Flow代替LiveData的一種表現,在獲取的資料的時候我們可以通過Flow的末端操作符collect來拿到值進行操作。pagingSourceFactory = {GithubPagingSource()}這段程式碼就是把載入資料的GithubPagingSource傳給了Pager`,這樣Paging就會用它來作為用於分頁的資料來源。

定義ViewModel

``` class MainViewModel: ViewModel() {

fun getPagingData(): Flow<PagingData<RepositoryItem>>{

    return Repository.getGithubPagingData().cachedIn(viewModelScope)
}

} `` 在ViewModel中獲取Repository管理的資料來源,achedIn()函式作用是在viewModelScope`這個作用域內對伺服器返回的資料進行快取,可以在系統配置發生改變時,如橫豎屏切換,可以直接讀快取而不用在網路請求資料。

和RecyclerView結合使用

這裡要先說明一下Paging是必須和RecyclerView一起結合使用的,我們先來定義RecyclerView的item佈局item_repository.xml: ```

<TextView
    android:id="@+id/tv_name"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textColor="@color/purple_700"
    android:textSize="18sp"
    android:textStyle="bold"
    android:maxLines="1"
    android:ellipsize="end"
    android:layout_marginTop="8dp" />

<TextView
    android:id="@+id/tv_desc"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textColor="@color/black"
    android:textSize="16sp"
    android:maxLines="8"
    android:ellipsize="end"
    android:layout_marginTop="4dp" />


<LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:layout_gravity="end"
    android:layout_marginTop="4dp"
    android:layout_marginBottom="8dp">

    <ImageView
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:layout_gravity="center_vertical"
        android:src="@drawable/icon_star"
        android:scaleType="centerCrop" />

    <TextView
        android:id="@+id/tv_star"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="16sp"
        android:maxLines="1"
        android:ellipsize="end"
        android:layout_marginStart="2dp"/>

</LinearLayout>

``` 上面程式碼的圖片資源可以到這裡下載:https://www.iconfont.cn/?spm=a313x.7781069.1998910419.d4d0a486a

定義RecyclerView的介面卡,結合Paging使用是必須繼承PagingDataAdapter: ``` class RepositoryAdapter(private val context: Context): PagingDataAdapter(COMPARATOR) {

companion object{
    private val COMPARATOR = object : DiffUtil.ItemCallback<RepositoryItem>() {
        override fun areItemsTheSame(oldItem: RepositoryItem, newItem: RepositoryItem): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: RepositoryItem, newItem: RepositoryItem): Boolean {
            return oldItem == newItem
        }
    }
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = getItem(position)
    item?.let {
        holder.tvName.text = it.name
        holder.tvDesc.text = it.description
        holder.tvStar.text = it.stargazersCount.toString()
    }

    holder.llItem.setOnClickListener {

        val intent = Intent(context,CommonWebActivity::class.java)
        intent.putExtra("url",item?.htmlUrl)
        context.startActivity(intent)

    }
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.item_repository, parent, false)
    return ViewHolder(view)
}


class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
    val tvName: TextView = itemView.findViewById(R.id.tv_name)
    val tvDesc: TextView = itemView.findViewById(R.id.tv_desc)
    val tvStar: TextView = itemView.findViewById(R.id.tv_star)
    val llItem: LinearLayout = itemView.findViewById(R.id.ll_item)
}

} ```

這裡和我們平時用的RecyclerView.Adapter差不多,主要是多了一個通過DiffUtil實現的COMPARATOR,這個在COMPARATOR是一個必須的引數。我們也看到介面卡並沒有傳遞資料來源列表,因為這些都通過Paging自身管理了。

Activity中的佈局: ```

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    android:id="@+id/refreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="8dp">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_margin="8dp" />

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


<ProgressBar
    android:id="@+id/progress_bar"
    style="?android:attr/progressBarStyleInverse"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent" />

```

Activity的邏輯程式碼:

``` class MainActivity : AppCompatActivity() {

private val recyclerView by bindView<RecyclerView>(R.id.recycler_view)
private val progressBar by bindView<ProgressBar>(R.id.progress_bar)
private val refreshLayout by bindView<SwipeRefreshLayout>(R.id.refreshLayout)

private val mAdapter = RepositoryAdapter(this)

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)

    recyclerView?.layoutManager = LinearLayoutManager(this)
    recyclerView?.adapter = mAdapter
    lifecycleScope.launch {
        mainViewModel.getPagingData().collect { pagingData ->
            mAdapter.submitData(pagingData)
        }
    }
    mAdapter.addLoadStateListener {
        when (it.refresh) {
            is LoadState.NotLoading -> {
                progressBar?.visibility = View.INVISIBLE
                recyclerView?.visibility = View.VISIBLE
                refreshLayout?.isRefreshing = false

            }
            is LoadState.Loading -> {
                refreshLayout?.isRefreshing = true
                progressBar?.visibility = View.VISIBLE
                recyclerView?.visibility = View.INVISIBLE
            }
            is LoadState.Error -> {
                progressBar?.visibility = View.INVISIBLE
                refreshLayout?.isRefreshing = false
            }
        }
    }

    refreshLayout?.setOnRefreshListener {
        recyclerView?.swapAdapter(mAdapter,true)
        mAdapter.refresh()
    }
}

} ```

這裡程式碼都比較簡單,關鍵方法就是mAdapter.submitData(pagingData),此方法呼叫就會讓Paging的分頁功能開始執行。mainViewModel.getPagingData().collectFlow訂閱獲取資料的體現,類似LiveDataobserver。效果如下:

1234.gif

如果我們要在底部新增載入狀態的效果可以通過繼承LoadStateAdapter來實現,然後通過在RecycleView設定adapter時通過mAdpter.withLoadStateFooter(FooterAdapter { mAdpter.retry() })呼叫就行了。

總結

Paging3最基本的使用就介紹完了,對於的一般開發使用已經是足夠的了。當然,它還有其他的用法,如RemoteMediator結合Room使用,這裡就不展開贅述了。相對於我們平時用的比較多的分頁開源框架,如:BaseRecyclerViewAdapterHelper,Paging3無需我們再去監聽類似loadMore()這樣的方法而去對page的處理,我們主要按照Paging3的規則編寫好邏輯程式碼告訴它如何載入資料,具體載入那一頁的資料就交給Paging3處理即可。