在 Compose 中实现缓存列表数据提升用户体验(Stale-while-revalidate)

news2025/2/1 19:58:34

前言

最近在利用业余时间使用 Compose 实现一个 Github APP 客户端。

对标的是 GSY 大佬使用多种不同语言框架实现的 Github APP。

在实现过程中发现一些问题,因为这个客户端的数据几乎全部来自于 Github API,所以 UI 渲染也极度依赖于请求到的数据。

而由于众所周知的原因,我们在使用 Github API 时速度令人着急,甚至索性直接无法拿到数据。

进而就会导致我编写的这个 APP 在用户层面看起来似乎好像非常的 “卡顿”。

事实上,并非是 APP 卡顿,只是因为数据没有加载出来而已。

那么,应该怎么解决这个问题呢?

我现在做了两种解决方案:

  1. 增加加载动画、过渡效果等
  2. 增加数据缓存,在请求数据时先展示缓存数据,请求到数据后再把最新数据更新到 UI

对于方案 1,Compose 已经有成熟的方案可以使用,但是对于方案 2,就需要我们自己去实现了。

缓存数据需求分析

在我的这个 Github APP 中,展示数据主要是使用 SwipeRefresh 配合 LazyColumnpaging3 实现。

注意:SwipeRefreshaccompanist 项目的其中一个库,已经被标记为废弃

效果大概如下:

1.png

可以看到点开 APP 进入首页就是用于展示当前用户的动态数据的页面,此时如果我们不做缓存处理的话,那么进入 APP 后看到的将是一片空白(或加载动画),显然对用户不太友好。

所以加入缓存是十分有必要的。

正如上面所说,我在写这个页面时使用了 paging3 实现分页加载数据。

而事实上,在 paging3 提供的数据源支持中,不仅支持从网络获取数据,同时也支持联动 room 实现本地数据缓存(RemoteMediator):

paging3-layered-architecture.svg

RemoteMediator 会在 paging3 需要数据时从数据库缓存中查询数据并返回给 paging3 ,只有当缓存数据用尽或者数据过时用户手动刷新等情况时才会从网络请求新的数据。

使用此方案会保证 paging3 始终使用本地数据库作为唯一数据源:

A RemoteMediator implementation helps load paged data from the network into the database, but doesn’t load data directly into the UI. Instead, the app uses the database as the source of truth. In other words, the app only displays data that has been cached in the database. A PagingSource implementation (for example, one generated by Room) handles loading cached data from the database into the UI.

因为这个方案不是我们今天要说的重点,所以这里不再赘述,有需要的可以看我之前写的另外一篇文章:使用Compose实现基于MVI架构、retrofit2、支持 glance 小部件的TODO应用

通过上述大致说明,我们可以明确 RemoteMediator 保证唯一的数据源是本地数据库,并且在数据不足时或者手动刷新时才从网络获取数据并填充进本地缓存。

这就会造成一个问题,如果我们需要保证用户体验,在打开 APP 时始终有数据展示,那只能开启初始化时不主动刷新,如此一来,paging3 会始终使用本地缓存的数据而不会去主动请求新的数据,这显然不符合我们这种数据时效性要求相对较高的场景。

那或许我们可以开启每次初始化都刷新数据,但是如此一来相当于每次进入 APP 都会重新请求一次网络数据而不会使用缓存数据,这不就相当于压根没有使用缓存,在用户看来依旧是刚打开就是 “空白一片” 。

综上所述,RemoteMediator 并不符合我们的需求。

在参考 GSY 大佬的 GitHub APP 时,我发现他并没有用什么框架去实现我所说的这种缓存需求,而是自己写了一套缓存加载逻辑。

他的逻辑理解起来也很简单:在加载数据时首先查询本地数据库是否有缓存,如果有缓存则先将缓存取出并展示。然后无论是否有缓存,在查询缓存结束后立即开始发送网络请求,在接收到网络请求数据时先将其缓存到本地数据库,然后用新的数据替换当前UI。

