孟老闆 Paging3 (二) 結合Room

孟老闆發表於2021-06-22

Paging3 (二)  結合Room

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

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

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

 

Room: 官方文件點這裡

Paging3: 官方文件點這裡.

 

本文內容:

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

 

本文導包: 

//ViewModel, livedata, lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.2.0"
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'

//協程
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8'

//room
implementation "androidx.room:room-runtime:2.3.0"
kapt "androidx.room:room-compiler:2.3.0"
implementation("androidx.room:room-ktx:2.3.0")

//Paging
implementation "androidx.paging:paging-runtime:3.0.0"

 

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

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

@Entity
class RoomEntity(
    @Ignore
    //狀態標記重新整理條目方式, 用於ListAdapter;  但在 Paging 中廢棄了
    override var hasChanged: Boolean= false,
    @ColumnInfo
    //選中狀態,  這裡用作是否點贊.
    override var hasChecked: Boolean = false)
    : BaseCheckedItem {

    @PrimaryKey
    var id: String = ""     //主鍵
    @ColumnInfo
    var name: String? = null    //變數 name  @ColumnInfo 可以省去
    @ColumnInfo
    var title: String? = null   //變數 title

    @Ignore
    var content: String? = null //某內容;  @Ignore 表示不對映為表欄位
    @Ignore
    var index: Int = 0


    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as RoomEntity

        if (hasChecked != other.hasChecked) return false
        if (name != other.name) return false

        return true
    }

    override fun hashCode(): Int {
        var result = hasChecked.hashCode()
        result = 31 * result + (name?.hashCode() ?: 0)
        return result
    }
}

 

2. 建立 Dao

Room 必備的 Dao類; 

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

@Dao
interface RoomDao {
    //刪除單條資料
    @Query("delete  from RoomEntity where id = :id ")
    suspend fun deleteById(id:String)

    //修改單條資料
    @Update
    suspend fun updRoom(entity: RoomEntity) //修改點贊狀態;

    //新增資料方式
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(list: MutableList<RoomEntity>)

    //配合Paging;  返回 PagingSource
    @Query("SELECT * FROM RoomEntity")
    fun pagingSource(): PagingSource<Int, RoomEntity>

    //清空資料;  當頁面重新整理時清空資料
    @Query("DELETE FROM RoomEntity")
    suspend fun clearAll()
}

 

 

3. Database

Room 必備;

@Database(entities = [RoomEntity::class, RoomTwoEntity::class], version = 8)
abstract class RoomTestDatabase : RoomDatabase() {
    abstract fun roomDao(): RoomDao
    abstract fun roomTwoDao(): RoomTwoDao

    companion object {
        private var instance: RoomTestDatabase? = null
        fun getInstance(context: Context): RoomTestDatabase {
            if (instance == null) {
                instance = Room.databaseBuilder(
                    context.applicationContext,
                    RoomTestDatabase::class.java,
                    "Test.db" //資料庫名稱
                )
//                    .allowMainThreadQueries()   //主執行緒中執行
                    .fallbackToDestructiveMigration() //資料穩定前, 重建.
//                    .addMigrations(MIGRATION_1_2) //版本升級
                    .build()
            }
            return instance!!
        }
    }
}

 

4. 重點來了 RemoteMediator

官方解釋: 

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

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

注意:   

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

MediatorResult.Error 類似於 LoadResult.Error;  

 

