Android Jetpack系列--6. Paging3使用详解

定义

  • Google 推出的一个应用于 Android 平台的分页加载库;
  • Paging3和之前版本相差很多,完全可以当成一个新库去学习
  • 之前我们使用ListView和RecyclerView实现分页功能并不难,那么为啥需要paging3呢?
  • 它提供了一套非常合理的分页架构,我们只需要按照它提供的架构去编写业务逻辑,就可以轻松实现分页功能;
  • 关联知识点:协程、Flow、MVVM、RecyclerView、DiffUtil

优点

  1. 使用内存缓存数据;
  2. 内置请求去重,更有效率的显示数据;
  3. RecyclerView自动加载更多
  4. 支持Kotlin的协程和Flow,以及LiveData和RxJava2
  5. 内置状态处理:刷新,错误,加载等

使用流程如下:

需求:
  • 展示GitHub上所有Android相关的开源库,以Star数量排序,每页返回5条数据;
1. 引入依赖
//paging3
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-beta03'
// 用于测试
testImplementation "androidx.paging:paging-common-ktx:3.0.0-beta03"
// [可选] RxJava 支持
implementation "androidx.paging:paging-rxjava2-ktx:3.0.0-beta03"
//retrofit网络请求库
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. 创建数据模型类 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. 定义网络请求接口 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. 配置数据源
  • 自定义一个子类继承PagingSource,然后重写 load() 函数,并在这里提供对应当前页数的数据, 这一步才真正用到了Paging3
  • PagingSource的两个泛型参数,一个是页数类型,一个是数据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中实现接口请求
  • PagingConfig的一个参数prefetchDistance,用于表示距离底部多少条数据开始预加载,设置0则表示滑到底部才加载,默认值为分页大小;若要让用户对加载无感,适当增加预取阈值即可,比如调整到分页大小的5倍;
  • cachedIn() 是 Flow<PagingData> 的扩展方法,用于将服务器返回的数据在viewModelScope这个作用域内进行缓存,假如手机横竖屏发生了旋转导致Activity重新创建,Paging 3就可以直接读取缓存中的数据,而不用重新发起网络请求了。
//1. Repository中实现网络请求
object Repository {
    private const val PAGE_SIZE = 5
    private val gitHubService = ApiService.create()
    fun getPagingData(): Flow<PagingData<Repo>> {
        // PagingConfig的一个参数prefetchDistance,用于表示距离底部多少条数据开始预加载,
        // 设置0则表示滑到底部才加载。默认值为分页大小。
        // 若要让用户对加载无感,适当增加预取阈值即可。 比如调整到分页大小的5倍
        return Pager(config = PagingConfig(pageSize = PAGE_SIZE, prefetchDistance = PAGE_SIZE * 5),
            pagingSourceFactory = { RepoPagingSource(gitHubService) }).flow
    }
}
//2. ViewModel中调用Repository
class Paging3ViewModel : ViewModel() {
    fun getPagingData(): Flow<PagingData<Repo>> {
        return Repository.getPagingData().cachedIn(viewModelScope)
    }
}
6. 实现RecyclerView的Adapter
  • 必须继承 PagingDataAdapter
