24、数据库与Paging3分页:PagingSource实现、数据库作为数据源、边界回调与网络加载
分页加载,说白了就是「数据太多,一次拿不完」。
我早年做第一个新闻客户端时,傻乎乎地一次性加载了500条数据。结果呢?列表卡成PPT,用户直接卸载。嗯,从那以后,我再也不敢小看分页了。
Android官方后来推出了Paging3库,专门解决这个问题。今天我们就聊聊,怎么把SQLite数据库和Paging3结合起来,做一个既快又稳的分页方案。
核心思路:数据库作为唯一数据源,Paging3负责从数据库读取分页数据,同时通过边界回调触发网络请求,把新数据写入数据库,再由数据库通知列表刷新。
PagingSource:分页的基石
Paging3里最核心的接口就是PagingSource。它定义了「怎么加载一页数据」和「怎么知道还有没有下一页」。
我个人习惯把PagingSource和Room的DAO配合使用。来看一个例子:
class ArticlePagingSource(
private val articleDao: ArticleDao
) : PagingSource<Int, ArticleEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ArticleEntity> {
val page = params.key ?: 1
val pageSize = params.loadSize
return try {
val articles = articleDao.getArticlesPaged(page, pageSize)
LoadResult.Page(
data = articles,
prevKey = if (page > 1) page - 1 else null,
nextKey = if (articles.isNotEmpty()) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, ArticleEntity>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestItemToPosition(anchorPosition)?.id?.toInt()
}
}
}
这里有个坑,我踩过好几次。getRefreshKey如果返回null,刷新时会回到第一页,用户体验很差。我曾经因为这个被测试小姐姐追着骂了三天……后来我改成用anchorPosition计算当前页,总算消停了。
数据库作为唯一数据源
很多团队喜欢「网络直接给Paging3喂数据」,我不太推荐。你想想看,用户滑到第10页,突然断网了,列表直接空白,多尴尬。
我的做法是:数据库永远是真数据源。网络只是「后台搬运工」。
Room的DAO需要支持分页查询:
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY publishTime DESC LIMIT :limit OFFSET :offset")
suspend fun getArticlesPaged(limit: Int, offset: Int): List<ArticleEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(articles: List<ArticleEntity>)
}
注意这里用的是LIMIT + OFFSET。数据量小的时候没问题,但如果你有几十万条数据,OFFSET越大越慢。我后面会讲怎么优化。
边界回调与网络加载
光有数据库还不够,数据从哪来?得从网络来。Paging3提供了RemoteMediator,专门处理「数据库没数据时去网络拉」的逻辑。
class ArticleRemoteMediator(
private val api: ArticleApi,
private val dao: ArticleDao
) : RemoteMediator<Int, ArticleEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
if (lastItem == null) 1 else (lastItem.id / state.config.pageSize) + 1
}
}
return try {
val response = api.fetchArticles(page, state.config.pageSize)
dao.insertAll(response.data)
MediatorResult.Success(endOfPaginationReached = response.data.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
这里有个细节:PREPEND(向上翻页)我直接返回成功,因为新闻列表很少需要往上加载旧数据。但如果你做的是聊天记录,PREPEND就得好好实现了。
PositionalDataSource 与 ItemKeyedDataSource
Paging3之前的老版本有PositionalDataSource和ItemKeyedDataSource。虽然现在官方推荐统一用PagingSource,但理解它们的区别,能帮你更好地设计分页策略。
| 类型 | 适用场景 | Key类型 | 典型例子 |
|---|---|---|---|
| PositionalDataSource | 按位置分页,知道总条数 | Int(页码/偏移量) | 本地数据库列表 |
| ItemKeyedDataSource | 按最后一条数据的某个字段分页 | 任意类型(如ID、时间戳) | 朋友圈、微博时间线 |
| PageKeyedDataSource | API返回了下一页的URL或页码 | Int/String(页键) | RESTful API分页 |
我个人更倾向于ItemKeyedDataSource的思路。为什么?因为按ID分页比按OFFSET分页稳定得多。你想想看,如果用户在翻页过程中,数据库新增了一条数据,OFFSET分页会导致数据重复或遗漏。而按ID分页,只要ID是递增的,就不会出问题。
我的建议:如果你的数据有自增ID或时间戳,优先用ItemKeyed方式实现PagingSource。代码稍微复杂一点,但数据一致性更好。
分页性能优化
性能优化是个大话题,我挑几个最实用的点说说。
1. 避免OFFSET大偏移
OFFSET 1000000意味着数据库要扫描100万行再丢弃,太慢了。改用「游标分页」:
@Query("SELECT * FROM articles WHERE id < :lastId ORDER BY id DESC LIMIT :pageSize")
suspend fun getArticlesAfter(lastId: Long, pageSize: Int): List<ArticleEntity>
这样每次查询都走索引,速度飞快。我在一个百万级数据的项目里试过,从3秒降到了20毫秒。
2. 预加载与缓存
Paging3默认会预加载下一页。你可以通过PagingConfig调整:
PagingConfig(
pageSize = 20,
prefetchDistance = 5, // 还剩5条时触发加载
enablePlaceholders = false // 关闭占位符,减少UI闪烁
)
我习惯把prefetchDistance设成pageSize / 4左右。太小了会频繁加载,太大了浪费流量。
3. 数据库索引
分页查询的字段一定要加索引。比如按publishTime排序:
CREATE INDEX idx_articles_publish_time ON articles(publishTime DESC);
不加索引的话,数据量一上来,分页查询就是全表扫描。我曾经在一个生产环境里排查了整整两天,才发现是索引没建……
4. 避免在主线程操作数据库
Paging3的load方法默认在后台线程执行,但如果你在DAO里用了LiveData或Flow,要注意切换线程。Room默认支持协程,但如果你混用回调,很容易出ANR。
注意:不要在PagingSource的load方法里做网络请求!网络请求应该放在RemoteMediator里。PagingSource只负责从本地读取数据,这样才能保证列表的流畅性。
整体架构图
下面这张图展示了数据库+Paging3的完整数据流:
数据流向很清晰:用户滑动列表 → PagingSource从Room读数据 → 数据不够时RemoteMediator触发网络请求 → 数据写入Room → Room通知PagingSource刷新 → UI更新。
这个闭环的好处是:用户永远看到的是本地数据,网络只是后台补充。即使离线,列表也能正常展示。
总结
数据库+Paging3的组合,是目前Android分页加载的最佳实践。核心就三点:
- PagingSource负责从Room读取分页数据,实现
load和getRefreshKey - RemoteMediator负责边界回调,触发网络请求并写入数据库
- 性能优化:用游标分页代替OFFSET、加索引、合理设置预加载距离
我在好几个日活百万的项目里都用这套方案,从来没出过问题。你照着这个思路做,分页这块基本稳了。