Android_Jetpack:Paging组件之PageKeyedDataSource的MVVM使用

PageKeyedDataSource是比较常见的一种DataSource,适用于数据源以“页”的方式进行请求的情况。例如,请求参数为page=2&pagesize=10,则表示数据源以10条数据为一页,当前返回第二页的5条数据。

接下来首先来按照MVVM架构跑通一个笑话大全数据接口,这里使用的是最新笑话接口,key是我自己注册的。

接口:http://v.juhe.cn/joke/content/text.php?key=您申请的KEY&page=1&pagesize=10
接口返回数据示例:

{
    "error_code": 0,
    "reason": "Success",
    "result": {
        "data": [
            {
                "content": "女生分手的原因有两个,\r\n一个是:闺蜜看不上。另一个是:闺蜜看上了。",
                "hashId": "607ce18b4bed0d7b0012b66ed201fb08",
                "unixtime": 1418815439,
                "updatetime": "2014-12-17 19:23:59"
            },
            {
                "content": "老师讲完课后,问道\r\n“同学们,你们还有什么问题要问吗?”\r\n这时,班上一男同学举手,\r\n“老师,这节什么课?”",
                "hashId": "20670bc096a2448b5d78c66746c930b6",
                "unixtime": 1418814837,
                "updatetime": "2014-12-17 19:13:57"
            },
            ......
        ]
    }
}

首先添加相关依赖,这里使用Retrofit作为网络请求库。其余的按照需要依次添加即可。由于该接口为http接口,需要在AndroidManifest添加

android:networkSecurityConfig="@xml/network_config"

network_config.xml

<?xml version ="1.0" encoding ="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system"/>
        </trust-anchors>
    </base-config>
</network-security-config>

以及别忘了添加网络权限。

Model:

//按照"最新笑话"定义
data class JokeResponse(val reason:String, val result:Result,@SerializedName("error_code") val errorCode :Int) {
    data class Result(val data:List<Joke>)
    data class Joke(val content:String, val hashId:String,val unixtime:Long,val updatetime:String)
}

web请求相关:

interface JokeService {
    @GET("content/text.php?key=${MyApplication.KEY}")
    fun getJokes(@Query("page") page:Int,@Query("pagesize") pagesize:Int):
            Call<JokeResponse>//text.php?page=1&pagesize=10&key=
}

object ServiceCreator {
    private const val BASE_URL = "http://v.juhe.cn/joke/"
    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    fun <T> create(serviceClass:Class<T>):T = retrofit.create(serviceClass)
    inline fun <reified T> create():T = create(T::class.java)
}

//统一的网络数据源访问入口,对所有网络请求的API进行封装,单例类
object RetrofitNetwork {
    //获取最新笑话列表
    private val jokeService = ServiceCreator.create(JokeService::class.java)
    suspend fun searchJokes(page:Int,pagesize:Int) = jokeService.getJokes(page,pagesize).await()

    private suspend fun <T> Call<T>.await():T{
        Log.d("suspendCoroutine",request().toString())
        return suspendCoroutine {continuation->
            enqueue(object : Callback<T> {
                override fun onResponse(call: Call<T>, response: Response<T>){
                    val body = response.body()
                    Log.d("RetrofitNetwork",body.toString())
                    if (body!=null) continuation.resume(body)
                    else continuation.resumeWithException(RuntimeException("response body is null"))
                }
                override fun onFailure(call: Call<T>, t: Throwable){
                    Log.d("onFailure",continuation.toString())
                    continuation.resumeWithException(t)
                }
            })
        }
    }
}

//仓库层的统一封装入口,单例类
object Repository {
    //查看最新笑话
    fun searchJokes(page:Int,pagesize:Int) = fire(Dispatchers.IO){
        val jokeInfoResponse = RetrofitNetwork.searchJokes(page,pagesize)
        Log.d("jokeInfoResponse",jokeInfoResponse.toString())
        if (jokeInfoResponse.errorCode == 0){
            val joke= jokeInfoResponse.result.data
            Result.success(joke)
        }else{
            Result.failure(RuntimeException("joke response status is ${jokeInfoResponse.reason}"))
        }
    }
    //使用suspend关键字,以表示所有传入的Lambda表达式中的代码也是有挂起函数上下文的
    private fun <T> fire(context: CoroutineContext, block: suspend ()->Result<T>) = liveData<Result<T>>(context) {
        val result = try {
            block()
        }catch (e:Exception){
            Result.failure<JokeResponse>(e)
        }
        emit(result as Result<T>)
    }
}

viewModel:

class JokeViewModel : ViewModel() {
    private val searchJokeLiveData = MutableLiveData<ArrayList<Int>>()
    val jokeLiveData = Transformations.switchMap(searchJokeLiveData){searchInfo->
        Repository.searchJokes(searchInfo[0],searchInfo[1])
    }
    fun searchJokes(page:Int,pagesize:Int){
        val searchJoke = arrayListOf<Int>(page,pagesize)
        searchJokeLiveData.value = searchJoke
    }
}

