Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for local cache + data change listeners #15

Open
cvb941 opened this issue Jun 11, 2022 · 4 comments
Open

Support for local cache + data change listeners #15

cvb941 opened this issue Jun 11, 2022 · 4 comments

Comments

@cvb941
Copy link

cvb941 commented Jun 11, 2022

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.

@joreilly
Copy link
Owner

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

@martinbonnin
Copy link
Contributor

martinbonnin commented Jun 11, 2022

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.

@cvb941
Copy link
Author

cvb941 commented Jun 12, 2022

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.

@cvb941
Copy link
Author

cvb941 commented Jun 12, 2022

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants