新的 Paging Library 成为了 Architecture Components 的一部分。虽然现在还是alpha阶段,但是无疑你已经开始准备尝试了!我不准备全去讲它的用法,因为本文只是对Chris Craik 这篇文章的补充。
因为官方的示例第一眼看上去好像它只能跟 Room 一起使用,如果我们不需要Room的话,可能就不想用它了。让我们看一个简单的例子,证明其实并不是这样。
假设我们想写一些测试应用来测试我们的API。我们不想使用任何的数据库或者存储,但是仍然希望高效的做这件事情,不让它一次性加载完所有数据,尽管目的只是测试,那也是相当恐怖的。你第一时间会想到什么?是不是 onScrollListener 之类的技术 , 或者是在 onBindViewHolder 中判断是否应该开始获取数据?总之,你是在思考如何按需获取分页数据。但是你在思考的时候忘记了参考我们的API。好了,让我们看看可以从这个库中得到什么。
我们准备用 Kitsu API作为例子,任务很简单,就是用列表显示API获取的内容,是的我们的API支持分页。
首先我们需要一个带有列表的Activity:
class KitsuMainActivity : AppCompatActivity() { private val viewModel by lazy { ViewModelProviders.of(this).get(KitsuViewModel::class.java) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) initFlysearch(savedInstanceState) searchForResults("Android") } private fun initKitsu() { val kitsuAdapter = KitsuPagedListAdapter() searchResultsRecyclerView.adapter = kitsuAdapter viewModel.allKitsu.observe(this, Observer(kitsuAdapter::setList)) } private fun searchForResults(queryFilter: String) { viewModel.setQueryFilter(queryFilter) initKitsu() } }
KitsuMainActivity.kt hosted with ❤ by GitHub
没什么特别之处。只有一个简单的ViewModel。
现在让我们看看ViewModel的实现:
class KitsuViewModel(app: Application) : AndroidViewModel(app) { private var allKitsuLiveData: LiveData<PagedList<KitsuItem>>? = null val allKitsu: LiveData<PagedList<KitsuItem>> get() { if (null == allKitsuLiveData) { allKitsuLiveData = KitsuMediaPagedListProvider.allKitsu().create(0, PagedList.Config.Builder() .setPageSize(PAGED_LIST_PAGE_SIZE) .setInitialLoadSizeHint(PAGED_LIST_PAGE_SIZE) .setEnablePlaceholders(PAGED_LIST_ENABLE_PLACEHOLDERS) .build())!! } return allKitsuLiveData ?: throw AssertionError("Check your threads ...") } fun setQueryFilter(queryFilter: String) { KitsuMediaPagedListProvider.setQueryFilter(queryFilter) allKitsuLiveData = null // invalidate } companion object { private const val PAGED_LIST_PAGE_SIZE = 20 private const val PAGED_LIST_ENABLE_PLACEHOLDERS = false } }
KitsuViewModel.kt hosted with ❤ by GitHub
这里要注意的是我们把placeholders禁用了(setEnablePlaceholders(false)),为什么要这样做呢?因为如果你要用placeholders来显示empty view的话,我们必须指定一个明确的item数目,而这里无法知道到底有多少个item。实际上item的个数我们使用的是 (DataSource#COUNT_UNDEFINED) 。
让我们来看看 PagedList 的provider:
object KitsuMediaPagedListProvider { private val dataSource = object: KitsuLimitOffsetNetworkDataSource<KitsuItem>(KitsuRestApi) { override fun convertToItems(items: KitsuResponse, size: Int): List<KitsuItem> { return List(size, { index -> items.data.elementAtOrElse(index, { KitsuItem(0, null, null) }) }) } } fun allKitsu(): LivePagedListProvider<Int, KitsuItem> { return object : LivePagedListProvider<Int, KitsuItem>() { override fun createDataSource(): KitsuLimitOffsetNetworkDataSource<KitsuItem> = dataSource } } fun setQueryFilter(queryFilter: String) { dataSource.queryFilter = queryFilter } }
rawKitsuMediaPagedListProvider.kt hosted with ❤ by GitHub
这里我们创建了一个自定义的DataSource对象,实现了它的抽象方法convertToItems(items: KitsuResponse, size: Int),该方法用于将从数据源获得的数据转换成List。为了能够改变查询的关键词,我们把它作为一个单独的变量。最后我们使用这个datasource创建 LivePagedListProvider ,稍后我们将使用它来创建LiveData。你可能也注意到了,这里我们传入了 API object ,使用 Retrofit 来获取数据。
数据是什么样的呢?并不神秘,只是从Retrofit调用转换而来的简单的数据:
class KitsuResponse( val data: List<KitsuItem>) data class KitsuItem( val id: Int, val type: String?, val attributes: KitsuItemAttributes?) data class KitsuItemAttributes( val synopsis: String?, val subtype: String?, val titles: KitsuItemAttributesTitles?, val posterImage: KitsuItemAttributesImage?) data class KitsuItemAttributesTitles( val en_jp: String?) data class KitsuItemAttributesImage( val small: String?)
KitsuData.kt hosted with ❤ by GitHub
现在该看看我们自定义的DataSource抽象类长什么样了:
abstract class KitsuLimitOffsetNetworkDataSource<T> protected constructor( val dataProvider: KitsuRestApi) : TiledDataSource<T>() { var queryFilter: String = "" override fun countItems(): Int = DataSource.COUNT_UNDEFINED protected abstract fun convertToItems(items: KitsuResponse, size: Int): List<T> override fun loadRange(startPosition: Int, loadCount: Int): List<T>? { val response = dataProvider.getKitsu(queryFilter, startPosition, loadCount).execute().body() return convertToItems(response, response.data.size) } }
KitsuLimitOffsetNetworkDataSource.kt hosted with ❤ by GitHub
abstract class KitsuLimitOffsetNetworkDataSource<T> protected constructor( val dataProvider: KitsuRestApi) : TiledDataSource<T>() { var queryFilter: String = "" override fun countItems(): Int = DataSource.COUNT_UNDEFINED protected abstract fun convertToItems(items: KitsuResponse, size: Int): List<T> override fun loadRange(startPosition: Int, loadCount: Int): List<T>? { val response = dataProvider.getKitsu(queryFilter, startPosition, loadCount).execute().body() return convertToItems(response, response.data.size) } }
KitsuLimitOffsetNetworkDataSource.kt hosted with ❤ by GitHub
为了方便起见我们使用 TiledDataSource ,但是这可能不是正确的方式,因为TiledDataSource需要提供明确的item数目。但是因为我们禁用了placeholder,所以它将被转换成ContiguousDataSource,然后一切就很方便了。
因为我们的API是支持分页的,所以你会发现DataSource的 loadRange 方法实现起来太简单了。而且在loadRange方法中做耗时操作也是可以的,因为它是在后台线程被调用的。
api调用的相关代码是这样的:
object KitsuRestApi { private val kitsuApi: KitsuSpecApi init { val retrofit = Retrofit.Builder() .baseUrl("https://kitsu.io/api/edge/") .addConverterFactory(MoshiConverterFactory.create()) .build() kitsuApi = retrofit.create(KitsuSpecApi::class.java) } fun getKitsu(filter: String, offset: Int, limit: Int): Call<KitsuResponse> { return kitsuApi.filterKitsu(filter, limit, offset) } } interface KitsuSpecApi { @GET("anime") fun filterKitsu( @Query("filter[text]") filter: String, @Query("page[limit]") limit: Int, @Query("page[offset]") offset: Int): Call<KitsuResponse> }
KitsuAPI.kt hosted with ❤ by GitHub
我觉得到这里就基本完成了。再来看看UI部分的Adapter 和 ViewHolder:
class KitsuViewHolder(parent :ViewGroup) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.kitsu_item, parent, false)) { var item : KitsuItem? = null fun bindTo(item : KitsuItem?) { this.item = item itemView.itemTypeView.text = item?.type?.capitalize() ?: "Ouhh..." itemView.itemSubtypeView.text = item?.attributes?.subtype?.capitalize() ?: "Ouhhhhh..." itemView.itemNameView.text = item?.attributes?.titles?.en_jp?.capitalize() ?: "Ouhhhhhhhh..." itemView.itemSynopsisView.text = item?.attributes?.synopsis?.capitalize() ?: "Ouhhhhhhhhhhh...\nYou know what?\nThe quick brown fox jumps over the lazy dog!" val imageUrl = item?.attributes?.posterImage?.small if (null != imageUrl) { itemView.itemCoverView.visibility = View.VISIBLE Glide.with(itemView.context) .load(imageUrl) .apply(RequestOptions().placeholder(R.drawable.empty_placeholder)) .transition(DrawableTransitionOptions.withCrossFade()) .into(itemView.itemCoverView) } else { Glide.with(itemView.context).clear(itemView.itemCoverView) itemView.itemCoverView.setImageResource(R.drawable.empty_placeholder) } } } class KitsuPagedListAdapter : PagedListAdapter<KitsuItem, KitsuViewHolder>(diffCallback) { override fun onBindViewHolder(holder: KitsuViewHolder, position: Int) { holder.bindTo(getItem(position)) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KitsuViewHolder = KitsuViewHolder(parent) companion object { private val diffCallback = object : DiffCallback<KitsuItem>() { override fun areItemsTheSame(oldItem: KitsuItem, newItem: KitsuItem): Boolean = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: KitsuItem, newItem: KitsuItem): Boolean = oldItem == newItem } } }
KitsuUIParts.kt hosted with ❤ by GitHub
这里的ViewHolder没有什么好说的,唯一有点怪异的就是有许多“Oughhh…”。有点意思的是KitsuPagedListAdapter,它继承了 PagedListAdapter 。因为这里所有东西都是基于页面的,这个adapter是一个特殊的类。我们还提供了DiffCallback来对比item,利用 diff 算法 高效的处理列表中发生的变化。
终于完成了,下面是效果:
译者注:其实看不出来分页效果对吧,因为它并没有加载等待的提示。
对了,差点忘记提一下 Chris Craik 告诉我们的话:
Paging alpha1 doesn’t drop data — wanted to get that in, but wasn’t able to for the first alpha. Will be added in the future, and the in-memory max count will be configurable.
更新:别忘了去收听 Florina Muntenescu 参与的关于 Android Architecture Paging Library 的节目!点击这里。
源码地址:https://github.com/brainail/.samples/tree/master/ArchPagingLibraryWithNetwork