Cache Strategy in Flutter

Romain Gréaume
8 min readNov 15, 2022

--

🔔 The package is out 🔔

You can now set up a caching strategy easily and quickly: https://pub.dev/packages/flutter_cache_strategy

Feel free to put a like to the package on pub.dev !

Cache management is a crucial thing to deal with in a mobile app.

In this article, I’ll tell you how we set up our strategy cache in my company, Beapp.

What is it ?

If you read this article, I think you know what cache is, but just in case…

Cache is basically storing data in your device’s storage. The idea next is to manipulate this data.

Why use cache ?

  • If the user has a bad connection or if he doesn’t have internet
  • To limit API calls, especially regarding data that don’t require being often refreshed
  • To store sensitive data (we go through this later)

A picture is worth a thousand words :

Cache Strategy Scheme

As you can see, the main purpose of the cache is to always try to display the data to the user.

Regarding sensitive data, I dissociate the user cache from the network cache for these reasons :

  • The network cache is more ephemeral than the user cache.
  • In contrast, the user cache stores sensitive data like access token, refresh token, which must be secure and not accessible by the user.
  • More specifically, a refresh token can have a long-running validity period (up to several months) while classic data could be refreshed after one hour, which would cause unnecessary API calls.

It is therefore good practice to separate these strategies, even if they can be merged.

Now we understand what cache is, let’s dive into the code !

How to set up these strategies ?

The file tree looks like this :

-- lib
----- core
------- cache
--------- storage
--------- strategy

In a subfolder storage we create a file storage.dart which contains an abstract class Storage

This class is a “contract” where we declare methods to manipulate data.

As I said, we will manipulate them across our app but for it, we need a way to store them in the device.

We use the Hive package, which is a key/value based storage solution.

To summarize, Hive creates a folder in the device’s storage where you can store a hive Box, which contains key:value data.

We can easily access this box by its name.

Now we can implement these methods from Storage abstract class. We create a cache_storage.dart file, always inside storage subfolder.

The principle is simple :

  • We create a hive instance at CacheStorage creation.
  • Each time we manipulate data, we will open our Hive box (with its name) and execute the method triggered (get, write, delete…).
  • We access data value easily with its key.

Now that we have our methods to manipulate data, we can set up different strategies to fit our different use cases in the application with a uniform call syntax.

We start to create a contract cache_strategy.dart in cache root. This contract allows us to apply one of the strategies and configure it.

  • defaultTTLValue is the time to live value of the data stored in the cache. In other terms: after this period, the data is considered invalid.
  • _storeCacheData() allows storing data thanks to a CacheWrapper which we will see later.
  • _isValid() checks if the cache fetch is still valid, compared to defaultTTLValue
  • invokeAsync() will fetch data from a remote location (usually from a Web Service) using the asyncBloc method passed as parameters, storing and returning the retrieved data.
  • fetchCacheData() will fetch the data from the cache via the key parameter, convert the JSON received with the Cache Wrapper to check if it is still valid and if it is, return the serialized data in a Dart object with the corresponding type, thanks to the serializerBloc.
  • applyStrategy() will execute the strategy to choose, with all parameters needed.

With these explanations, we can see the path of any strategy implemented :

  • We call applyStrategy() to indicate which strategy we want to apply, with the required parameters.
  • To fetch cached data fetchCacheData() is called. The method checks the validity with _isValid() and returns data or null.
  • To fetch from WS, we triggered invokeAsync() which, once the data is received, put them in cache with _storeCacheData().

Concerning the CacheWrapper you can create a file cache_wrapper.dart at the root cache folder.

CacheWrapper is a class that, as its name indicates, allows wrapping the received data. It takes 2 arguments, generic typed data which allows wrapping any type of data, and cachedDate which is automatically set at the date and time when the data is stored in the cache.

The fromJson() and toJson() methods convert the received data, either JSON for caching or Map to use it in the code.

CacheWrapper can therefore be interpreted as a “wrapper” that encompasses the cached data and allows this data to be encoded/decoded.

At this step of this article, our structure folders look like that :

-- lib
----- core
------- cache
--------- storage
----------- storage.dart
----------- cache_storage.dart
--------- cache_strategy.dart

Now that we’ve seen the definition of what our strategies can do, let’s dive into their implementation.

In a new strategy folder in cache root, we will create as many files as we have strategies.

Each strategy will be a singleton, so there will be only one instance of each strategy in the app.

We could use get_it to inject our strategies, but that increases the dependency on the package and all the disadvantages we know about third-party, so we create them ourselves.

Each strategy will inherit from the abstractCacheStrategy class. They will implement each of their strategies respectively with applyStrategy() method.

AsyncOrCache

