Android paging3实现本地缓存加载数据

预备知识

主流的分页方式

页码分页

传统的点到哪页哪页,简单粗暴。如果有新增数据,那每一页的数据都需要调整

游标分页

以小红书为例

第一次请求

  • 传入的游标参数为空(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}"
    }
}

效果

有网络

无网络

相关推荐
柑橘乌云_2 分钟前
vue中如何在父组件监听子组件的生命周期
前端·javascript·vue.js
北海天空42 分钟前
react-scripts的webpack.config.js配置解析
前端
LilyCoder1 小时前
HTML5中华美食网站源码
前端·html·html5
拾光拾趣录1 小时前
模块联邦(Module Federation)微前端方案
前端·webpack
江湖人称小鱼哥1 小时前
react接口防抖处理
前端·javascript·react.js
GISer_Jing2 小时前
腾讯前端面试模拟详解
前端·javascript·面试
saadiya~2 小时前
前端实现 MD5 + AES 加密的安全登录请求
前端·安全
zeqinjie2 小时前
回顾 24年 Flutter 骨架屏没有释放 CurvedAnimation 导致内存泄漏的血案
前端·flutter·ios
萌萌哒草头将军2 小时前
🚀🚀🚀 Webpack 项目也可以引入大模型问答了!感谢 Rsdoctor 1.2 !
前端·javascript·webpack
小白的代码日记2 小时前
Springboot-vue 地图展现
前端·javascript·vue.js