做客户端开发这些年,最绕不开的一个场景就是列表页。从服务端拉一批数据,塞进 RecyclerView,用户滑到底了再翻下一页,Cache 层再做一次去重合并。这个需求看着简单,落到工程上就会牵扯出数据库写入策略、分页加载状态、UI 刷新时机、内存抖动控制等一堆问题。Android Jetpack 里的 Room 和 Paging 3,一个管本地数据库读写,一个管按需分批加载,组合起来基本覆盖了离线优先架构下列表页的完整链路。这篇文章我准备从零开始,用一个小项目把这些组件的用法串起来讲,重点放在“为什么这么用”和“实际写代码时该注意什么”,适合正在学习 Jetpack 或者项目里准备接入分页功能的同学。
动手写代码之前,先把思路理清楚。我见过很多初学者一上来就照着文档敲两个标注注解的类,然后把 Paging 的 Adapter 绑上去就完事,但一旦遇到分页边界条件、数据库升级或者列表刷新场景就满脸问号。如果把这套架构拆开看,其实就三件事:Room 负责把数据稳稳地落进 SQLite,Paging 决定每批该取多少条、什么时候去取下一批,RecyclerView 负责把这些数据高效地渲染出来。
1. 为什么把 Room 和 Paging 放在一起用
1.1 从列表加载的痛点聊起
假设我们要写一个用户列表页。最简单的做法是把服务端接口返回的所有数据一次性加载到内存里,往 RecyclerView 一扔,完事。数据量小的时候怎么都行,但一旦数据量涨到几千条甚至几万条,问题就来了:接口响应会越来越慢,慢网络下用户看到的就是一直转圈的白屏;加载到内存的开销也不小,大列表还会导致滚动掉帧、内存占用飙升。
所以常规做法就是分页加载——每次只取 20 条或者 50 条,滑到底部了再去取下一批。服务端接口分页相对好写,传一个 page 或 offset 参数就行,但数据到了客户端,你还需要维护一个 ArrayList 不断往里面塞新数据,同时处理“加载中”“加载失败”“没有更多了”这些状态。更麻烦的是,如果列表数据需要缓存到本地数据库,每次分页拉回来的数据还要先写数据库、再从数据库读出来刷新 UI,这个流程如果全靠手写,状态管理很容易写出 bug。
1.2 Room 和 Paging 的分工逻辑
聊到这儿,Room 和 Paging 的分工就很清晰了。Room 是数据库层,帮你把 Java 对象和 SQLite 表结构建立映射,提供增删改查的接口,避免手写一堆 SQL 和 Cursor 转换代码。Paging 是列表加载层,它负责监控当前 RecyclerView 的滚动位置,当快滑到底部时,自动触发加载下一批数据。
这俩组件单独用都不算复杂,但组合起来才是完整方案:Paging 可以不依赖数据库,只从网络请求数据,但网络抖动时用户看到的列表可能会空一截或者出现错乱;Room 也可以不依赖 Paging,自己写 limit offset 查询来加载分页,但你需要手动处理各种加载状态。两者搭配,Room 带来的稳定性加上 Paging 带来的加载状态自动化,最终呈现出的是一个对用户非常平滑的列表体验。
1.3 和其他方案比,这套组合赢在哪
很多项目里还在用“手写分页 + 手写数据库”的方案,比如维护一个 offset/limit 变量、每次翻页时手动查询数据库。这种方案的缺点很明显:分页状态散落在 Activity 或者 ViewModel 里,配置变更(比如旋转屏幕)后容易丢状态;重复代码多,换一个列表页又得重写一遍。
Room 加 Paging 的做法则把分页逻辑放进了 DataSource 层,Paging 内部通过 StateFlow 维护当前加载状态,Activity / Fragment 只负责观察数据流并提交给 Adapter。对比之下,少了大量模板代码,并且 Room 还提供了对 PagingSource 的原生支持,数据库查询即可直接作为分页数据源,中间不需要自己做数据转换。当然没有说手写方案一定不行,在数据量极小、需求固定的场景下手写也许更灵活,但只要是中大型项目,组件化的优势会越来越明显。
2. Room 基础使用:先把数据库层立起来
2.1 环境配置与依赖引入
使用 Room 前,先在模块的 build.gradle 里加依赖。Room 2.6.1 是目前比较稳定的版本,它内部依赖了 SQLite 和协程,所以通常还需要加上 room-ktx 来获取协程相关的扩展方法。
android { // 建议开启 Java 8 支持 compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" }如果是 Kotlin 项目,记得在文件顶部加apply plugin: 'kotlin-kapt',确保编译器能处理 Room 的注解。如果项目使用 KSP,也可以把 kapt 换成 KSP 插件,速度更快,但配置方式稍有不同。
注意:Room 编译时会对 Entity、Dao 做类型检查,很多错误在写代码时不会标红,但一编译就报错。环境配置这一关没过,后面都是白搭。
2.2 Entity 实体定义
Entity 就是一张表结构。我这里写一个最简单的用户表:主键自增、用户名、年龄、头像地址。用@Entity注解标记类,tableName指定表名。如果不写 tableName,默认使用类名作为表名。
@Entity(tableName = "users") data class User( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "user_id") val userId: Long = 0L, @ColumnInfo(name = "user_name") val userName: String, val age: Int? = null, @ColumnInfo(name = "avatar_url") val avatarUrl: String? = null )这里有几个细节值得说道说道:
其一,@PrimaryKey(autoGenerate = true)表示主键自增,适合本地生成 ID 的场景。如果服务端返回的数据本身有唯一 ID,就不应该自增,直接用服务端 ID 作为主键。否则,同一份数据从服务端拉下来两次就会在数据库里产生两条记录。
其二,@ColumnInfo允许你为字段指定列名。数据库里用下划线命名法更常见也更清晰,比如avatar_url,而 Kotlin 属性用驼峰命名法avatarUrl,两者对应关系就在这个注解里声明。
其三,如果字段可为空,比如用户可能没有设置年龄,用Int?而不是Int。Room 会把 Kotlin 的可空类型映射为 SQLite 的可空列,这里如果搞错了,插入数据时可能直接闪退。
2.3 DAO 数据访问接口
DAO 是 Room 中与 SQL 打交道的核心接口。不用写实现类,Room 在编译时会自动生成实现代码。
@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUsers(users: List<User>) @Query("SELECT * FROM users ORDER BY user_id DESC LIMIT :limit OFFSET :offset") suspend fun getUsersByPage(limit: Int, offset: Int): List<User> @Query("DELETE FROM users") suspend fun clearAll() @Query("SELECT COUNT(*) FROM users") suspend fun getUserCount(): Int }@Insert注解帮我省掉了写INSERT INTO语句的过程。onConflict = OnConflictStrategy.REPLACE表示如果插入时发生主键冲突,直接替换旧数据,这在做服务端数据同步时非常常用。@Query注解中直接写 SQL,参数通过:limit这种冒号语法绑定到方法参数上。
2.3.1 关于 suspend 函数
上面示例里 DAO 方法都用了suspend修饰,表示这些方法只能在协程里调用。Room 会帮忙做线程切换,数据库的耗时不阻塞主线程。新手容易犯的错误是按照旧习惯调用dao.query()直接拿结果——在协程环境下这种代码编译都过不去。
2.3.2 关于返回类型
DAO 方法可以返回List<User>,也可以返回Flow<List<User>>。返回 Flow 时,只要表数据发生变化,Flow 会自动发出一份包含最新数据的 List,这是 Room 做响应式 UI 更新的基础。记住:Flow 返回的是“随着表变化而变化的数据流”,List 返回的是一次性快照。两者用途完全不同。
2.4 Database 与整体骨架
有了 Entity 和 DAO,接下来建 Database 类,把它作为整个数据库的入口。
@Database( entities = [User::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao 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_database.db" ) .build() .also { INSTANCE = it } } } } }@Database注解中,entities 列出所有实体类,version 是数据库版本号,用于之后的迁移升级。exportSchema = false表示不导出数据库 Schema 文件,如果在正式项目里建议开启并配置 schema 目录,这样数据库升级时你能看到完整的历史结构。
单例模式下用@Volatile修饰 INSTANCE,配合synchronized保证线程安全。这一步必须做,因为数据库实例在整个 App 生命周期内只需要一个,多个实例会带来连接资源浪费甚至数据不一致的问题。
2.5 一个不该省的细节:类型转换器
Room 默认支持的字段类型包括基本类型、String、ByteArray 等。如果你想把一个对象直接存进数据库,比如用户有一个List<String>的兴趣标签,Room 就会在编译时报错,提示没有对应类型的转换器。
解决办法是自定义 TypeConverter。举个例子,用 JSON 字符串存储一个 List 字段:
class Converters { @TypeConverter fun fromStringList(value: List<String>): String { return JSONArray(value).toString() } @TypeConverter fun toStringList(value: String): List<String> { val array = JSONArray(value) return List(array.length()) { i -> array.getString(i) } } }然后在 Database 类上通过@TypeConverters(Converters::class)注册。这样所有 Entity 中用到List<String>类型的字段,Room 都会自动走这两个方法完成序列化和反序列化。
这部分属于 Room 的进阶内容,但在实际项目中,没有类型转换器几乎是寸步难行的。一个数据类里多几个复杂类型字段,就必须配转换器,提前了解能省下不少排查编译错误的时间。
3. Paging 基础使用:分页加载的核心机制
3.1 Paging 3 的关键概念
Paging 组件从 2.0 到 3.0 变化很大。在 Paging 3 中,核心类有这么几个:
PagingSource:分页数据的来源,一个必须实现的抽象类,负责加载每一页的数据。PagingConfig:分页配置,如每页多少条、预加载几条、是否启用占位符。Pager:基于PagingSource和PagingConfig创建可观察数据流的工厂类。PagingData:实际传给 Adapter 的数据容器,内部承载着分页状态和页面数据。PagingDataAdapter:继承自 RecyclerView.Adapter,专门消费PagingData。
理解这套概念有个很简单的生活类比:把列表想象成长江流,PagingSource 是上游水库,负责按需放水;PagingConfig 是闸门大小和提前开闸距离;Pager 是控制中心;PagingData 是每一次放出来的水;PagingDataAdapter 是水渠,把水送到 RecyclerView 的每一个单元格里。
3.2 自定义 PagingSource
PagingSource 的 load 方法是核心。当列表滑动到预加载距离时,Paging 会调用它请求新一页数据。看个最通用的实现:
class UserPagingSource( private val userDao: UserDao ) : PagingSource<Int, User>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> { return try { val currentPage = params.key ?: 0 val offset = currentPage * params.loadSize val users = userDao.getUsersByPage(params.loadSize, offset) val nextKey = if (users.size == params.loadSize) { currentPage + 1 } else { null } LoadResult.Page( data = users, prevKey = if (currentPage > 0) currentPage - 1 else null, nextKey = nextKey ) } catch (e: Exception) { LoadResult.Error(e) } } }几个值得耐心的细节:
LoadParams里有三个重要字段:key是当前请求页的标识,loadSize是本次要加载的数据条数,placeholderEnabled是否启用占位符。Paging 会根据你设置的 pageSize 和当前滚动位置自动计算 loadSize,所以你不要在 load 方法里把这个参数写死。
LoadResult.Page接收三个参数:data 是本次加载的数据列表,nextKey 是接下来请求的 key。当返回的数据条数小于 loadSize,说明已经是最后一页,nextKey 返回 null 代表没有更多数据。
prevKey 是为了支持向上翻页,但要注意这里返回的是页码减一,如果使用 offset 模式,可能需要换成上一批数据的偏移位置。在只做下拉刷新但不做上翻加载的历史场景里,prevKey 用 null 也无所谓。
3.3 基于 Flow 的数据流接入
配置好 PagingSource 后,用 Pager 创建数据流:
class UserViewModel( private val userDao: UserDao ) : ViewModel() { val usersFlow: Flow<PagingData<User>> = Pager( config = PagingConfig( pageSize = 20, prefetchDistance = 5, enablePlaceholders = false ), pagingSourceFactory = { UserPagingSource(userDao) } ).flow.cachedIn(viewModelScope) }PagingConfig的参数含义如下:
pageSize:每个 Page 加载的数据量,相当于传统分页里的一页多少条。prefetchDistance:距离列表底部多少条时触发预加载。设 5 就是用户快看到倒数第 5 条时开始请求新一页。enablePlaceholders:是否在数据还没加载出来时先给列表项占个空位。开启占位符会影响列表项 count 的计算,通常建议设置为 false,避免 item 出现一闪而过的问题。
.flow让 Pager 返回 Flow,.cachedIn(viewModelScope)把分页数据流缓存在 ViewModel 的作用域中。这一步非常关键,如果少了 cachedIn,每次配置变更(比如旋转屏幕)后数据都会重新加载,用户体验会非常差。
3.4 适配器 PagingDataAdapter 用法
PagingDataAdapter 的写法和普通 RecyclerView.Adapter 基本一样,但必须配合 DiffUtil,否则列表更新时会闪屏甚至崩掉。
class UserAdapter : PagingDataAdapter<User, UserAdapter.UserViewHolder>(UserDiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder { val binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) return UserViewHolder(binding) } override fun onBindViewHolder(holder: UserViewHolder, position: Int) { val user = getItem(position) ?: return holder.bind(user) } class UserViewHolder(private val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(user: User) { binding.tvName.text = user.userName binding.tvAge.text = user.age?.toString() ?: "--" } } companion object { val UserDiffCallback = object : DiffUtil.ItemCallback<User>() { override fun areItemsTheSame(oldItem: User, newItem: User): Boolean { return oldItem.userId == newItem.userId } override fun areContentsTheSame(oldItem: User, newItem: User): Boolean { return oldItem == newItem } } } }这里有两点特别注意。
第一,在onBindViewHolder里使用getItem(position)获取数据时,返回值是可空的,因为 Paging 可能在异步加载数据时正在更新列表。用?: return安全处理即可。
第二,areItemsTheSame必须用主键做判断,areContentsTheSame才用内容全等做判断。DiffUtil 会先判断这是不是同一条数据,再判断内容有没有变化,以此决定是否需要刷新这条 item。主键判断写错了,会导致列表更新时频繁重新绑定,滑动时明显掉帧。
4. Room + Paging 整合实操:一个完整的用户分页列表
4.1 数据层设计:Room 原生 PagingSource 的妙处
上一节里我手写了 UserPagingSource 配合 limit offset 查询。这种写法没有任何问题,但还有更优雅的姿势,Room 官方支持直接返回PagingSource:
@Dao interface UserDao { @Query("SELECT * FROM users ORDER BY user_id DESC") fun getUserPagingSource(): PagingSource<Int, User> }看到区别没有——没有任何limit/offset参数,也没有suspend。Room 会在编译时自动生成一个实现,它内部会去管理 limit offset 的生成逻辑。这样 UserPagingSource 这个类全部删掉,ViewModel 里直接传入 Dao 的方法引用:
val usersFlow = Pager( config = PagingConfig( pageSize = 20, enablePlaceholders = false ) ) { userDao.getUserPagingSource() }.flow.cachedIn(viewModelScope)你以为这就完了?Room 生成的 PagingSource 还有更多隐藏价值:它内置了对数据库进度的跟踪,当表数据变化时会自动触发重新加载,并且由于 SQLite 本身有行号的概念,它用的是行号而不是传统的 offset 偏移量,所以在页面很大的时候性能比手写 limit offset 更稳定。
注意:手动写
PagingSource<Int, User>也有它的价值,比如你的数据源是网络,或者需要对服务器返回的数据做二次加工后再展示,这时候还是需要自定义。如果数据源就是 Room 表,优先用官方提供的直接返回方式,省心省力。
4.2 页面接入:从 ViewModel 到 RecyclerView
数据层搞定后,接页面。在 Fragment 或 Activity 中创建 Adapter,然后把 ViewModel 里的 Flow 收集起来:
class UserFragment : Fragment() { private val viewModel: UserViewModel by viewModels { UserViewModelFactory() } private lateinit var adapter: UserAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) adapter = UserAdapter() binding.recyclerView.adapter = adapter lifecycleScope.launch { viewModel.usersFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } } }collectLatest是协程 Flow 的一个操作符,它会在有新数据到达时取消上一个未完成的收集操作。在分页场景里极其合适:用户快速下滑时可能会触发多次submitData,使用 collectLatest 能保证始终以最新的数据为准。
viewModel的创建要用by viewModels,这是 Fragment 扩展,确保 ViewModel 跟随 Fragment 的 lifecycle。如果把viewModelScope里的 Flow 比作货架上的货,那cachedIn(viewModelScope)就是给货架上了锁,不管页面怎么重建,货物都还在架上。
4.3 参数细节:pageSize 与 prefetchDistance 的权衡
这个参数组合值得单独写一节,因为实际调优时我最常被问到的就是“为什么我的列表加载一直转圈”或者“为什么一次加载几十条数据还是卡顿”。
- pageSize 与 list 可见数量有关。手机屏幕一般能显示 10-15 条 item,那 pageSize 设 20 比较合适,够滑一段,也不会一次加载太多导致卡顿。
- 如果列表中 item 较复杂,比如每一条都包含图片,那么 pageSize 可以调小一些,比如 15,避免一次性加载太多图片造成内存压力。
- prefetchDistance 默认值是 pageSize,一般不需要改。如果网络慢、刷新频繁的场景,可以适当调大,提前多加载一页,减少用户看到 loading 的时间。但 prefetchDistance 设置太大,等于提前加载的可视范围外数据,反而失去分页的意义。
enablePlaceholders我前文建议设 false。开启占位符后,加载第一页的 item 数量会直接等于页面总数量,这会导致快速滚动时可能看到空 item,体验并不好。除非你有强烈的“统一列表高度”需求,否则用 false 就好。
5. 踩坑实录与排查技巧
5.1 kapt 与编译器版本不匹配
Room 在 Kotlin 项目中使用注解处理器,最常见的编译期问题就是 kapt 版本不兼容。比如 Kotlin 1.8 与 Room 2.6.1 的 kapt 组合正常,但升级 Kotlin 后如果没有同步升级 kapt 插件,就可能在编译时报诡异错误,类似UnsupportedOperationException或者Caused by: java.lang.IllegalStateException。
解决办法:确认 Kotlin 插件版本与 kapt 插件版本一致,并且 Room 版本不低于当前 Kotlin 支持的版本。多模块项目里,kapt 依赖和 Room 依赖必须在各模块统一版本,避免传递依赖冲突。
5.2 PagingSource 的 Key 参数类型陷阱
PagingSource 的泛型签名是PagingSource<Key, Value>,Key 类型可以是 Int、Long 甚至一个自定义的字符串对象。我们在手写实现时很容易把 key 类型设为 Int,但在 Room 原生生成的 PagingSource 中,作者类型是PagingSource<Int, User>,这两种 Key 类型如果混用,一定会出现运行期类型转换错误。
具体表现是:列表正常显示第一页,滑动到底部时突然抛异常,日志指向 load 方法内部某个位置,多半就是 Key 类型对不上。排查思路就是检查 Pager 与 PagingSource 的泛型是否完全一致,不要一个用 Int 一个用 Long 或者数据类。
5.3 数据库升级忘写 Migration
Room 表结构升级是另一个高频坑。比如 v1 的时候表里没有 age 字段,v2 加了 age 字段,如果只是把 Entity 里加上字段然后把 version 改成 2,没有提供 Migration,运行时会直接崩溃,错误信息提示A migration from 1 to 2 was required but not found。
正确做法是写一个 Migration 对象:
val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE users ADD COLUMN age INTEGER") } } Room.databaseBuilder(context, AppDatabase::class.java, "app_database.db") .addMigrations(MIGRATION_1_2) .build()这个坑在本项目里没体现出来,因为版本没升级过,但只要是上线的 App,几乎必然要走一遭。提前在心中留一个印象:version 变了,Migration 必须跟上。
5.4 提交数据后列表闪烁或者位置丢失
提交数据后列表会自动回顶部,或者滚动位置不对,常见原因有三:
数据库表里没有明确的主键排序方式,每次查询返回顺序不稳定,导致 DiffUtil 判断大量数据是新的,重新绑定了一片 item。解决办法是在查询 SQL 末尾加ORDER BY user_id DESC之类的稳定排序。
areItemsTheSame使用的唯一标识在数据更新后发生了变化,比如新旧数据的主键 id 字段没有沿用同一个值,就会导致 DiffUtil 认为这些都是新 item。尽量保证服务端数据和本地数据的 ID 是同一个概念。
最后,在submitData后手动调用了adapter.notifyDataSetChanged()。这是大忌,PagingDataAdapter会自己处理差异更新,再手动全量通知等于废弃了 DiffUtil 的优化。这个问题在老代码改造时容易混进来,记得检查。
5.5 刷新列表的正确姿势
列表首次加载很简单,页面创建后自动触发第一页加载。但下拉刷新是常态需求,怎么做才不破坏 Paging 的状态?
一种常见做法是给PagingSource一个可变的刷新键,或者继承PagingSource的 invalidate 机制。PagingDataAdapter 提供了refresh()方法,它会触发 PagingSource 失效并重新加载第一页:
binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() }adapter.refresh()会令 PagingSource 的 load 方法重新走一遍,从 key=0 开始拉数据,达到刷新效果。如果刷新后需要滚动到列表顶部,需要你在数据加载完成的回调里手动执行recyclerView.scrollToPosition(0)。
如果你做了下拉刷新后,列表位置没有回顶部,用户又快速往下翻,可能会看到旧数据和新增数据混杂,这种现象源自 Paging 内部的并行加载机制。最好在刷新完成后通过AdapterDataObserver监听一次数据变化,再去滚动列表。
最后分享两个实用习惯
这套组合用顺手之后,我个人的体会是:能用 Room 原生返回 PagingSource 就绝不手写 limit offset,能少暴露一个状态就少暴露一个状态。大部分时候我们并不需要在 View 层感知“当前正在加载第几页”,Paging 帮我们把页面状态抽象成了 Loading / Error / Empty 三种状态,业务代码里只需要响应这三种状态即可。
另一个小技巧是,给 PagingSource 加一个MediatorLiveData或者Flow来暴露列表底部的加载状态,实现加载更多失败后的重试按钮。Paging 3 官方提供了CombinedLoadStates,Adapter 里可以通过adapter.loadStateFlow观察到所有页面加载状态,在 Activity 里直接对它 collect,不过这个属于进阶话题了。
最后再补一句:分页和数据库缓存不是银弹,尤其在数据量超过十万条、服务端又经常分页调整排序规则的场景下,还是需要引入服务端游标分页之类的机制来配合。但绝大多数中小型 App 的需求,Room 加 Paging 的组合绰绰有余,先把它用扎实,后续再根据业务复杂度去扩展,路会顺很多。