Implementing Image Caching Strategies in Flutter

In Flutter development, displaying images efficiently is crucial for providing a smooth user experience. Images can be sourced from local storage, network URLs, or assets. Implementing robust image caching strategies can significantly improve app performance, reduce bandwidth consumption, and ensure that images load quickly and seamlessly.

Why Implement Image Caching in Flutter?

  • Improved Performance: Reduces image loading times, enhancing user experience.
  • Reduced Bandwidth Usage: Decreases data consumption, which is especially important for mobile users.
  • Offline Availability: Caches images for offline access, allowing users to view previously loaded images even without an internet connection.
  • Cost Efficiency: Reduces the number of network requests, which can lower costs associated with cloud storage and data transfer.

Image Providers in Flutter

Flutter offers several built-in image providers:

  • AssetImage: Loads images from your Flutter project’s assets.
  • NetworkImage: Loads images from a URL over the network.
  • FileImage: Loads images from a file in the local file system.
  • MemoryImage: Loads images from a byte array in memory.

Basic Image Display

Before diving into caching strategies, let’s review basic image display using the Image widget.

Loading Images from Assets


import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Asset Image Example'),
        ),
        body: Center(
          child: Image.asset('assets/my_image.png'),
        ),
      ),
    );
  }
}

Make sure to declare the image in your pubspec.yaml file:


flutter:
  assets:
    - assets/my_image.png

Loading Images from the Network


import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Network Image Example'),
        ),
        body: Center(
          child: Image.network(
            'https://via.placeholder.com/150',
            loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
              if (loadingProgress == null) return child;
              return CircularProgressIndicator(
                value: loadingProgress.expectedTotalBytes != null
                    ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
                    : null,
              );
            },
            errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
              return Text('Failed to load image');
            },
          ),
        ),
      ),
    );
  }
}

In this example, a placeholder and error message are displayed during loading and if the image fails to load, respectively.

Image Caching Strategies

Implementing image caching involves multiple approaches, including using built-in mechanisms and third-party libraries.

1. Built-in Caching

Flutter’s Image widget performs basic caching automatically. Images fetched from the network are cached by default in the Flutter framework. This is sufficient for simple use cases but lacks advanced control over caching behavior.

The CachedNetworkImage package provides more advanced caching capabilities and can be a great addition to your app.

2. Using the CachedNetworkImage Package

The cached_network_image package is a popular choice for handling network image caching. It automatically caches images and provides placeholders and error widgets.

Step 1: Add the Dependency

Add cached_network_image to your pubspec.yaml file:


dependencies:
  cached_network_image: ^3.2.0
Step 2: Use the CachedNetworkImage Widget

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Cached Network Image Example'),
        ),
        body: Center(
          child: CachedNetworkImage(
            imageUrl: 'https://via.placeholder.com/150',
            placeholder: (context, url) => CircularProgressIndicator(),
            errorWidget: (context, url, error) => Icon(Icons.error),
          ),
        ),
      ),
    );
  }
}

In this example:

  • imageUrl: The URL of the image to load.
  • placeholder: Widget to display while the image is loading.
  • errorWidget: Widget to display if an error occurs.

3. Custom Caching Implementation

For more fine-grained control, you can implement a custom caching solution using libraries like flutter_cache_manager and managing local storage.

Step 1: Add Dependencies

Add the necessary dependencies to your pubspec.yaml file:


dependencies:
  flutter_cache_manager: ^3.3.0
  path_provider: ^2.0.0
Step 2: Implement Custom Image Cache Manager

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';

class CustomCacheManager {
  static final CustomCacheManager _instance = CustomCacheManager._internal();

  factory CustomCacheManager() {
    return _instance;
  }

  CustomCacheManager._internal();

  final CacheManager _cacheManager = CacheManager(
    Config(
      'my_custom_cache_key',
      maxNrOfCacheObjects: 200,
      stalePeriod: const Duration(days: 7),
    ),
  );

  Future getFileFromCache(String url) async {
    return _cacheManager.getSingleFile(url);
  }
}

class CustomCachedImage extends StatelessWidget {
  final String imageUrl;

  const CustomCachedImage({Key? key, required this.imageUrl}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: CustomCacheManager().getFileFromCache(imageUrl),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Image.file(snapshot.data!);
        } else {
          return CircularProgressIndicator();
        }
      },
    );
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Custom Cached Image Example'),
        ),
        body: Center(
          child: CustomCachedImage(imageUrl: 'https://via.placeholder.com/150'),
        ),
      ),
    );
  }
}

