Android Paging库使用详解(小结)

作者:袖梨 2022-06-25

Android分页包能够更轻易地在RecyclerView里面缓慢且优雅地加载数据.

许多应用从数据源消耗数据, 数据源里面有大量的数据, 但是一次却只展示一小部分.

分页包帮助应用观测和展示大量数据的合理数目的子集. 这个功能有如下几个优势:

  • 数据请求消耗更少的网络带宽和系统资源.
  • 即使在数据更新期间, 应用依然对用户输入响应迅速.

添加分页依赖

按照如下代码添加依赖:

dependencies {
  def paging_version = "1.0.0"

  implementation "android.arch.paging:runtime:$paging_version"

  // alternatively - without Android dependencies for testing
  testImplementation "android.arch.paging:common:$paging_version"

  // optional - RxJava support, currently in release candidate
  implementation "android.arch.paging:rxjava2:1.0.0-rc1"
}

备注: 分页包帮助开发者在UI的列表容器中顺畅地展示数据, 而不管是使用设备内部的数据库还是从应用后端拉取数据.

库架构

分页库的核心构件是PagedList类, 它是一个集合, 用于异步加载应用数据块或者数据页. 该类在应用的其它架构之间充当中介.

Data

每一个PagedList实例从DataSource中加载最新的应用数据. 数据从应用后端或者数据库流入PagedList对象. 分页包支持多样的应用架构, 包括脱机数据库和与后台服务器通讯的数据库.

UI

PagedList类通过PagedListAdapter加载数据项到RecyclerView里面. 在加载数据的时候, 这些类协同工作, 拉取数据并展示内容, 包括预取看不见的内容并在内容改变时加载动画.

支持不同的数据架构

分页包支持应用架构, 包括应用拉取数据的地方是从后台服务器, 还是本机数据库, 还是两者的结合.

只有网络

要展示后台数据, 需要使用Retrofit的同步版本, 加载信息到自定义的DataSource对象中.
备注: 分页包的DataSource对象并没有提供任何错误处理机制, 因为不同的应用需要用不同的方式处理和展示UI错误. 如果错误发生了, 顺从结果的回调, 然后稍后重试.

只有数据库

要设置RecyclerView观测本地存储, 偏向于使用Room持久化库. 用这种方式, 无论任何时候数据库数据插入或者修改, 这些改变会自动地在负责展示这些数据的RecyclerView展示出来.

网络+数据库

在开始观测数据库之后, 你能够通过使用PagedList.BoundaryCallback来监听数据库什么时候过期. 之后, 你可能从网络拉取更多的数据, 并把它们插入到数据库中. 如果UI正在展示数据库, 以上就是你所需要做的全部.

下面的代码片断展示了BoundaryCallback的使用实例:

class ConcertViewModel {
  fun search(query: String): ConcertSearchResult {
    val boundaryCallback =
        ConcertBoundaryCallback(query, myService, myCache)
    // Error-handling not shown in this snippet.
    val networkErrors = boundaryCallback.networkErrors
  }
}

class ConcertBoundaryCallback(
    private val query: String,
    private val service: MyService,
    private val cache: MyLocalCache
) : PagedList.BoundaryCallback() {
  override fun onZeroItemsLoaded() {
    requestAndSaveData(query)
  }

  override fun onItemAtEndLoaded(itemAtEnd: Concert) {
    requestAndSaveData(query)
  }
}

处理网络错误

在使用网络拉取或者分页的数据, 而这些数据正在使用分页包展示的时候, 不总是把网络分为要么"可用"要么"不可能"是很重要的, 因为许多连接是间歇性或者成片的:

  • 特定的服务器可能不能响应网络请求;
  • 设备可能联接了慢的或者弱的网络;

应用应该检查每一个请求是否成功, 并且在网络不可用的情形下, 尽可能快地恢复. 比如, 你可以为用户提供一个"重试"按钮, 如果数据没有刷新成功的话. 如果在数据分页期间发生错误, 最好自动地重新分页请求.