This strategy will first call the endpoint to retrieve the data. If an error is thrown (for various reasons: error 401,403,500…), we retrieve the last data stored in the device’s cache. If there is nothing in the cache or invalid data, we return the error previously raised in order to handle it in our state manager (we’ll see it later).

CacheOrAsync

This last strategy is the same as the previous one, it is just reversed. First, we check if data is stored in the cache, if the result is null, we trigger a WS call. If an error is thrown, we handle it in the state manager.

JustAsync

This strategy calls the Web Service to fetch data.

JustCache

This strategy only uses the data stored in the device’s cache. The disadvantage is that if the application finds no data, nullwill be returned.

For the last two strategies, they could be directly replaced by a direct call to the cache or the network, but here we keep a uniform way of making the call.

Now that we’ve seen different strategies, let’s use them !

At the root cache folder, we create a cache_manager.dart file.

This file will contain all the logic to build our cache strategy. It will be injected directly into our code (I’ll come back to this point later).

Let me explain this file :

→ It’s separated into two classes : CacheManager and StrategyBuilder

CacheManager holds the entry point with the from() method. StrategyBuilder holds the other methods that allow us to build our cache session through some parameters such as asyncFunc, serializer etc…

  • defaultSessionName allows us to put a global name to the cache session that will be opened. For example, if we create a cache session for each logged-in user, we can set firstName + lastName + id of the user as the defaultSessionName, so we can easily manipulate the entire cache session with this name.
  • from(): This method creates a StrategyBuilder instance of a generic type <T> that allows returning any type : List, String, Object… A key parameter is passed, and it will be used in buildSessionKey() method for the hive box’s name. cacheStorage instance is also passed as a parameter so that the StrategyBuildercan use it and pass it to CacheStrategy.
    Finally, the StrategyBuilder's withSession() method is used to name the current cache session.
  • clear(): Allow to clear cache in different ways. We can clean a cache session with Strategy Builder's defaultSessionName or with prefix parameter, or clean all cache created.

Once the from() method is called, it’s the turn of the StrategyBuilder methods to be called :

  • withAsync(): we provide the AsyncBloc<T> function to our builder, who will fetch the data from a remote source (ex: an API).
  • withSerializer(): we provide the serializer/deserializer to our builder, which is responsible for transforming the JSON data received to a dart object and vice versa, with SerializerBloc<T> function.

Since the default serialization/deserialization in Dart is not optimized for complex objects, Flutter advises using a package (json_serializable).
It will generate methods automatically for each of our DTOs, which we then inject directly into the serializerBloc for the serialization of the data received from the cache.

  • withTtl(): provide the time to live for cache, by default we set it to 1 hour.
  • withStrategy(): received the strategy singleton which is chosen. Inject directly a singleton allows one to custom/add different strategies, it’s more flexible than an enum for example.
  • execute(): the latter method triggers the applyStrategy() method to execute the cache strategy.

How to use this strategy ?

Now that we’ve seen the theory, let’s get down to the practical side with the implementation of our cache strategy in our application.

I assure you, this is the simplest part.

First, we need to inject the CacheManagerwe’ve created. To do this we use the get_it package which will use dependency injection to create a singleton that can be used throughout the code base.

I advise you to create a service_locator.dart file in the core folder of your app.

So we have our CacheManager to manage the strategy and hold CacheStorage instance for storage.

This setupGetIt() method will be triggered in the app root starter to inject the CacheManager singleton.

As we try to work in clean architecture, our breakdown looks like that :

-- data
----- datasource
----- domain
----- dto
----- repository

The one we are most interested in is the repository folder because it acts as a gateway when it receives an input dto from the datasource to transform it into an entity, from the domain.

Let’s take an example of an application that will display the work to be done by a student.
We will need a method to retrieve the assignment.

First, we inject our HomeworkDataSource and CacheManager with get_it.

The datasource will be used to call the endpoint and the manager to configure the strategy.

In the Future getHomeworkAssignment we expect to get a list of HomeworkDto which will be converted in HomeworkEntity. We see our strategy applied as we explain :

  • from() set which dto will be used and give the cache’s key.
  • withSerializer() injects the method which will deserialize the data.
  • withAsync() inject the API call with the necessary parameters.
  • withStategy() allows defining the strategy to be chosen. We directly inject the singleton.
  • execute() will trigger our strategy by sending the defined arguments to StrategyBuilder.

Now, with this configuration, our strategy will first trigger an API call to retrieve the data from the server. If the call raises an error, the strategy will try to retrieve the data from the cache and at the end, it will return the data (fresh or not) or null to our UI.

I hope I’ve been clear with all these explanations, it’s quite a long article but if you’ve been following this caching strategy step by step, it’s quite obvious and simple to use.

Feel free to share your opinion/feedback in the comments 🙏

Thanks for reading!

--

--