从0到人打造一款安卓app之18-集成Paging分页

287 阅读4分钟

集成Paging分页

手机应用总免不了要显示列表,例如朋友圈列表,用户列表。话题列表等等,有列表就免不了要分页,分页方案多种多样。现在尝试安卓官方的Android JetpackPaging 库

看了一下文档,内容还是很多的,一时半分无法下手。不管三七二十一,先整一个普通列表。

1.不使用Paging 库做一个RecyclerView普通列表

列表核心控件 SwipeRefreshLayout+RecyclerView

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

列表Item样式

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data />

    <LinearLayout
        style="@style/defaultSelectableItemBackground"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="start|center_vertical"
        android:orientation="horizontal"
        android:padding="0dp">

        <ImageView
            android:layout_width="44dp"
            android:layout_height="44dp"
            android:layout_marginStart="20dp"
            android:layout_marginTop="10dp"
            android:layout_marginBottom="10dp"
            android:src="@mipmap/ic_launcher"/>

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="44dp"
            android:layout_marginStart="8dp"
            android:gravity="center_vertical"
            android:orientation="vertical">

            <TextView
                android:id="@+id/tvUserName"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="1" />

            <TextView
                android:id="@+id/tvLastLoginTime"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="上次登录日期" />
        </LinearLayout>
    </LinearLayout>
</layout>

Fragment

class ChooseUserFragment : BaseFragment<FragmentChooseUserLayoutBinding>() {

    private var userAdapter: UserAdapter? = null
    override fun getLayoutId() = R.layout.fragment_choose_user_layout

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding?.recyclerView?.apply {
            layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
            userAdapter = UserAdapter(requireContext())
            adapter = userAdapter
        }
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                queryUsers(1, "测").observe(viewLifecycleOwner) {
                    if (it is ApiSuccessResponse) {
                        userAdapter?.addUsers(it.body)
                    }
                }
            }
        }
    }
}

class UserAdapter(val context: Context) : RecyclerView.Adapter<UserViewHolder>() {
    private val userList = mutableListOf<UserInfo>()

    fun addUsers(users: List<UserInfo>) {
        userList.addAll(users)
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        return UserViewHolder(
            UserViewHolderItemLayoutBinding.bind(
                LayoutInflater.from(context).inflate(R.layout.user_view_holder_item_layout, parent, false)
            )
        )
    }

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        val user = userList[holder.absoluteAdapterPosition]
        holder.bindData(user)
    }

    override fun getItemCount(): Int {
        return userList.size
    }
}

class UserViewHolder(val binding: UserViewHolderItemLayoutBinding) : RecyclerView.ViewHolder(binding.root) {

    fun bindData(user:UserInfo) {
        binding.tvUserName.text = user.nickName
        binding.tvLastLoginTime.text = TimeUtils.date2String(Date(user.lastLoginTime))
    }
}

至此,一个请求接口成功后,显示用户列表的功能基本完成,但是没有下拉刷新,也没分页加载功能。

2.PagingSource

PagingSource<Key, Value> 有两种类型参数:KeyValue。键定义了用于加载数据的标识符,值是数据本身的类型。例如,如果您通过将 Int 页码传递给从网络加载各页 User 对象,则应选择 Int 作为 Key 类型,选择 User 作为 Value 类型。

下面的示例实现了按页码加载各页对象的 PagingSourceKey 类型为 IntValue 类型为 User

先创建一个UserInfoPagingSource,用于从网络加载用户列表。以page+pageSize方式分页。

class UserInfoPagingSource(val query: String) : PagingSource<Int, UserInfo>() {
    private val LogTag = "UserInfoPagingSource"

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UserInfo> {
        val nextPageNumber = params.key ?: 1
        LogUtils.d(LogTag,"load nextPageNumber:$nextPageNumber\tloadSize:${params.loadSize}")
        var result: LoadResult<Int, UserInfo>? = null
        queryUsers(nextPageNumber, query, params.loadSize)
            .asFlow()
            .catch { e ->
                LogUtils.d(LogTag, "catch e:$e")
                result = LoadResult.Error(e)
            }.onCompletion { t ->
                LogUtils.d(LogTag, "onCompletion t:$t")
            }.take(1)
            .collect { response ->
                LogUtils.d(LogTag, "collect response:$response")
                if (response is ApiSuccessResponse) {
                     result = LoadResult.Page(
                        data = response.body,
                        prevKey = null,
                        nextKey = if (response.body.isEmpty()) null else nextPageNumber + 1
                    )
                } else if (response is ApiErrorResponse) {
                    result = LoadResult.Error(RuntimeException(response.errorMessage))
                } else {
                    result = LoadResult.Invalid()
                }
            }
        return result ?: LoadResult.Invalid()
    }

