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之前的老版本有PositionalDataSourceItemKeyedDataSource。虽然现在官方推荐统一用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里用了LiveDataFlow,要注意切换线程。Room默认支持协程,但如果你混用回调,很容易出ANR。

注意:不要在PagingSource的load方法里做网络请求!网络请求应该放在RemoteMediator里。PagingSource只负责从本地读取数据,这样才能保证列表的流畅性。

整体架构图

下面这张图展示了数据库+Paging3的完整数据流:

Paging3 + Room 分页架构图 UI层 RecyclerView + PagingDataAdapter ViewModel PagingData Flow / LiveData PagingSource 从Room读取分页数据 RemoteMediator 边界回调,触发网络请求 Room Database SQLite本地存储 网络API Retrofit / Ktor 本地数据流 网络数据流

数据流向很清晰:用户滑动列表 → PagingSource从Room读数据 → 数据不够时RemoteMediator触发网络请求 → 数据写入Room → Room通知PagingSource刷新 → UI更新。

这个闭环的好处是:用户永远看到的是本地数据,网络只是后台补充。即使离线,列表也能正常展示。

总结

数据库+Paging3的组合,是目前Android分页加载的最佳实践。核心就三点:

  • PagingSource负责从Room读取分页数据,实现loadgetRefreshKey
  • RemoteMediator负责边界回调,触发网络请求并写入数据库
  • 性能优化:用游标分页代替OFFSET、加索引、合理设置预加载距离

我在好几个日活百万的项目里都用这套方案,从来没出过问题。你照着这个思路做,分页这块基本稳了。

公众号:蓝海资料掘金营,微信 deep3321