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"
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!! } } }
官方解釋:
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 } }
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