Paging3 (二)  結合Room

Paging 資料來源不開放, 無法隨意增刪改操作;  只能藉助 Room;

這就意味著:  從伺服器拉下來的資料全快取.  重新整理時資料全清再重新快取,  查詢條件變更時重新快取 [讓我看看]

當Room資料發生變化時,  會使記憶體中 PagingSource 失效。從而重新載入庫表中的資料

Room: 官方文件點這裡

Paging3: 官方文件點這裡.

本文內容:

  1. 實體類, Dao, DataBase 程式碼
  2. RemoteMediator 程式碼與講解
  3. ViewModel, DiffCallback, Adapter, Layout 程式碼
  4. 效果圖
  5. 總結

本文導包: 

  1. //ViewModel, livedata, lifecycle
  2. implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
  3. implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
  4. implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0"
  5. implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
  6. implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
  7.  
  8. //協程
  9. implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8"
  10. implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8'
  11.  
  12. //room
  13. implementation "androidx.room:room-runtime:2.3.0"
  14. kapt "androidx.room:room-compiler:2.3.0"
  15. implementation("androidx.room:room-ktx:2.3.0")
  16.  
  17. //Paging
  18. implementation "androidx.paging:paging-runtime:3.0.0"

1. 第一步, 建立實體類.

Room 需要 用 @Entity 註釋類;  @PrimaryKey 註釋主鍵

  1. @Entity
  2. class RoomEntity(
  3. @Ignore
  4. //狀態標記重新整理條目方式, 用於ListAdapter; 但在 Paging 中廢棄了
  5. override var hasChanged: Boolean= false,
  6. @ColumnInfo
  7. //選中狀態, 這裡用作是否點贊.
  8. override var hasChecked: Boolean = false)
  9. : BaseCheckedItem {
  10.  
  11. @PrimaryKey
  12. var id: String = "" //主鍵
  13. @ColumnInfo
  14. var name: String? = null //變數 name @ColumnInfo 可以省去
  15. @ColumnInfo
  16. var title: String? = null //變數 title
  17.  
  18. @Ignore
  19. var content: String? = null //某內容; @Ignore 表示不對映為表字段
  20. @Ignore
  21. var index: Int = 0
  22.  
  23. override fun equals(other: Any?): Boolean {
  24. if (this === other) return true
  25. if (javaClass != other?.javaClass) return false
  26.  
  27. other as RoomEntity
  28.  
  29. if (hasChecked != other.hasChecked) return false
  30. if (name != other.name) return false
  31.  
  32. return true
  33. }
  34.  
  35. override fun hashCode(): Int {
  36. var result = hasChecked.hashCode()
  37. result = 31 * result + (name?.hashCode() ?: 0)
  38. return result
  39. }
  40. }

2. 建立 Dao

Room 必備的 Dao類;

這裡提供了 5個函式;    看註釋就好了.

  1. @Dao
  2. interface RoomDao {
  3. //刪除單條資料
  4. @Query("delete from RoomEntity where id = :id ")
  5. suspend fun deleteById(id:String)
  6.  
  7. //修改單條資料
  8. @Update
  9. suspend fun updRoom(entity: RoomEntity) //修改點贊狀態;
  10.  
  11. //新增資料方式
  12. @Insert(onConflict = OnConflictStrategy.REPLACE)
  13. suspend fun insertAll(list: MutableList<RoomEntity>)
  14.  
  15. //配合Paging; 返回 PagingSource
  16. @Query("SELECT * FROM RoomEntity")
  17. fun pagingSource(): PagingSource<Int, RoomEntity>
  18.  
  19. //清空資料; 當頁面重新整理時清空資料
  20. @Query("DELETE FROM RoomEntity")
  21. suspend fun clearAll()
  22. }

3. Database

