☰
Room + Paging 组合实战:Android 本地列表分页加载与调优
2026/10/14 17:21:53 网站建设 项目流程

做 Android 列表类应用做久了,我心里一直有个很朴素的判断标准:一个 App 要是能把本地数据安安稳稳地存下来、再顺顺畅畅地刷出来,这已经赢了一大半。很多项目初期只顾着 CRUD,等数据量一上来,滑动卡顿、加载黑屏、内存吃紧全都来了。这个问题的标准解法,就是 Android Jetpack 里的两个架构组件:Room 负责本地数据库的存取,Paging 负责数据分页加载。两个加在一起,既能让你少写几百行繁琐的 SQLite 样板代码,又能让列表哪怕面对十万条数据也依然平稳如初。

这篇博文会从零开始,带着你把 Room+Paging 的组合跑通。我会按照实际开发里最常见的项目路径来讲:先讲为什么要这么组合,再做环境配置,然后分别拆解 Room 和 Paging 各自的基础使用链路,最后把两者从 Dao 一路串联到 UI,给出一份可以直接跑到手机上的最小示例。文章最后会把我自己实践中踩过的坑和调参经验一并写出来,适合刚接触 Jetpack 架构组件的开发者,也适合那些已经在用 Room 但还没引入分页思路的同行参考。

1. 为什么把 Room 和 Paging 绑在一起用

1.1 Room 解决了原生 SQLite 的哪些麻烦

很多老项目还在手工写 SQLiteOpenHelper,说实话,那日子我过过。你得自己管理数据库连接、维护 Cursor、手动把 Cursor 里的数据一条条读出来塞进 Bean,还要祈祷自己写的 SQL 没拼错字符串。最难受的是,这些代码几乎没有编译期检查,表结构一变,运行到中途才崩溃。

Room 把这一层全部包装掉了。你只要定义好实体类、数据访问对象和数据库类,剩下的事它替你干:编译期校验 SQL 语句是否合法,自动生成模板代码,返回的查询结果可以直接变成对象列表,再也不用碰 Cursor。用一句话总结,Room 让你以"面向对象"的方式写 SQLite,而不是把 SQL 字符串到处塞。这个库从 2017 年推出到现在,已经成了官方架构组件里最稳的一块基石,后来者和替代品不少,但大多数项目的本地存储最终都收敛到了 Room 上。

1.2 Paging 解决的是"一次性加载"的病

Paging 要解决的问题并不是"能不能加载",而是"怎么加载才能不把 App 拖垮"。假设你本地存了五万条聊天记录,直接一次性 SELECT 出来塞给 RecyclerView,光创建对象就够让主线程卡顿半天,内存也会瞬间飙升。可用户在真实使用场景里,根本不会一口气看完五万条,他只需要当前屏幕能看到的二三十条,顶多再往下多滑一点。

Paging 的设计思路就是按需分批取数。它像你搬家时用的小推车,一趟只搬几十个箱子,而不是非要把整间仓库的所有货一次性扛上楼。Paging 内部会根据用户滑动的位置,自动决定什么时候加载下一页、提前多远加载、保留多少旧数据,甚至能感知列表头部和尾部的边界。引入 Paging 后,你会发现自己再也不用手写"滑到倒数第几条就加载下一页"这种临时逻辑,那套逻辑简直又重复又容易出 bug。

1.3 最典型的适用场景:本地数据列表

有人觉得 Paging 主要是给网络请求分页用的,比如下拉刷新加载下一页资讯。其实 Paging 在本地数据上的价值同样很高,尤其是数据源来自 Room 的时候。试想这些常见场景:聊天软件的历史消息记录、音乐 App 的本地歌曲列表、笔记应用按时间倒序排列的笔记条目、甚至是你自己做的记账本里每一笔账单。数据都存在本地数据库里,少则几千条,多则几十万条,直接全部加载显然不明智。

