Android笔记(二十三):Paging3分页加载库结合Compose的实现分层数据源访问

Android笔记(二十二):Paging3分页加载库结合Compose的实现网络单一数据源访问一文中,实现了单一数据源的访问。在实际运行中,往往希望不是单纯地访问网络数据,更希望将访问的网络数据保存到移动终端的SQLite数据库中,使得移动应用在离线的状态下也可以从数据库中获取数据进行访问。在本笔记中,将讨论多层次数据的访问,即结合网络资源+本地SQLite数据库中的数据的处理。在本笔记中,仍然采用Android笔记(二十二)中的网络资源:

上列展示的json数组包含了多个json对象,每个json对象的格式类似下列形式:

kotlin 复制代码
{"actors":"演员",
"directors":"导演",
"intro":"电影简介",
"poster":"http://localhost:5000/photo/s_ratio_poster/public/p2626067725.jpg",
"region":"地区",
"release":"发布年份",
"trailer_url":"https://localhost:5000/trailer/268661/#content",
"video_url":"https://localhost:5000/d04d3c0d2132a29410dceaeefa97e725/view/movie/M/402680661.mp4"}

一、分层次访问数据的架构

与单一数据源结构不同在于增加了RemoteMediator。当应用的已缓存数据用尽时,RemoteMediator 会充当来自 Paging 库的信号。可以使用此信号从网络加载更多数据并将其存储在本地数据库中,PagingSource 可以从本地数据库加载这些数据并将其提供给界面进行显示。

当需要更多数据时,Paging 库从 RemoteMediator 实现调用 load() 方法。这是一项挂起功能,因此可以放心地执行长时间运行的工作。此功能通常从网络源提取新数据并将其保存到本地存储空间。

此过程会处理新数据,但长期存储在数据库中的数据需要进行失效处理(例如,当用户手动触发刷新时)。这由传递到 load() 方法的 LoadType 属性表示。LoadType 会通知 RemoteMediator 是需要刷新现有数据,还是提取需要附加或前置到现有列表的更多数据。

通过这种方式,RemoteMediator 可确保应用以适当的顺序加载用户要查看的数据。

二、定义实体类

1.定义Film类

kotlin 复制代码
@Entity(tableName="films")
data class Film(
    @PrimaryKey(autoGenerate = false)
    @SerializedName("name")
    val name:String,
    @SerializedName("release")
    val release:String,
    @SerializedName("region")
    val region:String,
    @SerializedName("directors")
    val directors:String,
    @SerializedName("actors")
    val actors:String,
    @SerializedName("intro")
    val intro:String,
    @SerializedName("poster")
    val poster:String,
    @SerializedName("trailer_url")
    val trailer:String,
    @SerializedName("video_url")
    val video:String
)

在上述代码中,将Film类映射为数据库中的数据表films。对应的数据表结构如下所示:

2.定义FilmRemoteKey类

因为从网络访问每一个条电影记录需要知道记录的上一页和下一页的内容,因此定义FilmRemoteKey类,代码如下:

kotlin 复制代码
@Entity(tableName = "filmRemoteKeys")
data class FilmRemoteKey(
    @PrimaryKey(autoGenerate = false)
    val name:String,
    val prePage:Int?,
    val nextPage:Int?
)

FilmRemoteKey对应的数据表结构如下:

name表示电影名,也是关键字

prePage表示记录的上一页的页码,因为第一页的所有记录没有上一页,因此,前5条记录的prePage均为空

nextPage表示记录的下一页的页面。

三、定义网络访问

1.网络访问服务接口

kotlin 复制代码
interface FilmApi {
    @GET("film.json")
    suspend fun getData(
        @Query("page") page:Int,
        @Query("size") size:Int
    ):List<Film>
}

2.Retrofit构建网络服务

kotlin 复制代码
object RetrofitBuilder {
    private const val  BASE_URL = "http://10.0.2.2:5000/"

    private fun getRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    val apiService:FilmApi = getRetrofit().create(FilmApi::class.java)
}

四、定义数据库的访问

1.电影数据访问对象的接口

kotlin 复制代码
@Dao
interface FilmDao {
    /**
     * 插入数据列表
     * @param  films List<Film>
     */
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(films: List<Film>)

    /**
     * 检索所有的Film记录
     * @return PagingSource<Int, Film>
     */
    @Query("select * from films")
    fun queryAll(): PagingSource<Int, Film>

    /**
     * Delete all
     * 删除表films中所有记录
     */
    @Query("DELETE FROM films")
    suspend fun deleteAll()
}