Room 必備;

  1. @Database(entities = [RoomEntity::class, RoomTwoEntity::class], version = 8)
  2. abstract class RoomTestDatabase : RoomDatabase() {
  3. abstract fun roomDao(): RoomDao
  4. abstract fun roomTwoDao(): RoomTwoDao
  5.  
  6. companion object {
  7. private var instance: RoomTestDatabase? = null
  8. fun getInstance(context: Context): RoomTestDatabase {
  9. if (instance == null) {
  10. instance = Room.databaseBuilder(
  11. context.applicationContext,
  12. RoomTestDatabase::class.java,
  13. "Test.db" //資料庫名稱
  14. )
  15. // .allowMainThreadQueries() //主執行緒中執行
  16. .fallbackToDestructiveMigration() //資料穩定前, 重建.
  17. // .addMigrations(MIGRATION_1_2) //版本升級
  18. .build()
  19. }
  20. return instance!!
  21. }
  22. }
  23. }

4. 重點來了 RemoteMediator

官方解釋: 

RemoteMediator 的主要作用是:在 Pager 耗盡資料或現有資料失效時,從網路載入更多資料。它包含 load() 方法,您必須替換該方法才能定義載入行為。

這個類要做的,  1.從伺服器拉資料存入資料庫; 2.重新整理時清空資料;  3.請求成功狀態. 

注意:   

endOfPaginationReached = true 表示: 已經載入到底了,沒有更多資料了

MediatorResult.Error 類似於 LoadResult.Error;

  1. @ExperimentalPagingApi
  2. class RoomRemoteMediator(private val database: RoomTestDatabase)
  3. : RemoteMediator<Int, RoomEntity>(){
  4. private val userDao = database.roomDao()
  5.  
  6. override suspend fun load(
  7. loadType: LoadType,
  8. state: PagingState<Int, RoomEntity>
  9. ): MediatorResult {
  10. return try {
  11. val loadKey = when (loadType) {
  12. //表示 重新整理.
  13. LoadType.REFRESH -> null //loadKey 是頁碼標誌, null代表第一頁;
  14. LoadType.PREPEND ->
  15. return MediatorResult.Success(endOfPaginationReached = true)
  16. LoadType.APPEND -> {
  17. val lastItem = state.lastItemOrNull()
  18. val first = state.firstItemOrNull()
  19.  
  20. Log.d("pppppppppppppppppp", "last index=${lastItem?.index} id=${lastItem?.id}")
  21. Log.d("pppppppppppppppppp", "first index=${first?.index} id=${first?.id}")
  22.  
  23. //這裡用 NoMoreException 方式顯示沒有更多;
  24. if(index>=15){
  25. return MediatorResult.Error(NoMoreException())
  26. }
  27.  
  28. if (lastItem == null) {
  29. return MediatorResult.Success(
  30. endOfPaginationReached = true
  31. )
  32. }
  33.  
  34. lastItem.index
  35. }
  36. }
  37.  
  38. //頁碼標誌, 官方文件用的 lastItem.index 方式, 但這方式似乎有問題,第一頁last.index 應當是9. 但博主這裡總是0 ,
  39. //也可以資料庫儲存. SharePrefences等;
  40. //如果資料庫資料僅用作 沒有網路時顯示. 不設定有效狀態或有效時長時, 則可以直接在 RemoteMediator 頁碼計數;
  41. // val response = ApiManager.INSTANCE.mApi.getDynamicList()
  42. val data = createListData(loadKey)
  43.  
  44. database.withTransaction {
  45. if (loadType == LoadType.REFRESH) {
  46. userDao.clearAll()
  47. }
  48. userDao.insertAll(data)
  49. }
  50.  
  51. //endOfPaginationReached 表示 是否最後一頁; 如果用 NoMoreException(沒有更多) 方式, 則必定false
  52. MediatorResult.Success(
  53. endOfPaginationReached = false
  54. )
  55. } catch (e: IOException) {
  56. MediatorResult.Error(e)
  57. } catch (e: HttpException) {
  58. MediatorResult.Error(e)
  59. }
  60. }
  61.  
  62. private var index = 0
  63. private fun createListData(min: Int?) : MutableList<RoomEntity>{
  64. val result = mutableListOf<RoomEntity>()
  65. Log.d("pppppppppppppppppp", "啦資料了當前index=$index")
  66. repeat(10){
  67. // val p = min ?: 0 + it
  68. index++
  69. val p = index
  70. result.add(RoomEntity().apply {
  71. id = "test$p"
  72. name = "小明$p"
  73. title = "幹哈呢$p"
  74. index = p
  75. })
  76. }
  77. return result
  78. }
  79. }

