Flutter’s caching strategy

Preface

Cache management is a very important thing in mobile applications.

In this article, I’ll tell you how to set up policy caching in my company’s Beapp.

text

W What happened?

If you read this, I assume you know what caching is, but just in case…

Caching basically stores data in the device’s memory.

W Why use caching?

  • If the user has a bad connection or no internet
  • Limit API calls, especially for data that does not need to be refreshed frequently
  • Store sensitive data (we discuss this later)

A picture is worth a thousand words:

Cache Strategy Scheme

Cache strategy plan

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

Regarding sensitive data, I separate the user cache from the network cache for the following reasons:

  • Network caches are more ephemeral than user caches.
  • In contrast, the user cache stores sensitive data such as access tokens and refresh tokens, which must be secure and cannot be accessed by users.
  • More specifically, refresh tokens may have a long validity period (up to several months), and classic data may be refreshed after an hour, which will result in unnecessary API calls.

Therefore, it is good practice to keep these strategies separate, even though they can be combined.

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

H How to establish these strategies?

The file tree looks like this:

-- lib

----- core

------- cache

--------- storage

--------- strategy

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

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

abstract class Storage {
  Future<void> write(String key, String value);

  Future<String?> read(String key);

  Future<void> delete(String key);

  Future<int> count({String? prefix});

  Future<void> clear({String? prefix});
}

As I said, we will manipulate them through our application, but for this 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 hiveBox containing key: value data.

We can easily access this box by its name.

Now we can implement these methods from the Storage abstract class.

class CacheStorage implements Storage {
  static const _hiveBoxName = "cache";

  CacheStorage()  {
    Hive.initFlutter() ;
  }

  @override
  Future<void> clear({String? prefix}) async {
    final box = await Hive.openBox(_hiveBoxName);
    if (prefix == null) {
      await box.clear() ;
    } else {
      for (var key in box.keys) {
        if (key is String && key.startsWith(prefix)) {
          await box.delete(key);
        }
      }
    }
  }

  @override
  Future<void> delete(String key) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.delete(key);
  }

  @override
  Future<String?> read(String key) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.get(key);
  }

  @override
  Future<void> write(String key, String value) async {
    final box = await Hive.openBox(_hiveBoxName);
    return box.put(key, value);
  }

  @override
  Future<int> count({String? prefix}) async {
    final box = await Hive.openBox(_hiveBoxName);
    if (prefix == null) {
      return box.length;
    } else {
      var count = 0;
      for (var key in box.keys) {
        if (key is String && key.startsWith(prefix)) {
          count++;
        }
      }
      return count;
    }
  }
}

The principle is simple:

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

Now that we have methods to manipulate the data, we can set up different strategies and use a unified calling syntax to adapt to different use cases in the application.

We start by creating a contract cache\_policy. Dart in cache root. This contract allows us to apply one of the strategies and configure it.

import 'dart:convert';

import 'package:flutter/foundation.dart';

import 'cache_manager.dart';
import 'cache_wrapper.dart';
import 'storage/storage.dart';

abstract class CacheStrategy {
  static const defaultTTLValue = 60 * 60 * 1000;

  Future _storeCacheData<T>(String key, T value, Storage storage) async {
    final cacheWrapper = CacheWrapper<T>(value, DateTime.now() .millisecondsSinceEpoch);
    await storage.write(key, jsonEncode(cacheWrapper.toJsonObject() ));
  }

  _isValid<T>(CacheWrapper<T> cacheWrapper, bool keepExpiredCache, int ttlValue) => keepExpiredCache || DateTime.now() .millisecondsSinceEpoch < cacheWrapper.cachedDate + ttlValue;

  Future<T> invokeAsync<T>(AsyncBloc<T> asyncBloc, String key, Storage storage) async {
    final asyncData = await asyncBloc() ;
    _storeCacheData(key, asyncData, storage);
    return asyncData;
  }