In this example:

  • A custom cache manager is created using flutter_cache_manager to handle caching logic.
  • The CustomCachedImage widget fetches images from the cache and displays them. If an image is not available in the cache, it fetches and caches it.

4. Disk Caching with sqflite

For more persistent and controlled caching, you can save image metadata and local file paths to an sqflite database.

Step 1: Add Dependencies

dependencies:
  sqflite: ^2.0.0
  path_provider: ^2.0.0
  http: ^0.13.0
Step 2: Implement Database Helper and Image Caching Logic

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:http/http.dart' as http;

class DatabaseHelper {
  static const _databaseName = "ImageCache.db";
  static const _databaseVersion = 1;
  static const table = 'images';

  static const columnId = '_id';
  static const columnImageUrl = 'image_url';
  static const columnFilePath = 'file_path';
  
  // Make this a singleton class
  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  static Database? _database;
  Future get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  _initDatabase() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _databaseName);
    return await openDatabase(
      path,
      version: _databaseVersion,
      onCreate: _onCreate,
    );
  }

  Future _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE $table (
        $columnId INTEGER PRIMARY KEY,
        $columnImageUrl TEXT NOT NULL,
        $columnFilePath TEXT NOT NULL
      )
    ''');
  }

  Future insert(String imageUrl, String filePath) async {
    Database db = await instance.database;
    return await db.insert(table, {columnImageUrl: imageUrl, columnFilePath: filePath});
  }

  Future?> getImage(String imageUrl) async {
    Database db = await instance.database;
    List> maps = await db.query(
      table,
      where: '$columnImageUrl = ?',
      whereArgs: [imageUrl],
    );
    if (maps.isNotEmpty) {
      return maps.first;
    }
    return null;
  }
}

class DiskCachedImage extends StatefulWidget {
  final String imageUrl;

  DiskCachedImage({Key? key, required this.imageUrl}) : super(key: key);

  @override
  _DiskCachedImageState createState() => _DiskCachedImageState();
}

class _DiskCachedImageState extends State {
  File? _imageFile;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _loadImage();
  }

  Future _loadImage() async {
    setState(() {
      _isLoading = true;
    });
    
    final dbHelper = DatabaseHelper.instance;
    var imageRecord = await dbHelper.getImage(widget.imageUrl);

    if (imageRecord != null) {
      // Image found in database
      setState(() {
        _imageFile = File(imageRecord[DatabaseHelper.columnFilePath]);
        _isLoading = false;
      });
    } else {
      // Image not in database, download it
      final response = await http.get(Uri.parse(widget.imageUrl));
      if (response.statusCode == 200) {
        Directory appDocDir = await getApplicationDocumentsDirectory();
        String filePath = join(appDocDir.path, '${widget.imageUrl.hashCode}.jpg');
        File file = File(filePath);
        await file.writeAsBytes(response.bodyBytes);

        // Save to database
        await dbHelper.insert(widget.imageUrl, filePath);

        setState(() {
          _imageFile = file;
          _isLoading = false;
        });
      } else {
        setState(() {
          _isLoading = false;
        });
        print('Failed to load image: ${response.statusCode}');
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return _isLoading
        ? CircularProgressIndicator()
        : _imageFile != null
            ? Image.file(_imageFile!)
            : Text('Failed to load image');
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Disk Cached Image Example'),
        ),
        body: Center(
          child: DiskCachedImage(imageUrl: 'https://via.placeholder.com/150'),
        ),
      ),
    );
  }
}

Key Components:

  • Database Setup: The code initializes an SQLite database to store the image URL and its local file path.
  • Caching Logic: It checks if the image exists in the database. If not, it downloads the image, saves it locally, and stores the information in the database.

Advanced Caching Techniques

Several advanced techniques can further optimize image caching in Flutter:

  • Image Size Optimization: Resize images before caching to reduce storage and bandwidth consumption.
  • Cache Invalidation Strategies: Implement strategies to invalidate cache entries based on TTL (time-to-live) or specific events.
  • Lazy Loading: Load images only when they are about to be displayed, reducing initial load times.
  • Content Delivery Networks (CDNs): Use CDNs to deliver images, further improving loading times by serving images from geographically closer servers.

Conclusion

Implementing efficient image caching strategies in Flutter is essential for optimizing app performance and providing a smooth user experience. Whether you use built-in caching mechanisms, third-party libraries like cached_network_image, or custom implementations with local storage, caching can drastically improve loading times, reduce bandwidth usage, and ensure images are available even offline. By combining these caching strategies with advanced techniques such as image optimization and lazy loading, you can deliver a top-tier image-rich application to your users.