class RepoAdapter : PagingDataAdapter<Repo, RepoAdapter.ViewHolder>(COMPARATOR) {
    companion object {
        //因为Paging 3在内部会使用DiffUtil来管理数据变化,所以这个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的实现
  • 用于实现加载更多,必须继承自LoadStateAdapter,
  • retry():使用Kotlin的高阶函数来给重试按钮注册点击事件
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()是触发Paging 3分页功能的核心; 它接收一个PagingData参数,这个参数我们需要调用ViewModel中返回的Flow对象的collect()函数才能获取到,collect()函数有点类似于Rxjava中的subscribe()函数,总之就是订阅了之后,消息就会源源不断往这里传。不过由于collect()函数是一个挂起函数,只有在协程作用域中才能调用它,因此这里又调用了lifecycleScope.launch()函数来启动一个协程。
  • 加载更多:通过mAdapter.withLoadStateFooter实现;
  • 下拉刷新:这里下来刷新是配合SwipeRefreshLayout使用,在其OnRefreshListener中调用mAdapter.refresh(),并在mAdapter.addLoadStateListener中处理下拉刷新的UI逻辑;
  • 虽然有withLoadStateHeader,但它并不是用于实现刷新,而是加载上一页,需要当前起始页>1时才生效
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)
            }
        }
        //监听加载状态
        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 的区别:
  • PagingSource:实现单一数据源以及如何从该数据源中查找数据,推荐用于加载有限的数据集(本地数据库),例如 Room,数据源的变动会直接映射到 UI 上;
  • RemoteMediator:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上;
  • 可以使用 RemoteMediator 实现从网络加载分页数据更新到数据库中,使用 PagingSource 从数据库中查找数据并显示在 UI 上
RemoteMediator的使用
  1. 定义数据源
// 本地数据库存储使用的Room,Room使用相关的之后会在另一篇文章中详细介绍,这里直接贴代码了
//1. 定义实体类,并添加@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. 定义数据访问对象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. 数据库常量管理
interface Constants {
    /**
     * 数据库名称
     */
    String DB_NAME = "JetpackDemoDataBase.db";

    /**
     * 数据库版本
     */
    int DB_VERSION = 1;
}
  1. 实现 RemoteMediator
// 1. RemoteMediator 目前是实验性的 API ,所有实现 RemoteMediator 的类
//都需要添加 @OptIn(ExperimentalPagingApi::class) 注解,
//使用 OptIn 注解,要App的build.gradle中配置
android {
    kotlinOptions {
        freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
    }
}

//2. 自定义RepoMediator,继承RemoteMediator
//RemoteMediator 和 PagingSource 相似,都需要覆盖 load() 方法,但是其参数不同
@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) {
            //首次访问 或者调用 PagingDataAdapter.refresh()时
            LoadType.REFRESH -> null
            //在当前加载的数据集的开头加载数据时
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            //下拉加载更多时
            LoadType.APPEND -> {
                val lastItem = state.lastItemOrNull()
                if (lastItem == null) {
                    return MediatorResult.Success(
                        endOfPaginationReached = true
                    )
                }
                lastItem.page
            }
        }

        //无网络则加载本地数据
        if (!AppHelper.mContext.isConnectedNetwork()) {
            return MediatorResult.Success(endOfPaginationReached = true)
        }

        //请求网络分页数据
        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
            )
        }

        //插入数据库
        db.withTransaction {
            if (loadType==LoadType.REFRESH){
                repoDao.clear()
            }
            repoDao.insert(items)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    }
}
  1. 在 Repository 中构建 Pager
object Repository {
    const val PAGE_SIZE = 5
    private val gitHubService = ApiService.create()
    private val db = AppDatabase.instance
    private val pagingConfig = PagingConfig(
        // 每页显示的数据的大小
        pageSize = PAGE_SIZE,
        // 开启占位符
        enablePlaceholders = true,
        // 预刷新的距离,距离最后一个 item 多远时加载数据
        // 默认为 pageSize
        prefetchDistance = PAGE_SIZE,
        // 初始化加载数量,默认为 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
    )
}
  1. 在 ViewModel 获取数据
class Paging3ViewModel : ViewModel() {
    fun getPagingData2(): LiveData<PagingData<Repo>> =
        Repository.getPagingData2().cachedIn(viewModelScope).asLiveData()
}
  1. 在Activity中注册观察者
 viewModel.getPagingData2().observe(this, {
            mAdapter.submitData(lifecycle, it)
        })
  • 到此打完收工,跑一下代码,发现无网络情况下就会加载数据库中的数据,有网络就会从网络请求数据更新数据库并刷新UI界面

我是今阳,如果想要进阶和了解更多的干货,欢迎关注微信公众号 “今阳说” 接收我的最新文章

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容