@ExperimentalPagingApi
class RoomRemoteMediator(private val database: RoomTestDatabase)
    : RemoteMediator<Int, RoomEntity>(){
    private val userDao = database.roomDao()

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, RoomEntity>
    ): MediatorResult {
        return try {
            val loadKey = when (loadType) {
                //表示 重新整理.
                LoadType.REFRESH -> null    //loadKey 是頁碼標誌, null代表第一頁;
                LoadType.PREPEND ->
                    return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                    val first = state.firstItemOrNull()

                    Log.d("pppppppppppppppppp", "last index=${lastItem?.index}  id=${lastItem?.id}")
                    Log.d("pppppppppppppppppp", "first index=${first?.index}  id=${first?.id}")

                    //這裡用 NoMoreException 方式顯示沒有更多;
                    if(index>=15){
                        return MediatorResult.Error(NoMoreException())
                    }

                    if (lastItem == null) {
                        return MediatorResult.Success(
                            endOfPaginationReached = true
                        )
                    }

                    lastItem.index
                }
            }

            //頁碼標誌, 官方文件用的 lastItem.index 方式,  但這方式似乎有問題,第一頁last.index 應當是9. 但博主這裡總是0 ,
            //也可以資料庫儲存.  SharePrefences等;
            //如果資料庫資料僅用作 沒有網路時顯示.  不設定有效狀態或有效時長時,  則可以直接在 RemoteMediator 頁碼計數;
//            val response = ApiManager.INSTANCE.mApi.getDynamicList()
            val data = createListData(loadKey)

            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    userDao.clearAll()
                }
                userDao.insertAll(data)
            }

            //endOfPaginationReached 表示 是否最後一頁;  如果用 NoMoreException(沒有更多) 方式, 則必定false
            MediatorResult.Success(
                endOfPaginationReached = false
            )
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }

    private var index = 0
    private fun createListData(min: Int?) : MutableList<RoomEntity>{
        val result = mutableListOf<RoomEntity>()
        Log.d("pppppppppppppppppp", "啦資料了當前index=$index")
        repeat(10){
//            val p = min ?: 0 + it
            index++
            val p = index
            result.add(RoomEntity().apply {
                id = "test$p"
                name = "小明$p"
                title = "幹哈呢$p"
                index = p
            })
        }
        return result
    }
}

 

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

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

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

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

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

例如: 

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

 

5.ViewModel

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

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

class RoomModelTest(application: Application) : AndroidViewModel(application) {
    @ExperimentalPagingApi
    val flow = Pager(
        config = PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10),
        remoteMediator = RoomRemoteMediator(RoomTestDatabase.getInstance(application))
    ) {
        RoomTestDatabase.getInstance(application).roomDao().pagingSource()
    }.flow
        .cachedIn(viewModelScope)


    fun praise(info: RoomEntity) {
        info.hasChecked = !info.hasChecked  //這裡有個坑
        info.name = "我名變了"
        viewModelScope.launch {
            RoomTestDatabase.getInstance(getApplication()).roomDao().updRoom(info)
        }
    }

    fun del(info: RoomEntity) {
        viewModelScope.launch {
            RoomTestDatabase.getInstance(getApplication()).roomDao().deleteById(info.id)
        }
    }
}

 

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

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

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

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

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

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

 

class DiffCallbackPaging: DiffUtil.ItemCallback<RoomEntity>() {
    /**
     * 比較兩個條目物件  是否為同一個Item
     */
    override fun areItemsTheSame(oldItem: RoomEntity, newItem: RoomEntity): Boolean {
        return oldItem.id == newItem.id
    }

    /**
     * 再確定為同一條目的情況下;  再去比較 item 的內容是否發生變化;
     * 原來我們使用 狀態標識方式判斷;  現在我們要改為 Equals 方式判斷;
     * @return true: 代表無變化;  false: 有變化;
     */
    override fun areContentsTheSame(oldItem: RoomEntity, newItem: RoomEntity): Boolean {
//        return !oldItem.hasChanged
        if(oldItem !== newItem){
            Log.d("pppppppppppp", "不同")
        }else{
            Log.d("pppppppppppp", "相同")
        }
        return oldItem == newItem
    }
}

 

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

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

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

 

7. 貼出 Fragment 程式碼:

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