2.电影页码数据访问对象的接口

kotlin 复制代码
@Dao
interface FilmRemoteKeyDao {
    @Query("SELECT * FROM filmRemoteKeys WHERE name = :name")
    suspend fun findByName(name:String):FilmRemoteKey

    @Insert(onConflict =OnConflictStrategy.REPLACE)
    suspend fun insertAllKeys(remoteKeys:List<FilmRemoteKey>)

    @Query("DELETE FROM filmRemoteKeys")
    suspend fun deleteAllKeys()
}

3.创建数据库

kotlin 复制代码
@Database(entities = [Film::class,FilmRemoteKey::class], version = 1)
abstract class FilmDatabase : RoomDatabase() {
    abstract fun filmDao(): FilmDao
    abstract fun filmRemoteKeyDao():FilmRemoteKeyDao

    companion object{
        private var instance: FilmDatabase? = null

        /**
         * 单例模式创建为一个FilmDB对象实例
         */
        @Synchronized
        fun getInstance(context:Context = FilmApp.context): FilmDatabase {
            instance?.let{
                return it
            }
            return Room.databaseBuilder(
                context,
                FilmDatabase::class.java,
                "filmDB.db"
            ).build()
        }
    }
}

五、定义代码层

1.定义RemoteMediator类

kotlin 复制代码
@OptIn(ExperimentalPagingApi::class)
class FilmRemoteMediator(
    private val database:FilmDatabase,
    private val networkService:FilmApi
) : RemoteMediator<Int, Film>() {
    private val filmDao = database.filmDao()
    private val filmRemoteKeyDao = database.filmRemoteKeyDao()

    override suspend fun load(loadType: LoadType,state: PagingState<Int, Film>): MediatorResult {
        return try{
            /**
             *  从数据库获取缓存的当前页面
             */
            val currentPage:Int = when(loadType){
                //UI初始化刷新
                LoadType.REFRESH-> {
                    val remoteKey:FilmRemoteKey? = getRemoteKeyToCurrentPosition(state)
                    remoteKey?.nextPage?.minus(1)?:1
                }
                //在当前列表头添加数据使用
                LoadType.PREPEND-> {
                    val remoteKey = getRemoteKeyForTop(state)
                    val prevPage = remoteKey?.prePage?:return MediatorResult.Success(remoteKey!=null)
                    prevPage
                }
                //尾部加载更多的记录
                LoadType.APPEND->{
                    val remoteKey = getRemoteKeyForTail(state)
                    val nextPage = remoteKey?.nextPage?:return MediatorResult.Success(remoteKey!=null)
                    nextPage
                }
            }

            /**
             * 联网状态下的处理
             * 获取网络资源
             * response
             */
            val response = networkService.getData(currentPage,5)
            val endOfPaginationReached = response.isEmpty()

            val prePage = if(currentPage == 1) null else currentPage-1
            val nextPage = if(endOfPaginationReached) null else currentPage+1

            database.withTransaction{
                //刷新记录,需要删除原有的记录
                if(loadType == LoadType.REFRESH){
                    filmDao.deleteAll()
                    filmRemoteKeyDao.deleteAllKeys()
                }
                //获取的记录映射成对应的索引记录
                val keys:List<FilmRemoteKey> = response.map{film:Film->
                    FilmRemoteKey(film.name,prePage,nextPage)
                }

                filmRemoteKeyDao.insertAllKeys(keys)
                filmDao.insertAll(response)
            }

            MediatorResult.Success(endOfPaginationReached)

        }catch(e:IOException){
            MediatorResult.Error(e)
        }catch(e:HttpException){
            MediatorResult.Error(e)
        }
    }

    /**
     * 获取当前位置对应的FilmRemoteKey
     * @param state PagingState<Int, Film>
     * @return FilmRemoteKey?
     */
    private suspend fun getRemoteKeyToCurrentPosition(state:PagingState<Int,Film>):FilmRemoteKey?=
        state.anchorPosition?.let{position:Int->
            state.closestItemToPosition(position)?.name?.let{name:String->
                filmRemoteKeyDao.findByName(name)
            }
        }

    /**
     * 获取当前页面从头部第一个位置对应的FilmRemoteKey
     * @param state PagingState<Int, Film>
     * @return FilmRemoteKey?
     */
    private suspend fun getRemoteKeyForTop(state:PagingState<Int,Film>):FilmRemoteKey?=
        state.pages.firstOrNull{ it:PagingSource.LoadResult.Page<Int,Film>->
            it.data.isNotEmpty()
        }?.data?.firstOrNull()?.let{film:Film->
            filmRemoteKeyDao.findByName(film.name)
        }

    /**
     * 获取当前尾部最后一个位置对应的FilmRemoteKey
     * @param state PagingState<Int, Film>
     * @return FilmRemoteKey?
     */
    private suspend fun getRemoteKeyForTail(state:PagingState<Int,Film>):FilmRemoteKey?=
        state.pages.lastOrNull{it:PagingSource.LoadResult.Page<Int,Film>->
            it.data.isNotEmpty()
        }?.data?.lastOrNull()?.let{film:Film->
            filmRemoteKeyDao.findByName(film.name)
        }
}

