「Jetpack - Paging3使用」
一、前言
Paging3,分页加载库,基于Paging2的基础上做了很大的改动,可以说完全是两个库,刚好现有的项目也用到了Paging2,可以说是痛并快乐着。而Paging3依然没有处理呼声最高的两个“需求”:局部增删的实现。当然到第三个版本仍然没有改动,Google肯定是有着自己的思考,这里是IssueTracker。
二、Paging优势
- 内置对错误处理功能,重试、刷新等。
- 对Kotlin协程和流Flow提供了一流的支持。
- 对于结合RecyclerView使用时,自带自动请求下一页,也就是分页功能,可以说是丝滑。
- 内置请求信息去重功能,避免流量浪费,资源利用率较高,同时支持内存缓存,处理分页时高效利用系统资源。
三、使用与结构分层
1.依赖
//引入依赖
dependencies {
def paging_version = "3.1.0"
implementation "androidx.paging:paging-runtime:$paging_version"
}
2.结构分层
一般主要层级会分为三层,请求层、ViewModel层、UI页面层。
- 请求层
主要是对数据源的定义与处理PagingSource,划重点Google推荐的是单一可信数据源,即是官方的Demo,配合网络请求与本地数据库,即是从网络获取数据也并不是直接显示到UI页面上。而是更新了数据库后,数据库的变动驱动UI页面的展示。确保了数据来源单一。这么做有什么好处与不足,一步步探讨。RemoteMediator主要是用来处理数据的分页,同时数据可以来自于网络与本地,起到了整合的作用。
- ViewModel层
数据配置Pager配合PagingConfig对数据流PagingData进行配置,如请求的数据size大小,是否开启null占位符等。
- UI层
适配器的定义PagingDataAdapter,分页处理加载数据的核心类。跟普通的适配器区别不是很大,但是需要配合DiffUtil对数据进行去重判断。
- 结构分层图
四、几个重要类
1.PagingSource
public abstract class PagingSource<Key : Any, Value : Any> {...}
PagingSource的实例用于为PagingData的实例加载数据页面,每次刷新数据都会有一个单独PagingData与之对应,而配合的DiffUtil则可以处理重复内容的去重工作。Key在请求网络数据时可以表示对应的页码,请求的是数据库的数据时也可以表示为位置Position。Value则是对应DTO或者PO,当然通常的项目中对于上层UI所使用的数据一般并不会直接使用原始数据。首先服务端返回的数据并不是都能够被完全用于UI,为了简洁都会通过Mapper做一次映射,转化成合理的VO数据。也即是DTO/PO ----Mapper<>–>VO.
2.RemoteMediator
@ExperimentalPagingApi
public abstract class RemoteMediator<Key : Any, Value : Any> {....}
//目前还是实验性的Api,在以后的版本更新中可能会存在变动,不是很建议使用在线上的环境中
协同网络数据与本地数据库Room,但是官方的推荐做法并不是直接使用网络数据作为数据源,是将网络数据缓存到本地数据库,由数据库担任唯一的数据源来驱动页面。实际开发过程中,数据是有实效性的,应该在合适的时机使本地数据失效而以服务端数据为主,并刷新到本地数据库。这就要定义初始化类型initialize:
//两种机制
public enum class InitializeAction {
LAUNCH_INITIAL_REFRESH,
SKIP_INITIAL_REFRESH
}
-
LAUNCH_INITIAL_REFRESH:完全刷新本地数据,会阻塞包括PREPEND、APPEND,直到全量刷新成功以后返回新的数据
-
SKIP_INITIAL_REFRESH:加载本地数据,跳过远程刷新。
对于RemoteMediator加载方法中Load里包含了一个参数LoadType,那么这个LoadType是什么呢?其实就是定义了刷新机制,集合RecyclerView的用户操作,不断上滑的过程中,Paging请求下一页的内容。或者切换了不同搜索条件那么自然是全量刷新,而查看已经加载过的数据,可以理解为从内存数据中加载某一段数据,也即是中间部分的数据。简单的里脊就是LoadType是用来监听UI操作的。
public enum class LoadType {
//全量刷新
REFRESH,
//从初始开始加载数据(PaingData)
PREPEND,
//从PagingData最后一条开始加载数据,需要从网络获取
APPEND
}
3.Pager
可以直接创建一个单纯的网络数据分页,同时也支持本地与网络共享的状态。唯一的区别就是需要提供数据库Room的查询方法,并且提供RemoteMediator实例。当然还包括一些配置条件PagingConfig,如网络加载数据一页的条目,是否开启null占位。
//...
val customDao = database.customDao()
val pager = Pager(
config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false),
remoteMediator = CustomRemoteMediator(query,service,database),
pagingSourceFactory = pagingSourceFactory
).flow
//....
companion object {
const val NETWORK_PAGE_SIZE = 50
}
五、官网Demo
官网的CodeLab基于Paging3与Room结合的方式实现了通过关键字从Github搜索代码仓库的小应用,跟着走一遍可以加深对Paging3的理解,当然目前某些Api还是实验性质的,需要等一等正式版。项目地址CodeLab,源码地址Github.编译的时候可能会报错,主要原因是因为Kotlin 1.6.0 版本在Room中(2.3.0)不支持使用 suspend @QUERY,需要升级Room的版本为2.4.0-alpha03。
官网的这个Demo将数据的唯一来源定为从Room中获取,网络数据缓存到本地,本地数据库的变动通知到UI页面的刷新。
整体数据获取结构分层:
- 数据库为单一可信数据来源Single Source of Truth,而Pager的构成部分包括RemoteMediator与本地数据库PagingSource。首先数据是从数据库获取的,当缓存的数据已经被完全加载完毕,会触发拉取远程数据并缓存到本地,本地数据的变更驱动UI完成刷新。
1.数据模型Model
服务端获取的数据实体定义,并新建数据表repos
@Entity(tableName = "repos")
data class Repo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String,
@field:SerializedName("full_name") val fullName: String,
@field:SerializedName("description") val description: String?,
@field:SerializedName("html_url") val url: String,
@field:SerializedName("stargazers_count") val stars: Int,
@field:SerializedName("forks_count") val forks: Int,
@field:SerializedName("language") val language: String?
)
2.数据库DB
远程键值表定义,
@Entity(tableName = "remote_keys")
data class RemoteKeys(
@PrimaryKey val repoId: Long,
val prevKey: Int?,
val nextKey: Int?
)
@Dao
interface RemoteKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKey: List<RemoteKeys>)
@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?
@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}
@Dao
interface RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
@Query(
"SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC"
)
fun reposByName(queryString: String): PagingSource<Int, Repo>
@Query("DELETE FROM repos")
suspend fun clearRepos()
}
@Database(
entities = [Repo::class, RemoteKeys::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
abstract fun remoteKeysDao(): RemoteKeysDao
companion object {
@Volatile
private var INSTANCE: RepoDatabase? = null
fun getInstance(context: Context): RepoDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE
?: buildDatabase(context).also { INSTANCE = it }
}
private fun buildDatabase(context: Context) =
Room.databaseBuilder(
context.applicationContext,
RepoDatabase::class.java, "Github.db"
).build()
}
}
3.核心GithubRemoteMediator
主要看load中的具体实现:
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
val page = when(loadType) {...}
val apiQuery = query + IN_QUALIFIER
try {
val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
val repos = apiResponse.items
val endOfPaginationReached = repos.isEmpty()
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (){...}
}
- page:加载的具体页,可以是全量即初始化时,通过loadType来确定,而loadType则是根据用户操作有关
- apiQuery:网络请求的参数,搜索的关键字
- 拿到服务端的结果后主要做了几件事:更新本地的远程键,简单的理解就是记录本次加载的“位置”或者“锚点”,这个位置对应远程服务端数据的位置,并将数据存储到本地(如果是全量刷新,本地数据会先本清空)。
4.项目具体源码与效果图
5.局部刷新
PagingAdapter继承自Recycler.Adapter,但是需要实现局部刷新可以通过 snapshot() 拿到一份只读数据的拷贝如:
fun refreshByPotion(position: Int, newItem: CustomVO?) {
if (position < 0 || position >= snapshot().size || null == newItem) {
return
}
snapshot()[position]?.age = newItem.age
snapshot()[position]?.name = newItem.name
notifyItemChanged(position)
}
6.思考
PagingAdapter并没有提供remove/add方法,这也是被一直诟病的点,但是Paging真的是一个垃圾的库嘛?其实不然,官方其实给出了它的使用场景,数据变动不大,即服务端数据变动频率不高,获取的数据以为展示为主,并没有太多的交互。那么及时在离线的情况下系统依然是可以运行良好的。普通的业务场景可能并合适使用Paging,那么我的理解,既然不合适就没有必要硬要往上套用,选择合适的库或组件将复杂的业务简单化而不是将简单的场景复杂化。
六、实际项目中使用
在餐饮行业中,餐厅一般都会有点餐系统,需要满足什么需求呢,离线可用。菜品会变动,需要及时更新,但是更新频率较低。总结如下:
- 本地需要保存菜品信息(Room数据库),保证离线时可用。
- 首次登录需要批量拉取服务端所有菜品信息保存到本地数据库。
- 菜品有下架,上架,售罄等状态,这里的变更基于MQTT通知实现,通过比对本地菜品的版本号码作全量或者增量刷新。
- 数据单一来源仅仅从本地数据库获取。
可以发现,这个场景下天然适合Paging库,而目前项目中还是使用的Paging2,并没有迁移到Paging3原因是目前还有很多实验性的Api。
1.基于Paging2实现
- 根据分类获取菜品,凉菜、热菜等
public LiveData<PagedList<DishSpuVO>> queryByCategoryId(String categoryId) {
StoreDB storeDB = StoreDBManage.getInstance().getDataBase();
if (storeDB == null) {
return null;
}
return new LivePagedListBuilder<>(storeDB
.DishSpuDAO()
.queryByCategoryId(categoryId)
.map(DishProductPO::transform),
50).build();
}
- 根据下发的通知同步本地菜品,并保存版本号(基于本地版本号与服务端版本号对比来决定是否更新数据)
@NotifyType(type = NotifyType.DISH_CHANGE)
public class DishChangeHandler implements INotify {
@Override
public void process(String jsonData) {
//根据版本号判断是否更新本地数据库,当数据库变动,会驱动Paging刷新UI
Flowable<Object> dishFlowable = SyncRepository.syncProduct(jsonData, 0L);
}
}
当然特定的场景使用Paging还是个不错的选择,官方预计短期内也不会考虑添加局部增删操作,分页库数据本身就是一个数据快照,如果作类似这种增量的增删操作,势必只能使原先PagingSource快照失效,设置新的数据快照,这显然浪费系统资源,性能上也会打折扣。