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

前言

最近在利用业余时间使用 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

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

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

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

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

paging3-layered-architecture

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 去刷新变化的数据,而不是粗暴的替换整个数据源。

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