更新已有应用

如果应用已经从网络或者数据库消费数据, 很大可能可以直接升级到分页库提供的功能.

自定义分页解决方案

如果你使用了自定义功能加载数据源中的小的数据集, 你可以使用PagedList类取代这个逻辑. PagedList类实例提供了内建的连接, 到通用的数据源. 这些实例也提供了在应用中引用的RecyclerView的适配器.

使用列表而非分页加载的数据

如果你使用内存里的列表作为UI适配器的后备数据结构, 考虑使用PagedList类观测数据更新, 如果列表中数据项变得很多的话. PagedList实例既可以使用LiveData也可以使用Observable对UI传递数据更新, 同时最小化了加载时间和内存使用. 然而, 应用中使用PagedList对象代替List并不要求对UI结构和数据更新逻辑作任何改变.

使用CursorAdapter将数据cursor与列表视图联系起来

应用也许会使用CursorAdapter将数据从Cursor跟ListView连接起来. 在这种情况下, 通常需要从ListView迁移到RecyclerView, 然后使用Room或者PositionalDataSource构件代替Cursor, 当然, 这主要依据于Cursor实例能否访问SQLite数据库.

在一些情况下, 比如使用Spinner实例的时候, 你仅仅提供了Adapter本身. 然后一个库使用了加载进adapter中的数据, 并展示了数据. 在这些情况下, 把adapter数据类型转化为LiveData, 之后在尝试使用将这些数据项在UI中填充起来之前, 将这个列表在ArrayAdapter对象中包裹起来.

使用AsyncListUtil异步加载内容

如果你在使用AsyncListUtil对象异步地加载和展示分组信息的话, 分页包将会使得加载数据更加方便:

  • 数据并不需要定位. 分页包让你直接从后台使用网络提供的键加载数据.
  • 数据量太大. 使用分页包可以将数据加载分页直到没有任何数据留下.
  • 更方便地观测数据. 分页包能够展示应用在可观测数据结构中持有的ViewModel.

 数据库例子

 使用LiveData观测分页数据

下面的示例代码展示了所有一起工作的碎片. 当演唱会事件在数据库中添加, 删除或者修改的修改的时候, RecyclerView中的内容自动且高效地更新:

@Dao
interface ConcertDao {
  // The Integer type parameter tells Room to use a PositionalDataSource
  // object, with position-based loading under the hood.
  @Query("SELECT * FROM user ORDER BY concert DESC")
  fun concertsByDate(): DataSource.Factory
}

class MyViewModel(concertDao: ConcertDao) : ViewModel() {
  val concertList: LiveData> = LivePagedListBuilder(
      concertDao.concertsByDate(),
      /* page size */ 20
  ).build()
}

class MyActivity : AppCompatActivity() {
  public override fun onCreate(savedState: Bundle?) {
    super.onCreate(savedState)
    val viewModel = ViewModelProviders.of(this)
        .get(MyViewModel::class.java!!)
    val recyclerView = findViewById(R.id.concert_list)
    val adapter = ConcertAdapter()
    viewModel.concertList.observe(this, { pagedList ->
        adapter.submitList(pagedList) })
    recyclerView.setAdapter(adapter)
  }
}

class ConcertAdapter() :
    PagedListAdapter(DIFF_CALLBACK) {
  fun onBindViewHolder(holder: ConcertViewHolder, position: Int) {
    val concert = getItem(position)
    if (concert != null) {
      holder.bindTo(concert)
    } else {
      // Null defines a placeholder item - PagedListAdapter automatically
      // invalidates this row when the actual object is loaded from the
      // database.
      holder.clear()
    }
  }

  companion object {
    private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
      // Concert details may have changed if reloaded from the database,
      // but ID is fixed.
      override fun areItemsTheSame(oldConcert: Concert,
          newConcert: Concert): Boolean =
          oldConcert.id == newConcert.id

      override fun areContentsTheSame(oldConcert: Concert,
          newConcert: Concert): Boolean =
          oldConcert == newConcert
    }
  }
}