4.1 重寫 initialize()  檢查快取的資料是否已過期

有的時候,我們剛查詢的資料, 不需要立刻更新.  所以需要告訴 RemoteMediator: 資料是否有效;

這時候就要重寫  initialize();   判斷策略嘛,  例如db, Sp儲存上次拉取的時間等

InitializeAction.SKIP_INITIAL_REFRESH:  表示資料有效, 無需重新整理

InitializeAction.LAUNCH_INITIAL_REFRESH: 表示資料已經失效, 需要立即拉取資料替換重新整理;

例如:

  1. /**
  2. * 判斷 資料是否有效
  3. */
  4. override suspend fun initialize(): InitializeAction {
  5. val lastUpdated = 100 //db.lastUpdated() //最後一次更新的時間
  6. val timeOutVal = 300 * 1000
  7. return if (System.currentTimeMillis() - lastUpdated >= timeOutVal)
  8. {
  9. //資料仍然有效; 不需要重新從伺服器拉取資料;
  10. InitializeAction.SKIP_INITIAL_REFRESH
  11. } else {
  12. //資料已失效, 需從新拉取資料覆蓋, 並重新整理
  13. InitializeAction.LAUNCH_INITIAL_REFRESH
  14. }
  15. }

5.ViewModel

Pager 的建構函式 需要傳入 我們自定義的 remoteMediator 物件;

然後我們還增了:  點贊(指定條目重新整理);  刪除(指定條目刪除)  操作;

  1. class RoomModelTest(application: Application) : AndroidViewModel(application) {
  2. @ExperimentalPagingApi
  3. val flow = Pager(
  4. config = PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10),
  5. remoteMediator = RoomRemoteMediator(RoomTestDatabase.getInstance(application))
  6. ) {
  7. RoomTestDatabase.getInstance(application).roomDao().pagingSource()
  8. }.flow
  9. .cachedIn(viewModelScope)
  10.  
  11. fun praise(info: RoomEntity) {
  12. info.hasChecked = !info.hasChecked  //這裡有個坑
  13. info.name = "我名變了"
  14. viewModelScope.launch {
  15. RoomTestDatabase.getInstance(getApplication()).roomDao().updRoom(info)
  16. }
  17. }
  18.  
  19. fun del(info: RoomEntity) {
  20. viewModelScope.launch {
  21. RoomTestDatabase.getInstance(getApplication()).roomDao().deleteById(info.id)
  22. }
  23. }
  24. }

6. 有一點必須要注意:  DiffCallback

看過我 ListAdapter 系列 的小夥伴,應該知道.  我曾經用 狀態標記方式作為 判斷 Item 是否變化的依據;

但是在 Paging+Room 的組合中, 就不能這樣用了;

因為 在Paging中 列表資料的改變,  完全取決於 Room 資料庫中儲存的資料.

當我們要刪除或點贊操作時, 必須要更新資料庫指定條目的內容;

而當資料庫中資料發生改變時,  PagingSource 失效, 原有物件將會重建. 所以 新舊 Item 可能不再是同一實體, 也就是說記憶體地址不一樣了.

  1. class DiffCallbackPaging: DiffUtil.ItemCallback<RoomEntity>() {
  2. /**
  3. * 比較兩個條目物件 是否為同一個Item
  4. */
  5. override fun areItemsTheSame(oldItem: RoomEntity, newItem: RoomEntity): Boolean {
  6. return oldItem.id == newItem.id
  7. }
  8.  
  9. /**
  10. * 再確定為同一條目的情況下; 再去比較 item 的內容是否發生變化;
  11. * 原來我們使用 狀態標識方式判斷; 現在我們要改為 Equals 方式判斷;
  12. * @return true: 代表無變化; false: 有變化;
  13. */
  14. override fun areContentsTheSame(oldItem: RoomEntity, newItem: RoomEntity): Boolean {
  15. // return !oldItem.hasChanged
  16. if(oldItem !== newItem){
  17. Log.d("pppppppppppp", "不同")
  18. }else{
  19. Log.d("pppppppppppp", "相同")
  20. }
  21. return oldItem == newItem
  22. }
  23. }

細心的小夥伴應該能發現, 在 areContentsTheSame 方法中,我列印了一行日誌. 

