预备知识
主流的分页方式
页码分页
传统的点到哪页哪页,简单粗暴。如果有新增数据,那每一页的数据都需要调整

游标分页
以小红书为例
第一次请求
- 传入的游标参数为空(
null
、""
或后端规定的初始值) - 后端返回数据 + 一个游标值(
cursor_score
)
第二次请求
- 把第一次返回的游标值当作参数传给后端
- 后端会从该游标对应的位置之后继续取数据,返回下一批数据 + 新游标

循环往复
- 每次用上一次返回的新游标去请求下一页
- 如果
hasMore=false
或游标为空,就表示到底了
这样做的好处是,数据的获取位置由游标直接标识,不会因为中间插入或删除数据而影响顺序(比传统 page=1,2,3 更稳)
Room数据库基础使用
使用 Room 将数据保存到本地数据库 | App data and files | Android Developers
Retrofit基础使用
介绍 |改造 --- Introduction | Retrofit
谷歌推荐使用的分页库
Paging 库概览 | App architecture | Android Developers
根据官网介绍的结构,也就是说要实现本地请求数据还能离线加载。核心流程:需要在Repository
层优先查询本地Room
数据,本地数据为空则拉取最新数据,拿到数据后通过Room
插入或更新本地数据。网络请求失败时,展示本地数据。然后在Viewmodel
层获取到封装好的Flow<PagingData>
发送给UI
层,通过collectLatest
监听Flow
发送数据源到继承PagingDataAdapter
的适配器里渲染。

