Cache Strategy in Flutter
🔔 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 :
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 itskey
.
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, null
will 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 setfirstName + lastName + id
of the user as thedefaultSessionName
, 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… Akey
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 theStrategyBuilder
can use it and pass it toCacheStrategy
.
Finally, theStrategyBuilder'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 withprefix
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 CacheManager
we’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!