客製化 Flutter Image Provider
之前在 flutter app 中使用 Optimized Cached Image 來展示圖片,選擇他的原因也很簡單,因為當時剛學 flutter 沒多久就要直接上陣了。先選一個看起來簡單好用的再說;而且他還可以設置 cache key,避免重複抓取,感覺起來就不錯。
然而後來圖片一多起來之後,就發現問題了,往往 app 打開滑沒幾下就 crash。看 log 是 OOM 問題,上網查了一下,有人說是 memory leak。大致上就是 image disposed 之後,沒有正確的釋放 ram,app 佔用的 ram 越來越多,就直接觸發 OOM killer。測試了一下原生的 NetworkImage 沒有這個問題。所以應該就是 Optimized Cached Image 的問題了。
另外就是在我的程式裡,widget 只拿到 media id,需要先到後端去查 media 的 presigned url 返回給 app,app 再用 presigned url 抓圖片資料。也就是原來的程式碼裡一直都是 future builder + image,這造成的問題就是頁面一刷新就要重新抓 presigned url,大大降低了效率,也大大提升了後端的負載。
因此決定客製化一個業務專用的 image provider。
經一番 google 之後,找到了一篇寫得很淺顯易懂的文章: Flutter 网络图片的缓存和加载。
按他的方法客製了一個自己的版本,目標:
- 使用 ffcache 做圖片快取存儲,如果 cache 中有圖片的資料,就直接讀取 cache;如果 cache 沒有,就從網路上下載。
- 將查詢業務 media 資料的程式邏輯放到 image provider 中自動查詢,一方面簡化輸入的參數,另一方面 widget 無需再用 future builder,減少 rebuild 過程中無意義的消耗。
以下展示程式碼:
-
drivers/cache.dart
:MyCache
做為 abstraction layer 存取 ffcache api。其他程式用getCache
函數取得 cache 實例。import 'package:ffcache/ffcache.dart'; import '../logger.dart' as logger; class MyCache { FFCache? _cache; bool isReady = false; Future initialize() async { logger.debug('initializing cache'); _cache = FFCache(); await _cache?.init(); isReady = true; } Future<bool> hasKey(String key) { return _cache!.has(key); } Future removeKey(String key) { return _cache!.remove(key); } // bytes data Future getBytes(String key) => _cache!.getBytes(key); Future putBytes(String key, List<int> bytes, Duration? timeout) { return timeout != null ? _cache!.setBytesWithTimeout(key, bytes, timeout) : _cache!.setBytes(key, bytes); } // json data Future getJson(String key) => _cache!.getJSON(key); Future putJson(String key, dynamic value, Duration? timeout) { return timeout != null ? _cache!.setJSONWithTimeout(key, value, timeout) : _cache!.setJSON(key, value); } } final _instant = MyCache(); Future initCache() async { if (!_instant.isReady) { await _instant.initialize(); } } MyCache getCache() { return _instant; }
-
image_provider.dart
:- 獲取圖片與 metadata 的程式邏輯放到
_loadAsync
函數裡,拿到圖片資料之後再 callinstantiateImageCodec
繪圖。 - 存取 cache 中的資料需要 cache key, 我做法是用圖片的 MD5 值(由後端提供),沒有就用 URL 的 MD5。
import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui show Codec; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart' as http; import '../apis.dart'; import '../constants.dart'; import '../drivers/cache.dart'; import '../logger.dart' as logger; import '../models.dart'; const _utf8encoder = Utf8Encoder(); String md5sum(String key) { var content = _utf8encoder.convert(key); var digest = md5.convert(content); return digest.toString(); } var _cache = getCache(); Future<Uint8List> getBytes(String key) async { var exist = await _cache.hasKey(key); if (exist) { var bytes = await _cache.getBytes(key); return Uint8List.fromList(bytes); } return Uint8List(0); } Future saveBytes(String key, Uint8List bytes, Duration timeout) { return _cache.putBytes(key, bytes, timeout); } class MyImageProvider extends ImageProvider<MyImageProvider> { final BuildContext context; final MyMedia media; final Map<String, String> headers; final double scale; final Duration timeout; MyImageProvider({ required this.context, required this.media, this.headers = const {}, this.scale = 1.0, this.timeout = const Duration(hours: 1), }); @override Future<MyImageProvider> obtainKey(ImageConfiguration configuration) { return SynchronousFuture<MyImageProvider>(this); } @override int get hashCode => hashValues(media.mediaId, scale); @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) { return false; } final MyImageProvider _type = other; return media.mediaId == _type.media.mediaId && scale == _type.scale; } Future<ui.Codec> _loadAsync(MyImageProvider key) async { assert(key == this); // 先看 cache 裡面有沒有 var cacheKey = media.checksum ?? md5sum(media.presigned); var cachedBytes = await getBytes(cacheKey); if (cachedBytes.isNotEmpty) { return PaintingBinding.instance .instantiateImageCodec(cachedBytes) .catchError( (err) { logger.error('unexpected image error (cache)\n$err'); _cache.removeKey(cacheKey); throw err; }, ); } // 從線上抓圖片 var api = MyAPI.of(context)!; var url = media.presigned ?? api.presignedMediaUrl(media.mediaId); Uint8List? bytes; while (bytes?.isEmpty ?? true) { var resp = await http.get(Uri.parse(url), headers: headers); switch (resp.statusCode) { case HttpStatus.ok: bytes = resp.bodyBytes; if (bytes.isEmpty) { throw Exception('Invalid image, url: $url'); } saveBytes(cacheKey, bytes, timeout); break; case HttpStatus.forbidden: url = api.presignedMediaUrl(media.mediaId); break; default: throw Exception( 'HTTP request failed, statusCode: ${resp.statusCode}, url: $url'); } } // 另一種寫法 (不知道有什麼區別) // import 'dart:ui' as ui show instantiateImageCodec; // return ui.instantiateImageCodec(bytes); return PaintingBinding.instance.instantiateImageCodec(bytes!); } @override ImageStreamCompleter load(MyImageProvider key, DecoderCallback decode) { return MultiFrameImageStreamCompleter(codec: _loadAsync(key), scale: scale); } }
- 獲取圖片與 metadata 的程式邏輯放到
這樣就可以用了。使用的方式:
Image(
image: MyImageProvider(media: data),
fit: BoxFit.cover,
);
或:
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: MyImageProvider(media: data),
fit: BoxFit.cover,
),
),
);