    override fun getRefreshKey(state: PagingState<Int, UserInfo>): Int? {
        // Try to find the page key of the closest page to anchorPosition, from
        // either the prevKey or the nextKey, but you need to handle nullability
        // here:
        //  * prevKey == null -> anchorPage is the first page.
        //  * nextKey == null -> anchorPage is the last page.
        //  * both prevKey and nextKey null -> anchorPage is the initial page, so
        //    just return null.
        return state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
    }
}

3.ViewModel

接下来,您需要来自 PagingSource 实现的分页数据流。通常,您应在 ViewModel 中设置数据流。Pager 类提供的方法可显示来自 PagingSourcePagingData 对象的响应式流。Paging 库支持使用多种流类型,包括 FlowLiveData 以及 RxJava 中的 FlowableObservable 类型。

当您创建 Pager 实例来设置响应式流时,必须为实例提供 PagingConfig 配置对象和告知 Pager 如何获取 PagingSource 实现实例的函数:

class ChooseUserViewModel : ViewModel() {

    val loadUserFlow = Pager(PagingConfig(pageSize = 15, initialLoadSize = 15)) {
        UserInfoPagingSource(query = "测")
    }.flow
        .cachedIn(viewModelScope)

}

4.PagingDataAdapter

然后修改之前写好的RecyclerView.Adapter,主要修改之处,是从继承RecyclerView.Adapter变成继承PagingDataAdapter,因为 PagingDataAdapter需要一个diffCallback,所以还需要一个单例对象UserComparator

class UserAdapter(val context: Context) : PagingDataAdapter<UserInfo, UserViewHolder>(diffCallback = UserComparator) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        return UserViewHolder(
            UserViewHolderItemLayoutBinding.bind(
                LayoutInflater.from(context).inflate(R.layout.user_view_holder_item_layout, parent, false)
            )
        )
    }

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        val user = getItem(position)
        holder.bindData(user)
    }
}
//单例对象
object UserComparator : DiffUtil.ItemCallback<UserInfo>() {
    override fun areItemsTheSame(oldItem: UserInfo, newItem: UserInfo): Boolean {
        // Id is unique.
        return oldItem.objectId == newItem.objectId
    }

    override fun areContentsTheSame(oldItem: UserInfo, newItem: UserInfo): Boolean {
        return oldItem == newItem
    }
}

5.列表数据更新

主要变化之处是,自字定的继承RecyclerView.Adapter的适配器,需要自己定义一个成员变量List<UserInfo>保存数据,然后还需要一个列表加载下一页时增加数据的方法并且notifyDataSetChanged,例如

private val userList = mutableListOf<UserInfo>()

fun addUsers(users: List<UserInfo>) {
    userList.addAll(users)
    notifyDataSetChanged()
}

Adapter 继承自PagingDataAdapter后就不需要了。paging库会自动更新数据列表

更新数据方法,从调用自定义的addUsers方法,变成userAdapter?.submitData(pagingData)

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    ....省略一些代码...s
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            chooseUserViewModel.loadUserFlow.collect{ pagingData->
                userAdapter?.submitData(pagingData)
            }
        }
    }
}

到这一个时候,一个会自动分页的列表已经做好了。但是没有下拉刷新,也没有加载失败提示。

6.添加header,footer,和分隔线

实现添加添加header,footer,和分隔线,其实就是转换数据流的知识点。可以对流实现filter 过滤,map转换等中间操作。

viewModel里的流添加map操作

class ChooseUserViewModel : ViewModel() {