  Future<T?> fetchCacheData<T>(String key, SerializerBloc serializerBloc, Storage storage, {bool keepExpiredCache = false, int ttlValue = defaultTTLValue}) async {
    final value = await storage.read(key);
    if (value != null) {
      final cacheWrapper = CacheWrapper.fromJson(jsonDecode(value));
      if (_isValid(cacheWrapper, keepExpiredCache, ttlValue)) {
        if (kDebugMode) print("Fetch cache data for key $key: ${cacheWrapper.data}");
        return serializerBloc(cacheWrapper.data);
      }
    }
    return null;
  }

  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc serializerBloc, int ttlValue, Storage storage);
}
  • DefaultTTLValue is the real-time value of the data stored in the cache. In other words: after this period of time, the data is considered invalid.
    -\_storeCacheData() allows storing data via CacheWrapper, which we will see later.
    -\_isValid() Check if the cached fetch is still valid compared to defaultTTLValue
  • InvkeAsync() will retrieve data from a remote location (usually from a Web service) using the syncBloc method passed as a parameter, and store and return the retrieved data.
  • FetchCacheData() will fetch the data from the cache via the key parameter, convert the JSON received by the Cache Wrapper to check if it is still valid, and if so, return the serialized data in a Dart object with the corresponding type, thanks to the seralizerBloc.
  • ApplicyStrategy() will execute the selected strategy with all required parameters.

Through these explanations, we can see the implementation path of any strategy:

  • We call applyStrategy() to indicate which strategy we want to apply, along with the required parameters.
  • To check cached data fetchCacheData(), this method uses \_isValid() to check validity and returns data or null.
  • To get data from WS, we trigger invekAsync() and once the data is received, we put them into the cache together with \_storeCacheData().
class CacheWrapper<T> {
  final T data;
  final int cachedDate;

  CacheWrapper(this.data, this.cachedDate);

  CacheWrapper.fromJson(json)
      : cachedDate = json['cachedDate'],
        data = json['data'];

  Map toJson()  => {'cachedDate': cachedDate, 'data': data};

  @override
  String toString()  => "CacheWrapper{cachedDate=$cachedDate, data=$data}";
}

Regarding CacheWrapper, you can create a file cache_wrapper.dart in the root cache folder.

As its name suggests, CacheWrapper is a class that allows wrapping received data. It takes two parameters, a generic type of data that allows wrapping any type of data, and a cachedDate that is automatically set on the date and time the data is stored in the cache.

The From JSON() and toJson() methods convert the received data into JSON for caching or a Map for using it in code.

Therefore, CacheWrapper can be interpreted as a “wrapper” that contains cache data and allows encoding/decoding of this data.

For this step of the article, our structure folder looks like this:

-- lib

----- core

------- cache

--------- storage

----------- storage.dart

----------- cache_storage.dart

--------- cache_strategy.dart

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

In the new policies folder in the cache root directory we will create files for all policies.

Each policy is a singleton, so there is only one instance of each policy in the application.

We could use get_it to inject our policies, but this adds a dependency on the package and all the disadvantages of third parties that we know about, so we created them ourselves.

Each strategy will inherit from the abstract CacheStrategy class, and they will each implement their own strategy using the 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 cache. If there is nothing in the cache or invalid data, we return the previously raised error in order to handle it in the state manager (you will see it later).

class AsyncOrCacheStrategy extends CacheStrategy {
  static final AsyncOrCacheStrategy _instance = AsyncOrCacheStrategy._internal() ;

  factory AsyncOrCacheStrategy()  {
    return _instance;
  }

  AsyncOrCacheStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async => await invokeAsync(asyncBloc, key, storage).onError(
        (RestException restError, stackTrace) async {
          if (restError.code == 403 || restError.code == 404) {
            storage.clear(prefix: key);
            return Future.error(restError);
          } else {
            return await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue) ?? Future.error(restError);
          }
        },
      );
}

CacheOrAsync

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

class  CacheOrAsyncStrategy  extends  CacheStrategy  {
   static  final CacheOrAsyncStrategy _instance = CacheOrAsyncStrategy._internal() ;

  factory CacheOrAsyncStrategy()  {
    return _instance;
  }

  CacheOrAsyncStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async =>
      await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue) ?? await invokeAsync(asyncBloc, key, storage);
}

Just sync

This policy calls a web service to get the data.

class JustAsyncStrategy extends CacheStrategy {
  static final JustAsyncStrategy _instance = JustAsyncStrategy._internal() ;

  factory JustAsyncStrategy()  {
    return _instance;
  }

  JustAsyncStrategy._internal() ;

  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async => await invokeAsync(asyncBloc, key, storage);
}

JustCache

class JustCacheStrategy extends CacheStrategy {
  static final JustCacheStrategy _instance = JustCacheStrategy._internal() ;

  factory JustCacheStrategy()  {
    return _instance;
  }

  JustCacheStrategy._internal() ;
  @override
  Future<T?> applyStrategy<T>(AsyncBloc<T> asyncBloc, String key, SerializerBloc<T> serializerBloc, int ttlValue, Storage storage) async =>
      await fetchCacheData(key, serializerBloc, storage, ttlValue: ttlValue);
}

This policy only uses data stored in the device cache. The disadvantage is that if the application cannot find the data, null is returned.

For the last two strategies, they can be replaced by direct calls to the cache or network, but here we retain a unified way of calling them.

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

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

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

import 'cache_strategy.dart';
import 'storage/cache_storage.dart';

typedef AsyncBloc<T> = Function;
typedef SerializerBloc<T> = Function(dynamic);

class CacheManager {
  final CacheStorage cacheStorage;

  CacheManager(
    this.cacheStorage,
  );

  String? defaultSessionName;

  StrategyBuilder from<T>(String key) => StrategyBuilder<T>(key, cacheStorage).withSession(defaultSessionName);

  Future clear({String? prefix}) async {
    if (defaultSessionName != null && prefix != null) {
      await cacheStorage.clear(prefix: "${defaultSessionName}_$prefix");
    } else if (prefix != null) {
      await cacheStorage.clear(prefix: prefix);
    } else if (defaultSessionName != null) {
      await cacheStorage.clear(prefix: defaultSessionName);
    } else {
      await cacheStorage.clear() ;
    }
  }
}

class StrategyBuilder<T> {
  final String _key;
  final CacheStorage _cacheStorage;

  StrategyBuilder(this._key, this._cacheStorage);

  late AsyncBloc<T> _asyncBloc;
  late SerializerBloc<T> _serializerBloc;
  late CacheStrategy _strategy;
  int _ttlValue = CacheStrategy.defaultTTLValue;
  String? _sessionName;

  StrategyBuilder withAsync(AsyncBloc<T> asyncBloc) {
    _asyncBloc = asyncBloc;
    return this;
  }

  StrategyBuilder withStrategy(CacheStrategy strategyType) {
    _strategy = strategyType;
    return this;
  }

  StrategyBuilder withTtl(int ttlValue) {
    _ttlValue = ttlValue;
    return this;
  }

  StrategyBuilder withSession(String? sessionName) {
    _sessionName = sessionName;
    return this;
  }

  StrategyBuilder withSerializer(SerializerBloc serializerBloc) {
    _serializerBloc = serializerBloc;
    return this;
  }

  String buildSessionKey(String key) => _sessionName != null ? "${_sessionName}_$key" : key;

  Future<T?> execute()  async {
    try {
      return await _strategy.applyStrategy<T?>(_asyncBloc, buildSessionKey(_key), _serializerBloc, _ttlValue, _cacheStorage);
    } catch (exception) {
      rethrow;
    }
  }
}

Let me explain this file:

→ It is divided into two classes: CacheManager and Strategies yBuilder