Room 和 Paging 的组合专门为这类场景做了一整套丝滑配合:Room 的查询可以天然返回一个 PagingSource 数据源对象,Paging 拿到这个对象后自己管理分页逻辑,再通过 Flow 把分页后的一批批数据吐给界面层。你不用自己维护"当前加载到第几页""还有没有更多"这类状态变量,只关心界面怎么展示每一行数据就够了。这也是我觉得这两个库天生就该一起用的原因。

2. 环境准备:依赖版本和项目骨架的取舍

2.1 Gradle 依赖清单与版本注意事项

动手写代码之前,先把依赖配好。以我目前常用的稳定版本为例,Room 用的是 2.6.1,Paging 用的是 3.3.0,Kotlin 使用 2.0.x 系列,配合 KSP 编译器插件。在模块的 build.gradle 文件里,大致是这样的:

plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'com.google.devtools.ksp' } android { namespace 'com.example.roompagingdemo' compileSdk 35 } dependencies { def room_version = "2.6.1" def paging_version = "3.3.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" ksp "androidx.room:room-compiler:$room_version" implementation "androidx.paging:paging-runtime-ktx:$paging_version" implementation "androidx.activity:activity-ktx:1.9.0" implementation "androidx.fragment:fragment-ktx:1.8.0" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0" implementation "androidx.recyclerview:recyclerview:1.3.2" }

注意:Room 一定要把 room-ktx 也加上,否则 Flow、协程这类 Kotlin 特性用起来会别扭。Paging 的 paging-runtime-ktx 同样要与你项目里其他 AndroidX 库的版本匹配,不要只升级其中一个,不然容易出现依赖冲突。还有最重要的一点,Room 的注解处理器要和你用的编译器插件保持一致,如果用 KSP 就写 ksp,如果用 kapt 就写 kapt,二者混用会直接编不过。

2.2 项目分包建议:Entity/Dao/Database/Repository/ViewModel/Adapter

基础 Demo 虽然小,但我还是建议从一开始就按职责分包,别图省事全塞在一个包里。我习惯这样分:

  • data/entity:数据库表对应的实体类
  • data/dao:数据访问接口
  • data/database:RoomDatabase 定义
  • data/repository:仓储层,对 ViewModel 屏蔽数据库细节
  • ui/adapter:RecyclerView 的 Adapter 和 DiffUtil
  • ui/viewmodel:存放 ViewModel
  • ui/screen:Activity/Fragment 或 Compose 页面

这种分法并不复杂,但是当你把项目从 Demo 往真实工程扩展时,会发现每一层的职责非常清晰。ViewModel 只拿仓库层给它的分页数据流,完全不知道底层是 Room 还是服务器。Adapter 只负责展示和回收 View,也不关心数据到底是怎么来的。这样以后想换数据源,改动面会被压到最小。

2.3 为什么我用 KSP 而不是 kapt

再说一个新手容易忽略的选型问题:注解处理器选 KSP 好还是 kapt 好。kapt 是 Kotlin 注解处理的传统方案,它需要把 Kotlin 代码先翻译成 Java 再处理,速度慢,而且每次重新编译都有一截额外耗时。KSP 是专门为 Kotlin 设计的注解处理机制,直接识别 Kotlin 语法树,速度明显更快,尤其在 Room 这种注解比较多的库上,差距会放大。

现在 Room 官方已经明确支持 KSP,所以新项目直接上 KSP 就行,不用纠结。唯一要留神的是 KSP 插件版本必须和 Kotlin 版本匹配,版本号差一位都有可能在编译时报出很诡异的符号找不到错误。这个坑我在后面专门写一节细说。

3. Room 基础链路:Entity、Dao、Database 怎么写才能省心

3.1 Entity:表结构、索引和默认值

用 Room 的第一步,是定义一张表。假设我们要做一条简单的消息记录,字段包括自增主键 id、消息内容 content、创建时间 createTime。实体类大致长这样:

@Entity( tableName = "messages", indices = [Index(value = ["createTime"])] ) data class MessageEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val content: String, @ColumnInfo(name = "create_time") val createTime: Long )

