区分设计良好和不良的模块,最重要的因素在于,该模块在多大程度上对其他模块隐藏了它的内部数据和其他实现细节。------《代码大全2》
1. 引言:问题从何而来
这是一个真实的业务场景,发生在前后端协同工作的过程中。
1.1 场景复现
在"手机主题管理" APP 中,用户可以在"我的主题"页查看自己账号下的所有已购买主题。由于已购买主题跟随账号,且并不是所有已购主题都可以适用于当前手机。
因此,在 APP 页面展示"我的主题"页时,将已购买主题分为两类:
- 本机已适配的主题:能够应用在当前手机的主题。
- 本机未适配的主题:当前手机无法应用的主题,这部分主题仍然属于用户的账号已购资产。
APP 页面交互图如下:
由于后端架构设计所限,该分页请求接口无法做到分别查询"已适配"、"未适配"的主题数据,只能在同个接口里将这两种类型混合返回,并且一次分页数据请求中,两种数据量是不固定的。
1.2 痛点剖析
由上述背景信息总结出以下3个痛点:
- 前端分页展示不均衡,首屏加载可能需要多次网络请求。
- 业务逻辑侵入 UI 层,UI 本不应关心底部实现(是1次还是多次请求)。
- 不同业务都在重复写类似逻辑。
1.3 目标
针对这种分类加载的场景,我们希望抽象出一个可复用的优先级分页加载能力,该能力不仅可以应用在主题页面,也可以应用在其它相似场景下,并且对于 UI 层隐藏实现,做到无痛接入。
2. 需求分析与建模
2.1 "分页"的本质是什么?
"分页"是一种前后端接口设计的通用范式,这样做的目的是降低单次请求数据量,提升接口响应速度,加快大前端的页面加载。本质上,是 对于数据的分批、有序、延迟加载。
2.2 优先级分页的特殊性
由于在基础分页需求上,增加了"优先级"属性,导致单次接口请求返回的数据,不足以撑满 APP 一页的显示,请求次数存在不确定性。
2.3 对需求的抽象拆解
综合上述信息,从数据流向的角度,可以将过程抽象划分为4块:
- 数据来源:分页 API。
- 数据分类:高/低优先级(类别数也可以大于2个,处理思路是一致的)。
- 数据汇聚:APP 一屏所展示的数量(screenSize)。
- 缓冲策略:池化(pool)思想,在 APP 侧管理数据池。
2.4 用户交互图
假设前端一屏展示10条数据,且后端接口同样在一次请求中返回10条。前端优先展示"本机已适配"的主题绿色圆圈
,当全部已适配主题展示完成后,才展示"本机未适配"主题,该主题以红色方块
表示。
- 第1次请求:用户进入到"我的主题"页时,进行第一次网络请求,此时返回的10条数据里,有8条是高优先级,另有2条属于低优先级。APP 判断当前首屏(需要10条数据)未填满,因此需要进行第2次分页数据请求。
- 第2次请求:返回的10条数据里,同样是8条高优先级+2条低优先级,此时已经足以填满 APP 首屏,因此 APP 进行数据展示。
- 用户上滑列表:由于在第2次网络请求时,后端通知"无下一页",意味着 APP 已经将该用户在云端的全部数据拉取到本地,此时展示余下6条高优先级数据,和4条低优先级数据(分别来自于2次接口请求)。

