分页 — 一种逐页加载数据的技术,用于移动应用和Web服务处理大量记录集。根据 Android Developers Documentation (2025),正确实现分页可以降低API负载、节省流量并改善用户体验。逐页加载允许应用程序逐步显示内容,无需等待所有数据完全加载。
要点
分页(来自英语 pagination — 分页) — 将大型数据集分成连续部分(页)的技术。在移动应用中,分页用于加载消息列表、新闻推送、产品目录、订单历史以及任何其他具有潜在无限记录数量的集合。
没有分页,应用程序必须同时加载所有数据,这导致等待时间长、流量消耗大以及在弱设备上运行不稳定。带分页的API请求仅返回一部分数据和用于加载下一部分的元信息 — 这样应用程序控制接收信息的量。
分页的主要指标:页面大小(page size)— 一页上的记录数(通常为10-50),以及页码或游标 — 指向集合中当前位置的指针。页面大小的选择取决于数据类型:对于紧凑元素(名称)20-30就足够,对于带图片的卡片 — 10-15。
移动设备资源有限:RAM容量、处理器速度和流量限制。分页解决三个关键任务:减少内存消耗(内存中只存储可见元素),加快首次显示(第一部分加载比整个集合更快)和节省流量(仅在用户滚动列表时加载数据)。
有四种主要的分页类型,每种解决特定的任务。方法的选择取决于对数据一致性的要求、API架构、存储类型以及客户端和服务器端允许的实现复杂度。
| 类型 | 工作原理 | 稳定性 | 大容量下的速度 |
|---|---|---|---|
| Offset | SQL中的LIMIT + OFFSET | 低 | 随OFFSET增加而下降 |
| Cursor | WHERE id > last_id | 高 | 稳定(O(log n)) |
| Keyset | WHERE key > last_key | 高 | 稳定(O(log n)) |
| Time-based | WHERE created_at < last_time | 中 | 有索引时稳定 |
Offset分页适用于静态或很少更新的数据集,当简单实现很重要时。Cursor和Keyset — 用于频繁插入的动态数据。Time-based — 用于按创建时间排序的时间线推送。 GraphQL标准 Relay使用游标分页作为唯一推荐的方法。
Offset分页 — 最简单的逐页加载类型。客户端传递page和limit(或offset和limit)参数,服务器应用SQL OFFSET和LIMIT。例如,page=2, limit=20将返回21到40条记录。这种方法直观易懂,易于在任何技术栈上实现。
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items")
async def get_items(
page: int = Query(default=1, ge=1),
limit: int = Query(default=20, le=100)
):
offset = (page - 1) * limit
items = await fetch_items(offset, limit)
total = await count_items()
return {
"items": items,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit
}
Offset分页的主要缺点 — 跳过和重复记录的问题。如果在两个请求之间向表中添加了新记录,OFFSET会偏移:用户可能会两次看到同一条记录或错过新记录。这对于新闻推送和聊天来说至关重要,因为一致性很重要。
另一个问题 — 在大型OFFSET上性能下降。数据库必须在返回结果之前扫描并跳过前offset条记录。在offset=100000时,即使使用LIMIT 20,服务器也会花费明显的时间进行扫描。PostgreSQL和MySQL表现出随OFFSET增加而线性速度下降。
Offset分页仍然是以下场景的最佳选择:管理面板(数据很少更改,需要在页面之间导航),报告和历史日志(固定的数据切片),带过滤的目录(可以导航到任何页面)。Offset在客户端实现也最简单 — RecyclerView配合Paging 3开箱即支持。
Keyset分页 使用唯一键(通常是主键)来过滤记录。请求使用WHERE id > last_seen_id而不是OFFSET。这确保了无论记录数量如何都能稳定性能,并且插入时没有重复,因为新记录总是具有更大的id。
-- Offset 分页(有问题的)
SELECT * FROM posts
ORDER BY id
LIMIT 20 OFFSET 100;
-- Keyset 分页(稳定的)
SELECT * FROM posts
WHERE id > 100
ORDER BY id
LIMIT 20;
Time-based分页(或时间游标)使用created_at时间戳进行导航。客户端发送最后加载记录的时间戳,服务器返回在此时间戳之前或之后创建的记录。该方法在社交网络和新闻推送中很流行,其中记录顺序由发布时间决定。
Time-based分页的特点 — 如果两条记录在同一毫秒内创建,可能会出现重复。为了消除这个问题,将time-based键与唯一id结合:WHERE (created_at, id) < (last_time, last_id)。这种复合游标保证了每条记录的唯一性和精确顺序。
Keyset分页需要具有唯一且单调递增值的列(自增id,UUID v7)。Time-based适用于任何具有created_at的表,但需要额外的重复处理。主要区别:Keyset在任何插入操作中稳定工作,而Time-based对相同时间戳敏感。
分页类型的选择取决于数据的性质和对用户体验的要求。以下是移动开发中典型场景的建议。没有通用解决方案 — 每种方法都有其最优的应用领域。
Android Paging 3库通过PagingSource支持所有分页类型。对于Offset — 使用Int键(page)的PagingSource,对于Cursor — 使用String或Long键(cursor)的。PagingSource自动管理加载、缓存和错误重试。
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> {
val cursor = params.key ?: Long.MAX_VALUE
return try {
val response = api.getPosts(cursor, params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = response.items.lastOrNull()?.id
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Long, Post>): Long? {
return state.anchorPosition?.let {
state.closestItemToPosition(it)?.id
}
}
}
页面大小影响加载速度和对性能的感知。对于移动应用,最佳范围是每页10-25个项目。少于10 — 对API的请求过于频繁,滚动不连贯。超过25 — 在慢速网络上第一部分加载时间长。
对于图片和视频,页面大小减少到5-10,因为每个项目需要额外时间加载媒体。对于文本元素列表(评论、日志),大小可以增加到30-50条记录。建议页面大小通过API可配置,以便客户端可以适应不同的网络条件。
常见问题
分页 — 分块加载数据,而不是一次全部加载。就像一本书:你读一页,然后翻到下一页。在应用程序中,这意味着在滚动列表时,下一部分数据被加载,而不是整个列表一次加载,从而节省流量和内存。
Offset计算记录:“跳过20条,返回接下来的10条”。如果在加载之间添加了新记录 — 编号就会偏移。Cursor使用最后一条记录的唯一标识符:“返回ID = 100之后的10条记录”。新记录不影响位置。
对于移动应用程序,最佳为10-25个项目。对于带图片的列表 — 5-10,对于文本推送 — 20-30。大小取决于单个项目的平均大小:项目越重,为了快速显示,页面应该越小。
使用Android Jetpack的Paging 3库。它提供PagingSource用于加载,PagingData用于响应式流,PagingDataAdapter用于滚动时自动加载。该库通过自定义PagingSource支持Offset、Cursor和Keyset分页。
无限滚动 — 一种UI模式,在接近列表末尾时自动加载新的数据部分。分页 — 是分块加载数据的机制,而无限滚动是其显示方式之一。替代方案是“加载更多”按钮。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。