前陣子分享了一篇Jetpack的高質量文:深入解析Android Jetpack - 讓天下沒有難做的 App,大家表示不夠過癮,這裏就另外分享一篇,希望對大家的學習和工作有所幫助。
原文地址:guolin 大佬
各位小夥伴們大家早上好。
隨着Android 11的正式發佈,Jetpack家族也引入了許多新的成員。我之前有承諾過,對於新引入的App Startup、Hilt、Paging 3,我會分別寫一篇文章進行介紹。
現在,關於App Start和Hilt的文章我都已經寫完了,請參考 Jetpack新成員,App Startup一篇就懂 和 Jetpack新成員,一篇文章帶你玩轉Hilt和依賴注入 。
那麼本篇文章,我們要學習的自然就是Paging 3了。
Paging 3簡介
Paging是Google推出的一個應用於Android平臺的分頁加載庫。
事實上,Paging並不是現在纔剛剛推出的,而是之前就已經推出過兩個版本了。
但Paging 3和前面兩個版本的變化非常大,甚至可以說是完全不同的東西了。所以即使你之前沒有學習過Paging的用法也沒有關係,把Paging 3當成是一個全新的庫去學習就可以了。
我相信一定會有很多朋友在學習Paging 3的時候會產生和我相同的想法:本身Android上的分頁功能並不難實現,即使沒有Paging庫我們也完全做得出來,但爲什麼Paging 3要把一個本來還算簡單的功能設計得如此複雜呢?
是的,Paging 3很複雜,至少在你還不瞭解它的情況下就是如此。我在第一次學習Paging 3的時候就直接被勸退了,心想着何必用這玩意委屈自己呢,自己寫分頁功能又不是做不出來。
後來本着擁抱新技術的態度,我又去學習了一次Paging 3,這次算是把它基本掌握了,並且還在我的新開源項目 Glance 當中應用了Paging 3的技術。
如果現在再讓我來評價一下Paging 3,那麼我大概是經歷了一個由吐槽到真香的過程。理解了Paging 3之後,你會發現它提供了一套非常合理的分頁架構,我們只需要按照它提供的架構去編寫業務邏輯,就可以輕鬆實現分頁功能。我希望大家在看完這篇文章之後,也能覺得Paging 3香起來。
不過,本篇文章我不能保證它的易懂性。雖然很多朋友都覺得我寫的文章簡單易懂,但Paging 3的複雜性在於它關聯了太多其他的知識,如協程、Flow、MVVM、RecyclerView、DiffUtil等等,如果你不能將相關聯的這些知識都有所瞭解,那麼想要掌握Paging 3就會更有難度。
另外,由於Paging 3是Google基於Kotlin協程全新重寫的一個庫,所以它主要是應用於Kotlin語言(Java也能用,但是會更加複雜),並且以後這樣的庫會越來越多,比如Jetpack Compose等等。如果你對於Kotlin還不太瞭解的話,可以去參郭霖的新書《第一行代碼 Android 第3版》。
上手Paging 3
經過我自己的總結,我發現如果零散去介紹一些Paging 3的知識點是很難能掌握得了這個庫的。最好的學習方式就是直接上手,用Paging 3去做一個項目,項目做完了,你也基本就掌握了。本篇文章中我們就會採用這種方式來學習。
另外,我相信大家之前應該都做過分頁功能,正如我所說,這個功能並不難實現。但是現在,請你完全忘掉過去你所熟知的分頁方案,因爲它不僅對理解Paging 3沒有幫助,反而在很大程度上會影響你對Paging 3的理解。
是的,不要想着去監聽列表滑動事件,滑動到底部的時候發起一個網絡請求加載下一頁數據。Paging 3完全不是這麼用的,如果你還保留着這種過去的實現思路,在學習Paging 3的時候會很受阻。
那麼現在就讓我們開始吧。
首先新建一個Android項目,這裏我給它起名爲Paging3Sample。
接下來,我們在build.gradle的dependencies當中添加必要的依賴庫:
dependencies {
...
implementation 'androidx.paging:paging-runtime:3.0.0-beta01'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}
注意雖然我剛纔說,Paging 3是要和很多其他關聯庫結合到一起工作的,但是我們並不需要將這些關聯庫一一手動引入,引入了Paging 3之後,所有的關聯庫都會被自動下載下來。
另外這裏還引入了Retrofit的庫,因爲待會我們會從網絡上請求數據,並通過Paging 3進行分頁展示。
那麼在正式開始涉及Paging 3的用法之前,讓我們先來把網絡相關的代碼搭建好,方便爲Paging 3提供分頁數據。
這裏我準備採用GitHub的公開API來作爲我們這個項目的數據源,請注意GitHub在國內雖然一般都是可以訪問的,但有時接口並不穩定,如果你無法正常請求到數據的話,請自行科學上網。
我們可以嘗試在瀏覽器中請求如下接口地址:
https://api.github.com/search/repositories?sort=stars&q=Android&per_page=5&page=1
這個接口表示,會返回GitHub上所有Android相關的開源庫,以Star數量排序,每頁返回5條數據,當前請求的是第一頁。
服務器響應的數據如下,爲了方便閱讀,我對響應數據進行了簡化:
{
"items": [
{
"id": 31792824,
"name": "flutter",
"description": "Flutter makes it easy and fast to build beautiful apps for mobile and beyond.",
"stargazers_count": 112819,
},
{
"id": 14098069,
"name": "free-programming-books-zh_CN",
"description": ":books: 免費的計算機編程類中文書籍,歡迎投稿",
"stargazers_count": 76056,
},
{
"id": 111583593,
"name": "scrcpy",
"description": "Display and control your Android device",
"stargazers_count": 44713,
},
{
"id": 12256376,
"name": "ionic-framework",
"description": "A powerful cross-platform UI toolkit for building native-quality iOS, Android, and Progressive Web Apps with HTML, CSS, and JavaScript.",
"stargazers_count": 43041,
},
{
"id": 55076063,
"name": "Awesome-Hacking",
"description": "A collection of various awesome lists for hackers, pentesters and security researchers",
"stargazers_count": 42876,
}
]
}
簡化後的數據格式還是非常好理解的,items數組中記錄了第一頁包含了哪些庫,其中name表示該庫的名字,description表示該庫的描述,stargazers_count表示該庫的Star數量。
那麼下面我們就根據這個接口來編寫網絡相關的代碼吧,由於這部分都是屬於Retrofit的用法,我會介紹的比較簡略。
首先根據服務器響應的Json格式定義對應的實體類,新建一個Repo類,代碼如下所示:
data class Repo(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String?,
@SerializedName("stargazers_count") val starCount: Int
)
然後定義一個RepoResponse類,以集合的形式包裹Repo類:
class RepoResponse(
@SerializedName("items") val items: List<Repo> = emptyList()
)
接下來定義一個GitHubService用於提供網絡請求接口,如下所示:
interface GitHubService {
@GET("search/repositories?sort=stars&q=Android")
suspend fun searchRepos(@Query("page") page: Int, @Query("per_page") perPage: Int): RepoResponse
companion object {
private const val BASE_URL = "https://api.github.com/"
fun create(): GitHubService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GitHubService::class.java)
}
}
}
這些都是Retrofit的標準用法,現在當調用searchRepos()函數時,Retrofit就會自動幫我們向GitHub的服務器接口發起一條網絡請求,並將響應的數據解析到RepoResponse對象當中。
好了,現在網絡相關的代碼都已經準備好了,下面我們就開始使用Paging 3來實現分頁加載功能。
Paging 3有幾個非常關鍵的核心組件,我們需要分別在這幾個核心組件中按部就班地實現分頁邏輯。
首先最重要的組件就是PagingSource,我們需要自定義一個子類去繼承PagingSource,然後重寫load()函數,並在這裏提供對應當前頁數的數據。
新建一個RepoPagingSource繼承自PagingSource,代碼如下所示:
class RepoPagingSource(private val gitHubService: GitHubService) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
return try {
val page = params.key ?: 1 // set page 1 as default
val pageSize = params.loadSize
val repoResponse = gitHubService.searchRepos(page, pageSize)
val repoItems = repoResponse.items
val prevKey = if (page > 1) page - 1 else null
val nextKey = if (repoItems.isNotEmpty()) page + 1 else null
LoadResult.Page(repoItems, prevKey, nextKey)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? = null
}
這段代碼並不長,但卻需要好好解釋一下。
在繼承PagingSource時需要聲明兩個泛型類型,第一個類型表示頁數的數據類型,我們沒有特殊需求,所以直接用整型就可以了。第二個類型表示每一項數據(注意不是每一頁)所對應的對象類型,這裏使用剛纔定義的Repo。
然後在load()函數當中,先通過params參數得到key,這個key就是代表着當前的頁數。注意key是可能爲null的,如果爲null的話,我們就默認將當前頁數設置爲第一頁。另外還可以通過params參數得到loadSize,表示每一頁包含多少條數據,這個數據的大小我們可以在稍後設置。
接下來調用剛纔在GitHubService中定義的searchRepos()接口,並把page和pageSize傳入,從服務器獲取當前頁所對應的數據。
最後需要調用LoadResult.Page()函數,構建一個LoadResult對象並返回。注意LoadResult.Page()函數接收3個參數,第一個參數傳入從響應數據解析出來的Repo列表即可,第二和第三個參數分別對應着上一頁和下一頁的頁數。針對於上一頁和下一頁,我們還額外做了個判斷,如果當前頁已經是第一頁或最後一頁,那麼它的上一頁或下一頁就爲null。
這樣load()函數的作用就已經解釋完了,可能你會發現,上述代碼還重寫了一個getRefreshKey()函數。這個函數是Paging 3.0.0-beta01版本新增的,以前的alpha版中並沒有。它是屬於Paging 3比較高級的用法,我們本篇文章涉及不到,所以直接返回null就可以了。
PagingSource相關的邏輯編寫完成之後,接下來需要創建一個Repository類。這是MVVM架構的一個重要組件,還不瞭解的朋友可以去參考《第一行代碼 Android 第3版》第15章的內容。
object Repository {
private const val PAGE_SIZE = 50
private val gitHubService = GitHubService.create()
fun getPagingData(): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(PAGE_SIZE),
pagingSourceFactory = { RepoPagingSource(gitHubService) }
).flow
}
}
這段代碼雖然很短,但是卻不易理解,因爲用到了協程的Flow。我無法在這裏展開解釋Flow是什麼,你可以簡單將它理解成協程中對標RxJava的一項技術。
當然這裏也沒有用到什麼複雜的Flow技術,正如你所見,上面的代碼很簡短,相比於理解,這更多是一種固定的寫法。
我們定義了一個getPagingData()函數,這個函數的返回值是Flow<PagingData<Repo>>,注意除了Repo部分是可以改的,其他部分都是固定的。
在getPagingData()函數當中,這裏創建了一個Pager對象,並調用.flow將它轉換成一個Flow對象。在創建Pager對象的時候,我們指定了PAGE_SIZE,也就是每頁所包含的數據量。又指定了pagingSourceFactory,並將我們自定義的RepoPagingSource傳入,這樣Paging 3就會用它來作爲用於分頁的數據源了。
將Repository編寫完成之後,我們還需要再定義一個ViewModel,因爲Activity是不可以直接和Repository交互的,要藉助ViewModel纔可以。新建一個MainViewModel類,代碼如下所示:
class MainViewModel : ViewModel() {
fun getPagingData(): Flow<PagingData<Repo>> {
return Repository.getPagingData().cachedIn(viewModelScope)
}
}
代碼很簡單,就是調用了Repository中定義的getPagingData()函數而已。但是這裏又額外調用了一個cachedIn()函數,這是用於將服務器返回的數據在viewModelScope這個作用域內進行緩存,假如手機橫豎屏發生了旋轉導致Activity重新創建,Paging 3就可以直接讀取緩存中的數據,而不用重新發起網絡請求了。
寫到這裏,我們的這個項目已經完成了一大半了,接下來開始進行界面展示相關的工作。
由於Paging 3是必須和RecyclerView結合使用的,下面我們定義一個RecyclerView的子項佈局。新建repo_item.xml,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:maxLines="1"
android:ellipsize="end"
android:textColor="#5194fd"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/description_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:maxLines="10"
android:ellipsize="end" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="end"
tools:ignore="UseCompoundDrawables">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="5dp"
android:src="@drawable/ic_star"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/star_count_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical" />
</LinearLayout>
</LinearLayout>
這個佈局中使用到了一個圖片資源,可以到本項目的源碼中去獲取,源碼地址見文章最底部。
接下來定義RecyclerView的適配器,但是注意,這個適配器也比較特殊,必須繼承自PagingDataAdapter,代碼如下所示:
class RepoAdapter : PagingDataAdapter<Repo, RepoAdapter.ViewHolder>(COMPARATOR) {
companion object {
private val COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean {
return oldItem == newItem
}
}
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val name: TextView = itemView.findViewById(R.id.name_text)
val description: TextView = itemView.findViewById(R.id.description_text)
val starCount: TextView = itemView.findViewById(R.id.star_count_text)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.repo_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val repo = getItem(position)
if (repo != null) {
holder.name.text = repo.name
holder.description.text = repo.description
holder.starCount.text = repo.starCount.toString()
}
}
}
相比於一個傳統的RecyclerView Adapter,這裏最特殊的地方就是要提供一個COMPARATOR。因爲Paging 3在內部會使用DiffUtil來管理數據變化,所以這個COMPARATOR是必須的。如果你以前用過DiffUtil的話,對此應該不會陌生。
除此之外,我們並不需要傳遞數據源給到父類,因爲數據源是由Paging 3在內部自己管理的。同時也不需要重寫getItemCount()函數了,原因也是相同的,有多少條數據Paging 3自己就能夠知道。
其他部分就和普通的RecyclerView Adapter沒什麼兩樣了,相信大家都能夠看得明白。
接下來就差最後一步了,讓我們把所有的一切都集成到Activity當中。
修改activity_main.xml佈局,在裏面定義一個RecyclerView和一個ProgressBar:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
然後修改MainActivity中的代碼,如下所示:
class MainActivity : AppCompatActivity() {
private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }
private val repoAdapter = RepoAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = repoAdapter
lifecycleScope.launch {
viewModel.getPagingData().collect { pagingData ->
repoAdapter.submitData(pagingData)
}
}
repoAdapter.addLoadStateListener {
when (it.refresh) {
is LoadState.NotLoading -> {
progressBar.visibility = View.INVISIBLE
recyclerView.visibility = View.VISIBLE
}
is LoadState.Loading -> {
progressBar.visibility = View.VISIBLE
recyclerView.visibility = View.INVISIBLE
}
is LoadState.Error -> {
val state = it.refresh as LoadState.Error
progressBar.visibility = View.INVISIBLE
Toast.makeText(this, "Load Error: ${state.error.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
這裏最重要的一段代碼就是調用了RepoAdapter的submitData()函數。這個函數是觸發Paging 3分頁功能的核心,調用這個函數之後,Paging 3就開始工作了。
submitData()接收一個PagingData參數,這個參數我們需要調用ViewModel中返回的Flow對象的collect()函數才能獲取到,collect()函數有點類似於Rxjava中的subscribe()函數,總之就是訂閱了之後,消息就會源源不斷往這裏傳。
不過由於collect()函數是一個掛起函數,只有在協程作用域中才能調用它,因此這裏又調用了lifecycleScope.launch()函數來啓動一個協程。
其他地方應該就沒什麼需要解釋的了,都是一些傳統RecyclerView的用法,相信大家都能看得懂。
好了,這樣我們就把整個項目完成了,在正式運行項目之前,別忘了在你的AndroidManifest.xml文件中添加網絡權限:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.paging3sample">
<uses-permission android:name="android.permission.INTERNET" />
...
</manifest>
現在運行一下程序,效果如下圖所示:
可以看到,GitHub上Android相關的開源庫已經成功顯示出來了。並且你可以不斷往下滑,Paging 3會自動加載更多的數據,彷彿讓你永遠也滑不到頭一樣。
如次一來,使用Paging 3來進行分頁加載的效果也就成功完成了。
總結一下,相比於傳統的分頁實現方案,Paging 3將一些瑣碎的細節進行了隱藏,比如你不需要監聽列表的滑動事件,也不需要知道知道何時應該加載下一頁的數據,這些都被Paging 3封裝掉了。我們只需要按照Paging 3搭建好的框架去編寫邏輯實現,告訴Paging 3如何去加載數據,其他的事情Paging 3都會幫我們自動完成。
在底部顯示加載狀態
根據Paging 3的設計,其實我們理論上是不應該在底部看到加載狀態的。因爲Paging 3會在列表還遠沒有滑動到底部的時候就提前加載更多的數據(這是默認屬性,可配置),從而產生一種好像永遠滑不到頭的感覺。
然而凡事總有意外,比如說當前的網速不太好,雖然Paging 3會提前加載下一頁的數據,但是當滑動到列表底部的時候,服務器響應的數據可能還沒有返回,這個時候就應該在底部顯示一個正在加載的狀態。
另外,如果網絡條件非常糟糕,還可能會出現加載失敗的情況,此時應該在列表底部顯示一個重試按鈕。
那麼接下來我們就來實現這個功能,從而讓項目變得更加完善。
創建一個footer_item.xml佈局,用於顯示加載進度條和重試按鈕:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
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="Retry" />
</FrameLayout>
然後創建一個FooterAdapter來作爲RecyclerView的底部適配器,注意它必須繼承自LoadStateAdapter,如下所示:
class FooterAdapter(val retry: () -> Unit) : LoadStateAdapter<FooterAdapter.ViewHolder>() {
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val progressBar: ProgressBar = itemView.findViewById(R.id.progress_bar)
val retryButton: Button = itemView.findViewById(R.id.retry_button)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.footer_item, parent, false)
val holder = ViewHolder(view)
holder.retryButton.setOnClickListener {
retry()
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) {
holder.progressBar.isVisible = loadState is LoadState.Loading
holder.retryButton.isVisible = loadState is LoadState.Error
}
}
這仍然是一個非常簡單的Adapter,需要注意的地方大概只有兩點。
第一點,我們使用Kotlin的高階函數來給重試按鈕註冊點擊事件,這樣當點擊重試按鈕時,構造函數中傳入的函數類型參數就會被回調,我們待會將在那裏加入重試邏輯。
第二點,在onBindViewHolder()中會根據LoadState的狀態來決定如何顯示底部界面,如果是正在加載中那麼就顯示加載進度條,如果是加載失敗那麼就顯示重試按鈕。
最後,修改MainActivity中的代碼,將FooterAdapter集成到RepoAdapter當中:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
recyclerView.adapter = repoAdapter.withLoadStateFooter(FooterAdapter { repoAdapter.retry() })
...
}
}
代碼非常簡單,只需要改動一行,調用RepoAdapter的withLoadStateFooter()函數即可將FooterAdapter集成到RepoAdapter當中。
另外注意這裏使用Lambda表達式來作爲傳遞給FooterAdapter的函數類型參數,在Lambda表示式中,調用RepoAdapter的retry()函數即可重新加載。
這樣我們就把底部顯示加載狀態的功能完成了,現在來測試一下吧,效果如下圖所示。
可以看到,首先我在設備上開啓了飛行模式,這樣當滑動到列表底部時就會顯示重試按鈕。
然後把飛行模式關閉,並點擊重試按鈕,這樣加載進度條就會顯示出來,並且成功加載出新的數據了。
最後
本文到這裏就結束了。
不得不說,我在文章中講解的這些知識點仍然只是Paging 3的基本用法,還有許多高級用法文中並沒有涵蓋。當然,這些基本用法也是最最常用的用法,所以如果你並不打算成爲Paging 3大師,掌握文中的這些知識點就已經足夠應對日常的開發工作了。
如果你還想要進一步進階學習Paging 3,可以參考Google官方的Codelab項目,地址是:
https://developer.android.com/codelabs/android-paging
我們剛纔一起編寫的Paging3Sample項目其實就是從Google官方的Codelab項目演化而來的,我根據自己的理解重寫了這個項目並進行了一定的簡化。直接學習原版項目,你將能學到更多的知識。
最後,如果你需要獲取Paging3Sample項目的源碼,請訪問以下地址:
https://github.com/guolindev/Paging3Sample