不得不说,这个思路非常清晰也非常符合我的需求。

Stale-while-revalidate

后来我查阅了大量的资料,意图找到能够实现这种逻辑的框架了,但是找了一圈没找到 Compose 或者说 安卓 可用的相关框架。

倒是找到了这种缓存逻辑的名字:Stale-while-revalidate

原来这种需求是有自己名字的,而它的核心思想也很简单:

The stale-while-revalidate directive instructs CloudFront to immediately deliver stale responses to users while it revalidates caches in the background. The stale-if-error directive defines how long CloudFront should reuse stale responses if there’s an error, which provides a better user experience.

简单说就是在后台请求新数据的同时先使用旧数据(缓存数据)。

那么,即然没有现成的框架,我们只能自己来实现了。

实现 Compose 的请求数据缓存

注意:本节内容假设读者已了解 paging3 的基本使用方法

因为我们需要在请求时先展示缓存数据,所以我们先这样定义一个通用的 LazyColumn

@Composable
private fun <T: BaseUIModel>BasePagingLazyColumn(
    pagingItems: LazyPagingItems<T>?,
    cacheItems: List<T>? = null,
    itemUi: @Composable ColumnScope.(data: T) -> Unit,
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(bottom = 2.dp)
    ) {

        if (pagingItems == null) {
            item {
                Text(text = "No Data")
            }
        }
        else {
            val count = cacheItems?.size ?: pagingItems.itemCount
            items(count, key = {
                if (cacheItems == null) pagingItems.peek(it)!!.lazyColumnKey else cacheItems[it].lazyColumnKey
            }) {
                val item = if (cacheItems == null) pagingItems[it] else cacheItems[it]
                if (item != null) {
                    Column{
                        itemUi(data = item)
                    }
                }
            }

            if (pagingItems.itemCount < 1) {
                if (pagingItems.loadState.refresh == LoadState.Loading) {
                    item {
                        Text(text = "Loading...")
                    }
                }
                else {
                    item {
                        Text(text = "No More data")
                    }
                }
            }
        }
    }
}

在该函数中,我们接收三个参数:

  • pagingItems 即 paging 返回的从服务器加载的最新数据
  • cacheItems 即本地缓存的数据
  • itemUi 即要展示的UI

然后只要 cacheItems 不为空我们就优先展示 cacheItems 中的数据,如果 cacheItems 为空才展示 pagingItems 的数据。

之后,我们需要实现 pagingPagingSource,这里我选择了 Github APP 中相对简单的获取 ISSUE 评论来举例:

class IssueCommentsPagingSource(
    private val issueService: IssueService,
    private val dataBase: CacheDB,
    private val onLoadFirstPageSuccess: () -> Unit
): PagingSource<Int, IssueUIModel>() {

    // ……
    
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, IssueUIModel> {
        try {
            val nextPageNumber = params.key ?: 1
            val response = issueService.getIssueComments()

            // ……

            val issueUiModel = response.body()

            if (nextPageNumber == 1) { // 缓存第一页
                dataBase.cacheDB().insertIssueComment(
                    DBIssueComment(
                        // ……
                    )
                )

                if (!issueUiModel.isNullOrEmpty()) {
                    onLoadFirstPageSuccess()
                }
            }

            return LoadResult.Page(
                data = issueUiModel ?: listOf(),
                prevKey = null, // 设置为 null 表示只加载下一页
                nextKey = if (nextPageNumber >= totalPage || totalPage == -1) null else nextPageNumber + 1
            )
        } catch (e: Exception) {
            return LoadResult.Error(e)
        }
    }

    // ……
}

为了避免混淆,我省略掉了大多数非关键代码。

在该处代码中我们首先使用 issueService.getIssueComments() 获取到最新最新的评论列表,然后判断如果是加载的第一页数据的话就将其缓存进数据库 dataBase.cacheDB().insertIssueComment(DBIssueComment(issueUiModel)) 并且回调 onLoadFirstPageSuccess() 函数,用于业务逻辑中处理更新UI等操作。

