Last active
July 2, 2026 15:54
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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