Cache Strategy in Flutter
In relation to this article, I’m creating a package (WIP) to set up a caching strategy easily and quickly: https://github.com/romgrm/flutter_cache_strategy
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 :
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 :
In a subfolder
storage we create a file
storage.dart which contains an abstract class
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
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
The principle is simple :
- We create a hive instance at
- 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
valueeasily with its
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 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
CacheWrapperwhich we will see later.
- _isValid() checks if the cache fetch is still valid, compared to
- invokeAsync() will fetch data from a remote location (usually from a Web Service) using the
asyncBlocmethod 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
- 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 check 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
CacheWrapper you can create a file
cache_wrapper.dart at the root
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 :
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 abstract
CacheStrategy class. They will implement each of their strategies respectively with
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).
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.
This strategy calls the Web Service to fetch data.
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
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 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
defaultSessionNameallows 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 + idof the user as the
defaultSessionName, so we can easily manipulate the entire cache session with this name.
- from(): This method creates a
StrategyBuilderinstance of a generic type
<T>that allows returning any type : List, String, Object… A
keyparameter is passed, and it will be used in buildSessionKey() method for the hive box’s name.
cacheStorageinstance is also passed as a parameter so that the
StrategyBuildercan use it and pass it to
StrategyBuilder'swithSession() 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 defaultSessionNameor with
prefixparameter, 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
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
As we try to work in clean architecture, our breakdown looks like that :
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
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
CacheManager with get_it.
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
dtowill 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
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!