Paging3 是 Google 官方为开发者提供的分页组件库(Paging 的最新版本)
Android 中分页功能常见的设计方法 在业务开发中由于数据信息过多,为了加速页面数据展示,提升用户体验和更高效低利用网络带宽和系统资源,分页加载成了每个 App 必有的功能之一。
在 Paging 出现之前实现分页功能基本有两种方式:
为 RecycleView 添加 header 和 footer 并自行处理滑动加载等事件
借助第三方开源框架处理业务逻辑(也是基于第一种方式实现的)
Google 为了统一分页加载的实现方案,以使开发者更多地专注于业务功能的实现,推出了分页加载库 Paging,Paging3 作为 Paging 组件的最新版本,比 Paging 更加便捷。
网络请求的封装与使用 首先在 build.gradle 中添加网络请求相关的配置:
1 2 3 4 5 6 7 8 9 dependencies { implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation("com.squareup.okhttp3:logging-interceptor:4.7.2" ) def paging_version = "3.1.1" implementation "androidx.paging:paging-runtime:$paging_version" testImplementation "androidx.paging:paging-common:$paging_version" }
测试接口使用 玩Android 的开放API - 问答
新建接口文件类:
1 2 3 object BaseApi { val BASE_URL = "https://www.wanandroid.com/" }
1 2 3 4 interface TestApi { @GET("wenda/list/{pageId}/json" ) suspend fun getData (@Path("pageId" ) pageId:Int ) :TestDataBean }
新建接口返回的对应的 body 实体数据:
TestDataBean.kt 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 data class TestDataBean ( val `data `: Data, val errorCode: Int , val errorMsg: String ) data class Data ( val curPage: Int , val datas: List<DataX>, val offset: Int , val over: Boolean , val pageCount: Int , val size: Int , val total: Int ) data class DataX ( val apkLink: String, val audit: Int , val author: String, val canEdit: Boolean , val chapterId: Int , val chapterName: String, val collect: Boolean , val courseId: Int , val desc: String, val descMd: String, val envelopePic: String, val fresh: Boolean , val host: String, val id: Int , val link: String, val niceDate: String, val niceShareDate: String, val origin: String, val prefix: String, val projectLink: String, val publishTime: Long , val realSuperChapterId: Int , val selfVisible: Int , val shareDate: Long , val shareUser: String, val superChapterId: Int , val superChapterName: String, val tags: List<Tag>, val title: String, val type: Int , val userId: Int , val visible: Int , val zan: Int ) data class Tag ( val name: String, val url: String )
将创建 service 的过程封装起来,新建 RetrofitServiceBuilder 类,提供 createService 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 object RetrofitServiceBuilder { fun <T> createService (clazz:Class <T >,baseApi:String = BaseApi.BASE_URL) :T?{ if (!NetworkUtils.isConnected(MyApplication.applicationContext())){ Toast.makeText(MyApplication.applicationContext(),"网络未连接" ,Toast.LENGTH_SHORT).show() return null } val interceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger{ override fun log (message: String ) { HttpLoggingInterceptor.Logger.DEFAULT.log(message) } }) interceptor.setLevel(HttpLoggingInterceptor.Level.BODY) val builder = OkHttpClient.Builder().addInterceptor(interceptor) val retrofit:Retrofit = Retrofit.Builder() .baseUrl(baseApi) .client(builder.build()) .addConverterFactory(GsonConverterFactory.create()) .build() return retrofit.create(clazz) } }
上述代码中处理了无网络时的提示语,并为 retrofit 添加了网络请求日志等方法。baseApi 默认为 BaseApi.BASE_URL 的值,可通过传参修改其值,在实际开发中可能还需要处理是否需要校验 Token、超时时长等参数配置。
使用 Paging3 实现网络数据的分页加载 官方推荐的最佳架构 使用 Paging 可以更高效地实现分页加载功能,Paging3 的使用完全符合官网推荐的最佳架构模式。好的架构应当满足关注点分离、持久性模型驱动页面等原则。Google 官方推荐的一种常见架构模式 MVVM 如下:
Repository 是数据仓库层,负责从 Room 或网络请求中获取数据,这里新建 TestRepository 类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class TestRepository { fun loadStudentMessage () : Flow<PagingData<DataX>> { return Pager( config = PagingConfig(pageSize = 8 ), pagingSourceFactory = {TestDataSource()} ).flow } }
ViewModel 层通过调用 Repository 层获取数据,并通过 LiveData 或 Flow 将数据发射到 UI 层,UI 层感知到数据变化后将数据展示出来:
1 2 3 4 5 6 7 8 class TestViewModel (application: Application):AndroidViewModel(application) { fun loadStudentMessage () : Flow<PagingData<DataX>> { return TestRepository().loadStudentMessage().cachedIn(viewModelScope) } }
接下来查看 Paging3 中的数据源 PagingSource 是如何实现的
PagingSource 的定义与使用 PagingSource 是 Paging3 中的核心组件之一,用于处理数据加载逻辑,定义 TestDataSource 继承自 PagingSource:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 class TestDataSource : PagingSource <Int, DataX > () { private val testApi = RetrofitServiceBuilder.createService(TestApi::class .java) override suspend fun load (params: LoadParams <Int >) : LoadResult<Int , DataX> { try { val currentPage = params.key ?: 1 val data = testApi?.getData(currentPage) Log.d("TAG" ,"当前页码$currentPage " ) if (currentPage == 1 ) Log.d("TAG" ,"总页码${data?.data?.pageCount} " ) val nextPage = if (currentPage < (data ?.data ?.pageCount ?: 0 )) { currentPage + 1 } else { null } val prevKey = if (currentPage > 1 ) { currentPage - 1 } else { null } when (data ?.errorCode) { 0 -> { data .data .datas.let { return LoadResult.Page( data = it, prevKey = prevKey, nextKey = nextPage ) } } else -> {} } }catch (e:Exception){ return LoadResult.Error(e) } return LoadResult.Error(throwable = IOException()) } override fun getRefreshKey (state: PagingState <Int , DataX>) : Int ? { return null } }
PagingDataAdapter 的定义与使用 为了让数据显示在 RecycleView 控件上,需要为 RecycleView 绑定一个适配器。在 Paging3 分页库中同样需要为其绑定一个适配器,新建 TestPagingDataAdapter 并继承自 PagingDataAdapter:
先查看 PagingDataAdapter 的 构造方法:
1 2 3 4 5 abstract class PagingDataAdapter <T : Any, VH : RecyclerView.ViewHolder > @JvmOverloads constructor ( diffCallback: DiffUtil.ItemCallback<T>, mainDispatcher: CoroutineDispatcher = Dispatchers.Main, workerDispatcher: CoroutineDispatcher = Dispatchers.Default ) : RecyclerView.Adapter<VH>() { ... }
PagingDataAdapter 继承自 RecyclerView.Adapter<VH>
,所以 PagingDataAdapter 的功能与普通的 Adapter 无异,只是需要指定 DiffUtil.ItemCallback 参数。DiffUtil.ItemCallback 用于计算列表中两个非空项目之间差异的回调。需要注意的是,DiffUtil.ItemCallback 是 RecycleView 组件中的功能,而不是 Paging3 中的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class TestPagingDataAdapter : PagingDataAdapter <DataX,RecyclerView.ViewHolder > (object : DiffUtil.ItemCallback<DataX>(){ override fun areItemsTheSame ( oldItem: DataX , newItem: DataX ) : Boolean { return oldItem.id == newItem.id } @SuppressLint("DiffUtilEquals" ) override fun areContentsTheSame ( oldItem: DataX , newItem: DataX ) : Boolean { return oldItem == newItem } }){ override fun onBindViewHolder (holder: RecyclerView .ViewHolder , position: Int ) { val mHolder = holder as TestDataViewHolder val bean:DataX?=getItem(position) bean?.let { mHolder.dataBindingUtil.bean = it } } override fun onCreateViewHolder (parent: ViewGroup , viewType: Int ) : RecyclerView.ViewHolder { val binding:ItemTestDataBinding=ItemTestDataBinding.inflate(LayoutInflater.from(parent.context),parent,false ) return TestDataViewHolder(binding) } class TestDataViewHolder (var dataBindingUtil:ItemTestDataBinding):RecyclerView.ViewHolder(dataBindingUtil.root){ } }
将结果显示在 UI 上 这里使用了 DataBinding 数据绑定功能:
item_test_data.xml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <?xml version="1.0" encoding="utf-8" ?> <layout xmlns:android ="http://schemas.android.com/apk/res/android" > <data > <variable name ="bean" type ="com.example.littlehelper.ui.test.DataX" /> </data > <LinearLayout android:layout_width ="match_parent" android:layout_height ="wrap_content" android:orientation ="vertical" android:padding ="5dp" > <TextView android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="@{bean.author}" android:textColor ="#000000" android:textSize ="20sp" /> <TextView android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ='@{bean.title}' android:textSize ="16sp" /> <View android:layout_width ="match_parent" android:layout_height ="1dp" android:background ="#999999" /> </LinearLayout > </layout >
在 Activity 中调用 ViewModel 中加载数据的方法,并将数据设置给 adapter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class TestActivity : AppCompatActivity () { private val TAG = "TAG_TestActivity" private lateinit var binding: ActivityTestBinding private lateinit var testViewModel: TestViewModel override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) binding = ActivityTestBinding.inflate(layoutInflater) setContentView(binding.root) initData() } private fun initData () { testViewModel = ViewModelProvider(this ).get (TestViewModel::class .java) val testPagingDataAdapter = TestPagingDataAdapter() binding.rv.layoutManager = LinearLayoutManager(this ) binding.rv.adapter = testPagingDataAdapter lifecycleScope.launch { try { testViewModel.loadStudentMessage().collect{ testPagingDataAdapter.submitData(it) } }catch (e:Exception){ Log.d(TAG,e.toString()) } } } }
效果图示例:
监听加载状态 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 testPagingDataAdapter.addLoadStateListener { when (it.refresh){ is LoadState.Loading ->{ Log.d(TAG,"正在加载" ) } is LoadState.Error ->{ Log.d(TAG,"加载错误" ) } is LoadState.NotLoading ->{ Log.d(TAG,"未加载,无错误" ) } } }
上述代码通过 PagingAdapter 的 addLoadStateListener 方法进行监听,监听参数是一个 CombinedLoadStates 类,CombinedLoadStates 类中每种状态及其对应的使用场景如下:
值类型
使用场景
refresh
数据刷新时
prepend
数据向上一页加载时
append
数据向下一页加载时
source
从 PagingSource 加载
mediator
从 RemoteMediator 加载
这里以 “refresh 数据刷新时” 为例,LoadState 的状态有三种,其状态值与含义描述如下:
状态值
含义描述
Loading
正在加载中
Error
加载错误
NotLoading
没有在加载,一般在加载前或加载完成后
程序在下翻浏览数据时,可能由于网络异常等问题导致数据加载失败,这时可在页面中隐藏一个重试按钮,当出现异常时,将按钮展示出来即可进行相应的业务处理。而 Paging3 提供了更简单的实现方式,首先定义一个底部布局,放一个重试按钮和 ProgressBar:
效果图示例:
item_test_foot.xml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <?xml version="1.0" encoding="utf-8" ?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android ="http://schemas.android.com/apk/res/android" android:layout_width ="match_parent" android:layout_height ="wrap_content" xmlns:app ="http://schemas.android.com/apk/res-auto" > <LinearLayout android:id ="@+id/ll_loading" android:layout_width ="match_parent" android:layout_height ="wrap_content" android:gravity ="center" android:orientation ="horizontal" android:visibility ="gone" app:layout_constraintStart_toStartOf ="parent" app:layout_constraintTop_toTopOf ="parent" > <TextView android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="正在加载数据......" android:textSize ="18sp" /> <ProgressBar android:layout_width ="20dp" android:layout_height ="20dp" /> </LinearLayout > <Button android:id ="@+id/btn_retry" android:layout_width ="match_parent" android:layout_height ="wrap_content" android:text ="加载失败,重新请求" android:visibility ="gone" app:layout_constraintStart_toStartOf ="parent" app:layout_constraintTop_toBottomOf ="@id/ll_loading" /> </androidx.constraintlayout.widget.ConstraintLayout >
接着定义一个 LoadStateViewHolder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class LoadStateViewHolder (parent: ViewGroup, var retry: () -> Unit ) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context).inflate( R.layout.item_test_foot, parent, false ) ) { val itemLoadStateBindingUtil: ItemTestFootBinding = ItemTestFootBinding.bind(itemView) fun bindStatue (loadState: LoadState ) { if (loadState is LoadState.Error) { Log.d("TAG" ,"----------LoadState.Error----" ) itemLoadStateBindingUtil.btnRetry.visibility = View.VISIBLE itemLoadStateBindingUtil.btnRetry.setOnClickListener { retry() } }else if (loadState is LoadState.Loading){ Log.d("TAG" ,"--------LoadState.Loading------" ) itemLoadStateBindingUtil.llLoading.visibility = View.VISIBLE }else if (loadState is LoadState.NotLoading){ Log.d("TAG" ,"--------LoadState.NotLoading------" ) } } }
然后创建一个 LoadStateFootAdapter 使其继承自 LoadStateAdapter,并指定 ViewHolder 为 LoadStateViewHolder,之后通过 onBindViewHolder 方法调用 bindStatue 方法:
1 2 3 4 5 6 7 8 9 class LoadStateFootAdapter (private val retry:()->Unit ):LoadStateAdapter<LoadStateViewHolder>() { override fun onBindViewHolder (holder: LoadStateViewHolder , loadState: LoadState ) { (holder as LoadStateViewHolder).bindStatue(loadState) } override fun onCreateViewHolder (parent: ViewGroup , loadState: LoadState ) : LoadStateViewHolder { return LoadStateViewHolder(parent,retry) } }
最后通过 withLoadStateFooter 方法为 adapter 添加底部状态布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 binding.rv.adapter = testPagingDataAdapter.withLoadStateFooter( footer = LoadStateFootAdapter( retry={ testPagingDataAdapter.retry() } ) )
上述代码会在按钮重试事件的回调中调用 testPagingDataAdapter.retry() 方法,retry 方法是 PagingAdapter 提供的重试方法,除此之外,还有 refresh(刷新)等方法。
原理解析 Paging3 组件的使用流程比较固定,它主要是在 Respository 中调用 PagingSource,在 ViewModel 中调用 Respository 并返回 Flow<PagingData>
到 UI 层,最后 UI 层接收数据并进行展示。
Paging 库如何契合应用架构的示例图:
接下来看看 Paging3 是如何做到自动加载更多内容的,从 PagingDataAdapter 的 getItem 方法说起:
PagingDataAdapter.kt 1 2 protected fun getItem (@IntRange(from = 0) position: Int ) = differ.getItem(position)
differ.getItem 方法如下:
AsyncPagingDataDiffer.kt 1 2 3 4 5 6 7 8 9 fun getItem (@IntRange(from = 0) index: Int ) : T? { try { inGetItem = true return differBase[index] } finally { inGetItem = false } }
differBase 是 PagingDataDiffer 对象,查看 PagingDataDiffer 的 get 方法:
PagingDataDiffer.kt 1 2 3 4 5 6 7 public operator fun get (@IntRange(from = 0) index: Int ) : T? { lastAccessedIndexUnfulfilled = true lastAccessedIndex = index receiver?.accessHint(presenter.accessHintForPresenterIndex(index)) return presenter.get (index) }
receiver 实例是 UiReceiver 接口,它里面包含了 accessHint 以及供使用的 retry 和 refresh 方法:
UiReceiver.kt 1 2 3 4 5 internal interface UiReceiver { fun accessHint (viewportHint: ViewportHint ) fun retry () fun refresh () }
最终 accessHint 会调用 PageFetcherSnapshot 类的 accessHint 方法:
PageFetcher.kt 1 2 3 4 5 6 7 8 9 10 inner class PagerUiReceiver <Key : Any, Value : Any > constructor ( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val pageFetcherSnapshot: PageFetcherSnapshot<Key, Value>, private val retryEventBus: ConflatedEventBus<Unit > ) : UiReceiver { override fun accessHint (viewportHint: ViewportHint ) { pageFetcherSnapshot.accessHint(viewportHint) } ... }
PageFetcherSnapshot.kt 1 2 3 fun accessHint (viewportHint: ViewportHint ) { hintHandler.processHint(viewportHint) }
查看 HintHandler:
HintHandler.kt 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private inner class HintFlow { var value: ViewportHint? = null set (value) { field = value if (value != null ) { _flow.tryEmit(value) } } private val _flow = MutableSharedFlow<ViewportHint>( replay = 1 , onBufferOverflow = BufferOverflow.DROP_OLDEST ) val flow: Flow<ViewportHint> get () = _flow }
_flow 是 MutableSharedFlow,它通过 tryEmit 方法发送数据。接下来 hintHandler 查看接收数据的地方:
PageFetcherSnapshot.kt 1 2 3 4 5 6 7 8 hintHandler.hintFor(loadType) .drop(if (generationId == 0 ) 0 else 1 ) .map { hint -> GenerationalViewportHint(generationId, hint) } }.simpleRunningReduce { previous, next -> if (next.shouldPrioritizeOver(previous, loadType)) next else previous }.conflate().collect { generationalHint -> doLoad(loadType, generationalHint) }
最终走到 doLoad 方法中:
PageFetcherSnapshot.kt 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 private suspend fun doLoad ( loadType: LoadType , generationalHint: GenerationalViewportHint ) { ... var loadKey: Key? = stateHolder.withLock { state -> state.nextLoadKeyOrNull( loadType, generationalHint.generationId, generationalHint.hint.presentedItemsBeyondAnchor(loadType) + itemsLoaded, )?.also { state.setLoading(loadType) } } var endOfPaginationReached = false loop@ while (loadKey != null ) { val params = loadParams(loadType, loadKey) val result: LoadResult<Key, Value> = pagingSource.load(params) when (result) { is Page<Key, Value> -> { val nextKey = when (loadType) { PREPEND -> result.prevKey APPEND -> result.nextKey else -> throw IllegalArgumentException( "Use doInitialLoad for LoadType == REFRESH" ) } ... } ... } }
当 loadKey 不为 null 时,会调用 pagingSource 的 load 方法,loadKey 是通过 stateHolder 的 nextLoadKeyOrNull 方法获取的:
PageFetcherSnapshot.kt 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private fun PageFetcherSnapshotState<Key, Value> .nextLoadKeyOrNull ( loadType: LoadType , generationId: Int , presentedItemsBeyondAnchor: Int ) : Key? { if (generationId != generationId(loadType)) return null if (sourceLoadStates.get (loadType) is Error) return null if (presentedItemsBeyondAnchor >= config.prefetchDistance) return null return if (loadType == PREPEND) { pages.first().prevKey } else { pages.last().nextKey } }
当最后一个 Item 的距离小于 prefetchDistance(即预加载距离)时,会返回 nextKey 开始加载下一页,这样 Paging3 就实现了用户无感知分页加载的功能。
备注 参考资料 :
《Android Jetpack开发 原理解析与应用实战》
Paging 库概览
欢迎关注微信公众号:非也缘也