这里有两个细节值得说。第一个是 @PrimaryKey(autoGenerate = true),它表示自增主键,插入数据时只要不传 id,Room 会自动帮你生成。第二个是 indices 属性,我在 createTime 上建了一个普通索引,因为后面分页查询几乎都是按创建时间倒序排的,这条索引能让 ORDER BY 查询快不少。不过索引不是白给的,它会在每次插入、更新时额外增加写操作开销。数据量不算大时倒无所谓,但几千条起步的列表,有索引和没索引的查询速度差距会变得非常明显。

如果你在实体里存了一些不是基础类型的字段,比如一个 List 或者枚举,Room 默认不认识它们。这时要给对应的字段加 @TypeConverters 注解,专门写一个转换器把类型转成 String 存进表里或者读出来。基础 Demo 里可以暂时不用,但正式项目里基本逃不掉。

3.2 Dao:插入数据的两种方式,以及为 Paging 预留的查询方法

Dao 层是一个接口,用来描述你对数据表的操作。最基本的插入方法无非两种选择,@Insert 和 @Upsert。@Insert 直接插入,如果主键冲突会抛异常;@Upsert 会做插入或更新二选一,适合那种希望按主键覆盖的场景。如果是批量写入数据,方法的参数可以直接传集合,Room 会自动用事务包住整批插入,效率更高。

查询方法就有讲究了。正常我们写查询,习惯返回 List ,但是一旦引入 Paging,这个查询方法的返回值就要变成 PagingSource:

@Dao interface MessageDao { @Insert suspend fun insertMessage(message: MessageEntity) @Query("SELECT * FROM messages ORDER BY createTime DESC") fun pagingSource(): PagingSource<Int, MessageEntity> }

关键点就在 pagingSource() 这个方法。它没有 suspend 修饰,返回的是一个可观察的数据源对象,Paging 后续会拿这个对象分批执行查询。注意这里的 SQL 必须带一个稳定的 ORDER BY 子句,否则分页结果会出现条目重复或者漏掉的问题。数据库里的行本身没有固定顺序,你不排序,它随时可能以一种你无法预测的顺序返回,分页就会乱掉。

3.3 Database:单例、创建策略和迁移基础

数据库类要继承 RoomDatabase,并且用注解声明有哪些实体、版本号是多少。我一般还会给它配一个单例对象,避免整个 App 创建多个数据库连接浪费资源。

@Database( entities = [MessageEntity::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun getInstance(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "app.db" ) .fallbackToDestructiveMigration() .build() .also { INSTANCE = it } } } } }

这里我特意用了 fallbackToDestructiveMigration(),它表示数据库升级时如果找不到对应的迁移规则,就直接销毁旧表重建。这个方法对基础学习非常省事,但真实生产环境绝不能这么随意,否则用户辛苦攒的本地数据会在一次版本升级后全部蒸发。正规做法是定义 Migration 对象,在升级时用 ALTER TABLE 之类的操作保留数据。

单例模式里那个 @Volatile 加双重锁,是为了保证多线程环境下数据库实例唯一。Room 的数据库构建操作本身不轻,用 applicationContext 而不是 activity 的 context 可以有效避免 Activity 销毁后数据库还被持有着导致的内存泄漏。

4. Paging 基础链路:PagingSource、Pager、PagingDataAdapter

4.1 PagingSource 的 load() 方法到底怎么写

PagingSource 是 Paging 库的数据源抽象,它描述的是"我这一页数据应该从哪里拿、怎么拿"。Room 返回的 PagingSource 是不用我们自己的写的,但这并不代表我们不需要理解它,因为一旦数据源不是 Room,比如从网络或内存列表里分页,你就得自己实现。

理解 PagingSource 的核心,就是 load() 方法。它接收一个 LoadParams 参数,里面包含了要加载的页码或偏移量、请求条数,以及一个可选的初始化加载键。方法返回 LoadResult.Page 或 LoadResult.Error。一个最朴素的实现长这样:

