孟老板 Paging3 (二) 结合Room

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

回到顶部

 
posted @ 2021-06-22 11:22  孟老板  阅读(1497)  评论(1编辑  收藏  举报