    val loadUserFlow = Pager(PagingConfig(pageSize = 15, initialLoadSize = 15)) {
        UserInfoPagingSource(query = "测")
    }.flow.map { pagingData ->
        pagingData.map { user ->
            UiModel.UserModel(user)
        }.insertSeparators { before, after ->
            when {
                before == null -> null
                after == null -> UiModel.SeparatorModel(UiModel.FOOTER, before = before, after = after)
                else -> UiModel.SeparatorModel(description = UiModel.ITEM_DECORATION, before = before, after = after)
            }
        }
    }.cachedIn(viewModelScope)

}

相应的UserComparatorUserAdapter也需要相应的修改。

sealed class UiModel {
    data class UserModel(val user: UserInfo) : UiModel()
    data class SeparatorModel(val description: String, val before: UserModel?, val after: UserModel?) : UiModel()
    companion object {
        const val FOOTER = "footer"
        const val ITEM_DECORATION = "itemDecoration"
    }
}

class UserAdapter(val context: Context) : PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(diffCallback = UserComparator) {
   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val viewHolder = when (viewType) {
            R.layout.user_view_holder_item_layout -> UserViewHolder(
                UserViewHolderItemLayoutBinding.bind(
                    LayoutInflater.from(context).inflate(R.layout.user_view_holder_item_layout, parent, false)
                )
            )
            R.layout.user_view_holder_footer_item_layout -> FooterViewHolder(
                UserViewHolderFooterItemLayoutBinding.bind(
                    LayoutInflater.from(context).inflate(R.layout.user_view_holder_footer_item_layout, parent, false)
                )
            )
            R.layout.user_view_holder_item_decoration_layout -> ItemDecorationViewHolder(
                LayoutInflater.from(context).inflate(R.layout.user_view_holder_item_decoration_layout, parent, false)
            )
            else -> UserViewHolder(
                UserViewHolderItemLayoutBinding.bind(
                    LayoutInflater.from(context).inflate(R.layout.user_view_holder_item_layout, parent, false)
                )
            )
        }
        return viewHolder
    }

    override fun getItemViewType(position: Int): Int {
        val uiModel = peek(position)
        return if (uiModel is UiModel.UserModel) {
            R.layout.user_view_holder_item_layout
        } else if (uiModel is UiModel.SeparatorModel && uiModel.description == UiModel.FOOTER) {
            R.layout.user_view_holder_footer_item_layout
        } else if (uiModel is UiModel.SeparatorModel && uiModel.description == UiModel.ITEM_DECORATION) {
            R.layout.user_view_holder_item_decoration_layout
        } else {
            super.getItemViewType(position)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val itemData = getItem(position)
        when (holder) {
            is UserViewHolder -> {
                val user = (itemData as? UiModel.UserModel)?.user
                holder.bindData(user)
                holder.itemView.setOnClickListener {
                    ToastUtils.showShort("userId:${user?.objectId}")
                }
            }
        }

    }
}

//单例对象
object UserComparator : DiffUtil.ItemCallback<UiModel>() {
    override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
        // Id is unique.
        val isSameUserItem =
            oldItem is UiModel.UserModel && newItem is UiModel.UserModel && oldItem.user.objectId == newItem.user.objectId
        val isSameSeparatorItem =
            oldItem is UiModel.SeparatorModel && newItem is UiModel.SeparatorModel && oldItem == newItem
        return isSameUserItem || isSameSeparatorItem
    }

    override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
        return oldItem == newItem
    }
}

class UserViewHolder(val binding: UserViewHolderItemLayoutBinding) : RecyclerView.ViewHolder(binding.root) {

    fun bindData(user: UserInfo?) {
        binding.tvUserName.text = user?.nickName
        binding.tvLastLoginTime.text = TimeUtils.date2String(Date(user?.lastLoginTime ?: 0))
    }
}

class FooterViewHolder(val binding: UserViewHolderFooterItemLayoutBinding) : RecyclerView.ViewHolder(binding.root) {

    fun bindData(user: UserInfo?) {

    }
}

class ItemDecorationViewHolder(view: View) : RecyclerView.ViewHolder(view) {
}

7.首次加载loading

如果需要实现首次加载时的loading,需要监听到加载状态变化,所以需要用到加载状态PagingDataAdapterloadStateFlow来实现