Activity:
这里只是做一个简单的接口测试,暂时没有写页面。

class PageKeyedDataSourceTestMainActivity : AppCompatActivity() {
    val viewModel by lazy { ViewModelProvider(this).get(JokeViewModel::class.java) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_positional_data_source_test_main)
        viewModel.searchJokes(1,10)
        viewModel.jokeLiveData.observe(this, Observer { result->
            val joke=result.getOrNull()

        })
    }
}

运行一下,查看Log,接口跑通即可。

接下来改为使用Paging组件分页请求网络数据,下面是一个关系图。
Android_Jetpack:Paging组件之PageKeyedDataSource的MVVM使用
红色部分的DataSource是根据不同种类分别有具体的实现,其余部分基本通用。
①首先添加一个PageKeyedDataSource类,代码如下:

class JokePageKeyedDataSource: PageKeyedDataSource<Int, JokeResponse.Joke>() {
    companion object{
        const val FIRST_PAGE=1
        const val PAGE_SIZE=10
    }
    override fun loadInitial(
        params: LoadInitialParams<Int>,
        callback: LoadInitialCallback<Int, JokeResponse.Joke>
    ) {
        //页面首次加载数据时调用,在该方法内调用API接口加载第一页数据
        val job = Job()
        val scope = CoroutineScope(job)
        scope.launch {
            Repository.searchJokes(FIRST_PAGE, PAGE_SIZE,callback)
        }
        //所有kotlinx.coroutines中的挂起函数都是可取消的,都不需要检查协程是否已取消,
        // 然后停止任务执行,或是抛出 CancellationException 异常。
//        job.cancel()

    }

    override fun loadAfter(
        params: LoadParams<Int>,
        callback: LoadCallback<Int, JokeResponse.Joke>
    ) {
        //加载下一页数据
        //接口实测最多20页,之后都只显示第20页的内容,因此20页以后不加载
        val nextKey:Int? = if (params.key>=20) {
            null
        }else{
            params.key+1
        }
        val job = Job()
        val scope = CoroutineScope(job)
        scope.launch {
            Repository.searchJokes(params.key, PAGE_SIZE,callback,nextKey)
        }
//        job.cancel()
    }

    override fun loadBefore(
        params: LoadParams<Int>,
        callback: LoadCallback<Int, JokeResponse.Joke>
    ) {
        //加载前一页数据
        //目前用不上,什么都不做
    }
}

里面我们实现了两个方法,分别是loadInitial()和loadAfter()。

  • loadInitial()
    页面首次加载数据时会调用这个方法,在该方法内调用API接口加载第一页数据。加载成功后,调用callback.onResult()方法将数据返回给PagedList。
callback.onResult(joke,null, JokePageKeyedDataSource.FIRST_PAGE +1)//第一个参数会交给PagedList

第一个参数是加载得到的数据,第二个参数是上一页key,由于不存在上一页,所以设置为null,第三个参数为下一页key,如果不存在,也设置为null。

  • loadAfter()
    加载下一页时会调用这个方法,params: LoadParams这个参数接收的是在loadInitial()中设置的下一页key,params.key得到下一页的key,通过这个key,请求下一页。请求成功供,和loadInitial()一样,也是通过调用callback.onResult()方法将数据返回给PagedList,同时再设置下一页的key。有一点需要注意,在设置下一页之前,需要盘算是否还有更多的数据,若没有数据,则将下一页的key设置为null,表示所有数据请求完毕。

②在Repository单例类中重载两个searchJokes()方法,如下:

suspend fun searchJokes(
    page:Int, pagesize:Int,
    callback: PageKeyedDataSource.LoadInitialCallback<Int, JokeResponse.Joke>) {
    val jokeInfoResponse = RetrofitNetwork.searchJokes(page,pagesize)
    Log.d("jokeInfoResponse",jokeInfoResponse.toString())
    if (jokeInfoResponse.errorCode == 0){
        val joke= jokeInfoResponse.result.data
        callback.onResult(joke,null, JokePageKeyedDataSource.FIRST_PAGE +1)//第一个参数会交给PagedList
    }else{
    }
}
suspend fun searchJokes(
    page:Int, pagesize:Int,
    callback: PageKeyedDataSource.LoadCallback<Int, JokeResponse.Joke>,previousOrNexPageKey: Int?) {
    val jokeInfoResponse = RetrofitNetwork.searchJokes(page,pagesize)
    Log.d("jokeInfoResponse",jokeInfoResponse.toString())
    if (jokeInfoResponse.errorCode == 0){
        val joke= jokeInfoResponse.result.data
        callback.onResult(joke,previousOrNexPageKey)//第一个参数会交给PagedList
    }else{
    }
}
    

这样PageKeyedDataSource和API Service就写完了。接下来我们需要建立一个DataSourceFactory,它负责创建JokePageKeyedDataSource,并使用LiveData包装JokePageKeyedDataSource,将其暴露给JokeViewModel。

③创建JokeDataSourceFactory。