使用RxJava2观测分页数据

如果你偏爱使用RxJava2而非LiveData, 那么你可以创建Observable或者Flowable对象:

 class MyViewModel(concertDao: ConcertDao) : ViewModel() {
   val concertList: Flowable> = RxPagedListBuilder(
       concertDao.concertsByDate(),
       /* page size */ 50
   ).buildFlowable(BackpressureStrategy.LATEST)
 }

之后你可以按照如下代码开始和停止观测数据:

class MyActivity : AppCompatActivity() {
  private lateinit var adapter: ConcertAdapter
  private lateinit var viewModel: MyViewModel

  private val disposable = CompositeDisposable()

  public override fun onCreate(savedState: Bundle?) {
    super.onCreate(savedState)
    val recyclerView = findViewById(R.id.concert_list)
    viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java!!)
    adapter = ConcertAdapter()
    recyclerView.setAdapter(adapter)
  }

  override fun onStart() {
    super.onStart()
    disposable.add(viewModel.concertList.subscribe({
        flowableList -> adapter.submitList(flowableList)
    }))
  }

  override fun onStop() {
    super.onStop()
    disposable.clear()
  }
}

基于RxJava2解决方案的ConcertDao和ConcertAdapter代码, 和基于LiveData解决方案的代码是一样的.

UI构件及其出发点

将UI和视图模型联接起来 

你可以按照如下方式, 将LiveData实例跟PagedListAdapter联系起来:

private val adapter = ConcertPagedListAdapter()
private lateinit var viewModel: ConcertViewModel

override fun onCreate(savedInstanceState: Bundle?) {
  viewModel = ViewModelProviders.of(this)
      .get(ConcertViewModel::class.java)
  viewModel.concerts.observe(this, adapter::submitList)
}

当数据源提供一个新PagedList实例的时候, activity会将这些对象改善给adapter. PagedListAdapter实现, 定义了更新如何计算, 自动地处理分页和列表不同. 由此, 你的ViewHolder只需要绑定到特定的提供项:

class ConcertPagedListAdapter() : PagedListAdapter(
    object : DiffUtil.ItemCallback() {
  // The ID property identifies when items are the same.
  override fun areItemsTheSame(oldItem: Concert, newItem: Concert)
      = oldItem.id = newItem.id

  // Use the "==" operator (or Object.equals() in Java-based code) to know
  // when an item's content changes. Implement equals(), or write custom
  // data comparison logic here.
  override fun areContentsTheSame(oldItem: Concert, newItem: Concert) =
      oldItem.name == newItem.name && oldItem.date == newItem.date
  }
) {
  override fun onBindViewHolder(holder: ConcertViewHolder, position: Int) {
    val concert: Concert? = getItem(position)

    // Note that "concert" is a placeholder if it's null
    holder.bind(concert)
  }
}

PagedListAdapter使用PagedList.Callback对象处理分页加载事件. 当用户滑动时, PagedListAdapter调用PagedList.loadAround()方法将从DataSource中拉聚拢数据项提示提供给基本的PagedList.
备注: PageList是内容不可变的. 这意味着, 尽管新内容能够被加载到PagedList实例中, 但已加载项一旦加载完成便不能发生改变. 由此, 如果PagedList中的内容发生改变, PagedListAdapter对象将会接收到一个包含已更新信息的全新的PagedList.

实现diffing回调

先前的代码展示了areContentsTheSame()的手动实现, 它比较了对象的相关的域. 你也可以使用Java中的Object.equals()方法或者Kotlin中的==操作符. 但是要确保要么实现了对象中的equals()方法或者使用了kotlin中的数据对象.

使用不同的adapter类型进行diffing

