Implementing Various Data Caching Strategies (In-Memory, Disk-Based) in Flutter

In Flutter, efficiently managing data is critical for building responsive and performant applications. One effective approach is to implement various data caching strategies. Data caching helps reduce network requests and improve app load times by storing frequently accessed data locally. This post explores different data caching strategies in Flutter, including in-memory caching and disk-based caching, complete with detailed examples.

Why Data Caching is Important in Flutter

  • Improved Performance: Caching data locally reduces latency and speeds up data retrieval.
  • Reduced Network Usage: By serving data from the cache, you minimize the number of network requests.
  • Offline Support: Cached data can be accessed even when the device is offline, providing a seamless user experience.
  • Cost Savings: Less network traffic can lead to lower costs, especially on metered connections.

Types of Caching Strategies

  1. In-Memory Caching: Stores data in RAM for quick access.
  2. Disk-Based Caching: Stores data on the device’s storage for persistent caching.
  3. Hybrid Caching: Combines in-memory and disk-based caching to optimize performance and persistence.

1. In-Memory Caching in Flutter

In-memory caching involves storing data directly in the device’s RAM. This is the fastest form of caching, suitable for frequently accessed and small-sized data.

Implementation

Here’s how you can implement in-memory caching in Flutter using a simple cache class:

class InMemoryCache<T> {
  final Map<String, T> _cache = {};

  Future<T> get(String key, Future<T> Function() loadFunc) async {
    if (_cache.containsKey(key)) {
      print('Fetching $key from in-memory cache');
      return _cache[key]!;
    }

    print('Loading $key from source');
    final value = await loadFunc();
    _cache[key] = value;
    return value;
  }

  void set(String key, T value) {
    _cache[key] = value;
  }

  void remove(String key) {
    _cache.remove(key);
  }

  void clear() {
    _cache.clear();
  }
}

// Example Usage
final cache = InMemoryCache<String>();

Future<void> main() async {
  final data1 = await cache.get('data1', () async {
    // Simulate loading data from network
    await Future.delayed(Duration(seconds: 1));
    return 'Data 1 from Network';
  });
  print('Data 1: $data1');

  final data2 = await cache.get('data1', () async {
    // Simulate loading data from network
    await Future.delayed(Duration(seconds: 1));
    return 'Data 1 from Network';
  });
  print('Data 2: $data2'); // Fetched from cache

  cache.set('data3', 'Data 3');
  final data3 = await cache.get('data3', () async {
    await Future.delayed(Duration(seconds: 1));
    return 'Data 3 from Network';
  });
  print('Data 3: $data3'); // Fetched from set
}

In this example:

  • InMemoryCache class uses a Map to store data.
  • The get method retrieves data from the cache if it exists, otherwise loads it using the provided loadFunc and stores it in the cache.
  • The set, remove, and clear methods allow you to manage the cached data.

2. Disk-Based Caching in Flutter

Disk-based caching involves storing data on the device’s local storage. This approach provides persistent caching, meaning data is retained across app sessions.

Using path_provider and sqflite Packages

For disk-based caching, you can use the path_provider package to get the local storage directory and the sqflite package for structured data caching using a SQLite database.

Step 1: Add Dependencies

Include the following dependencies in your pubspec.yaml file:

dependencies:
  path_provider: ^2.0.0
  sqflite: ^2.0.0
Step 2: Implement Disk-Based Cache

Here’s an example of how to use sqflite for disk-based caching:

import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'dart:io';

class DiskCache {
  late Database db;

  Future<void> initialize() async {
    Directory directory = await getApplicationDocumentsDirectory();
    String path = '${directory.path}/cache.db';
    db = await openDatabase(
      path,
      version: 1,
      onCreate: (Database db, int version) async {
        await db.execute(
          'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT)',
        );
      },
    );
  }

  Future<String?> get(String key) async {
    List<Map<String, dynamic>> results = await db.query(
      'cache',
      where: 'key = ?',
      whereArgs: [key],
    );
    if (results.isNotEmpty) {
      print('Fetching $key from disk cache');
      return results.first['value'] as String;
    }
    return null;
  }

  Future<void> set(String key, String value) async {
    print('Storing $key in disk cache');
    await db.insert(
      'cache',
      {'key': key, 'value': value},
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<void> remove(String key) async {
    await db.delete(
      'cache',
      where: 'key = ?',
      whereArgs: [key],
    );
  }

  Future<void> clear() async {
    await db.delete('cache');
  }
}

// Example Usage
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized(); // Ensure Flutter is initialized
  final cache = DiskCache();
  await cache.initialize();

  Future<String> loadData(String key) async {
    // Simulate loading data from network
    await Future.delayed(Duration(seconds: 1));
    return 'Data from Network for $key';
  }

  String? data1 = await cache.get('data1');
  if (data1 == null) {
    data1 = await loadData('data1');
    await cache.set('data1', data1);
  }
  print('Data 1: $data1');

  String? data2 = await cache.get('data1'); // Fetched from cache
  print('Data 2: $data2');

  await cache.remove('data1');
  String? data3 = await cache.get('data1'); // Fetched from cache
  print('Data 3: $data3');
}

In this example:

