前提摘要
最近项目需要实现类似xx网盘和xx相册的云盘相册效果,刚好有空整理了一下实现思路,记录下来。
目标功能
我们的目标是实现以下几点核心功能:
-
数据按日期从新到旧排序
-
动态布局支持:RecyclerView中需要插入【一行一列】和【一行多列】两种布局
-
流畅的分页加载
-
本地与在线数据的混合展示:实现同一天的在线数据和本地数据在一个列表中交替展示
分页加载实现
安卓的分页处理以前主要靠自己写逻辑,比如通过循环分页读取数据,并在用户滑动到特定位置时加载更多。但现在有了 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秒,也就是,标题所在的时间戳是当天的时间戳的最大值,后续插入的时间肯定比这个时间戳小