Android Jetpack Room+Paging3列表分页加载实践
做客户端开发这些年最绕不开的一个场景就是列表页。从服务端拉一批数据塞进 RecyclerView用户滑到底了再翻下一页Cache 层再做一次去重合并。这个需求看着简单落到工程上就会牵扯出数据库写入策略、分页加载状态、UI 刷新时机、内存抖动控制等一堆问题。Android Jetpack 里的 Room 和 Paging 3一个管本地数据库读写一个管按需分批加载组合起来基本覆盖了离线优先架构下列表页的完整链路。这篇文章我准备从零开始用一个小项目把这些组件的用法串起来讲重点放在“为什么这么用”和“实际写代码时该注意什么”适合正在学习 Jetpack 或者项目里准备接入分页功能的同学。动手写代码之前先把思路理清楚。我见过很多初学者一上来就照着文档敲两个标注注解的类然后把 Paging 的 Adapter 绑上去就完事但一旦遇到分页边界条件、数据库升级或者列表刷新场景就满脸问号。如果把这套架构拆开看其实就三件事Room 负责把数据稳稳地落进 SQLitePaging 决定每批该取多少条、什么时候去取下一批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: ListUser) Query(SELECT * FROM users ORDER BY user_id DESC LIMIT :limit OFFSET :offset) suspend fun getUsersByPage(limit: Int, offset: Int): ListUser 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 方法可以返回ListUser也可以返回FlowListUser。返回 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 等。如果你想把一个对象直接存进数据库比如用户有一个ListString的兴趣标签Room 就会在编译时报错提示没有对应类型的转换器。解决办法是自定义 TypeConverter。举个例子用 JSON 字符串存储一个 List 字段class Converters { TypeConverter fun fromStringList(value: ListString): String { return JSONArray(value).toString() } TypeConverter fun toStringList(value: String): ListString { val array JSONArray(value) return List(array.length()) { i - array.getString(i) } } }然后在 Database 类上通过TypeConverters(Converters::class)注册。这样所有 Entity 中用到ListString类型的字段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 自定义 PagingSourcePagingSource 的 load 方法是核心。当列表滑动到预加载距离时Paging 会调用它请求新一页数据。看个最通用的实现class UserPagingSource( private val userDao: UserDao ) : PagingSourceInt, User() { override suspend fun load(params: LoadParamsInt): LoadResultInt, 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: FlowPagingDataUser 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 : PagingDataAdapterUser, 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.ItemCallbackUser() { 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 官方支持直接返回PagingSourceDao interface UserDao { Query(SELECT * FROM users ORDER BY user_id DESC) fun getUserPagingSource(): PagingSourceInt, 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 更稳定。注意手动写PagingSourceInt, 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 的泛型签名是PagingSourceKey, ValueKey 类型可以是 Int、Long 甚至一个自定义的字符串对象。我们在手写实现时很容易把 key 类型设为 Int但在 Room 原生生成的 PagingSource 中作者类型是PagingSourceInt, User这两种 Key 类型如果混用一定会出现运行期类型转换错误。具体表现是列表正常显示第一页滑动到底部时突然抛异常日志指向 load 方法内部某个位置多半就是 Key 类型对不上。排查思路就是检查 Pager 与 PagingSource 的泛型是否完全一致不要一个用 Int 一个用 Long 或者数据类。5.3 数据库升级忘写 MigrationRoom 表结构升级是另一个高频坑。比如 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 方法重新走一遍从 key0 开始拉数据达到刷新效果。如果刷新后需要滚动到列表顶部需要你在数据加载完成的回调里手动执行recyclerView.scrollToPosition(0)。如果你做了下拉刷新后列表位置没有回顶部用户又快速往下翻可能会看到旧数据和新增数据混杂这种现象源自 Paging 内部的并行加载机制。最好在刷新完成后通过AdapterDataObserver监听一次数据变化再去滚动列表。最后分享两个实用习惯这套组合用顺手之后我个人的体会是能用 Room 原生返回 PagingSource 就绝不手写 limit offset能少暴露一个状态就少暴露一个状态。大部分时候我们并不需要在 View 层感知“当前正在加载第几页”Paging 帮我们把页面状态抽象成了 Loading / Error / Empty 三种状态业务代码里只需要响应这三种状态即可。另一个小技巧是给 PagingSource 加一个MediatorLiveData或者Flow来暴露列表底部的加载状态实现加载更多失败后的重试按钮。Paging 3 官方提供了CombinedLoadStatesAdapter 里可以通过adapter.loadStateFlow观察到所有页面加载状态在 Activity 里直接对它 collect不过这个属于进阶话题了。最后再补一句分页和数据库缓存不是银弹尤其在数据量超过十万条、服务端又经常分页调整排序规则的场景下还是需要引入服务端游标分页之类的机制来配合。但绝大多数中小型 App 的需求Room 加 Paging 的组合绰绰有余先把它用扎实后续再根据业务复杂度去扩展路会顺很多。