class JokeDataSourceFactory:DataSource.Factory<Int,JokeResponse.Joke> (){
    private val liveDataSource = MutableLiveData<JokePageKeyedDataSource>()
    override fun create(): DataSource<Int, JokeResponse.Joke> {
        val dataSource = JokePageKeyedDataSource()
        liveDataSource.postValue(dataSource)
        return dataSource
    }
}

④修改JokeViewModel,添加如下代码:

//通过LivePagedListBuilder创建和配置PageList,并使用LiveData包装PageList,将其暴露给Activity
    var jokePagedList:LiveData<PagedList<JokeResponse.Joke>>
    init {
        val config = PagedList.Config.Builder()
            .setEnablePlaceholders(true)//设置控件占位
            .setPageSize(JokePageKeyedDataSource.PAGE_SIZE)//设置每页大小,通常与DataSource中请求参数值一致
            .setPrefetchDistance(3)//设置当前距离底部还有多少条数据时开始加载下一页数据
            .setInitialLoadSizeHint(JokePageKeyedDataSource.PAGE_SIZE*4)//设置首次加载数据数量,默认3倍
            .setMaxSize(65536*JokePageKeyedDataSource.PAGE_SIZE)//设置PagedList所能承受的最大数量,超过会异常
            .build()
        jokePagedList =
            LivePagedListBuilder<Int,JokeResponse.Joke>(JokeDataSourceFactory(),config).build()
    }
    

现在只剩下页面显示部分了。
⑤编写页面布局文件。
activity_positional_data_source_test_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/jokeRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
  </LinearLayout>
  

joke_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">
    <TextView
        android:id="@+id/contentText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
    <TextView
        android:paddingTop="15dp"
        android:id="@+id/updateTimeText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>

以上两个简单的布局文件没什么需要强调的,接下来进入正题。

⑥编写展示列表数据的JokePagedListAdapter。

class JokePagedListAdapter(val context: Context):PagedListAdapter<JokeResponse.Joke,JokePagedListAdapter.JokeViewHolder> (DIFF_CALLBACK){
    companion object{
        //DiffUtil用于计算两个数据列表之间的差异,只会更新需要更新的数据源,不需要刷新整个数据源
        //比notifyDataSetChanged()对整个数据源刷新效率高,且可以轻松地为列表加入动画效果
        private val DIFF_CALLBACK=object :DiffUtil.ItemCallback<JokeResponse.Joke>(){
            //检测两个对象是否代表同一个Item
            override fun areItemsTheSame(
                oldItem: JokeResponse.Joke,
                newItem: JokeResponse.Joke
            ): Boolean {
                return oldItem.hashId == newItem.hashId
            }
            //检测两个Item是否存在不一样的数据
            override fun areContentsTheSame(
                oldItem: JokeResponse.Joke,
                newItem: JokeResponse.Joke
            ): Boolean {
               return oldItem == newItem
            }
        }
    }
    inner class JokeViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
        val contentText: TextView = itemView.findViewById(R.id.contentText)
        val updateTimeText: TextView = itemView.findViewById(R.id.updateTimeText)
    }

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

    override fun onBindViewHolder(holder: JokeViewHolder, position: Int) {
        val joke = getItem(position)//若当前有数据则知己与UI控件绑定,反之getItem通知PagedList去获取下一页数据
        if (joke != null) {
            holder.contentText.text = joke.content
            holder.updateTimeText.text = joke.updatetime
        }else{
            holder.contentText.text = ""
            holder.updateTimeText.text = ""
        }
    }
}

JokePagedListAdapter需要继承自PagedListAdapter,注意下在onBindViewHolder()方法中调用getItem()方法的注释。PagedList在收到通知后会让DataSource执行具体的数据获取工作。

⑦修改PageKeyedDataSourceTestMainActivity。

class PageKeyedDataSourceTestMainActivity : AppCompatActivity() {
    val viewModel by lazy { ViewModelProvider(this).get(JokeViewModel::class.java) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_positional_data_source_test_main)
//        viewModel.searchJokes(1,10)
//        viewModel.jokeLiveData.observe(this, Observer { result->
//            val joke=result.getOrNull()
//
//        })
        jokeRecyclerView.layoutManager = LinearLayoutManager(this)
        jokeRecyclerView.setHasFixedSize(true)
        jokeRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
        val jokePagedListAdapter = JokePagedListAdapter(this)
        viewModel.jokePagedList.observe(this,Observer {jokes->
           jokePagedListAdapter.submitList(jokes)

        })
        jokeRecyclerView.adapter = jokePagedListAdapter
    }
}

注释掉原来的代码,现在我们将jokeRecyclerView与JokePagedListAdapter进行绑定,当数据发生变化时,该变化会通过LiveData传递过来,然后再通过jokePagedListAdapter.submitList()方法刷新数据。

最后运行程序,界面显示如下:
Android_Jetpack:Paging组件之PageKeyedDataSource的MVVM使用
上划列表,自动显示下一页的内容。

上一篇:Paging(Dapper)


下一篇:OS L5-7:Inverted and Multi-Level Page Tables