joreilly / MortyComposeKMM

GraphQL based Jetpack Compose and SwiftUI Kotlin Multiplatform project (using https://rickandmortyapi.com/graphql)

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support for local cache + data change listeners

cvb941 opened this issue · comments

Hello and thanks for this nice sample project,

If I understand correctly, this sample uses the Apollo GraphQL client to load data from the network only. The Apollo client supports multiple types of local caches (memory, SQLite), which would make list scrolling potentially multiple times faster by utilizing the locally cached data.

The other point is regarding the use of subscription queries. I would like to automatically reload the list whenever the underlying data changes. To listen to data changes, the subscription GraphQL queries resulting in Kotlin flows are pretty simple to use. However, the Android paging library (from my understanding), does not really play well with these and the only way to refresh the paging data is to invalidate the whole paging data source, making it load everything again.

I'm interested in a sample for either one of these use cases, ideally a single solution for both of them. Has anyone else already achieved this? So far I have not been able to come up with something that works and is elegant, although I feel like there should be a way to do it.

@martinbonnin in case you've come across something like this?

Hi 👋

The Apollo client supports multiple types of local caches (memory, SQLite), which would make list scrolling potentially multiple times faster by utilizing the locally cached data.

What you'd get with SQLite cache is offline mode but scrolling performance is most likely going to be unafected because jetpack compose already works from the ViewModel repository that is 100% in memory.

To listen to data changes, the subscription GraphQL queries resulting in Kotlin flows are pretty simple to use. [...] The only way to refresh the paging data is to invalidate the whole paging data source, making it load everything again.

I think it depends what data you have from the subscription. Unless your subscription can send patches saying what changed and at what position (somewhat similar to what DiffUtils is doing), your only solution is to reload everything. Without having more details about your backend it's really hard to tell.

Pagination in general is a big topic these days, there's an issue open there, feel free to subscribe for more updates.

What you'd get with SQLite cache is offline mode but scrolling performance is most likely going to be unafected because jetpack compose already works from the ViewModel repository that is 100% in memory.

I did not realize that about the ViewModel caching, but yes, the offline support is what I'm looking after too, I forgot to mention that.

I think it depends what data you have from the subscription. Unless your subscription can send patches saying what changed and at what position (somewhat similar to what DiffUtils is doing), your only solution is to reload everything. Without having more details about your backend it's really hard to tell.

I'm looking to use Hasura to generate the GraphQL endpoint. You could make the individual paged queries (with offset + limit) as subscriptions and then reload just that one page on each update to the latest query data. This would potentially result in hundreds of subscriptions, but maybe some built-in multiplexing would take care of it.

Another approach could be to use the streaming subscriptions on the whole table to get notifications only about changes to individual elements, although now I realized that it only provides updates about newly added data only.

Thanks for the link you provided. I have subscribed to it and also checked the Store library from Dropbox.

All right, I have come up with something that seems to work. Here's a very crude code for an Android's Paging3 PagingSource:

class ApolloPagingSource<T : Operation.Data, D : Any>(
    private val pageQueryFactory: (limit: Int?, offset: Int?) -> ApolloCall<T>, // A query for loading a single page
    private val subscriptionQuery: ApolloCall<*>? = null, // A subscription query on the whole table
    val resultFactory: (T) -> List<D>,
) : CoroutineScopedPagingSource<Int, D>() {

  init {
      launch {
          // Observe the subscription query and invalidate on changes 
          // (drop the initial result since it is always sent by the server)
          subscriptionQuery?.toFlow()?.drop(1)?.collect {
              Log.d("TAG", "Subscription updated")
              invalidate()
          }
      }
  }

  override suspend fun load(params: LoadParams<Int>): LoadResult<Int, D> {
      try {
          val currentOffset = params.key ?: 0
          val limit = params.loadSize

          val query = pageQueryFactory(limit, currentOffset)
              .fetchPolicy(FetchPolicy.CacheAndNetwork) // Always check both cache and network

          val firstResponse = CompletableDeferred<ApolloResponse<T>>()

          // The query flow will emit either one or two responses.
          // One from the cache (if present) and one from the network
          launch {
              query.toFlow().onEach {
                  Log.d("TAG", "Collecting response ${it.data}")
              }.distinctUntilChangedBy { it.data } // If the 2nd response contains the same data, we effectively drop it
              .collectIndexed { index, response ->
                  if (index == 0) {
                      // Complete the firstResponse Deferred and display it in UI
                      firstResponse.complete(response)
                  } else {
                      // If the second response was different, this branch executes and invalidates the PagingSource
                      Log.d("TAG", "Cache and network differ, invalidating")
                      invalidate()
                  }
              }
          }

          val response = firstResponse.await()
          return if (response.hasErrors()) {
              LoadResult.Error(Throwable(response.errors?.toString()))
          } else {
              val data = resultFactory(response.data!!)
              LoadResult.Page(
                  data = data,
                  prevKey = null, // Only forward
                  nextKey = if (data.size < limit) null else currentOffset + limit
              )
          }

      } catch (e: Exception) {
          // Handle errors in this block and return LoadResult.Error if it is an
          // expected error (such as a network failure).
          return LoadResult.Error(e)
      }
  }
}

This code seems to tick all boxes (quick loads and offline support + data updates). Don't mind the error handling etc. in the code yet.

One fundamental problem with the code above is, that when the PagingSource is invalidated due to the cache and network being different, the whole load function is executed again, which means that another request to the network (and cache) will always be made.

To explain it better, when you start up the app and the cache data is stale, this is what happens:

  1. Cache loaded -> data displayed in UI
  2. Network loaded -> data gets cached
  3. invalidate() called
  4. Cache loaded -> data displayed in UI
  5. Network loaded -> this is redundant

Edit: Forgot to put the query flow collection in a launch bracket