如果你选择不从PagedListAdapter继承--比如你在使用一个提供了自己的adapter的库的时候--你依然可以通过直接使用AsyncPagedListDiffer对象使用分页包adapter的diffing功能.

在UI中提供占位符

在应用完成拉取数据之前, 如果你想UI展示一个列表, 你可以向用户展示占位符列表项. RecyclerView通过将列表项临时地设置为null来处理这个情况.

备注: 默认情况下, 分页包开启了占位符行为.

占位符有如下好处:

  • 支持scrollbar. PagedList向PagedListAdapter提供了大量的列表项. 这个信息允许adapter绘制一个表示列表已满的scrollbar. 当新的页加载时, scrollbar并不会跳动, 因为列表是并不没有改变它的size.
  • 不需要"正在加载"旋转指针. 因为列表大小已知, 没必要提醒用户有更多的数据项正在加载. 占位符本身表达了这个信息.

在添加占位符的支持之前, 请牢记以下先置条件:

  • 要求集合中数据可数. 来自Room持久化库的DataSource实例能够高效地计算数据项. 然而, 如果你在用自定义本地存储方案或者只有网络的数据架构, 想了解数据集中有多少数据项可能代价很高, 甚至不可能.
  • 要求adapter负责未加载数据项. 你正在使用的adapter或者展示机制来准备填充列表, 需要处理null列表项. 比如, 当将数据绑定到ViewHolder的时候, 你需要提供默认值表示未加载数据.
  • 要求数据相同数量的item view. 如果列表项数目能够基于内容发生改变, 比如, 社交网络更新, 交叉淡入淡出看起来并不好. 在这种情况下, 强烈推荐禁掉占位符.

数据构件及其出发点

构建可观测列表

通常情况下, UI代码观测LiveData对象(或者, 如果你在使用RxJava2, 是Flowable/Observable对象), 这个对象存在于应用的ViewModel中. 这个可观测对象形成了应用列表数据内容和展示的连接.

要创建这么一个可观测PagedList对象, 需要将DataSource.Factory实例传给LivePageListBuilder/RxPagedListBuilder对象. 一个DataSource对象对单个PagedList加载分页. 这个工厂类为内容更新创建PagedList实例, 比如数据库表验证, 网络刷新等. Room持久化库能够提供DataSource.Factory, 或者自定义.

如下代码展示了如何在应用的ViewModel类中使用Room的DataSource.Factory构建能力创建新的LiveData实例:

ConcertDao.kt:

interface ConcertDao {
   // The Integer type parameter tells Room to use a PositionalDataSource
   // object, with position-based loading under the hood.
   @Query("SELECT * FROM concerts ORDER BY date DESC")
   public abstract DataSource.Factory concertsByDate()
 }

ConcertViewModel.kt:

// The Integer type argument corresponds to a PositionalDataSource object.
val myConcertDataSource : DataSource.Factory =
    concertDao.concertsByDate()

val myPagedList = LivePagedListBuilder(myConcertDataSource, /* page size */ 20)
    .build()

定义分页配置

要想为复杂情形更深入地配置LiveData, 你也可以定义自己的分页配置. 尤其是, 你可以定义如下属性:

  • 页大小: 每一页的数据量.
  • 预取距离: 给定UI中最后可见项, 超过该项之后多少项, 分页包要尝试提前提取数据. 这个值应该比page size大几倍.
  • 占位符展示: 决定了UI是否会为还没有完成加载的数据项展示占位符.

如果你想要对分布包从数据库加载中设置更多的控件, 要像下面的代码一样, 传递自定义的Executor对象给LivePagedListBuilder:

EventViewModel.kt:

val myPagingConfig = PagedList.Config.Builder()
    .setPageSize(50)
    .setPrefetchDistance(150)
    .setEnablePlaceholders(true)
    .build()

// The Integer type argument corresponds to a PositionalDataSource object.
val myConcertDataSource : DataSource.Factory =
    concertDao.concertsByDate()