class MessagePagingSource( private val dao: MessageDao ) : PagingSource<Int, MessageEntity>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> { val page = params.key ?: 0 val pageSize = params.loadSize return try { val messages = dao.getMessagesByPage(page, pageSize) val prevKey = if (page > 0) page - 1 else null val nextKey = if (messages.size == pageSize) page + 1 else null LoadResult.Page( data = messages, prevKey = prevKey, nextKey = nextKey ) } catch (e: Exception) { LoadResult.Error(e) } } override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? { return state.anchorPosition?.let { anchorPosition -> state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) } } }

这段代码里最关键的是 nextKey 的判空逻辑。只有当当前返回的条数等于请求条数时,才认为后面还有下一页,否则就把 nextKey 设成 null,Paging 会据此判断已经到末尾了,避免无意义的下一次请求。getRefreshKey 则是处理列表刷新时应该把用户定位到哪一页的工具方法,用 anchorPosition 来找到当前可见页的位置,这样下拉刷新之后用户不会一下被甩回第一页。

4.2 PagingConfig 参数逐个聊

创建 Pager 的时候需要提供一个 PagingConfig,它的参数直接影响分页体验。我用表格把最常用的几个参数列一下:

参数作用我的建议
pageSize每页加载的条数不要太大,20 到 50 之间比较稳
prefetchDistance离底部还差多少条时提前加载下一页通常设成 pageSize 的一半或等于 pageSize
initialLoadSize首次加载的总条数一般为 pageSize 的 3 倍,保证首屏内容丰富
enablePlaceholders是否用占位符填充未知位置数据量大时建议 true,但 Room 场景可以设 false 简化 UI
maxSize内存中最多保留的条目数默认是 pageSize * 3,可以按需调整

说实话,这几个参数里最容易误导新手的就是 pageSize 越大越好。你想想,pageSize 设成 200,确实一下子加载很多,但是每次加载完成创建的对象也多,主线程要处理的 item 数量也大,滑动流畅度反而会下降。pageSize 的意义是平衡"请求次数"和"单次加载成本"。网络请求绕不开延迟的时候,pageSize 可以适当大一点;本地数据库查询很快,pageSize 反而更适合设小一些,让 UI 快速响应。

4.3 PagingDataAdapter 与 DiffUtil 的联动

Paging 把数据一批批交给 RecyclerView,需要一个特殊的 Adapter,叫 PagingDataAdapter。它和普通 RecyclerView.Adapter 最大的区别是:它会自己处理分页加载状态,并且天然支持 DiffUtil。每当有新的 PagingData 被提交,PagingDataAdapter 会通过 DiffUtil 对比新旧列表,只刷新真正变化的条目,而不是整个列表闪一下。

class MessageAdapter : PagingDataAdapter<MessageEntity, MessageViewHolder>(MessageDiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder { val binding = ItemMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false) return MessageViewHolder(binding) } override fun onBindViewHolder(holder: MessageViewHolder, position: Int) { val item = getItem(position) if (item != null) { holder.bind(item) } } object MessageDiffCallback : DiffUtil.ItemCallback<MessageEntity>() { override fun areItemsTheSame(oldItem: MessageEntity, newItem: MessageEntity): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: MessageEntity, newItem: MessageEntity): Boolean { return oldItem == newItem } } }

DiffUtil 的 areItemsTheSame 判断是不是同一条数据,我在这里用主键 id 判断;areContentsTheSame 判断内容有没有变化,这里直接比较整个 data class 就行。这套逻辑写好后,删除、修改单条消息都只会更新那条 item 的 View,列表整体不会出现闪烁错乱。没有 DiffUtil 的 RecyclerView 面对数据刷新时,刷新整页是常见行为,感官非常差。

5. 从 Dao 到 UI 的完整串联:一个可以直接跑的最小示例

5.1 Dao 返回 PagingSource 是整套流程的枢纽