博主是想看看, 當一個條目點贊時, 是隻有這一條記錄的實體失效重建了, 還是說整個列表的實體失效重建了

答案是: 一溜煙的 不同.  全都重建了.  為了單條目的點贊重新整理, 而重建了整個列表物件;  這是否是 拿裝置效能 換取 開發效率?

7. 貼出 Fragment 程式碼:

例項化 Adapter, RecycleView.  然後繫結一下 PagingData 的監聽即可

  1. @ExperimentalPagingApi
  2. override fun onLazyLoad() {
  3. mAdapter = SimplePagingAdapter(R.layout.item_room_test, object : Handler<RoomEntity>() {
  4. override fun onClick(view: View, info: RoomEntity) {
  5. when(view.id){
  6. R.id.tv_praise -> {
  7. mViewModel?.praise(info)
  8. }
  9. R.id.btn_del -> {
  10. mViewModel?.del(info)
  11. }
  12. }
  13. }
  14. }, DiffCallbackPaging())
  15.  
  16. val stateAdapter = mAdapter.withLoadStateFooter(MyLoadStateAdapter(mAdapter::retry))
  17. mDataBind.rvRecycle.let {
  18. it.layoutManager = LinearLayoutManager(mActivity)
  19. // **** 這裡不要給 mAdapter(主資料 Adapter); 而是給 stateAdapter ***
  20. it.adapter = stateAdapter
  21. }
  22.  
  23. //Activity 用 lifecycleScope
  24. //Fragments 用 viewLifecycleOwner.lifecycleScope
  25. viewLifecycleOwner.lifecycleScope.launchWhenCreated {
  26. mViewModel?.flow?.collectLatest {
  27. mAdapter.submitData(it)
  28. }
  29. }
  30. }

8. 貼出 Adapter 程式碼:

這裡就不封裝了, 有興趣的小夥伴, 可以參考我  ListAdapter 封裝系列

  1. open class SimplePagingAdapter<T: BaseItem>(
  2. private val layout: Int,
  3. protected val handler: BaseHandler? = null,
  4. diffCallback: DiffUtil.ItemCallback<T>
  5. ) :
  6. PagingDataAdapter<T, RecyclerView.ViewHolder>(diffCallback) {
  7.  
  8. override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
  9. return NewViewHolder(
  10. DataBindingUtil.inflate(
  11. LayoutInflater.from(parent.context), layout, parent, false
  12. ), handler
  13. )
  14. }
  15.  
  16. override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
  17. if(holder is NewViewHolder){
  18. holder.bind(getItem(position))
  19. }
  20. }
  21.  
  22. }

