Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active July 2, 2026 15:54
Show Gist options
  • Select an option

  • Save slightfoot/2bdf52a5ec0972344e2a18fa5c44e2cf to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/2bdf52a5ec0972344e2a18fa5c44e2cf to your computer and use it in GitHub Desktop.
Image Cache and Providers - by Simon Lightfoot :: HumpdayQandA on 1st July 2026 :: https://www.youtube.com/watch?v=0o6BohFJqWY
// MIT License
//
// Copyright (c) 2026 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'dart:convert' show utf8;
import 'dart:io';
import 'dart:ui' as ui show ImmutableBuffer;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'picsum.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// DEBUG only!
(WidgetsBinding.instance as PaintingBinding).imageCache.maximumSize = 0;
await DiskCache.init();
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Home(),
);
}
}
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
// TODO: use ScrollAwareImageProvider();
return Scaffold(
appBar: AppBar(
title: const Text('Image Cache Example'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
LruCache.instance.clear();
DiskCache.instance.clear();
setState(() {});
},
),
],
),
body: GridView.count(
cacheExtent: 0,
crossAxisCount: 3,
children: [
for (final url in imageThumbUrls) //
Padding(
padding: const EdgeInsets.all(8.0),
child: Image(
image: CachedImageProvider(url),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
debugPrint('error: $error\n$stackTrace');
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey.shade800,
),
child: const Icon(
Icons.error,
color: Colors.red,
),
);
},
),
),
],
),
);
}
}
class CachedImageProvider extends ImageProvider<String> {
const CachedImageProvider(this.url, {this.httpClient});
final String url;
final HttpClient? httpClient;
static final _sharedHttpClient = HttpClient()..autoUncompress = false;
// DEBUG ONLY
static int _availableCount = 4;
@override
Future<String> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(url);
}
static final _inFlightImages = <String, Completer<ImageInfo>>{};
@override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
// Search cache for existing in-memory image
final image = LruCache.instance.get(url);
if (image != null) {
debugPrint('Loading from memory cache $url');
// If we have a an image in the cache, return it immediately
return OneFrameImageStreamCompleter(SynchronousFuture(image.clone()));
}
if (_inFlightImages.containsKey(url)) {
return OneFrameImageStreamCompleter(_inFlightImages[url]!.future);
}
final completer = Completer<ImageInfo>();
_inFlightImages.putIfAbsent(url, () => completer);
Future<ImageInfo> loader;
// Check disk cache
if (DiskCache.instance.contains(url)) {
debugPrint('Loading from disk cache $url');
loader = DiskCache.instance.get(url).then((bytes) {
return _loadImageFromBytes(bytes, decode);
});
} else {
loader = _loadImageFromUrl(url, decode);
}
loader
.then((ImageInfo imageInfo) {
LruCache.instance.set(url, imageInfo);
completer.complete(imageInfo);
})
.catchError((error, stackTrace) {
LruCache.instance.remove(url);
completer.completeError(error, stackTrace);
})
.whenComplete(() {
_inFlightImages.remove(url);
});
return OneFrameImageStreamCompleter(completer.future);
}
Future<ImageInfo> _loadImageFromUrl(String url, ImageDecoderCallback decode) async {
// DEBUG delay to fake bad network
while (_availableCount <= 0) {
await Future.delayed(const Duration(milliseconds: 10));
}
_availableCount--;
await Future.delayed(const Duration(milliseconds: 250));
try {
debugPrint('Loading from network $url');
final resolved = Uri.base.resolve(url);
final request = await (httpClient ?? _sharedHttpClient).getUrl(resolved);
final response = await request.close();
if (response.statusCode != HttpStatus.ok) {
// Drain the response body to avoid leaks.
await response.drain<List<int>>(<int>[]);
throw Exception('Failed to load image from $url: ${response.statusCode}');
}
final bytes = await consolidateHttpClientResponseBytes(response);
if (bytes.lengthInBytes == 0) {
throw Exception('Image is an empty file: $resolved');
}
DiskCache.instance.set(url, bytes);
return await _loadImageFromBytes(bytes, decode);
} finally {
_availableCount++;
}
}
Future<ImageInfo> _loadImageFromBytes(Uint8List bytes, ImageDecoderCallback decode) async {
final codec = await decode(await ui.ImmutableBuffer.fromUint8List(bytes));
try {
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, debugLabel: url);
} finally {
codec.dispose();
}
}
}
class LruCache {
LruCache(this.maxSize);
final int maxSize;
final _cache = <String, ImageInfo>{};
// first is least recently used, newest is last
final _lru = <String>{};
static final instance = LruCache(50);
ImageInfo? get(String key) {
final imageInfo = _cache[key];
if (imageInfo != null) {
_lru.remove(key);
_lru.add(key);
return imageInfo;
}
return null;
}
void set(String key, ImageInfo image) {
_cache.putIfAbsent(key, () => image.clone());
_lru.add(key);
if (_cache.length > maxSize) {
remove(_lru.first);
}
}
void remove(String key) {
final image = _cache.remove(key);
_lru.remove(key);
image?.image.dispose();
}
void clear() {
for (final image in _cache.values) {
image.image.dispose();
}
_cache.clear();
_lru.clear();
}
}
class DiskCache {
DiskCache._(
this.directory,
this.maxSize,
this.maxCount,
);
final Directory directory;
final int maxSize;
final int maxCount;
static DiskCache? _instance;
static DiskCache get instance => _instance!;
static Future<void> init({
int maxSize = 100 * 1024 * 1024,
int maxCount = 100,
}) async {
if (_instance != null) return;
final dir = await getApplicationCacheDirectory();
final imageDir = Directory(path.join(dir.path, 'image_cache'));
imageDir.createSync(recursive: true);
// debugPrint('Disk cache: ${imageDir.path}');
_instance ??= DiskCache._(imageDir, maxSize, maxCount);
await _instance!.load();
}
// index of files in cache, key is sha1 of url
final _index = <String, File>{};
// first is least recently used, newest is last
final _lru = <String>{};
// size in bytes
int _currentSize = 0;
Future<void> load() async {
_index.clear();
_lru.clear();
_currentSize = 0;
await for (final file in directory.list()) {
if (file is! File) continue;
final key = path.basename(file.path);
_index[key] = file;
_currentSize += file.lengthSync();
_lru.add(file.path);
}
}
String keyFor(String url) {
return sha1.convert(utf8.encode(url)).toString();
}
bool contains(String url) {
return _index.containsKey(keyFor(url));
}
Future<Uint8List> get(String url) {
final key = keyFor(url);
final file = _index[key];
if (file != null) {
_lru.remove(key);
_lru.add(key);
return file.readAsBytes();
}
throw Exception('Not found in cache: $url');
}
Future<void> set(String url, Uint8List data) async {
final key = keyFor(url);
if (_index.containsKey(key)) {
return;
}
final file = File(path.join(directory.path, key));
await file.writeAsBytes(data);
// debugPrint('Wrote ${data.lengthInBytes} bytes to ${file.path}');
_index.putIfAbsent(key, () => file);
_lru.add(key);
_currentSize += data.lengthInBytes;
// evict least recently used
if (_currentSize > maxSize || _index.length > maxCount) {
_removeWithKey(_lru.first);
}
}
void remove(String url) {
_removeWithKey(keyFor(url));
}
void _removeWithKey(String key) {
final file = _index.remove(key);
_lru.remove(key);
if (file != null) {
file.deleteSync();
_currentSize -= file.lengthSync();
}
}
void clear() {
for (final file in _index.values) {
file.delete().ignore();
}
_index.clear();
_lru.clear();
_currentSize = 0;
}
}
// The Lorem Ipsum for photos.
// https://picsum.photos/
//
final imageThumbUrls = <String>[
'https://picsum.photos/id/10/600/400.jpg',
'https://picsum.photos/id/11/600/400.jpg',
'https://picsum.photos/id/12/600/400.jpg',
'https://picsum.photos/id/13/600/400.jpg',
'https://picsum.photos/id/14/600/400.jpg',
'https://picsum.photos/id/15/600/400.jpg',
'https://picsum.photos/id/16/600/400.jpg',
'https://picsum.photos/id/17/600/400.jpg',
'https://picsum.photos/id/18/600/400.jpg',
'https://picsum.photos/id/19/600/400.jpg',
'https://picsum.photos/id/20/600/400.jpg',
'https://picsum.photos/id/21/600/400.jpg',
'https://picsum.photos/id/22/600/400.jpg',
'https://picsum.photos/id/23/600/400.jpg',
'https://picsum.photos/id/24/600/400.jpg',
'https://picsum.photos/id/25/600/400.jpg',
'https://picsum.photos/id/26/600/400.jpg',
'https://picsum.photos/id/27/600/400.jpg',
'https://picsum.photos/id/28/600/400.jpg',
'https://picsum.photos/id/29/600/400.jpg',
'https://picsum.photos/id/30/600/400.jpg',
'https://picsum.photos/id/31/600/400.jpg',
'https://picsum.photos/id/32/600/400.jpg',
'https://picsum.photos/id/33/600/400.jpg',
'https://picsum.photos/id/34/600/400.jpg',
'https://picsum.photos/id/35/600/400.jpg',
'https://picsum.photos/id/36/600/400.jpg',
'https://picsum.photos/id/37/600/400.jpg',
'https://picsum.photos/id/38/600/400.jpg',
'https://picsum.photos/id/39/600/400.jpg',
'https://picsum.photos/id/40/600/400.jpg',
'https://picsum.photos/id/41/600/400.jpg',
'https://picsum.photos/id/42/600/400.jpg',
'https://picsum.photos/id/43/600/400.jpg',
'https://picsum.photos/id/44/600/400.jpg',
'https://picsum.photos/id/45/600/400.jpg',
'https://picsum.photos/id/46/600/400.jpg',
'https://picsum.photos/id/47/600/400.jpg',
'https://picsum.photos/id/48/600/400.jpg',
'https://picsum.photos/id/49/600/400.jpg',
'https://picsum.photos/id/50/600/400.jpg',
'https://picsum.photos/id/51/600/400.jpg',
'https://picsum.photos/id/52/600/400.jpg',
'https://picsum.photos/id/53/600/400.jpg',
'https://picsum.photos/id/54/600/400.jpg',
'https://picsum.photos/id/55/600/400.jpg',
'https://picsum.photos/id/56/600/400.jpg',
'https://picsum.photos/id/57/600/400.jpg',
'https://picsum.photos/id/58/600/400.jpg',
'https://picsum.photos/id/59/600/400.jpg',
'https://picsum.photos/id/60/600/400.jpg',
'https://picsum.photos/id/61/600/400.jpg',
'https://picsum.photos/id/62/600/400.jpg',
'https://picsum.photos/id/63/600/400.jpg',
'https://picsum.photos/id/64/600/400.jpg',
'https://picsum.photos/id/65/600/400.jpg',
'https://picsum.photos/id/66/600/400.jpg',
'https://picsum.photos/id/67/600/400.jpg',
'https://picsum.photos/id/68/600/400.jpg',
'https://picsum.photos/id/69/600/400.jpg',
'https://picsum.photos/id/70/600/400.jpg',
'https://picsum.photos/id/71/600/400.jpg',
'https://picsum.photos/id/72/600/400.jpg',
'https://picsum.photos/id/73/600/400.jpg',
'https://picsum.photos/id/74/600/400.jpg',
'https://picsum.photos/id/75/600/400.jpg',
'https://picsum.photos/id/76/600/400.jpg',
'https://picsum.photos/id/77/600/400.jpg',
'https://picsum.photos/id/78/600/400.jpg',
'https://picsum.photos/id/79/600/400.jpg',
'https://picsum.photos/id/80/600/400.jpg',
'https://picsum.photos/id/81/600/400.jpg',
'https://picsum.photos/id/82/600/400.jpg',
'https://picsum.photos/id/83/600/400.jpg',
'https://picsum.photos/id/84/600/400.jpg',
'https://picsum.photos/id/85/600/400.jpg',
'https://picsum.photos/id/87/600/400.jpg',
'https://picsum.photos/id/88/600/400.jpg',
'https://picsum.photos/id/89/600/400.jpg',
'https://picsum.photos/id/90/600/400.jpg',
'https://picsum.photos/id/91/600/400.jpg',
'https://picsum.photos/id/92/600/400.jpg',
'https://picsum.photos/id/93/600/400.jpg',
'https://picsum.photos/id/94/600/400.jpg',
'https://picsum.photos/id/95/600/400.jpg',
'https://picsum.photos/id/96/600/400.jpg',
'https://picsum.photos/id/98/600/400.jpg',
'https://picsum.photos/id/99/600/400.jpg',
'https://picsum.photos/id/100/600/400.jpg',
'https://picsum.photos/id/101/600/400.jpg',
'https://picsum.photos/id/102/600/400.jpg',
'https://picsum.photos/id/103/600/400.jpg',
'https://picsum.photos/id/104/600/400.jpg',
'https://picsum.photos/id/106/600/400.jpg',
'https://picsum.photos/id/107/600/400.jpg',
'https://picsum.photos/id/108/600/400.jpg',
'https://picsum.photos/id/109/600/400.jpg',
'https://picsum.photos/id/110/600/400.jpg',
'https://picsum.photos/id/111/600/400.jpg',
'https://picsum.photos/id/112/600/400.jpg',
];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment