定義
- Google 推出的一個(gè)應(yīng)用于 Android 平臺(tái)的分頁(yè)加載庫(kù);
- Paging3和之前版本相差很多,完全可以當(dāng)成一個(gè)新庫(kù)去學(xué)習(xí)
- 之前我們使用ListView和RecyclerView實(shí)現(xiàn)分頁(yè)功能并不難,那么為啥需要paging3呢?
- 它提供了一套非常合理的分頁(yè)架構(gòu),我們只需要按照它提供的架構(gòu)去編寫業(yè)務(wù)邏輯,就可以輕松實(shí)現(xiàn)分頁(yè)功能;
- 關(guān)聯(lián)知識(shí)點(diǎn):協(xié)程、Flow、MVVM、RecyclerView、DiffUtil
優(yōu)點(diǎn)
- 使用內(nèi)存緩存數(shù)據(jù);
- 內(nèi)置請(qǐng)求去重,更有效率的顯示數(shù)據(jù);
- RecyclerView自動(dòng)加載更多
- 支持Kotlin的協(xié)程和Flow,以及LiveData和RxJava2
- 內(nèi)置狀態(tài)處理:刷新,錯(cuò)誤,加載等
使用流程如下:
需求:
- 展示GitHub上所有Android相關(guān)的開(kāi)源庫(kù),以Star數(shù)量排序,每頁(yè)返回5條數(shù)據(jù);
1. 引入依賴
//paging3
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-beta03'
// 用于測(cè)試
testImplementation "androidx.paging:paging-common-ktx:3.0.0-beta03"
// [可選] RxJava 支持
implementation "androidx.paging:paging-rxjava2-ktx:3.0.0-beta03"
//retrofit網(wǎng)絡(luò)請(qǐng)求庫(kù)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
//下拉刷新
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
2. 創(chuàng)建數(shù)據(jù)模型類 RepoResponse
class RepoResponse {
@SerializedName("items") val items:List<Repo> = emptyList()
}
data class Repo(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("stargazers_count") val starCount: String,
)
3. 定義網(wǎng)絡(luò)請(qǐng)求接口 ApiService
interface ApiService {
@GET("search/repositories?sort=stars&q=Android")
suspend fun searRepos(@Query("page") page: Int, @Query("per_page") perPage: Int): RepoResponse
companion object {
private const val BASE_URL = "https://api.github.com/"
fun create(): ApiService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
}
4. 配置數(shù)據(jù)源
- 自定義一個(gè)子類繼承PagingSource,然后重寫 load() 函數(shù),并在這里提供對(duì)應(yīng)當(dāng)前頁(yè)數(shù)的數(shù)據(jù), 這一步才真正用到了Paging3
- PagingSource的兩個(gè)泛型參數(shù),一個(gè)是頁(yè)數(shù)類型,一個(gè)是數(shù)據(jù)item類型
class RepoPagingSource(private val apiService: ApiService) : PagingSource<Int, Repo>() {
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
return try {
val page = params.key ?: 1
val pageSize = params.loadSize
val repoResponse = apiService.searRepos(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)
}
}
}
5. 在ViewModel中實(shí)現(xiàn)接口請(qǐng)求
- PagingConfig的一個(gè)參數(shù)prefetchDistance,用于表示距離底部多少條數(shù)據(jù)開(kāi)始預(yù)加載,設(shè)置0則表示滑到底部才加載,默認(rèn)值為分頁(yè)大?。蝗粢層脩魧?duì)加載無(wú)感,適當(dāng)增加預(yù)取閾值即可,比如調(diào)整到分頁(yè)大小的5倍;
- cachedIn() 是 Flow<PagingData> 的擴(kuò)展方法,用于將服務(wù)器返回的數(shù)據(jù)在viewModelScope這個(gè)作用域內(nèi)進(jìn)行緩存,假如手機(jī)橫豎屏發(fā)生了旋轉(zhuǎn)導(dǎo)致Activity重新創(chuàng)建,Paging 3就可以直接讀取緩存中的數(shù)據(jù),而不用重新發(fā)起網(wǎng)絡(luò)請(qǐng)求了。
//1. Repository中實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求
object Repository {
private const val PAGE_SIZE = 5
private val gitHubService = ApiService.create()
fun getPagingData(): Flow<PagingData<Repo>> {
// PagingConfig的一個(gè)參數(shù)prefetchDistance,用于表示距離底部多少條數(shù)據(jù)開(kāi)始預(yù)加載,
// 設(shè)置0則表示滑到底部才加載。默認(rèn)值為分頁(yè)大小。
// 若要讓用戶對(duì)加載無(wú)感,適當(dāng)增加預(yù)取閾值即可。 比如調(diào)整到分頁(yè)大小的5倍
return Pager(config = PagingConfig(pageSize = PAGE_SIZE, prefetchDistance = PAGE_SIZE * 5),
pagingSourceFactory = { RepoPagingSource(gitHubService) }).flow
}
}
//2. ViewModel中調(diào)用Repository
class Paging3ViewModel : ViewModel() {
fun getPagingData(): Flow<PagingData<Repo>> {
return Repository.getPagingData().cachedIn(viewModelScope)
}
}
6. 實(shí)現(xiàn)RecyclerView的Adapter
class RepoAdapter : PagingDataAdapter<Repo, RepoAdapter.ViewHolder>(COMPARATOR) {
companion object {
//因?yàn)镻aging 3在內(nèi)部會(huì)使用DiffUtil來(lái)管理數(shù)據(jù)變化,所以這個(gè)COMPARATOR是必須的
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 binding: LayoutRepoItemBinding? =DataBindingUtil.bind(itemView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.binding?.repo=getItem(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view=LayoutInflater.from(parent.context).inflate(R.layout.layout_repo_item,parent,false)
return ViewHolder(view)
}
}
7. FooterAdapter的實(shí)現(xiàn)
- 用于實(shí)現(xiàn)加載更多,必須繼承自LoadStateAdapter,
- retry():使用Kotlin的高階函數(shù)來(lái)給重試按鈕注冊(cè)點(diǎn)擊事件
class FooterAdapter(val retry: () -> Unit) : LoadStateAdapter<FooterAdapter.ViewHolder>() {
class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) {
val binding=holder.binding as LayoutFooterItemBinding
when (loadState) {
is LoadState.Error -> {
binding.progressBar.visibility = View.GONE
binding.retryButton.visibility = View.VISIBLE
binding.retryButton.text = "Load Failed, Tap Retry"
binding.retryButton.setOnClickListener {
retry()
}
}
is LoadState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.retryButton.visibility = View.VISIBLE
binding.retryButton.text = "Loading"
}
is LoadState.NotLoading -> {
binding.progressBar.visibility = View.GONE
binding.retryButton.visibility = View.GONE
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder {
val binding: LayoutFooterItemBinding =
LayoutFooterItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding)
}
}
8. 在Activity中使用
- mAdapter.submitData()是觸發(fā)Paging 3分頁(yè)功能的核心; 它接收一個(gè)PagingData參數(shù),這個(gè)參數(shù)我們需要調(diào)用ViewModel中返回的Flow對(duì)象的collect()函數(shù)才能獲取到,collect()函數(shù)有點(diǎn)類似于Rxjava中的subscribe()函數(shù),總之就是訂閱了之后,消息就會(huì)源源不斷往這里傳。不過(guò)由于collect()函數(shù)是一個(gè)掛起函數(shù),只有在協(xié)程作用域中才能調(diào)用它,因此這里又調(diào)用了lifecycleScope.launch()函數(shù)來(lái)啟動(dòng)一個(gè)協(xié)程。
- 加載更多:通過(guò)mAdapter.withLoadStateFooter實(shí)現(xiàn);
- 下拉刷新:這里下來(lái)刷新是配合SwipeRefreshLayout使用,在其OnRefreshListener中調(diào)用mAdapter.refresh(),并在mAdapter.addLoadStateListener中處理下拉刷新的UI邏輯;
- 雖然有withLoadStateHeader,但它并不是用于實(shí)現(xiàn)刷新,而是加載上一頁(yè),需要當(dāng)前起始頁(yè)>1時(shí)才生效
class Paging3Activity : AppCompatActivity() {
private val viewModel by lazy {
ViewModelProvider(this).get(Paging3ViewModel::class.java)
}
private val mAdapter:RepoAdapter = RepoAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//在Activity中使用
val binding: ActivityPaging3Binding =
DataBindingUtil.setContentView(this, R.layout.activity_paging3)
binding.lifecycleOwner = this
//下拉刷新
binding.refreshlayout.setOnRefreshListener {
mAdapter.refresh()
}
binding.recyclerView.layoutManager = LinearLayoutManager(this)
//添加footer
binding.recyclerView.adapter = mAdapter.withLoadStateFooter(FooterAdapter {
mAdapter.retry()
})
// binding.recyclerView.adapter = repoAdapter.withLoadStateHeaderAndFooter(
// header = HeaderAdapter { repoAdapter.retry() },
// footer = FooterAdapter { repoAdapter.retry() }
// )
lifecycleScope.launch {
viewModel.getPagingData().collect {
mAdapter.submitData(it)
}
}
//監(jiān)聽(tīng)加載狀態(tài)
mAdapter.addLoadStateListener {
//比如處理下拉刷新邏輯
when (it.refresh) {
is LoadState.NotLoading -> {
binding.recyclerView.visibility = View.VISIBLE
binding.refreshlayout.isRefreshing = false
}
is LoadState.Loading -> {
binding.refreshlayout.isRefreshing = true
binding.recyclerView.visibility = View.VISIBLE
}
is LoadState.Error -> {
val state = it.refresh as LoadState.Error
binding.refreshlayout.isRefreshing = false
Toast.makeText(this, "Load Error: ${state.error.message}", Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
9. RemoteMediator
RemoteMediator 和 PagingSource 的區(qū)別:
- PagingSource:實(shí)現(xiàn)單一數(shù)據(jù)源以及如何從該數(shù)據(jù)源中查找數(shù)據(jù),推薦用于加載有限的數(shù)據(jù)集(本地?cái)?shù)據(jù)庫(kù)),例如 Room,數(shù)據(jù)源的變動(dòng)會(huì)直接映射到 UI 上;
- RemoteMediator:實(shí)現(xiàn)加載網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)并更新到數(shù)據(jù)庫(kù)中,但是數(shù)據(jù)源的變動(dòng)不能直接映射到 UI 上;
- 可以使用 RemoteMediator 實(shí)現(xiàn)從網(wǎng)絡(luò)加載分頁(yè)數(shù)據(jù)更新到數(shù)據(jù)庫(kù)中,使用 PagingSource 從數(shù)據(jù)庫(kù)中查找數(shù)據(jù)并顯示在 UI 上
RemoteMediator的使用
- 定義數(shù)據(jù)源
// 本地?cái)?shù)據(jù)庫(kù)存儲(chǔ)使用的Room,Room使用相關(guān)的之后會(huì)在另一篇文章中詳細(xì)介紹,這里直接貼代碼了
//1. 定義實(shí)體類,并添加@Entity注釋
@Entity
data class RepoEntity(
@PrimaryKey val id: Int,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "star_count") val starCount: String,
@ColumnInfo(name = "page") val page: Int ,
)
//2. 定義數(shù)據(jù)訪問(wèn)對(duì)象RepoDao
@Dao
interface RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(pokemonList: List<RepoEntity>)
@Query("SELECT * FROM RepoEntity")
fun get(): PagingSource<Int, RepoEntity>
@Query("DELETE FROM RepoEntity")
suspend fun clear()
@Delete
fun delete(repo: RepoEntity)
@Update
fun update(repo: RepoEntity)
}
//3. 定義Database
@Database(entities = [RepoEntity::class], version = Constants.DB_VERSION)
abstract class AppDatabase : RoomDatabase() {
abstract fun repoDao(): RepoDao
companion object {
val instance = AppDatabaseHolder.db
}
private object AppDatabaseHolder {
val db: AppDatabase = Room
.databaseBuilder(
AppHelper.mContext,
AppDatabase::class.java,
Constants.DB_NAME
)
.allowMainThreadQueries() //允許在主線程中查詢
.build()
}
}
//4. 數(shù)據(jù)庫(kù)常量管理
interface Constants {
/**
* 數(shù)據(jù)庫(kù)名稱
*/
String DB_NAME = "JetpackDemoDataBase.db";
/**
* 數(shù)據(jù)庫(kù)版本
*/
int DB_VERSION = 1;
}
- 實(shí)現(xiàn) RemoteMediator
// 1. RemoteMediator 目前是實(shí)驗(yàn)性的 API ,所有實(shí)現(xiàn) RemoteMediator 的類
//都需要添加 @OptIn(ExperimentalPagingApi::class) 注解,
//使用 OptIn 注解,要App的build.gradle中配置
android {
kotlinOptions {
freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
}
}
//2. 自定義RepoMediator,繼承RemoteMediator
//RemoteMediator 和 PagingSource 相似,都需要覆蓋 load() 方法,但是其參數(shù)不同
@OptIn(ExperimentalPagingApi::class)
class RepoMediator(
val api: ApiService,
val db: AppDatabase
) : RemoteMediator<Int, RepoEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, RepoEntity>
): MediatorResult {
val repoDao = db.repoDao()
val pageKey = when (loadType) {
//首次訪問(wèn) 或者調(diào)用 PagingDataAdapter.refresh()時(shí)
LoadType.REFRESH -> null
//在當(dāng)前加載的數(shù)據(jù)集的開(kāi)頭加載數(shù)據(jù)時(shí)
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
//下拉加載更多時(shí)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
if (lastItem == null) {
return MediatorResult.Success(
endOfPaginationReached = true
)
}
lastItem.page
}
}
//無(wú)網(wǎng)絡(luò)則加載本地?cái)?shù)據(jù)
if (!AppHelper.mContext.isConnectedNetwork()) {
return MediatorResult.Success(endOfPaginationReached = true)
}
//請(qǐng)求網(wǎng)絡(luò)分頁(yè)數(shù)據(jù)
val page = pageKey ?: 0
val pageSize = Repository.PAGE_SIZE
val result = api.searRepos(page, pageSize).items
val endOfPaginationReached = result.isEmpty()
val items = result.map {
RepoEntity(
id = it.id,
name = it.name,
description = it.description,
starCount = it.starCount,
page=page + 1
)
}
//插入數(shù)據(jù)庫(kù)
db.withTransaction {
if (loadType==LoadType.REFRESH){
repoDao.clear()
}
repoDao.insert(items)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
}
}
- 在 Repository 中構(gòu)建 Pager
object Repository {
const val PAGE_SIZE = 5
private val gitHubService = ApiService.create()
private val db = AppDatabase.instance
private val pagingConfig = PagingConfig(
// 每頁(yè)顯示的數(shù)據(jù)的大小
pageSize = PAGE_SIZE,
// 開(kāi)啟占位符
enablePlaceholders = true,
// 預(yù)刷新的距離,距離最后一個(gè) item 多遠(yuǎn)時(shí)加載數(shù)據(jù)
// 默認(rèn)為 pageSize
prefetchDistance = PAGE_SIZE,
// 初始化加載數(shù)量,默認(rèn)為 pageSize * 3
initialLoadSize = PAGE_SIZE
)
@OptIn(ExperimentalPagingApi::class)
fun getPagingData2(): Flow<PagingData<Repo>> {
return Pager(
config = pagingConfig,
remoteMediator = RepoMediator(gitHubService, db)
) {
db.repoDao().get()
}.flow.map { pagingData ->
pagingData.map { RepoEntity2RepoMapper().map(it) }
}
}
}
class RepoEntity2RepoMapper : Mapper<RepoEntity, Repo> {
override fun map(input: RepoEntity): Repo = Repo(
id = input.id,
name = input.name,
description = input.description,
starCount = input.starCount
)
}
- 在 ViewModel 獲取數(shù)據(jù)
class Paging3ViewModel : ViewModel() {
fun getPagingData2(): LiveData<PagingData<Repo>> =
Repository.getPagingData2().cachedIn(viewModelScope).asLiveData()
}
- 在Activity中注冊(cè)觀察者
viewModel.getPagingData2().observe(this, {
mAdapter.submitData(lifecycle, it)
})
- 到此打完收工,跑一下代碼,發(fā)現(xiàn)無(wú)網(wǎng)絡(luò)情況下就會(huì)加載數(shù)據(jù)庫(kù)中的數(shù)據(jù),有網(wǎng)絡(luò)就會(huì)從網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)更新數(shù)據(jù)庫(kù)并刷新UI界面
我是今陽(yáng),如果想要進(jìn)階和了解更多的干貨,歡迎關(guān)注微信公眾號(hào) “今陽(yáng)說(shuō)” 接收我的最新文章