Normalized cache
Apollo Android provides two different kinds of caches: an HTTP cache and a normalized cache. The HTTP cache is easier to set up but also has more limitations. This page focuses on the normalized cache. If you're looking for a simpler albeit coarser cache, take a look at the HTTP cache.
Data Normalization:
The normalized cache stores objects by ID.
1query BookWithAuthorName {
2 favoriteBook {
3 id
4 title
5 author {
6 id
7 name
8 }
9 }
10}
11
12query AuthorById($id: String!) {
13 author(id: $id) {
14 id
15 name
16 }
17 }
18}
In the above example, requesting the author of your favorite book with the AuthorById
query will return a result from the cache if you requested your favorite book before. This works because the author is stored only once in the cache and all the fields where retrieved in the initial BookWithAuthorName query. If you were to request more fields, like birthdate
for an example, that wouldn't work anymore.
To learn more about the process of normalization, check this blog post
Storing your data in memory
Apollo Android comes with an LruNormalizedCache
that will store your data in memory:
1// Create a 10MB NormalizedCacheFactory
2val cacheFactory = LruNormalizedCacheFactory(EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build())
3
4// Build the ApolloClient
5val apolloClient = ApolloClient.builder()
6 .serverUrl("https://...")
7 .normalizedCache(cacheFactory)
8 .build())
1// Create a 10MB NormalizedCacheFactory
2NormalizedCacheFactory cacheFactory = new LruNormalizedCacheFactory(EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build());
3
4// Build the ApolloClient
5ApolloClient apolloClient = ApolloClient.builder()
6 .serverUrl("https://...")
7 .normalizedCache(cacheFactory)
8 .build();
Persisting your data in a SQLite database
If the amount of data you store becomes too big to fit in memory or if you want your data to persist between app restarts, you can also use a SqlNormalizedCacheFactory
. A SqlNormalizedCacheFactory
will store your data in a SQLDelight database and is defined in a separate dependency:
1dependencies {
2 implementation("com.apollographql.apollo:apollo-normalized-cache-sqlite:x.y.z")
3}
Note: The apollo-normalized-cache-sqlite
dependency has Kotlin multiplatform support and has multiple variants (-jvm
, -android
, -ios-arm64
,...). If you are targetting Android and using custom buildTypes
, you will need to help Gradle resolve the correct artifact by defining matchingFallbacks:
1android {
2 buildTypes {
3 create("custom") {
4 // your code...
5 matchingFallbacks = listOf("debug")
6 }
7 }
8}
1android {
2 buildTypes {
3 custom {
4 // your code...
5 matchingFallbacks = ["debug"]
6 }
7 }
8}
Once the dependency is added, create the SqlNormalizedCacheFactory
:
1// Android
2val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory(context, "apollo.db")
3// JVM
4val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory("jdbc:sqlite:apollo.db")
5// iOS
6val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory("apollo.db")
7
8// Build the ApolloClient
9val apolloClient = ApolloClient.builder()
10 .serverUrl("https://...")
11 .normalizedCache(sqlNormalizedCacheFactory)
12 .build())
1// Android
2SqlNormalizedCacheFactory sqlNormalizedCacheFactory = new SqlNormalizedCacheFactory(context, "apollo.db")
3// JVM
4SqlNormalizedCacheFactory sqlNormalizedCacheFactory = new SqlNormalizedCacheFactory("jdbc:sqlite:apollo.db")
5
6// Build the ApolloClient
7ApolloClient apolloClient = ApolloClient.builder()
8 .serverUrl("https://...")
9 .normalizedCache(sqlNormalizedCacheFactory)
10 .build();
Chaining caches
To get the best of both caches, you can chain an LruNormalizedCacheFactory
with a SqlNormalizedCacheFactory
:
1
2val sqlCacheFactory = SqlNormalizedCacheFactory(context, "db_name")
3val memoryFirstThenSqlCacheFactory = LruNormalizedCacheFactory(
4 EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build()
5).chain(sqlCacheFactory)
6
1
2NormalizedCacheFactory sqlCacheFactory = new SqlNormalizedCacheFactory(context, "db_name");
3NormalizedCacheFactory memoryFirstThenSqlCacheFactory = new LruNormalizedCacheFactory(
4 EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build()
5).chain(sqlCacheFactory);
6
Reads will read from the first cache hit in the chain. Writes will propagate down the entire chain.
Specifying your object IDs
By default, Apollo Android uses the field path as key to store data. Back to the original example:
1query BookWithAuthorName {
2 favoriteBook {
3 id
4 title
5 author {
6 id
7 name
8 }
9 }
10}
11
12query AuthorById($id: String!) {
13 author(id: $id) {
14 id
15 name
16 }
17 }
18}
This will store the following records:
"favoriteBook"
:{"id": "book1", "title": "Les guerriers du silence", "author": "ApolloCacheReference{favoriteBook.author}"}
"favoriteBook.author"
:{"id": "author1", name": "Pierre Bordage"}
"author("id": "author1")"
:{"id": "author1", "name": "Pierre Bordage"}
"QUERY_ROOT"
:{"favoriteBook": "ApolloCacheReference{favoriteBook}", "author(\"id\": \"author1\")": "ApolloCacheReference{author(\"id\": \"author1\")}"}
This is undesirable, both because it takes more space, and because modifying one of those objects will not notify the watchers of the other. What you want instead is this:
"book1"
:{"id": "book1", "title": "Les guerriers du silence", "author": "ApolloCacheReference{author1}"}
"author1"
:{"id": "author1", name": "Pierre Bordage"}
"QUERY_ROOT"
:{"favoriteBook": "book1", "author(\"id\": \"author1\")": "author1"}
To do this, specify a CacheKeyResolver
when configuring your NormalizedCacheFactory
:
1val resolver: CacheKeyResolver = object : CacheKeyResolver() {
2 override fun fromFieldRecordSet(field: ResponseField, recordSet: Map<String, Any>): CacheKey {
3 // Retrieve the id from the object itself
4 return CacheKey.from(recordSet["id"] as String)
5 }
6
7 override fun fromFieldArguments(field: ResponseField, variables: Operation.Variables): CacheKey {
8 // Retrieve the id from the field arguments.
9 // In the example, this allows to know that `author(id: "author1")` will retrieve `author1`
10 // That sounds straightforward but without this, the cache would have no way of finding the id before executing the request on the
11 // network which is what we want to avoid
12 return CacheKey.from(field.resolveArgument("id", variables) as String)
13 }
14}
15
16val apolloClient = ApolloClient.builder()
17 .serverUrl("https://...")
18 .normalizedCache(cacheFactory, resolver)
19 .build()
1CacheKeyResolver resolver = new CacheKeyResolver() {
2 @NotNull @Override
3 public CacheKey fromFieldRecordSet(@NotNull ResponseField field, @NotNull Map<String, Object> recordSet) {
4 // Retrieve the id from the object itself
5 return CacheKey.from((String) recordSet.get("id"));
6 }
7
8 @NotNull @Override
9 public CacheKey fromFieldArguments(@NotNull ResponseField field, @NotNull Operation.Variables variables) {
10 // Retrieve the id from the field arguments.
11 // In the example, this allows to know that `author(id: "author1")` will retrive `author1`
12 // That sounds straightforward but without this, the cache would have no way of finding the id before executing the request on the
13 // network which is what we want to avoid
14 return CacheKey.from((String) field.resolveArgument("id", variables));
15 }
16};
17
18//Build the ApolloClient
19ApolloClient apolloClient = ApolloClient.builder()
20 .serverUrl("https://...")
21 .normalizedCache(cacheFactory, resolver)
22 .build();
For this resolver to work, every object in your graph needs to have a globally unique ID. If some of them don't have one, you can fall back to using the path as cache key by returning CacheKey.NO_KEY
.
Using the cache with your queries
You control how the cache is used with ResponseFetchers
:
1// Get a response from the cache if possible. Else, get a response from the network
2// This is the default behavior
3val apolloCall = apolloClient().query(BookWithAuthorName()).responseFetcher(ApolloResponseFetchers.CACHE_FIRST)
1// Get a response from the cache if possible. Else, get a response from the network
2// This is the default behavior
3ApolloCall apolloCall = apolloClient().query(new BookWithAuthorName()).responseFetcher(ApolloResponseFetchers.CACHE_FIRST)
Other possibilities are CACHE_ONLY
, NETWORK_ONLY
, CACHE_AND_NETWORK_ONLY
and NETWORK_FIRST
. See to the ResponseFetchers
class for more details.
Reacting to changes in the cache
One big advantage of using a normalized cache is that your UI can now react to changes in your cache data. If you want to be notified every time something changes in book1
, you can use a QueryWatcher
:
1 apolloClient.query(BookWithAuthorName()).watcher().toFlow().collect { response ->
2 // This will be called every time the book or author changes
3 }
1 apolloClient.query(new BookWithAuthorName()).watcher().enqueueAndWatch(new ApolloCall.Callback<T>() {
2 @Override public void onResponse(@NotNull Response<T> response) {
3 // This will be called every time the book or author changes
4 }
5
6 @Override public void onFailure(@NotNull ApolloException e) {
7 // This will be called if an error happens
8 }
9 });
Interacting with the cache
To manipulate the cache directly, ApolloStore
exposes read()
and write()
methods:
1 // Reading data from the store
2 val data = apolloClient.apolloStore.read(BookWithAuthorName()).execute()
3
4 // Create data to write
5 val data = BookWithAuthorName.Data(
6 id = "book1",
7 title = "Les guerriers du silence",
8 author = BookWithAuthorName.Author(
9 id = "author1",
10 name = "Pierre Bordage"
11 )
12 )
13 // Write to the store. All watchers will be notified
14 apolloClient.apolloStore.writeAndPublish(BookWithAuthorName(), data).execute()
Troubleshooting
If you are experiencing cache misses, check your cache size and eviction policy. Some records might have been removed from the cache. Increasing the cache size and/or retention period will help hitting your cache more consistently.
If you are still experiencing cache misses, you can dump the contents of the cache:
1val dump = apolloClient.getApolloStore().normalizedCache().dump();
2NormalizedCache.prettifyDump(dump)
1Map<KClass<?>, Map<String, Record>> dump = apolloClient.getApolloStore().normalizedCache().dump();
2NormalizedCache.prettifyDump(dump)
Make sure that no data is duplicated in the dump. If it is the case, it probably means that some objects have a wrong CacheKey
. Make sure to provide a CacheKeyResolver
that can work with your graph. All objects should have a unique and stable ID. That means that the ID should be the same no matter what path the object is in the graph. That also mean you have to include the identifier field in your queries to be able to use in from the CacheKeyResolver
.
Finally, make sure to design your queries so that you can reuse fields. A single missing field in the cache for a query will trigger a network fetch. Sometimes it might be useful to query an extra field early on so that it can be reused by other later queries.