然后,在我们的 VIewModel 中,我们这样写获取数据代码:

var isInit = false

private suspend fun loadCommentData() {
    
    val cacheData = dataBase.cacheDB().queryIssueComment(
        // ……
    )
    if (!cacheData.isNullOrEmpty()) {
        val body = cacheData[0].data?.fromJson<List<IssueEvent>>()
        if (body != null) {
            Log.i("el", "refreshData: 使用缓存数据")
            viewStates = viewStates.copy(cacheCommentList = body )
        }
    }

    issueCommentFlow = Pager(
        PagingConfig(pageSize = AppConfig.PAGE_SIZE, initialLoadSize = AppConfig.PAGE_SIZE)
    ) {
        IssueCommentsPagingSource(
            // ……
        ) {
            viewStates = viewStates.copy(cacheCommentList = null)
            isInit = true
        }
    }.flow.cachedIn(viewModelScope)

    viewStates = viewStates.copy(issueCommentFlow = issueCommentFlow)
}

上述代码首先从数据库中获取对应的数据,如果数据不为空则将其更新到 viewState 中,然后开始初始化 IssueCommentsPagingSource,此时 IssueCommentsPagingSource 会立即开始请求网络数据,并且如果请求成功则会更新到 issueCommentFlow 中,而且还会回调 onLoadFirstPageSuccess() 函数,在该函数中,我们把缓存数据重新设置为空以保证此时 UI 会使用 issueCommentFlow 的数据而不是继续使用缓存数据。

最后,我们会在 UI 代码中这样调用:

val commentList = viewState.issueCommentFlow?.collectAsLazyPagingItems()
val cacheList = viewState.cacheCommentList

BasePagingLazyColumn(
   commentList,
   cacheList
) {
     // ……
}

总结

至此,我们就实现了我们自己的 Stale-while-revalidate

完整的 Github APP 代码可以在这里找到: githubAppByCompose

但是,实际上这里代码还有一点小瑕疵,那就是我们在定义 BasePagingLazyColumn 时没有使用同一个数据源,这就会导致在网络请求完成,更新数据时会全屏闪烁一下。

对于这个闪烁,相信各位安卓开发大佬再熟悉不过了,在传统的安卓 view 体系中,更新诸如 RecyclerView 之类的列表 VIew 的数据时也会出现这个情况,而在传统 VIew 中解决这个情况的方式就是按需刷新列表,只刷新变动的列表项。

所以在这里,我们解决 Compose 中 LazyColumn 数据更新时屏幕闪烁的方法自然也是一样的,那就是我们应该写一个 diff 类,然后在 LazyColumn 中使用唯一的数据源,当从缓存数据切到网络数据时,应该是通过 diff 去刷新变化的数据,而不是粗暴的替换整个数据源。

当然,我们这篇文章只是抛砖引玉,所以具体的实现就不再说了,有需要的读者欢迎自己实践。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/631208.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

JAVA面向对象(三)

第三章 封装与继承 目录 第三章 封装与继承 1.1.封装 1.2.包 1.3.访问权限控制 1.4.static修饰符 1.4.1.成员变量 1.4.2.成员方法 1.4.3.代码块 总结 内容仅供学习交流&#xff0c;如有问题请留言或私信&#xff01;&#xff01;&#xff01;&#xff01;&#xff0…

java并发编程:ArrayBlockingQueue详解

文章目录 一、简介二、数据结构三、源码分析3.1 属性3.2 构造方法3.3 方法3.3.1 入队3.3.2 出队3.3.3 获取元素3.3.4 删除元素 四、总结 一、简介 ArrayBlockingQueue 顾名思义&#xff1a;基于数组的阻塞队列。数组是要指定长度的&#xff0c;所以使用 ArrayBlockingQueue 时…

在知乎逮到一个腾讯10年老测试,聊过之后收益良多...

老话说的好&#xff0c;这人呐&#xff0c;一单在某个领域鲜有敌手了&#xff0c;就会闲得蛋疼。前几天我在上班摸鱼刷知乎的时候认识了一位腾讯测试大佬&#xff0c;在腾讯工作了10年&#xff0c;因为本人天赋比较高&#xff0c;平时工作也兢兢业业&#xff0c;现在企业内有一…

Python基础知识讲解——main方法

前言 嗨喽&#xff0c;大家好呀~这里是爱看美女的茜茜呐 估计很多人跟我一样初学python看代码的时候先找一下main()方法&#xff0c;从main往下看。 但事实上python中是没有你理解中的“main()”方法的。 言归正传 if name "main":可以看成是python程序的入口&a…

数据结构与算法系列之习题练习

&#x1f497; &#x1f497; 博客:小怡同学 &#x1f497; &#x1f497; 个人简介:编程小萌新 &#x1f497; &#x1f497; 如果博客对大家有用的话&#xff0c;请点赞关注再收藏 &#x1f31e; 力扣习题 括号匹配问题。用队列实现栈。用栈实现队列。设计循环队列。 有效的括…

【数据分析案例】深度分析超市零售商店数据--Python数据分析实战

前言 咳咳&#xff0c;又是好久不见~这不高考已经结束了 对python感兴趣的准大学生们&#xff0c;是打算好好玩几个月还是&#xff0c;继续研究学习python呢~ &#x1f928; 我呢 还是建议大家劳逸结合哈哈 先玩再学习~ 当然啦 最重要的还是看你们自己呀 不过我以上这些都不能…

[NOI2007] 调兵遣将

题目描述 我军截获的情报显示&#xff0c;敌军正在集结兵力试图向我军重要的军械研究所发起进攻。由于我军正处于多线作战的状态&#xff0c;无法抽调大批兵力前去支援&#xff0c;指挥部决定通过有效的战前部署来提高胜率&#xff0c;减少伤亡和损失。 该军械研究所的平面图…

网络安全自学笔记+学习路线+就业规划(超详细)

每天都有新闻报道描述着新技术对人们的生活和工作方式带来的巨大乃至压倒性影响。与此同时有关网络攻击和数据泄露的头条新闻也是日益频繁。 攻击者可谓无处不在&#xff1a;企业外部充斥着黑客、有组织的犯罪团体以及民族国家网络间谍&#xff0c;他们的能力和蛮横程度正日渐…

如何使虚拟机自动生成ip地址

一. 打开虚拟机并登录账号进入命令行界面输入指令&#xff1a; vi /etc/sysconfig/network-scripts/ifcfg-ens33 通过指令进入到下面的界面当中 点击键盘输入 "i" 进入编辑模式将文件修改为 文件当中的 BOOTPROTO可以将ip地址定义为自动生成类型或者静态指定类型其中…

力扣 213. 打家劫舍 II

一、题目描述 你是一个专业的小偷&#xff0c;计划偷窃沿街的房屋&#xff0c;每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈&#xff0c;这意味着第一个房屋和最后一个房屋是紧挨着的。同时&#xff0c;相邻的房屋装有相互连通的防盗系统&#xff0c;如果两间相邻…

高考后计算机人工智能大类专业的选择建议

随着GPT的出现&#xff0c;很多人开始质疑是否还需要学计算机专业&#xff0c;计算机专业是否会消失。 先给结论&#xff0c;不会&#xff01; 只是会产生分层&#xff0c;大体上是这样的&#xff1a; 核心代码部分还是需要人来写的&#xff0c;只要是代码方式出现的结果&#…

了解ASEMI代理英飞凌TLE6208-6G其功能和应用的综合指南

