Android Paging3 实现在线与本地数据的高效融合与动态布局解析

69 阅读4分钟

前提摘要

最近项目需要实现类似xx网盘和xx相册的云盘相册效果,刚好有空整理了一下实现思路,记录下来。

目标功能

我们的目标是实现以下几点核心功能:

  1. 数据按日期从新到旧排序

  2. 动态布局支持:RecyclerView中需要插入【一行一列】和【一行多列】两种布局

  3. 流畅的分页加载

  4. 本地与在线数据的混合展示:实现同一天的在线数据和本地数据在一个列表中交替展示

分页加载实现

安卓的分页处理以前主要靠自己写逻辑,比如通过循环分页读取数据,并在用户滑动到特定位置时加载更多。但现在有了 Paging 3,可以更轻松地实现高效分页。

图片

PagingSource 的实现

首先,我们需要自定义一个 PagingSource,用来定义如何加载数据:

class CloudListPagingSource(private val token: String) : PagingSource<Int, CloudRecord>() {
   override fun getRefreshKey(state: PagingState<Int, CloudPhotoRecord>): Int? {
       return null
   }

   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CloudPhotoRecord> {
       return try {
           val page = params.key ?: FIRST_PAGE_INDEX
           val response = CloudService.getCloudPage(token, page)
           val responseData = response.data
           if (response.isSuccess && responseData != null) {
               val currentPage = responseData.current
               val prevKey = if (page > FIRST_PAGE_INDEX) currentPage - 1 else null
               val nextKey = if (page < responseData.totalPage) currentPage + 1 else null
               val dataList = mutableListOf<CloudPhotoRecord>()
               
               LoadResult.Page(data = dataList, prevKey = prevKey, nextKey = nextKey)
           } else {
               LoadResult.Error(RuntimeException(response.msg))
           }
       } catch (t: Throwable) {
           Timber.e(t)
           LoadResult.Error(t)
       }
   }
   
   companion object {
       private const val FIRST_PAGE_INDEX = 1
   }
}

Flow 的创建

通过 Pager 创建 Flow 流来加载分页数据。以下是一个简单的实现:

fun getCloudListByPage(token: String): Flow<PagingData<CloudRecord>> {
   return Pager(
       config = PagingConfig(30),
       pagingSourceFactory = {
           CloudListPagingSource(token)
       }).flow.cachedIn(viewModelScope)
}

PagingDataAdapter 的实现

我们需要一个适配器来处理分页数据,这里直接继承 PagingDataAdapter

class CloudPagingAdapter : PagingDataAdapter<Item, ItemPagingAdapter.ItemViewHolder>(DiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false)
        return ItemViewHolder(view)
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item)
    }

    class ItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        fun bind(item: Item?) {
            // Bind item to UI
        }
    }

    object DiffCallback : DiffUtil.ItemCallback<Item>() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem.id == newItem.id
        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean = oldItem == newItem
    }
}

在 Activity 或 Fragment 中订阅数据流:

launch(Dispatchers.IO) {
   cloudViewModel.getCloudListByPage(token).collectLatest {
       cloudPageAdapter.submitData(it)
   }
}

这样即可完成一个基础的分页需求,但是同时要注意的是,我们的需求比较复杂,这时候要考虑到第二点,也就是布局中需要插入不同的布局,RecyclerView插入item需要分别插入【一行一列】和【一行多列】的布局

动态布局插入多种类型的 Item

我们需要支持两种布局:单列布局多列布局。可以通过以下步骤实现:

定义 Item 的类型:

override fun getItemViewType(position: Int): Int {
    return getItem(position)?.type ?: ViewType.ITEM
}

2. 在 Adapter 中区分不同类型的 ViewHolder:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return if (viewType == ViewType.HEADER) {
        HeaderViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_header, parent, false))
    } else {
        ContentViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_content, parent, false))
    }
}



根据类型设置 GridLayoutManager 的跨度: 

val layoutManager = GridLayoutManager(context, 4) // 4列网格
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return if (cloudPagingAdapter.getItemViewType(position) == ViewType.HEADER) 4 else 1
    }
}
binding.rvPhoto.layoutManager = layoutManager

接下来讲到最后也是最复杂的一个逻辑:在线数据和本地数据混合的情况,需要考虑到以下几种情况

1、在线无数据时本地数据显示

2、在线有数据,但是本地数据时间新于在线数据,也就是分页第一页的时间之前有本地数据

3、在线有数据,但是分页最后一页结束时还有本地数据

4、中间穿插的本地数据

针对第一个情况决绝方案:

监听adapter的数据情况,判断加载中/加载失败/数据为空/加载成功

cloudPageAdapter.addLoadStateListener(mLoadStateListener)
private val mLoadStateListener: (CombinedLoadStates) -> Unit = {
   when (it.refresh) {
       is LoadState.NotLoading -> {
           mWaitDialog.dismiss()
           Timber.d("mLoadStateListener == LoadState.NotLoading")
           if (cloudPageAdapter.isEmpty()) {
               binding.ivEmpty.isVisible = true
               binding.rvPhoto.isVisible = false
           } else {
               binding.ivEmpty.isVisible = false
               binding.rvPhoto.isVisible = true
           }
       }

       is LoadState.Loading -> {
           mWaitDialog.showWaitDialog("加载资源中")
       }

       is LoadState.Error -> {
           Timber.d("mLoadStateListener == LoadState.Error")
           if (!canLoadLocal) {
               initLocalData()
           } else {
               mWaitDialog.dismiss()
               binding.ivEmpty.isVisible = true
               binding.rvPhoto.isVisible = false
           }
       }

       else -> {}
   }
}

针对第二种情况解决方案

在加载分页第一个数据时,判断是否有本地数据新于在线数据的,按时间加载

if (page == 1 && index == 0) {
   val topTempData = mCloudRecordDao.getRecordsAfter(endTime).sortedByDescending { it.mediaCreateTime }
   val topData = mergeLocalData(topTempData, endTime, cloudPage.date)
   Timber.d("topData==${preTime}==${startTime}===${topData}")
   cloudList.addAll(0, topData)
}

针对第三种情况解决方案

在分页最后一页判断,时间早于最后一页最后一条数据的时间后是否还有数据

针对第四种情况解决方案

中间穿插数据,也就是每次拿到分页数据的第一条和最后一条数据的时间做匹配,在这个时间区间内,查询本地是否有数据,如果有则插入

同时还有个注意情况,因为按照百度网盘的显示效果来说,有个日期标题,我们的标题插入,有2种思路:

1、获取当前页的所有在线和本地数据,然后在最后输出前按日期插入标题处理

2、在每一段数据处理时同时插入标题数据

从技术的角度来说,第一个方案最好做,但是也存在性能消耗的问题,第二个方案要保证每次时间插入时不会乱,这是个很大的挑战

我这边有个小技巧,因为每个数据都有时间戳,为了保证顺序不会乱的问题,在标题的item的时间戳插入的时间为当天的23点59分59秒,也就是,标题所在的时间戳是当天的时间戳的最大值,后续插入的时间肯定比这个时间戳小

原文地址:mp.weixin.qq.com/s/E6S7B7bLx…