到这里,两边的基础知识都铺垫完了,现在把整个链路串起来。你会发现,一切的大前提就是 Dao 里那个返回 PagingSource 的查询方法。Room 对这个返回类型有专门支持:它生成的代码里会封装一个观察查询的数据源,一旦数据库里的表发生变化,会自动让分页数据源失效并重新查询,从而实现"数据一变,列表自动刷新"的效果。

如果没有这个衔接,你就只能自己把 List 包成一个自定义的 PagingSource,还得手动去监听数据库更新然后触发 invalidate,工作量一下子多出不少。所以,凡是本地数据库分页,优先让 Dao 直接返回 PagingSource。我自己刚开始接触 Paging 的时候不知道这个特性,绕了一个大圈子,后来翻官方文档才明白,这其实是整个组合设计好的默认路径。

5.2 ViewModel 里暴露 Flow<PagingData<Item>>

数据源到位之后,ViewModel 的角色是暴露一个可观察的分页数据流。使用 Pager 构建器把 PagingConfig、数据源工厂和 PagingSource 工厂绑在一起,然后调用 flow() 得到 Flow<PagingData >。为了让旋转屏幕等配置变更时不至于重新加载数据,还需要用 cachedIn(viewModelScope) 把数据流缓存起来。

class MessageViewModel(private val dao: MessageDao) : ViewModel() { val messagesFlow: Flow<PagingData<MessageEntity>> = Pager( config = PagingConfig( pageSize = 30, prefetchDistance = 15, initialLoadSize = 90, enablePlaceholders = false ), pagingSourceFactory = { dao.pagingSource() } ).flow .cachedIn(viewModelScope) }

注意 Pager 的第一个参数 config 控制分页策略,第二个参数 pagingSourceFactory 是一个函数,每次刷新时 Paging 会调用它创建新的数据源。这里我传了一个 lambda,而不是直接传 dao.pagingSource() 的结果,这一点很重要。因为 PagingData 是会过期和重建的,如果只创建一次 PagingSource,后面刷新数据时就无法重新发起查询,列表就永远不会更新。cachedIn 则是 ViewModel 生命周期的伴侣,一旦 ViewModel 被清掉,缓存也随之消失。

5.3 UI 层收集 Flow 并驱动列表刷新

最后一公里在界面层。Activity 或 Fragment 里要拿到 ViewModel 的 Flow,再把它交给 Adapter 的 submitData。这里我会用 lifecycleScope 加 repeatOnLifecycle 来收集,确保页面不可见时不再接收数据:

class MessageFragment : Fragment() { private val viewModel: MessageViewModel by viewModels { ... } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val adapter = MessageAdapter() recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.adapter = adapter viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.messagesFlow.collect { pagingData -> adapter.submitData(pagingData) } } } } }

这里有一个容易踩的细节:收集 Flow 要放在 viewLifecycleOwner 的 scope 里,而不是 this 的 scope。Fragment 的 view 在销毁重建时,对象还活着,但视图已经没了,如果继续往旧 adapter 提交数据,轻则泄漏,重则崩溃。viewLifecycleOwner 能精确地跟着视图的生命周期走,销毁就自动取消收集。

拼到这一步,整个项目已经可以跑起来了。启动 App,往数据库里 insert 个几百条测试数据,然后滑动列表,你会看到 Paging 按需加载的效果:刚开始只有第一屏内容,越往下滑,越接近底部时新数据才会一批批出现,而且完全不会卡顿。

6. 我实践中踩过的坑和调参经验

6.1 Room 与 Paging 版本不匹配是最大的坑

第一个坑就能劝退不少人。Room 的 PagingSource 支持是随着版本迭代逐渐完善的,Room 2.6.x 配 Paging 3.1.x 以上问题不大,但如果你把 Room 降到 2.4 甚至更早,同时项目里用了 Paging 3.3,编译时就会报错找不到 PagingSource 对应的构造方法,或者运行时加载数据时报类型转换异常。