@ExperimentalPagingApi
override fun onLazyLoad() {
    mAdapter = SimplePagingAdapter(R.layout.item_room_test, object : Handler<RoomEntity>() {
        override fun onClick(view: View, info: RoomEntity) {
            when(view.id){
                R.id.tv_praise -> {
                    mViewModel?.praise(info)
                }
                R.id.btn_del -> {
                    mViewModel?.del(info)
                }
            }
        }
    }, DiffCallbackPaging())

    val stateAdapter = mAdapter.withLoadStateFooter(MyLoadStateAdapter(mAdapter::retry))
    mDataBind.rvRecycle.let {
        it.layoutManager = LinearLayoutManager(mActivity)
        // ****  這裡不要給 mAdapter(主資料 Adapter);  而是給 stateAdapter ***
        it.adapter = stateAdapter
    }

    //Activity 用 lifecycleScope
    //Fragments 用 viewLifecycleOwner.lifecycleScope
    viewLifecycleOwner.lifecycleScope.launchWhenCreated {
        mViewModel?.flow?.collectLatest {
            mAdapter.submitData(it)
        }
    }
}

 

8. 貼出 Adapter 程式碼:

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

open class SimplePagingAdapter<T: BaseItem>(
    private val layout: Int,
    protected val handler: BaseHandler? = null,
    diffCallback: DiffUtil.ItemCallback<T>
) :
    PagingDataAdapter<T, RecyclerView.ViewHolder>(diffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return NewViewHolder(
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context), layout, parent, false
            ), handler
        )
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if(holder is NewViewHolder){
            holder.bind(getItem(position))
        }
    }

}

 

9. 佈局檔案程式碼:

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable
            name="item"
            type="com.example.kotlinmvpframe.test.testroom.RoomEntity" />
        <variable
            name="handler"
            type="com.example.kotlinmvpframe.test.testtwo.Handler" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:paddingHorizontal="16dp"
        android:paddingVertical="28dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/tv_index_item"
            style="@style/tv_base_16_dark"
            android:gravity="center_horizontal"
            android:text="@{item.name}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>
        <TextView
            android:id="@+id/tv_title_item"
            style="@style/tv_base_16_dark"
            android:layout_width="0dp"
            android:textStyle="bold"
            android:lines="1"
            android:ellipsize="end"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="20dp"
            android:text="@{item.title}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toEndOf="@id/tv_index_item"
            app:layout_constraintEnd_toStartOf="@id/tv_praise"/>

        <TextView
            style="@style/tv_base_14_gray"
            android:gravity="center_horizontal"
            android:text='@{item.content ?? "暫無內容"}'
            android:layout_marginTop="4dp"
            app:layout_constraintTop_toBottomOf="@id/tv_index_item"
            app:layout_constraintStart_toStartOf="parent"/>

        <Button
            android:id="@+id/btn_del"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="刪除它"
            android:onClick="@{(view)->handler.onClick(view, item)}"
            android:layout_marginEnd="12dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@id/tv_praise"/>
        <TextView
            android:id="@+id/tv_praise"
            style="@style/tv_base_14_gray"
            android:layout_marginStart="12dp"
            android:padding="6dp"
            android:drawablePadding="8dp"
            android:onClick="@{(view)->handler.onClick(view, item)}"
            android:text='@{item.hasChecked? "已贊": "贊"}'
            android:drawableStart="@{item.hasChecked? @drawable/ic_dynamic_praise_on: @drawable/ic_dynamic_praise}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

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

原來這段程式碼有問題: 

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

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

 

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

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

//ViewModel
fun praise(info: RoomEntity) {
    //這裡可以用 新實體物件來做更新.  也可以單獨寫 SQL
    viewModelScope.launch {
        RoomTestDatabase.getInstance(getApplication()).roomDao().updPraise(info.id, !info.hasChecked)
    }
}

//Dao
//修改單條資料
@Query("update RoomEntity set hasChecked = :isPraise where id = :id")
suspend fun updPraise(id: String, isPraise: Boolean) //修改點贊狀態;


11. 貼出效果圖



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

Over

回到頂部

 

相關文章