→ CacheManager uses the from() method to save the entry point. Strategies yBuilder has some other methods that allow us to build cache sessions by passing some parameters (such as async functions, serializers, etc.). .

  • DefaultSessionName allows us to put a global name into the cache session that will be opened. For example, if we create a cache session for each logged in user, we can set the user’s firstName + lastName + id to defaultSessionName so that we can easily manipulate the entire cache session using this name.
  • From(): This method creates a Strategies yBuilder instance of generic type <T>, which allows to return any type: List, String, Object… A key parameter is passed, which will be used in hive in the buildSessionKey() method The name of the box. The AcheStorage instance is also passed as a parameter so that Strategies yBuilder can use it and pass it to CacheStrategy. Finally, the withSession() method of Strategies yBuilder is used to name the current cache session.
  • Clear() : Allows clearing the cache in different ways. We can use the defaultSessionName or prefix parameter of Strategy Builder to clean the cached session, or clean all caches created.

Once the from() method is called, it is the turn to call the Strategies yBuilder method:

  • With Async() : We provide the AsyncBloc < T > function to the builder, which will get data from remote sources (such as APIs).
  • WithSerializer() : We provide a serializer/deserializer for the builder, which is responsible for converting the received JSON data into dart objects and vice versa, using the SerializerBloc < T > function.

Since the default serialization/deserialization in Dart is not optimized for complex objects, Flutter recommends using a package (json_seralizable). It will automatically generate methods for each DTO and then inject these methods directly into the seralizerBloc for serializing the data received from the cache.

  • WithTtl() : Provides a time to live for the cache, by default we set it to 1 hour.
  • WithStrategy() : Receives the selected strategy singleton. Directly injecting a singleton pattern allows customizing/adding different strategies, for example, it is more flexible than enumerations.
  • Execute() : The latter method triggers the applyStrategy() method to execute the cache strategy.

H How to use this strategy?

Now that we understand the theory, let’s look at the practical implementation of caching strategies in an application.

I promise you, this is the easy part.

First, we need to inject the CacheManager we created. In order to do this, we use the get_it package, which will use dependency injection to create a singleton pattern that can be used throughout the code base.

I recommend that you create a service_locator.dart file in the core folder of your application.

final getIt = GetIt.instance;

void setupGetIt()  {
  // Cache
  getIt.registerSingleton<CacheManager>(CacheManager(CacheStorage() ));

}

Therefore, we use CacheManager to manage policies and save CacheStorage instances for storage.

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

When we try to work in a clean architecture, our failure looks like this:

-- data

----- datasource

----- domain

----- dto

----- repository

We are most interested in the repository folder because it acts as a gateway when receiving input DTOs from the data source, converting them into entities from the domain.

Let’s take an example of an application that will show students the work to be done. We need a way to retrieve the allocation.

class HomeworkAssignmentRepository {

  final apiProvider = getIt<HomeworkDataSource>() ;
  final _cacheManager = getIt<CacheManager>() ;

  Future<List<HomeworkEntity>?> getHomeworkAssignment(String courseId, String studentId) async {
    final List<HomeworkDto>? result = await _cacheManager
        .from<List<HomeworkDto>>("homework-assignment-$courseId-$studentId")
        .withSerializer((result) => HomeworkDto.fromJson(result))
        .withAsync(()  => apiProvider.fetchHomeworkAssignment(courseId, studentId))
        .withStrategy(AsyncOrCache() )
        .execute() ;

    if (result != null) {
      return List<HomeworkEntity>.from(result.map((dto) => dto.toEntity() ));
    }
    return null;

  }
}

First, we inject our HomeworkDataSource and CacheManager into get_it.

The data source will be used to call the endpoint and the manager will be used to configure the policy.

In the future getHomeworkAsmission, we hope to get a list of HomeworkD, which will be converted in HomeworkEntity. We see our strategy being applied and we explain:

  • From() sets which dto will be used and gives the cached key.
  • WithSerializer() injects a method that will deserialize data.
  • WithAsync() injects an API call with necessary parameters.
  • WithStrategy() allows defining the strategy to be selected.
  • Execute() will trigger our strategy by sending the defined parameters to Strategies yBuilder.

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

Conclusion

If this article is helpful to you, please forward it so that more friends can read it.

Maybe this operation only takes you 3 seconds, which is an inspiration to me, thank you.

Have a nice day~


© Cat Brother