处理办法其实很直接:去查看官方版本兼容表,把 Room 和 Paging 的版本一起往上升,不要只升其中一个。写这篇时我用的 Room 2.6.1 和 Paging 3.3.0 已经磨合得很稳了。还有前面说过的 KSP 和 Kotlin 版本匹配问题,升级 Kotlin 后忘记同步升级 KSP,整个项目会在注解处理阶段报一大堆符号找不到的错误,那画面真是让人头皮发麻。我的经验是,每次动版本先把 KSP 版本对齐到当前 Kotlin 版本要求再继续干活。

6.2 列表不刷新?其实是 PagingSource 的 invalidate 机制

初用这套组合时,我最常被问到的就是:往数据库里插了数据,为什么列表不更新?问题的根源在于对 invalidate 机制理解不到位。Room 自动创建的 PagingSource 本身会在数据表变化时自动失效,但如果你在测试时没有真的触发数据库写操作,或者你用的查询没有观察变化能力,那自然刷新不了。

另一种常见情况是你在某个页面长期持有 Flow,但没有对数据库做 CRUD 操作,而是直接调用了 adapter.refresh()。这个函数本身只是告诉当前 adapter 重新请求最新数据,它能不能真正拿到新数据,还要看 PagingSource 是否被新实例替换。Room 场景下,等数据库 insert 完成后,Paging 会收到表变化通知并自动 invalidated,此时列表才会自动更新。所以如果要手动制造刷新效果,别只调 refresh(),先确保数据库真的变了。

6.3 PagingConfig 调参:不是 pageSize 越大越好

我前面提过 pageSize 不宜过大,这里展开聊聊为何。有一次我在一个列表项高度较大的场景里把 pageSize 设成了 80,结果首屏加载的时候一次性创建了 80 个 ViewHolder,滑到一半明显感觉到掉帧。后来我把 pageSize 调成 30,prefetchDistance 调成 15,首屏内容大概 15 到 20 条就够填满屏幕了,这样初次加载只取 30 条,完全足够,滑动期间提前加载的粒度也更精细。

合理的初始参数组合应当根据屏幕可容纳的 item 数量来定。你可以先算一屏大概显示多少条,然后 pageSize 取它的一到两倍,prefetchDistance 取 pageSize 的一半。比如一屏显示 10 条,pageSize 用 20 或 30,prefetchDistance 用 10 或 15。这样即使快速滑动到底部,也能提前预加载,不至于出现加载转圈的黑屏区域。

6.4 崩溃、乱序和主线程问题

还有一些零碎但很要命的小问题,我也一并列出来。Room 默认不允许在主线程执行数据库操作,如果你在 UI 线程直接调 databaseBuilder.build() 或执行查询,会立刻抛出异常。正确做法是保持 Dao 方法为 suspend 或者让 Room 自动切换到后台线程。PagingSource 的 load 方法也别自己再做耗时操作,Room 返回的 PagingSource 已经处理过线程调度的细节,你只要专注于业务逻辑就好。

排序不唯一也是分页场景里很容易出现的隐蔽 bug。假设你的查询只 ORDER BY createTime,而 createTime 存在大量相同值,那么两次分页之间这些同值记录的顺序是不确定的,可能出现同一行被加载两次或者漏掉。解决办法通常是在 ORDER BY 后面追加一个唯一字段,比如 id,写成 ORDER BY createTime DESC, id DESC。这一条对分页查询来说几乎是必修课。

最后说一句我从实战里总结的习惯:把所有参数先按保守值跑通功能,再逐步调优。刚开始我总想一步到位把分页参数、缓存策略全部配成最优,结果性能没调好,问题倒是一堆。先把一条链路打通,数据能从数据库刷到界面,再去动 pageSize 和 prefetchDistance,你才能真正感受到每个参数带来的变化。这套组合最迷人的地方也在这里:它把复杂的分页机制藏得很深,你不需要跟每一页的加载状态较劲,只要把数据源描述清楚,剩下的交还给框架。希望这篇基础使用笔记能帮你把这条路走顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询