Implement caching list data in Compose to improve user experience (Stale-while-revalidate)

foreword

Recently, I used Compose to implement a Github APP client in my spare time.

The benchmark is the Github APP implemented by the GSY boss using a variety of different language frameworks.

Some problems were found during the implementation process, because almost all the data of this client comes from the Github API, so UI rendering is also extremely dependent on the requested data.

And due to well-known reasons, when we use the Github API, the speed is anxious, and we can't even get the data directly.

This will cause the APP I wrote to look very "stuck" at the user level.

In fact, it's not that the APP is stuck, it's just that the data is not loaded.

So, how should we solve this problem?

I have two solutions now:

  1. Add loading animations, transition effects, etc.
  2. Increase the data cache, display the cached data first when requesting data, and then update the latest data to the UI after requesting the data

For solution 1, Compose already has a mature solution that can be used, but for solution 2, we need to implement it ourselves.

Cache Data Requirements Analysis

In my Github APP, displaying data is mainly achieved using SwipeRefreshcooperation LazyColumnand paging3.

Note: SwipeRefreshis accompanistone of the libraries of the project, which has been marked as obsolete

The effect is roughly as follows:

1.png

It can be seen that clicking on the APP and entering the home page is a page for displaying the dynamic data of the current user. If we do not do caching at this time, then what we see after entering the APP will be blank (or loading animation), which is obviously for the user Not very friendly.

So adding cache is very necessary.

As mentioned above, when I wrote this page, I used to paging3implement page loading data.

In fact, in paging3the data source support provided by , it not only supports obtaining data from the network, but also supports linkage roomto realize local data caching ( RemoteMediator):

paging3-layered-architecture.svg

RemoteMediatorWhen the data is needed, the data will paging3be queried from the database cache and returned to paging3. Only when the cache data is exhausted or the data is out of date and the user manually refreshes it will request new data from the network.

Using this scheme ensures that paging3the local database is always used as the only data source:

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.

Because this solution is not the focus of what we are going to talk about today, so I won’t repeat it here. If you need it, you can read another article I wrote before: Using Compose to implement TODO applications based on MVI architecture, retrofit2, and supporting glance widgets

Through the above general description, we can clearly RemoteMediatorguarantee that the only data source is the local database, and only obtain data from the network and fill it into the local cache when the data is insufficient or manually refreshed.

This will cause a problem. If we need to ensure the user experience and always have data displayed when opening the APP, we can only enable it to not actively refresh during initialization. In this way, the paging3locally cached data will always be used instead of actively requested. New data, which obviously does not meet our relatively high data timeliness requirements scenario.

Then maybe we can enable refresh data every initialization, but this is equivalent to re-requesting network data every time you enter the app without using cached data, which is equivalent to not using cache at all, which is still the same in the eyes of users It is just "blank" when it is opened.

In summary, RemoteMediatorit does not meet our needs.

When referring to the GitHub APP of the GSY boss, I found that he did not use any framework to achieve the cache requirements I mentioned, but wrote a set of cache loading logic himself.

His logic is also very simple to understand: when loading data, first check whether the local database has a cache, and if there is a cache, first take out the cache and display it. Then, no matter whether there is a cache or not, the network request is sent immediately after the query cache ends, and when the network request data is received, it is first cached to the local database, and then the current UI is replaced with new data.

I have to say that this idea is very clear and very suitable for my needs.

Stale-while-revalidate

Later, I checked a lot of information, intending to find a framework that can realize this logic, but after searching around, I couldn't find Compose or related frameworks available for Android.

Instead, I found the name of this caching logic: Stale-while-revalidate .

It turns out that this requirement has its own name, and its core idea is also very simple:

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.

Simply put, it is to use old data (cache data) first while requesting new data in the background.

Well, since there is no ready-made framework, we can only implement it ourselves.

Implement Compose's request data cache

Note: This section assumes that the reader already understands the basic usage of paging3

Because we need to display the cached data first when requesting, we first define a general one like this 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")
                    }
                }
            }
        }
    }
}

In this function, we receive three parameters:

  • pagingItemsThat is, the latest data loaded from the server returned by paging
  • cacheItemsi.e. locally cached data
  • itemUiThat is, the UI to be displayed

Then as long as cacheItemsis not empty, we will display cacheItemsthe data in first, and cacheItemsonly display the data if is empty pagingItems.

After that, we need to implement paging, PagingSourcehere I choose the relatively simple method of obtaining ISSUE comments in Github APP as an example:

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)
        }
    }

    // ……
}

To avoid confusion, I have omitted most of the non-critical code.

In the code here, we first use issueService.getIssueComments()to get the latest comment list, and then judge if it is the first page of data loaded, cache it into the database dataBase.cacheDB().insertIssueComment(DBIssueComment(issueUiModel))and call back onLoadFirstPageSuccess()the function, which is used in the business logic to handle operations such as updating the UI.

Then, in our VIewModel, we write the data acquisition code like this:

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)
}

The above code first obtains the corresponding data from the database. If the data is not empty, it will be updated to viewStateand then initialized IssueCommentsPagingSource. At this time, IssueCommentsPagingSourcewill immediately start requesting network data, and if the request is successful issueCommentFlow, it will be updated to and will also call back onLoadFirstPageSuccess()function, in this function, we reset the cached data to empty to ensure that the UI will use issueCommentFlowthe data instead of continuing to use the cached data.

Finally, we will call it like this in the UI code:

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

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

Summarize

So far, we have implemented our own Stale-while-revalidate .

The complete Github APP code can be found here: githubAppByCompose

However, in fact, there is still a small flaw in the code here, that is, we BasePagingLazyColumndid not use the same data source when defining , which will cause the full screen to flicker when the network request is completed and the data is updated.

For this flickering, I believe that Android developers are very familiar with it. In the traditional Android view system, this situation will also occur when updating the data of the list VIew such as RecyclerView, and the way to solve this situation in the traditional VIew It is to refresh the list on demand, and only refresh the changed list items.

So here, our solution to LazyColumnscreen flickering when data is updated in Compose is naturally the same, that is, we should write a diff class, and then LazyColumnuse the only data source in it. When switching from cached data to network data, it should be Refresh the changed data through diff, instead of roughly replacing the entire data source.

Of course, this article of ours is just a throw away, so I won’t talk about the specific implementation. Readers who need it are welcome to practice by themselves.

Guess you like

Origin blog.csdn.net/sinat_17133389/article/details/131143662