2.定义PagingSource数据源

kotlin 复制代码
@ExperimentalPagingApi
class FilmRepository(
    private val filmApi:FilmApi,
    private val filmDatabase:FilmDatabase
) {

    fun getAllFilms(): Flow<PagingData<Film>> {
        val pagingSourceFactory:()->PagingSource<Int, Film> = {
            filmDatabase.filmDao().queryAll()
        }

        return Pager(
            config = PagingConfig(pageSize = 5),
            initialKey = null,
            remoteMediator = FilmRemoteMediator(filmDatabase,filmApi),
            pagingSourceFactory = pagingSourceFactory
        ).flow
    }
}

六、定义视图模型层

kotlin 复制代码
@OptIn(ExperimentalPagingApi::class)
class MainViewModel(): ViewModel() {
    val filmRepository:FilmRepository = FilmRepository(RetrofitBuilder.apiService,FilmDatabase.getInstance())
    fun getFilms()=filmRepository.getAllFilms()
}

七、定义界面层

1.单独电影界面的定义

kotlin 复制代码
@Composable
fun FilmCard(film: Film?) {
    Card(modifier = Modifier
        .fillMaxSize()
        .padding(2.dp),
        elevation = CardDefaults.cardElevation(5.dp),
        colors = CardDefaults.cardColors(containerColor = Color.DarkGray)){
        Column{
            Row(modifier = Modifier.fillMaxSize()){
                AsyncImage(
                    modifier=Modifier.width(180.dp).height(240.dp),
                    model = "${film?.poster}",
                    contentDescription = "${film?.name}")
                Column{
                    Text("${film?.name}",fontSize = 18.sp,color = Color.Green)
                    Text("导演:${film?.directors}",fontSize = 14.sp,color = Color.White)
                    Text("演员:${film?.actors}", fontSize = 14.sp,color = Color.Green)
                }
            }
            Text("${film?.intro?.subSequence(0,60)} ...",fontSize = 14.sp,color= Color.White)
            Row(horizontalArrangement = Arrangement.End,
                modifier = Modifier.fillMaxSize()){
                Text("More",fontSize=12.sp)
                IconButton(onClick ={}){
                    Icon(imageVector = Icons.Default.MoreVert,tint= Color.Green,contentDescription = "更多...")
                }
            }

        }
    }
}

2.定义电影列表

kotlin 复制代码
@Composable
fun FilmScreen(mainViewmodel:MainViewModel){
    val films = mainViewmodel.getFilms().collectAsLazyPagingItems()
    Column(horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.background(Color.White)){
        LazyColumn{
            items(films.itemCount){
                FilmCard(films[it])
            }
        }
    }
}

八、定义主活动MainActivity

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val mainViewModel:MainViewModel = viewModel()
            Ch11_DemoTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    FilmScreen(mainViewmodel = mainViewModel)
                }
            }
        }
    }
}

参考文献

Paging库概览

https://developer.android.google.cn/topic/libraries/architecture/paging/v3-overview?hl=zh-cn

相关推荐
BoomHe3 小时前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农10 小时前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos
鹏程十八少10 小时前
4.Android 30分钟手写一个简单版shadow, 从零理解shadow插件化零反射插件化原理
android·前端·面试
Kapaseker11 小时前
一杯美式搞定 Kotlin 空安全
android·kotlin
三少爷的鞋11 小时前
Android 协程时代,Handler 应该退休了吗?
android
tingshuo291720 小时前
S001 【模板】从前缀函数到KMP应用 字符串匹配 字符串周期
笔记
火柴就是我1 天前
让我们实现一个更好看的内部阴影按钮
android·flutter
砖厂小工1 天前
用 GLM + OpenClaw 打造你的 AI PR Review Agent — 让龙虾帮你审代码
android·github
张拭心1 天前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心1 天前
Android 17 来了!新特性介绍与适配建议
android·前端