加载状态监听回调有两种

一种是addLoadStateListener

pagingDataAdapter?.addLoadStateListener { 
    
}

一种是流收集

// Activities can use lifecycleScope directly, but Fragments should instead use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
  pagingAdapter.loadStateFlow.collectLatest { loadStates ->
    progressBar.isVisible = loadStates.refresh is LoadState.Loading
    retry.isVisible = loadState.refresh !is LoadState.Loading
    errorMsg.isVisible = loadState.refresh is LoadState.Error
  }
}

推荐是第二种,因为流有很多中间操作符,可以很方便的处理一些中间状态,例如distinctUntilChangedBy,filter

lifecycleScope.launchWhenCreated {
  adapter.loadStateFlow
    // Only emit when REFRESH LoadState for RemoteMediator changes.
    .distinctUntilChangedBy { it.refresh }
    // Only react to cases where REFRESH completes, such as NotLoading.
    .filter { it.refresh is LoadState.NotLoading }
    // Scroll to top is synchronous with UI updates, even if remote load was
    // triggered.
    .collect { binding.list.scrollToPosition(0) }
}

当this.refresh is LoadState.Loading时,其他都是 LoadState.NotLoading时,即为下拉刷新或者首次加载,配合adapter counter即可以判断出是否是下拉刷新状态还是首次加载状态,

fun CombinedLoadStates.isRefreshing() =
    this.append is LoadState.NotLoading
            && this.refresh is LoadState.Loading
            && this.prepend is LoadState.NotLoading

8.实现footer加载状态

前面实现的footer,只会在页面加载完成后提示,并不会在每一次加载下一页时提示,

定义并继承LoadStateAdapter

class UserLoadStateAdapter(val context: Context, private val retry: () -> Unit) : LoadStateAdapter<FooterViewHolder>() {

    override fun onBindViewHolder(holder: FooterViewHolder, loadState: LoadState) {
        holder.bindingLoadState(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): FooterViewHolder {
        return FooterViewHolder(
            UserViewHolderFooterItemLayoutBinding.bind(
                LayoutInflater.from(context).inflate(R.layout.user_view_holder_footer_item_layout, parent, false)
            )
        )
    }

然后使用withLoadStateHeaderAndFooter或者withLoadStateFooter``withLoadStateHeader生成新的Adapter赋值给RecyclerView的adapter

binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
    header = PostsLoadStateAdapter(adapter),
    footer = PostsLoadStateAdapter(adapter)
)

9.失败重试和下拉刷新或者首次加载失败刷新

PagingDataAdapter提供了几个方法,用于重试或者刷新


    /**
     * Retry any failed load requests that would result in a [LoadState.Error] update to this
     * [PagingDataAdapter].
     *
     * Unlike [refresh], this does not invalidate [PagingSource], it only retries failed loads
     * within the same generation of [PagingData].
     *
     * [LoadState.Error] can be generated from two types of load requests:
     *  * [PagingSource.load] returning [PagingSource.LoadResult.Error]
     *  * [RemoteMediator.load] returning [RemoteMediator.MediatorResult.Error]
     */
    fun retry() {
        differ.retry()
    }

    /**
     * Refresh the data presented by this [PagingDataAdapter].
     *
     * [refresh] triggers the creation of a new [PagingData] with a new instance of [PagingSource]
     * to represent an updated snapshot of the backing dataset. If a [RemoteMediator] is set,
     * calling [refresh] will also trigger a call to [RemoteMediator.load] with [LoadType] [REFRESH]
     * to allow [RemoteMediator] to check for updates to the dataset backing [PagingSource].
     *
     * Note: This API is intended for UI-driven refresh signals, such as swipe-to-refresh.
     * Invalidation due repository-layer signals, such as DB-updates, should instead use
     * [PagingSource.invalidate].
     *
     * @see PagingSource.invalidate
     *
     * @sample androidx.paging.samples.refreshSample
     */
    fun refresh() {
        differ.refresh()
    }

10.数据先加载到数据库然后才加载到界面

可能通过继承RemoteMediator实现

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    // ...
  }
}
val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

暂时没有用到,后面用到再详细看。