val myPagedList = LivePagedListBuilder(myConcertDataSource, myPagingConfig)
    .setFetchExecutor(myExecutor)
    .build()

选择正确的数据源类型

连接更最好地处理源数据结构的数据源很重要:

  • 如果加载的页嵌套了之前/之后页的key的话, 使用PageKeyDataSource. 比如, 比如你正在从网络中拉取社交媒体博客, 你也许需要传递从一次加载向下一次加载的nextPage token.
  • 如果需要使用每N项数据项的数据拉取每N+1项的话, 使用ItemKeyedDataSource. 比如, 你在为一个讨论型应用拉取螺纹评论, 你可能需要传递最后一条评论的ID来获取下一条评论的内容.
  • 如果你需要从数据商店中的任意位置拉取分页数据的话, 使用PositionalDataSource. 这个类支持请求任意位置开始的数据集. 比如, 请求也许返回从位置1200开始的20条数据.

通知数据非法

在使用分页包时, 在表或者行数据变得陈腐时, 取决于数据层来通知应用的其它层. 要想这么做的话, 需要从DataSource类中调用invalidate()方法.

备注: UI也可以使用"滑动刷新"模式来触发数据非法功能.

构建自己的数据源

如果你使用了自定义的数据解决方案, 或者直接从网络加载数据, 你可以实现一个DataSource子类. 下面的代码展示了数据源从给定的concert起始时间切断:

class ConcertTimeDataSource(private val concertStartTime: Date) :
    ItemKeyedDataSource() {
  override fun getKey(item: Concert) = item.startTime

  override fun loadInitial(
      params: LoadInitialParams,
      callback: LoadInitialCallback) {
    val items = fetchItems(concertStartTime, params.requestedLoadSize)
    callback.onResult(items)
  }

  override fun loadAfter(
      params: LoadParams,
      callback: LoadCallback) {
    val items = fetchItemsAfter(
      date = params.key,
      limit = params.requestedLoadSize)
    callback.onResult(items)
  }
}

通过创建真实的DataSource.Factory子类, 你之后能够加载自定义的数据到PagedList对象. 下面的代码展示了如何创建在之前代码中定义的自定义数据源:

class ConcertTimeDataSourceFactory(private val concertStartTime: Date) :
    DataSource.Factory() {
  val sourceLiveData = MutableLiveData()
  override fun create(): DataSource {
    val source = ConcertTimeDataSource(concertStartTime)
    sourceLiveData.postValue(source)
    return source
  }
}

考虑内容更新

当你构建可观测PagedList对象的时候, 考虑一下内容是如何更新的. 如果你直接从Room数据库中加载数据, 更新会自动地推送到UI上面.

如果你在使用分页的网络API, 通常你会有用户交互, 比如"滑动刷新", 把它作为信号去验证当前DataSource非法并请求一个新的. 这个行为出行在下面的代码中:

class ConcertActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    ...

    concertViewModel.refreshState.observe(this, Observer {
      swipeRefreshLayout.isRefreshing =
          it == NetworkState.LOADING
    })
    swipeRefreshLayout.setOnRefreshListener {
      concertViewModel.invalidateDataSource()
    }
  }
}

提供数据表现之间的映射

对于DataSource加载的数据, 分页包支持基于数据项和基于页的转换.

下面的代码中, concert名和日期的联合被映射成包含姓名和日期的字符串:

class ConcertViewModel : ViewModel() {
  val concertDescriptions : LiveData>
    init {
      val factory = database.allConcertsFactory()
          .map { concert ->
              concert.name + " - " + concert.date
          }
      concerts = LivePagedListBuilder(factory, 30).build()
    }
  }
}

如果在数据加载之后, 想要包裹, 转换或者准备item, 这将非常有用. 因为这个工作是在获取执行器中完成的, 你可以在其中执行花销巨大的工作, 比如, 从硬盘中读取, 查询数据库等.

备注: JOIN查询总是比作为map()一部分的查询要高效.

相关文章

精彩推荐