开始实践
自定义简单的RefreshPageLayout
主流的app对数据加载的方式基本都实现了:上拉刷新,下拉加载,数据空占位,网络错误提示。所以引用SmartRefreshLayout,实现上述基本功能 SmartRefreshLayout: Android智能下拉刷新框架
kotlin
class RefreshPageLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) {
private var loadingView: View? = null
private var emptyView: View? = null
private var noNetworkView: View? = null
private var footerView: View? = null
private lateinit var recyclerView: RecyclerView
private lateinit var smartRefreshLayout: SmartRefreshLayout
private var loadingLayoutRes: Int = 0
private var reloadListener: (() -> Unit)? = null
private var isLoading = false // 是否正在骨架加载状态
init {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RefreshPageLayout)
loadingLayoutRes = typedArray.getResourceId(R.styleable.RefreshPageLayout_loading_layout, 0)
typedArray.recycle()
}
override fun onFinishInflate() {
super.onFinishInflate()
if (childCount != 1) {
throw IllegalStateException("RefreshPageLayout must have exactly one direct child (RecyclerView)")
}
val contentView = getChildAt(0)
if (contentView !is RecyclerView) {
throw IllegalStateException("RefreshPageLayout's direct child must be a RecyclerView")
}
recyclerView = contentView
removeView(recyclerView)
smartRefreshLayout = SmartRefreshLayout(context)
smartRefreshLayout.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
smartRefreshLayout.addView(recyclerView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
addView(smartRefreshLayout)
if (loadingLayoutRes != 0) {
loadingView = LayoutInflater.from(context).inflate(loadingLayoutRes, this, false)
loadingView?.visibility = GONE
addView(loadingView)
}
}
// ========== 对外暴露的方法 ==========
fun setOnRefreshLoadMoreListener(listener: OnRefreshLoadMoreListener) {
smartRefreshLayout.setOnRefreshLoadMoreListener(listener)
}
fun finishRefresh(success: Boolean = true) {
smartRefreshLayout.finishRefresh(success)
}
fun finishLoadMore(success: Boolean = true, noMoreData: Boolean = false) {
smartRefreshLayout.finishLoadMore(success)
smartRefreshLayout.setNoMoreData(noMoreData)
}
fun startLoading() {
isLoading = true
loadingView?.visibility = VISIBLE
recyclerView.visibility = GONE
emptyView?.visibility = GONE
noNetworkView?.visibility = GONE
footerView?.visibility = GONE
}
private fun hideSkeleton() {
if (isLoading) {
loadingView?.visibility = GONE
isLoading = false
}
}
fun showContent() {
hideSkeleton()
recyclerView.visibility = VISIBLE
emptyView?.visibility = GONE
noNetworkView?.visibility = GONE
footerView?.visibility = GONE
}
fun showEmpty(@LayoutRes layoutId: Int = R.layout.layout_empty) {
hideSkeleton()
if (emptyView == null) {
emptyView = LayoutInflater.from(context).inflate(layoutId, this, false)
addView(emptyView)
}
emptyView?.visibility = VISIBLE
recyclerView.visibility = GONE
noNetworkView?.visibility = GONE
footerView?.visibility = GONE
}
fun showNoNetwork(@LayoutRes layoutId: Int = R.layout.layout_error) {
hideSkeleton()
if (noNetworkView == null) {
noNetworkView = LayoutInflater.from(context).inflate(layoutId, this, false)
addView(noNetworkView)
noNetworkView?.setOnClickListener {
reloadListener?.invoke()
}
}
noNetworkView?.visibility = VISIBLE
recyclerView.visibility = GONE
emptyView?.visibility = GONE
footerView?.visibility = GONE
}
fun setReloadListener(listener: () -> Unit) {
reloadListener = listener
}
private fun showLoadMoreFooter() {
if (footerView == null) {
footerView = LayoutInflater.from(context).inflate(R.layout.layout_footer_no_more, this, false)
addView(footerView)
}
footerView?.visibility = VISIBLE
}
fun getRecyclerView(): RecyclerView = recyclerView
fun getSmartRefreshLayout(): SmartRefreshLayout = smartRefreshLayout
}
那么流程就很清晰了
scss
┌───────────────────┐
│ RefreshPageLayout │ ← 下拉刷新 / 上拉加载动画
└─────────┬─────────┘
│ 触发
▼
┌───────────────────────┐
│ ViewModel │
│ getNotifications() │
│ → repository.flow │
│ .cachedIn(scope) │
└─────────┬─────────────┘
│
▼
┌───────────────────────────────┐
│ Pager(...) │
│ config = PagingConfig(...) │
│ remoteMediator = ... │
│ pagingSourceFactory = ... │
└─────────┬─────────────────────┘
│
┌──────┴──────────────────┐
▼ ▼
(PagingSource) (RemoteMediator)
从 Room 读取本地数据 从 Retrofit 拉取网络数据
▲ │
│ ▼
└──────── Room ← Retrofit + 数据存储 ──────┘
根据网络请求的结果,新建了相关Room数据库实体以及查询语句,也搭建了Retrofit的相关请求。
Repository
层
NotificationRepository
整合本地存储和网络请求
kotlin
object NotificationRepository {
private val dbManager = AppDatabaseManager.database
@OptIn(ExperimentalPagingApi::class)
fun getNotifications(token: String, msgStatus: String?): Flow<PagingData<Notification>> {
// 本地数据源
val pagingSourceFactory = { dbManager.appDatabaseDao().getAllPaged() }
return Pager(
/**
* 分页配置
* PagingConfig 参数解释
* pageSize 每次加载一页的大小
* enablePlaceholders = true 是否启用占位符 在你总数据量已知的情况下,可以在 RecyclerView 中先画出空白的位置,等数据加载到才填充进去。
* prefetchDistance 预取距离 动到距离 列表底部还有 2 条 的时候,Paging3 就会提前触发加载下一页
*/
config = PagingConfig(pageSize = 10, enablePlaceholders = true, prefetchDistance = 2),
// 网络请求数据并更新本地数据
remoteMediator = NotificationRemoteMediator(token, msgStatus, dbManager),
// 本地数据
pagingSourceFactory = pagingSourceFactory
).flow.map { pagingData ->
pagingData.map { it.toModel() }
}
}
}
NotificationRemoteMediator
网络请求并更新本地数据
此处我请求的接口返回的结果是:页码分页。最终结果以页码分页版为准。游标分页版仅供参考。
页码分页版
kotlin
@OptIn(ExperimentalPagingApi::class)
class NotificationRemoteMediator(
private val token: String,
private val msgStatus: String?,
private val database: AppDatabase
) : RemoteMediator<Int, NotificationEntity>() {
var nextPage: Int? = null // 用于维护下一次请求的页数
override suspend fun load(loadType: LoadType, state: PagingState<Int, NotificationEntity>): MediatorResult {
// 请求的页数
val page = when (loadType) {
LoadType.REFRESH -> {
// 下拉刷新,重新加载第一页
1
}
LoadType.APPEND -> {
// 加载下一页,如果 nextPage 为 null,说明没有更多数据了
nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.PREPEND -> {
// 加载上一页,这里直接返回不加载
return MediatorResult.Success(endOfPaginationReached = true)
}
}
try {
// 此处是请求网络数据,本接口返回的是页码分页,使用nextPage 维护下一次请求的页数
val response = RetrofitRepository.getMessageList(token, page, state.config.pageSize, msgStatus)
if (response is Result.Success) {
val data = response.data
val listData = data.list
// 当前页数是否小于总页数
nextPage = if (listData.current < listData.pages) listData.current + 1 else null
val records = data.list.records.map { NotificationEntity.fromModel(it) }
// 写入本地数据库
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.appDatabaseDao().clearAll()
}
database.appDatabaseDao().insertAll(records)
}
// 是否到底了,如果当前页数,大于返回的总页数,则通知 Paging 不要再继续请求下一页了
val endOfPaginationReached = data.list.current >= data.list.pages
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} else {
// 网络错误时判断本地有没有数据
val localCount = withContext(Dispatchers.IO) {
database.appDatabaseDao().count()
}
return if (localCount == 0) {
MediatorResult.Error(Exception("Network error, and no local data"))
} else {
// 网络错误但本地有数据,认为是加载结束,避免卡 loading
MediatorResult.Success(endOfPaginationReached = true)
}
}
} catch (e: Exception) {
val localCount = withContext(Dispatchers.IO) {
database.appDatabaseDao().count()
}
return if (localCount == 0) {
MediatorResult.Error(e)
} else {
MediatorResult.Success(endOfPaginationReached = true)
}
}
}
}
游标分页版
kotlin
@OptIn(ExperimentalPagingApi::class)
class NotificationRemoteMediator(
private val token: String,
private val msgStatus: String?,
private val database: AppDatabase
) : RemoteMediator<String, NotificationEntity>() {
// 维护下一次请求的游标
private var nextCursor: String? = null
override suspend fun load(loadType: LoadType, state: PagingState<String, NotificationEntity>): MediatorResult {
// 确定请求的游标
val cursor = when (loadType) {
LoadType.REFRESH -> {
// 刷新时,游标置空
null
}
LoadType.APPEND -> {
// 如果 nextCursor 为 null,说明没有更多数据
nextCursor ?: return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.PREPEND -> {
// 游标分页一般不支持向前加载
return MediatorResult.Success(endOfPaginationReached = true)
}
}
try {
val response = RetrofitRepository.getMessageListByCursor(
token = token,
cursor = cursor, // 用游标取数据
pageSize = state.config.pageSize,
msgStatus = msgStatus
)
if (response is Result.Success) {
val data = response.data
// 更新 nextCursor(如果没有更多数据,接口应返回 null 或空字符串)
nextCursor = data.nextCursor
val records = data.list.map { NotificationEntity.fromModel(it) }
// 写入数据库
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.appDatabaseDao().clearAll()
}
database.appDatabaseDao().insertAll(records)
}
// 如果 nextCursor 为空,说明到底了
return MediatorResult.Success(endOfPaginationReached = nextCursor == null)
} else {
val localCount = withContext(Dispatchers.IO) {
database.appDatabaseDao().count()
}
return if (localCount == 0) {
MediatorResult.Error(Exception("Network error, and no local data"))
} else {
MediatorResult.Success(endOfPaginationReached = true)
}
}
} catch (e: Exception) {
val localCount = withContext(Dispatchers.IO) {
database.appDatabaseDao().count()
}
return if (localCount == 0) {
MediatorResult.Error(e)
} else {
MediatorResult.Success(endOfPaginationReached = true)
}
}
}
}
Viewmodel
层
kotlin
class MessageViewModel : ViewModel() {
val pagingDataFlow = NotificationRepository.getNotifications(token, null)
.cachedIn(viewModelScope)
}
UI
层
MessageFragment
展示数据
kotlin
class MessageFragment : BaseFragment<LayoutMessageFragmentBinding>() {
override fun getLayoutResId() = R.layout.layout_message_fragment
private lateinit var viewModel : MessageViewModel
override fun initData() {
viewModel = ViewModelProvider(this).get(MessageViewModel::class.java)
// 初始化 adapter
val adapter = NotificationAdapter()
binding.recyclerView.adapter = adapter
// 监听 PagingData,提交给 adapter
lifecycleScope.launch {
viewModel.pagingDataFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
// SmartRefreshLayout 上拉触发 Paging3 自动加载下一页(不需手动处理,监听加载状态完成动画)
binding.refreshLayout.setOnRefreshLoadMoreListener(object : OnRefreshLoadMoreListener {
override fun onRefresh(refreshLayout: RefreshLayout) {
adapter.refresh()
}
override fun onLoadMore(refreshLayout: RefreshLayout) {
}
})
// 监听 Paging 3 加载状态,控制 SmartRefreshLayout 动画
lifecycleScope.launch {
adapter.loadStateFlow.collectLatest { loadStates ->
// 结束下拉刷新动画
if (loadStates.refresh is LoadState.NotLoading) {
binding.refreshLayout.finishRefresh(true)
}
// 结束上拉加载动画
if (loadStates.append is LoadState.NotLoading) {
binding.refreshLayout.finishLoadMore(true,true)
}
// 处理错误状态
val errorState = loadStates.source.append as? LoadState.Error
?: loadStates.source.prepend as? LoadState.Error
?: loadStates.source.refresh as? LoadState.Error
if (errorState != null) {
// 加载失败时结束动画并可弹提示
binding.refreshLayout.finishLoadMore(false,false)
binding.refreshLayout.finishRefresh(false)
Toast.makeText(requireContext(), "加载失败:${errorState.error.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
NotificationAdapter
适配器渲染item
kotlin
class NotificationAdapter : PagingDataAdapter<Notification, NotificationViewHolder>(diffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
val binding = ItemNotificationBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NotificationViewHolder(binding)
}
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
val item = getItem(position)
if (item != null) {
holder.bind(item, position)
}
}
companion object {
val diffCallback = object : DiffUtil.ItemCallback<Notification>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification) =
oldItem.id == newItem.id // 用唯一标识判断
override fun areContentsTheSame(oldItem: Notification, newItem: Notification) =
oldItem == newItem
}
}
}
class NotificationViewHolder(val binding: ItemNotificationBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(notification: Notification,position : Int) {
binding.title.text = "${notification.createTime} ${notification.moduleName}"
}
}
效果
有网络
无网络