编辑-Z TLE6208-6G是一款高度集成、通用且高效的汽车半桥驱动器&#xff0c;由英飞凌设计。这种功能强大的设备专门设计用于满足汽车应用的苛刻要求&#xff0c;如控制直流电机、螺线管和电阻负载。在本文中&#xff0c;我们将深入研究TLE6208-6G的功能、优点和应用&#xff0…

【Spring MVC】这几种传参方式这么强大,让我爱不释手,赶快与我一起去领略吧 ! ! !

前言: 大家好,我是良辰丫,在上2一篇文章中我们已经初步认识了Spring MVC,并且学习了热部署的配置,今天我们将继续开始我们的Spring MVC的学习! ! !&#x1f48c;&#x1f48c;&#x1f48c; &#x1f9d1;个人主页&#xff1a;良辰针不戳 &#x1f4d6;所属专栏&#xff1a;jav…

JDK9~17+Springboot3 @Resource常见问题和解决方案

一、常见问题描述 因为JDK版本升级的改动&#xff0c;在Jdk9~17环境下&#xff0c;搭建Springboot项目&#xff0c;会出现原有Resource&#xff08;javax.annotation.Resource&#xff09;不存在的问题&#xff0c;导致项目从Jdk8迁移到高版本时遇到的问题 原因 你可能会问&…

TiDB亿级数据亚秒响应查询将MySql数据全量迁移到TiDB

目录 1 下载安装TiDB工具包1.1 检查最新版本1.2 下载tidb-toolkit 2 Dumpling导出数据2.1 Dumpling工具简介2.2 导出需要的权限2.3 创建用户并授权2.4 验证数据库2.5 导出sql文件2.6 查看导出文件 3 TiDB Lightning导入数据3.1 TiDB Lightning简介3.2 TiDB Lightning 整体架构3…

蓝桥杯【第14届国赛】Python B组

本题解仅代表个人观点&#xff0c;仅供参考&#xff0c;欢迎各位指正 A&#xff1a;弹珠堆放 【问题描述】 小蓝有 20230610 颗磁力弹珠&#xff0c;他对金字塔形状尤其感兴趣&#xff0c;如下图所示&#xff1a; 高度为 1 的金字塔需要 1 颗弹珠&#xff1b; 高度为 2 的金字…

【Jetpack】使用 Room 中的 Migration 升级数据库异常处理 ( 多个数据库版本的迁移 | fallbackToDestructiveMigration() 函数处理升级异常 )

文章目录 一、Room#Migration 迁移工具升级数据库二、多个数据库版本的迁移三、数据库异常处理 - RoomDatabase.Builder#fallbackToDestructiveMigration() 函数四、完整代码示例 一、Room#Migration 迁移工具升级数据库 Room Migration 数据库迁移工具 是 Android Jetpack Arc…

【AI实战营第二期】第三次作业——基于 RTMDet 的气球检测(包含数据集)

作业&#xff1a;基于 RTMDet 的气球检测 背景&#xff1a;熟悉目标检测和 MMDetection 常用自定义流程。 任务&#xff1a; 基于提供的 notebook&#xff0c;将 cat 数据集换成气球数据集; 按照视频中 notebook 步骤&#xff0c;可视化数据集和标签; 使用MMDetection算法库…

01_Linux字符设备驱动开发

目录 字符设备驱动简介 驱动模块的加载和卸载 字符设备注册与注销 实现设备的具体操作函数 添加LICENSE和作者信息 Linux设备号的组成 设备号的分配 chrdevbase字符设备驱动开发实验 创建VSCode工程 添加头文件路径 编写实验程序 C库文件操作基本函数 编写测试APP…

苹果Vision Pro正式发布,下一个iPhone诞生了?

在库克即将退休之际&#xff0c;苹果开启了下一个十年。 2023年6月6日&#xff0c;在苹果WWDC开发者大会上&#xff0c;苹果发布了15寸的MacBook Air&#xff0c;以及一众iOS 17、iPad OS 17、Mac OS等系统的更新。当我们觉得这些常规更新有点不痛不痒&#xff0c;甚至想大呼“…