9. 佈局檔案程式碼:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <layout>
  3. <data>
  4. <variable
  5. name="item"
  6. type="com.example.kotlinmvpframe.test.testroom.RoomEntity" />
  7. <variable
  8. name="handler"
  9. type="com.example.kotlinmvpframe.test.testtwo.Handler" />
  10. </data>
  11. <androidx.constraintlayout.widget.ConstraintLayout
  12. xmlns:android="http://schemas.android.com/apk/res/android"
  13. xmlns:app="http://schemas.android.com/apk/res-auto"
  14. android:paddingHorizontal="16dp"
  15. android:paddingVertical="28dp"
  16. android:layout_width="match_parent"
  17. android:layout_height="wrap_content">
  18.  
  19. <TextView
  20. android:id="@+id/tv_index_item"
  21. style="@style/tv_base_16_dark"
  22. android:gravity="center_horizontal"
  23. android:text="@{item.name}"
  24. app:layout_constraintTop_toTopOf="parent"
  25. app:layout_constraintStart_toStartOf="parent"/>
  26. <TextView
  27. android:id="@+id/tv_title_item"
  28. style="@style/tv_base_16_dark"
  29. android:layout_width="0dp"
  30. android:textStyle="bold"
  31. android:lines="1"
  32. android:ellipsize="end"
  33. android:layout_marginStart="8dp"
  34. android:layout_marginEnd="20dp"
  35. android:text="@{item.title}"
  36. app:layout_constraintTop_toTopOf="parent"
  37. app:layout_constraintStart_toEndOf="@id/tv_index_item"
  38. app:layout_constraintEnd_toStartOf="@id/tv_praise"/>
  39.  
  40. <TextView
  41. style="@style/tv_base_14_gray"
  42. android:gravity="center_horizontal"
  43. android:text='@{item.content ?? "暫無內容"}'
  44. android:layout_marginTop="4dp"
  45. app:layout_constraintTop_toBottomOf="@id/tv_index_item"
  46. app:layout_constraintStart_toStartOf="parent"/>
  47.  
  48. <Button
  49. android:id="@+id/btn_del"
  50. android:layout_width="wrap_content"
  51. android:layout_height="wrap_content"
  52. android:text="刪除它"
  53. android:onClick="@{(view)->handler.onClick(view, item)}"
  54. android:layout_marginEnd="12dp"
  55. app:layout_constraintTop_toTopOf="parent"
  56. app:layout_constraintBottom_toBottomOf="parent"
  57. app:layout_constraintEnd_toStartOf="@id/tv_praise"/>
  58. <TextView
  59. android:id="@+id/tv_praise"
  60. style="@style/tv_base_14_gray"
  61. android:layout_marginStart="12dp"
  62. android:padding="6dp"
  63. android:drawablePadding="8dp"
  64. android:onClick="@{(view)->handler.onClick(view, item)}"
  65. android:text='@{item.hasChecked? "已贊": "贊"}'
  66. android:drawableStart="@{item.hasChecked? @drawable/ic_dynamic_praise_on: @drawable/ic_dynamic_praise}"
  67. app:layout_constraintTop_toTopOf="parent"
  68. app:layout_constraintBottom_toBottomOf="parent"
  69. app:layout_constraintEnd_toEndOf="parent"/>
  70. </androidx.constraintlayout.widget.ConstraintLayout>
  71. </layout>

10. 當博主執行時, 發現點贊沒變化 ...  什麼情況

原來這段程式碼有問題:

  1. fun praise(info: RoomEntity) {
  2. info.hasChecked = !info.hasChecked
  3. info.name = "我名變了"
  4. viewModelScope.launch {
  5. RoomTestDatabase.getInstance(getApplication()).roomDao().updRoom(info)
  6. }
  7. }
  1. info 是舊實體物件. 點贊狀態變為true;
    而資料庫更新後, 新實體物件的點贊狀態 也是 true;

    當下面這段程式碼執行時, 新舊物件的狀態一樣. Equals true; 所以列表沒有重新整理;
  1. override fun areContentsTheSame(oldItem: RoomEntity, newItem: RoomEntity): Boolean {
  2. // return !oldItem.hasChanged
  3. return oldItem == newItem
  4. }

怎麼辦?  只能讓舊實體的資料不變化: 如下所示, 單獨寫更新Sql;

或者 copy 一個新的實體物件, 變更狀態, 然後用新物件 更新資料庫;  我只能說  那好吧!

  1. //ViewModel
  2. fun praise(info: RoomEntity) {
  3. //這裡可以用 新實體物件來做更新. 也可以單獨寫 SQL
  4. viewModelScope.launch {
  5. RoomTestDatabase.getInstance(getApplication()).roomDao().updPraise(info.id, !info.hasChecked)
  6. }
  7. }
  8.  
  9. //Dao
  10. //修改單條資料
  11. @Query("update RoomEntity set hasChecked = :isPraise where id = :id")
  12. suspend fun updPraise(id: String, isPraise: Boolean) //修改點贊狀態;


  1. 11. 貼出效果圖


    總結:
    1.Paging 資料來源不開放, 只能通過 Room 做增刪改操作;
    2.如果只要求儲存第一頁資料, 用於網路狀態差時,儘快的頁面渲染. 而強制整個列表持久化儲存的話,博主認為這是一種資源浪費
    3.本地增刪改, 會讓列表資料失效. 為了單條記錄, 去重複建立整個列表物件. 無異於資源效能的浪費.
    4.因為是用Equals判斷條目變化, 所以需要額外注意, 舊物件的內容千萬不要更改. 更新時要用 Copy 物件去做. 這很彆扭;
    5.博主對 Paging 的瞭解不算深, 原始碼也沒看多少. 不知道上面幾條的理解是否有偏差. 但就目前來看,博主可能要 從入門到放棄了 [苦笑]

Over

回到頂部

  1.