2.5 数据流程图
该流程图描述了 APP 侧处理数据的步骤和分支逻辑。
3. 架构设计思路
这一章从宏观上进行分层设计,厘清不同层级之间的边界。
3.1 层级划分
层级 | 职责 | 可测试性设计 |
---|---|---|
Data Layer | 后端分页接口,返回混合数据 | 需要本地模拟设计,以验证上层 Loader 功能正确性 |
Loader Layer | PriorityPagingLoader 负责分拣和缓存 |
不需特别设计 |
Repo Layer | 封装 Data Layer 和 Loader Lader,提供给 VM | 不需特别设计 |
UI Layer | 观察 Flow<List<T>> ,它(VM)不需要关心底层实现 |
不需特别设计 |
关于可测试性,我们把 mock 实现安插在 Data Layer,即 DataSource 层,这样可以最大程度测试到上层的分拣和缓存逻辑。
3.2 职责边界
- 后端:保证分页一致性,接口应当具备幂等性。
- Loader:管理优先级与翻页边界,是本次设计的重点。
- Repo:集成 Loader 与后端接口,供 VM 加载下一页时调用。
- UI:只负责展示。
4. 实现细节
4.1 通用的优先分页加载器 PriorityPagingLoader<T>
这里采用 suspend fun
的阻塞接口形式,也可以将其改写成 Flow<T>
的实现。
kotlin
// Loader 中维护了加载到的页数和分优先级队列
class PriorityOrderLoader<T>(
private val pageSize: Int,
private val uiPageSize: Int,
private val fetcher: suspend (page: Int) -> List<T>,
private val isHighPriority: (T) -> Boolean = { false }
) {
private val highPriorityBuffer = mutableListOf<T>()
private val lowPriorityBuffer = mutableListOf<T>()
private var pageIndex = 1
private var reachedEnd = false
suspend fun loadFirstScreen(): List<T> {
highPriorityBuffer.clear()
lowPriorityBuffer.clear()
pageIndex = 1
reachedEnd = false
return loadNextScreen()
}
// 核心函数,在当前的状态下,加载下一页数据
suspend fun loadNextScreen(): List<T> {
ensureHighPriorityFilled()
val result = mutableListOf<T>()
val highNeeded = uiPageSize - result.size
result += takeFromBuffer(highPriorityBuffer, highNeeded)
// 若高优先级数据已加载完,开始展示低优先级
if (highPriorityBuffer.isEmpty() && reachedEnd) {
val lowNeeded = uiPageSize - result.size
result += takeFromBuffer(lowPriorityBuffer, lowNeeded)
}
return result
}
// 当前未填满高优先级数据,且后端仍有下一页
private suspend fun ensureHighPriorityFilled() {
while (highPriorityBuffer.size < uiPageSize && !reachedEnd) { // 未填满当前页
val page = fetcher(pageIndex++)
if (page.isEmpty()) { // 后端已无更多数据
reachedEnd = true
break
}
val (high, low) = page.partition(isHighPriority)
highPriorityBuffer += high
lowPriorityBuffer += low
}
}
// 从高/低优先级池子里面取对象
private fun takeFromBuffer(buffer: MutableList<T>, count: Int): List<T> {
val take = buffer.take(count)
repeat(take.size) { buffer.removeFirst() }
return take
}
}
4.2 使用示例(与 UI 的集成)
kotlin
// Repo 层
val loader = PriorityOrderLoader(
pageSize = 200,
uiPageSize = 20
) { page ->
api.getOrders(page) // suspend fun 返回 List<Order>
}
// 首屏
val firstScreen = loader.loadFirstScreen()
adapter.submitList(firstScreen)
// 更多
val moreScreen = loader.loadNextScreen()
adapter.submitList(moreScreen)
5. 接口可复用性与扩展性
5.1 可复用性
(T) -> Boolean
灵活定义优先级。泛型
支持任意数据类型。- 可自定义
pageSize
、screenSize
适应不同业务。
5.2 扩展性
- 多级优先级(高/中/低或更多级别)。
- 预拉取下一页(也可在 VM 中管理)。
6. 对前后端协同的再思考
6.1 前端视角下的隐患
- 分页语义模糊:分页页码(pageIndex)和业务排序(优先级)绑定在一起,导致前端分页失真。比如第1页取完还不够20条高优先级,要再请求第2页,会跳过一些低优先级数据。在数据稀疏的情况下,加载一页数据会有多次网络请求发生。
- 负担转移:前端不得不做复杂筛选和缓冲逻辑,这部分逻辑在 Android/iOS 双端都要分别实现,导致消耗双倍资源。
- 扩展性差:如果后面要增加"已安装"、"安装中"、"未安装"等类型,接口复杂度成倍增长。
6.2 当前方案是对低段位后端开发者的向下兼容
客观、中立地讲,在这次前后端合作过程中,暴露了后端架构设计的缺陷,在技术上有明显的短板 。这种分类请求有序接口的能力,理应是在后端进行实现的。站在全局角度看:把一个分页接口里混放两类逻辑不同的数据,是一个"可行但不优"的设计。这种做法把分页的边界模糊化了,实际上是让前端去弥补后端分页逻辑的空缺。