Paging3 (二) 結合Room
Paging 資料來源不開放, 無法隨意增刪改操作; 只能藉助 Room;
這就意味著: 從伺服器拉下來的資料全快取. 重新整理時資料全清再重新快取, 查詢條件變更時重新快取 [讓我看看]
當Room資料發生變化時, 會使記憶體中 PagingSource
失效。從而重新載入庫表中的資料
Room: 官方文件點這裡
Paging3: 官方文件點這裡.
本文內容:
本文導包:
- //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