  • DiskCache class manages a SQLite database to store cached data.
  • initialize method sets up the database connection and creates the cache table.
  • get, set, remove, and clear methods perform database operations to manage cached data.

3. Hybrid Caching Strategy

A hybrid caching strategy combines the benefits of both in-memory and disk-based caching. Data is first stored in memory for quick access, and then persisted to disk for long-term storage. This ensures both performance and data retention.

Implementation

Here’s a simple hybrid cache implementation combining InMemoryCache and DiskCache:

import 'package:flutter/widgets.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'dart:io';

class InMemoryCache<T> {
  final Map<String, T> _cache = {};

  Future<T?> get(String key, Future<T> Function() loadFunc) async {
    if (_cache.containsKey(key)) {
      print('Fetching $key from in-memory cache');
      return _cache[key]!;
    }
    return null;
  }

  void set(String key, T value) {
    _cache[key] = value;
  }

  void remove(String key) {
    _cache.remove(key);
  }

  void clear() {
    _cache.clear();
  }
}


class DiskCache {
  late Database db;

  Future<void> initialize() async {
    Directory directory = await getApplicationDocumentsDirectory();
    String path = '${directory.path}/cache.db';
    db = await openDatabase(
      path,
      version: 1,
      onCreate: (Database db, int version) async {
        await db.execute(
          'CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT)',
        );
      },
    );
  }

  Future<String?> get(String key) async {
    List<Map<String, dynamic>> results = await db.query(
      'cache',
      where: 'key = ?',
      whereArgs: [key],
    );
    if (results.isNotEmpty) {
      print('Fetching $key from disk cache');
      return results.first['value'] as String;
    }
    return null;
  }

  Future<void> set(String key, String value) async {
    print('Storing $key in disk cache');
    await db.insert(
      'cache',
      {'key': key, 'value': value},
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<void> remove(String key) async {
    await db.delete(
      'cache',
      where: 'key = ?',
      whereArgs: [key],
    );
  }

  Future<void> clear() async {
    await db.delete('cache');
  }
}


class HybridCache {
  final InMemoryCache<String> inMemoryCache = InMemoryCache<String>();
  final DiskCache diskCache = DiskCache();

  Future<void> initialize() async {
    await diskCache.initialize();
  }

  Future<String> get(String key, Future<String> Function() loadFunc) async {
    // Try in-memory cache
    final inMemoryData = await inMemoryCache.get(key, loadFunc);
    if (inMemoryData != null) {
      return inMemoryData;
    }

    // Try disk cache
    final diskData = await diskCache.get(key);
    if (diskData != null) {
      inMemoryCache.set(key, diskData); // Update in-memory cache
      return diskData;
    }

    // Load from source
    final data = await loadFunc();
    inMemoryCache.set(key, data);
    await diskCache.set(key, data);
    return data;
  }

  Future<void> remove(String key) async {
    inMemoryCache.remove(key);
    await diskCache.remove(key);
  }

  Future<void> clear() async {
    inMemoryCache.clear();
    await diskCache.clear();
  }
}

// Example Usage
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized(); // Ensure Flutter is initialized

  final hybridCache = HybridCache();
  await hybridCache.initialize();

  Future<String> loadData(String key) async {
    // Simulate loading data from network
    await Future.delayed(Duration(seconds: 1));
    return 'Data from Network for $key';
  }

  String data1 = await hybridCache.get('data1', () => loadData('data1'));
  print('Data 1: $data1');

  String data2 = await hybridCache.get('data1', () => loadData('data1')); // Fetched from in-memory cache
  print('Data 2: $data2');

  await hybridCache.remove('data1');
  String data3 = await hybridCache.get('data1', () => loadData('data1')); // Fetched from network again
  print('Data 3: $data3');
}

In this example:

  • HybridCache class combines InMemoryCache and DiskCache.
  • The get method first checks the in-memory cache, then the disk cache, and finally loads from the source if not found.
  • When data is loaded from the source, it’s stored in both in-memory and disk caches.

Best Practices for Data Caching in Flutter

  • Cache Invalidation: Implement strategies for invalidating stale data, such as TTL (Time-To-Live) or event-based invalidation.
  • Cache Size Management: Limit the size of the cache to avoid excessive memory or disk usage.
  • Background Updates: Use background tasks to refresh the cache periodically.
  • Error Handling: Handle cache read/write errors gracefully.
  • Security: Consider encrypting sensitive data stored in the cache.

Conclusion

Data caching is a vital technique for improving the performance and user experience of Flutter applications. By implementing various caching strategies, such as in-memory caching, disk-based caching, and hybrid caching, you can optimize data retrieval, reduce network usage, and provide offline support. Each caching strategy has its use cases, and the best approach depends on the specific requirements of your application.