Android技术知识Android开发经验谈Android开发

Android Jetpack Paging3

2022-05-02  本文已影响0人  一个有故事的程序员

导语

Jetpack简介及其它组件文章
分页加载是开发中非常常用的一种场景,我们会采用RecyclerView来进行开发,自己来维护列表数据,通过改变数据刷新列表展示,Jetpack为我们提供了一个库——Paging,Paging也是经历了多次更新,到Paging3时,之前和之前完全不一样了。

主要内容

具体内容

什么是Paging3

Paging3,分页加载库,基于Paging2的基础上做了很大的改动,可以说完全是两个库,刚好现有的项目也用到了Paging2,可以说是痛并快乐着。而Paging3依然没有处理呼声最高的两个“需求”:局部增删的实现。当然到第三个版本仍然没有改动,Google肯定是有着自己的思考,这里是IssueTracker

Paging3的优势

使用与结构分层

依赖
//引入依赖
dependencies {
  def paging_version = "3.1.0"
  implementation "androidx.paging:paging-runtime:$paging_version"
}
结构分层

一般主要层级会分为三层,请求层、ViewModel层、UI页面层。

Paging3重要类

PagingSource

PagingSource的实例用于为PagingData的实例加载数据页面,每次刷新数据都会有一个单独PagingData与之对应,而配合的DiffUtil则可以处理重复内容的去重工作。Key在请求网络数据时可以表示对应的页码,请求的是数据库的数据时也可以表示为位置Position。Value则是对应DTO或者PO,当然通常的项目中对于上层UI所使用的数据一般并不会直接使用原始数据。首先服务端返回的数据并不是都能够被完全用于UI,为了简洁都会通过Mapper做一次映射,转化成合理的VO数据。也即是DTO/PO ----Mapper<>–>VO.

RemoteMediator

协同网络数据与本地数据库Room,但是官方的推荐做法并不是直接使用网络数据作为数据源,是将网络数据缓存到本地数据库,由数据库担任唯一的数据源来驱动页面。实际开发过程中,数据是有实效性的,应该在合适的时机使本地数据失效而以服务端数据为主,并刷新到本地数据库。这就要定义初始化类型initialize:

public enum class LoadType {
  //全量刷新
  REFRESH,
  //从初始开始加载数据(PaingData)
  PREPEND,
  //从PagingData最后一条开始加载数据,需要从网络获取
  APPEND
}
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完成刷新。

数据模型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?
)
数据库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()
    }
}
核心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 (){...}
}
项目具体源码与效果图
效果图
Github
局部刷新

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)
}
思考

PagingAdapter并没有提供remove/add方法,这也是被一直诟病的点,但是Paging真的是一个垃圾的库嘛?其实不然,官方其实给出了它的使用场景,数据变动不大,即服务端数据变动频率不高,获取的数据以为展示为主,并没有太多的交互。那么及时在离线的情况下系统依然是可以运行良好的。普通的业务场景可能并合适使用Paging,那么我的理解,既然不合适就没有必要硬要往上套用,选择合适的库或组件将复杂的业务简单化而不是将简单的场景复杂化。

实际项目中使用

在餐饮行业中,餐厅一般都会有点餐系统,需要满足什么需求呢,离线可用。菜品会变动,需要及时更新,但是更新频率较低。总结如下:

可以发现,这个场景下天然适合Paging库,而目前项目中还是使用的Paging2,并没有迁移到Paging3原因是目前还有很多实验性的Api。

基于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快照失效,设置新的数据快照,这显然浪费系统资源,性能上也会打折扣。

文档

Paging3 developer
CodeLab
Github

更多内容戳这里(整理好的各种文集)

上一篇下一篇

猜你喜欢

热点阅读