Using Caching Strategies for Performance Enhancement in Flutter

In Flutter, optimizing performance is essential for providing a smooth and responsive user experience. One of the most effective techniques to improve performance is caching. Caching involves storing frequently accessed data in a cache memory to reduce the need for repeated data retrieval, thereby improving speed and efficiency. This article explores various caching strategies that Flutter developers can implement to enhance their application’s performance.

What is Caching?

Caching is the process of storing data in a temporary storage location so that future requests for that data can be served faster. By reducing the reliance on slower data sources (like network requests or disk reads), caching can significantly improve application speed and responsiveness.

Why Use Caching in Flutter?

  • Improved Performance: Reduces the time to load data, providing a faster user experience.
  • Reduced Latency: Avoids redundant network requests, minimizing latency.
  • Lower Bandwidth Usage: Reduces data consumption, which is particularly beneficial for users with limited data plans.
  • Offline Access: Enables the app to function (at least partially) even when there is no network connection.

Types of Caching Strategies in Flutter

1. In-Memory Caching

In-memory caching involves storing data directly in the application’s memory. This is the fastest form of caching but is limited by the available memory.

Implementation

Here’s how you can implement a simple in-memory cache using Dart:


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

  Future<T> get<T>(String key, Future<T> Function() load) async {
    if (_cache.containsKey(key)) {
      print('Fetching $key from in-memory cache');
      return _cache[key] as T;
    }
    print('Loading $key from source');
    final value = await load();
    _cache[key] = value;
    return value;
  }

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

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

// Example usage
final cache = InMemoryCache();

Future<String> fetchData(String key) async {
  // Simulate fetching data from a remote source
  await Future.delayed(Duration(seconds: 2));
  return 'Data for $key';
}

void main() async {
  // First time, data is fetched from the source
  String data1 = await cache.get('item1', () => fetchData('item1'));
  print(data1);

  // Second time, data is fetched from the cache
  String data2 = await cache.get('item1', () => fetchData('item1'));
  print(data2);

  cache.remove('item1');
  
  String data3 = await cache.get('item1', () => fetchData('item1'));
  print(data3);

  cache.clear();
  
    String data4 = await cache.get('item1', () => fetchData('item1'));
  print(data4);
}

In this example:

  • The InMemoryCache class uses a Map to store data.
  • The get method checks if the data exists in the cache. If it does, it returns the cached value. If not, it loads the data using the provided load function, stores it in the cache, and returns it.

2. Disk Caching

Disk caching involves storing data on the device’s storage. It is slower than in-memory caching but can store larger amounts of data persistently.

Implementation

You can use the path_provider and sqflite packages for disk caching:


dependencies:
  path_provider: ^2.0.0
  sqflite: ^2.0.0

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

class DiskCache {
  Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB();
    return _database!;
  }

  Future<Database> _initDB() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, "cache.db");
    return await openDatabase(path, version: 1, onCreate: _onCreate);
  }

  Future<void> _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE Cache (
        key TEXT PRIMARY KEY,
        value TEXT
      )
    ''');
  }

  Future<void> save(String key, String value) async {
    final db = await database;
    await db.insert(
      'Cache',
      {'key': key, 'value': value},
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

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

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

  Future<void> clear() async {
    final db = await database;
    await db.delete('Cache');
  }
}

// Example usage
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final cache = DiskCache();

  Future<String> fetchData(String key) async {
    // Simulate fetching data from a remote source
    await Future.delayed(Duration(seconds: 2));
    return 'Data for $key';
  }

  // First time, data is fetched from the source and saved to disk
  String? data1 = await cache.get('item1');
  if (data1 == null) {
    data1 = await fetchData('item1');
    await cache.save('item1', data1);
    print('Fetched from source and saved to disk: $data1');
  } else {
    print('Fetched from disk: $data1');
  }

  // Second time, data is fetched from the disk
  String? data2 = await cache.get('item1');
  print('Fetched from disk: $data2');
}

In this example:

  • The DiskCache class uses sqflite to create a database and store data.
  • The save method saves data to the database, and the get method retrieves data from the database.

3. Network Caching (HTTP Caching)

Network caching involves leveraging HTTP caching headers to instruct the browser or HTTP client to cache responses. This is typically done on the server-side.

Implementation

For Flutter apps, you can use the http package along with appropriate headers to enable caching:


dependencies:
  http: ^0.13.0

import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';

class NetworkCache {
  final cache = <String, CachedResponse>{};
  
  Future<String> getData(String url) async {
      if (cache.containsKey(url)) {
          final cachedResponse = cache[url]!;
          if (cachedResponse.isValid()) {
              print("Returning cached response for $url");
              return cachedResponse.body;
          } else {
              print("Cache expired for $url. Refreshing...");
              cache.remove(url);
          }
      }

      print("Fetching $url from network");
      final response = await http.get(Uri.parse(url), headers: {
        'Cache-Control': 'max-age=3600', // Example caching header
      });

      if (response.statusCode == 200) {
          final cachedResponse = CachedResponse(response.body, DateTime.now().add(Duration(hours: 1)));
          cache[url] = cachedResponse;
          return response.body;
      } else {
          throw Exception('Failed to load data');
      }
  }
}

class CachedResponse {
    final String body;
    final DateTime expiry;

    CachedResponse(this.body, this.expiry);

    bool isValid() {
        return DateTime.now().isBefore(expiry);
    }
}


void main() async {
    final networkCache = NetworkCache();

    // Simulate fetching data multiple times
    final url = "https://jsonplaceholder.typicode.com/todos/1";
    
    print(await networkCache.getData(url));
    print(await networkCache.getData(url)); // Returns cached data

    await Future.delayed(Duration(seconds: 3601)); // Wait for more than an hour, so the Cache Expiry expires
        print(await networkCache.getData(url)); // Data refetched from the API.
}

4. Image Caching

Flutter provides built-in caching for images loaded from the network using the CachedNetworkImage widget, which is part of the cached_network_image package.

Implementation

To implement this functionality , first, add the cached_network_image dependency to your `pubspec.yaml` file:


dependencies:
 cached_network_image: ^3.2.1

Import the package in your Dart file.


import 'package:cached_network_image/cached_network_image.dart';

Implement the caching strategy using this widget inside the `build` method.


CachedNetworkImage(
  imageUrl: "http://via.placeholder.com/350x150",
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
),

In this example:

  • CachedNetworkImage caches the images automatically.
  • placeholder shows a loading indicator while the image is being fetched.
  • errorWidget displays an error icon if the image fails to load.

Best Practices for Caching in Flutter

  • Choose the Right Cache Type: Select the caching strategy based on the data size, access frequency, and persistence requirements.
  • Cache Invalidation: Implement a mechanism to invalidate or refresh the cache when the data changes. This can be time-based, event-based, or manual.
  • Cache Expiry: Set an appropriate expiry time for cached data to ensure it remains relevant.
  • Error Handling: Handle cache read and write errors gracefully to prevent application crashes.
  • Memory Management: Be mindful of memory usage, especially with in-memory caching, to avoid out-of-memory errors.
  • Security: Ensure sensitive data stored in the cache is encrypted to protect user privacy.

Conclusion

Caching is a powerful tool for optimizing the performance of Flutter applications. By implementing appropriate caching strategies such as in-memory caching, disk caching, and network caching, developers can significantly improve the user experience by reducing load times, minimizing latency, and enabling offline access. Following best practices for cache management ensures that the cached data remains relevant and the application performs efficiently.