Flutter實戰(zhàn) 圖片加載原理與緩存

2021-03-09 14:42 更新

在本書前面章節(jié)已經(jīng)介紹過Image 組件,并提到 Flutter 框架對加載過的圖片是有緩存的(內(nèi)存),默認(rèn)最大緩存數(shù)量是1000,最大緩存空間為100M。本節(jié)便詳細(xì)介紹 Image 的原理及圖片緩存機制,下面我們先看看ImageProvider 類。

#14.5.1 ImageProvider

我們已經(jīng)知道Image 組件的image 參數(shù)是一個必選參數(shù),它是ImageProvider類型。下面我們便詳細(xì)介紹一下ImageProviderImageProvider是一個抽象類,定義了圖片數(shù)據(jù)獲取和加載的相關(guān)接口。它的主要職責(zé)有兩個:

  1. 提供圖片數(shù)據(jù)源
  2. 緩存圖片

我們看看ImageProvider抽象類的詳細(xì)定義:

abstract class ImageProvider<T> {


  ImageStream resolve(ImageConfiguration configuration) {
    // 實現(xiàn)代碼省略
  }
  Future<bool> evict({ ImageCache cache,
                      ImageConfiguration configuration = ImageConfiguration.empty }) async {
    // 實現(xiàn)代碼省略
  }


  Future<T> obtainKey(ImageConfiguration configuration); 
  @protected
  ImageStreamCompleter load(T key); // 需子類實現(xiàn)
}

#load(T key)方法

加載圖片數(shù)據(jù)源的接口,不同的數(shù)據(jù)源的加載方法不同,每個ImageProvider的子類必須實現(xiàn)它。比如NetworkImage類和AssetImage類,它們都是ImageProvider的子類,但它們需要從不同的數(shù)據(jù)源來加載圖片數(shù)據(jù):NetworkImage是從網(wǎng)絡(luò)來加載圖片數(shù)據(jù),而AssetImage則是從最終的應(yīng)用包里來加載(加載打到應(yīng)用安裝包里的資源圖片)。 我們以NetworkImage為例,看看其 load 方法的實現(xiàn):

@override
ImageStreamCompleter load(image_provider.NetworkImage key) {


  final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();

  
  return MultiFrameImageStreamCompleter(
    codec: _loadAsync(key, chunkEvents), //調(diào)用
    chunkEvents: chunkEvents.stream,
    scale: key.scale,
    ... //省略無關(guān)代碼
  );
}

我們看到,load方法的返回值類型是ImageStreamCompleter ,它是一個抽象類,定義了管理圖片加載過程的一些接口,Image Widget 中正是通過它來監(jiān)聽圖片加載狀態(tài)的(我們將在下面介紹Image 原理時詳細(xì)介紹)。

MultiFrameImageStreamCompleterImageStreamCompleter的一個子類,是 flutter sdk 預(yù)置的類,通過該類,我們以方便、輕松地創(chuàng)建出一個ImageStreamCompleter實例來做為load方法的返回值。

我們可以看到,MultiFrameImageStreamCompleter 需要一個codec參數(shù),該參數(shù)類型為Future<ui.Codec>。Codec 是處理圖片編解碼的類的一個 handler,實際上,它只是一個 flutter engine API 的包裝類,也就是說圖片的編解碼邏輯不是在 Dart 代碼部分實現(xiàn),而是在 flutter engine中實現(xiàn)的。Codec類部分定義如下:

@pragma('vm:entry-point')
class Codec extends NativeFieldWrapperClass2 {
  // 此類由flutter engine創(chuàng)建,不應(yīng)該手動實例化此類或直接繼承此類。
  @pragma('vm:entry-point')
  Codec._();


  /// 圖片中的幀數(shù)(動態(tài)圖會有多幀)
  int get frameCount native 'Codec_frameCount';


  /// 動畫重復(fù)的次數(shù)
  /// * 0 表示只執(zhí)行一次
  /// * -1 表示循環(huán)執(zhí)行
  int get repetitionCount native 'Codec_repetitionCount';


  /// 獲取下一個動畫幀
  Future<FrameInfo> getNextFrame() {
    return _futurize(_getNextFrame);
  }


  String _getNextFrame(_Callback<FrameInfo> callback) native 'Codec_getNextFrame';

我們可以看到Codec最終的結(jié)果是一個或多個(動圖)幀,而這些幀最終會繪制到屏幕上。

MultiFrameImageStreamCompleter 的 codec參數(shù)值為_loadAsync方法的返回值,我們繼續(xù)看_loadAsync方法的實現(xiàn):

 Future<ui.Codec> _loadAsync(
    NetworkImage key,
    StreamController<ImageChunkEvent> chunkEvents,
  ) async {
    try {
      //下載圖片
      final Uri resolved = Uri.base.resolve(key.url);
      final HttpClientRequest request = await _httpClient.getUrl(resolved);
      headers?.forEach((String name, String value) {
        request.headers.add(name, value);
      });
      final HttpClientResponse response = await request.close();
      if (response.statusCode != HttpStatus.ok)
        throw Exception(...);
      // 接收圖片數(shù)據(jù) 
      final Uint8List bytes = await consolidateHttpClientResponseBytes(
        response,
        onBytesReceived: (int cumulative, int total) {
          chunkEvents.add(ImageChunkEvent(
            cumulativeBytesLoaded: cumulative,
            expectedTotalBytes: total,
          ));
        },
      );
      if (bytes.lengthInBytes == 0)
        throw Exception('NetworkImage is an empty file: $resolved');
      // 對圖片數(shù)據(jù)進(jìn)行解碼
      return PaintingBinding.instance.instantiateImageCodec(bytes);
    } finally {
      chunkEvents.close();
    }
  }

可以看到_loadAsync方法主要做了兩件事:

  1. 下載圖片。
  2. 對下載的圖片數(shù)據(jù)進(jìn)行解碼。

下載邏輯比較簡單:通過HttpClient從網(wǎng)上下載圖片,另外下載請求會設(shè)置一些自定義的 header,開發(fā)者可以通過NetworkImageheaders命名參數(shù)來傳遞。

在圖片下載完成后調(diào)用了PaintingBinding.instance.instantiateImageCodec(bytes)對圖片進(jìn)行解碼,值得注意的是instantiateImageCodec(...)也是一個 Native API 的包裝,實際上會調(diào)用 Flutter engine 的instantiateImageCodec方法,源碼如下:

String _instantiateImageCodec(Uint8List list, _Callback<Codec> callback, _ImageInfo imageInfo, int targetWidth, int targetHeight)
  native 'instantiateImageCodec';

#obtainKey(ImageConfiguration)方法

該接口主要是為了配合實現(xiàn)圖片緩存,ImageProvider從數(shù)據(jù)源加載完數(shù)據(jù)后,會在全局的ImageCache中緩存圖片數(shù)據(jù),而圖片數(shù)據(jù)緩存是一個 Map,而 Map 的 key 便是調(diào)用此方法的返回值,不同的 key 代表不同的圖片數(shù)據(jù)緩存。

#resolve(ImageConfiguration) 方法

resolve方法是ImageProvider的暴露的給Image的主入口方法,它接受一個ImageConfiguration參數(shù),返回ImageStream,即圖片數(shù)據(jù)流。我們重點看一下resolve執(zhí)行流程:

ImageStream resolve(ImageConfiguration configuration) {
  ... //省略無關(guān)代碼
  final ImageStream stream = ImageStream();
  T obtainedKey; //
  //定義錯誤處理函數(shù)
  Future<void> handleError(dynamic exception, StackTrace stack) async {
    ... //省略無關(guān)代碼
    stream.setCompleter(imageCompleter);
    imageCompleter.setError(...);
  }


  // 創(chuàng)建一個新Zone,主要是為了當(dāng)發(fā)生錯誤時不會干擾MainZone
  final Zone dangerZone = Zone.current.fork(...);

  
  dangerZone.runGuarded(() {
    Future<T> key;
    // 先驗證是否已經(jīng)有緩存
    try {
      // 生成緩存key,后面會根據(jù)此key來檢測是否有緩存
      key = obtainKey(configuration);
    } catch (error, stackTrace) {
      handleError(error, stackTrace);
      return;
    }
    key.then<void>((T key) {
      obtainedKey = key;
      // 緩存的處理邏輯在這里,記為A,下面詳細(xì)介紹
      final ImageStreamCompleter completer = PaintingBinding.instance
          .imageCache.putIfAbsent(key, () => load(key), onError: handleError);
      if (completer != null) {
        stream.setCompleter(completer);
      }
    }).catchError(handleError);
  });
  return stream;
}

ImageConfiguration 包含圖片和設(shè)備的相關(guān)信息,如圖片的大小、所在的AssetBundle(只有打到安裝包的圖片存在)以及當(dāng)前的設(shè)備平臺、devicePixelRatio(設(shè)備像素比等)。Flutter SDK 提供了一個便捷函數(shù)createLocalImageConfiguration來創(chuàng)建ImageConfiguration 對象:

ImageConfiguration createLocalImageConfiguration(BuildContext context, { Size size }) {
  return ImageConfiguration(
    bundle: DefaultAssetBundle.of(context),
    devicePixelRatio: MediaQuery.of(context, nullOk: true)?.devicePixelRatio ?? 1.0,
    locale: Localizations.localeOf(context, nullOk: true),
    textDirection: Directionality.of(context),
    size: size,
    platform: defaultTargetPlatform,
  );
}

我們可以發(fā)現(xiàn)這些信息基本都是通過Context來獲取。

上面代碼 A 處就是處理緩存的主要代碼,這里的PaintingBinding.instance.imageCacheImageCache的一個實例,它是PaintingBinding的一個屬性,而 Flutter 框架中的PaintingBinding.instance是一個單例,imageCache事實上也是一個單例,也就是說圖片緩存是全局的,統(tǒng)一由PaintingBinding.instance.imageCache 來管理。

下面我們看看ImageCache類定義:

const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB


class ImageCache {
  // 正在加載中的圖片隊列
  final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
  // 緩存隊列
  final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};


  // 緩存數(shù)量上限(1000)
  int _maximumSize = _kDefaultSize;
  // 緩存容量上限 (100 MB)
  int _maximumSizeBytes = _kDefaultSizeBytes;

  
  // 緩存上限設(shè)置的setter
  set maximumSize(int value) {...}
  set maximumSizeBytes(int value) {...}

 
  ... // 省略部分定義


  // 清除所有緩存
  void clear() {
    // ...省略具體實現(xiàn)代碼
  }


  // 清除指定key對應(yīng)的圖片緩存
  bool evict(Object key) {
   // ...省略具體實現(xiàn)代碼
  }



 
  ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(), { ImageErrorListener onError }) {
    assert(key != null);
    assert(loader != null);
    ImageStreamCompleter result = _pendingImages[key]?.completer;
    // 圖片還未加載成功,直接返回
    if (result != null)
      return result;

 
    // 有緩存,繼續(xù)往下走
    // 先移除緩存,后再添加,可以讓最新使用過的緩存在_map中的位置更近一些,清理時會LRU來清除
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _cache[key] = image;
      return image.completer;
    }
    try {
      result = loader();
    } catch (error, stackTrace) {
      if (onError != null) {
        onError(error, stackTrace);
        return null;
      } else {
        rethrow;
      }
    }
    void listener(ImageInfo info, bool syncCall) {
      final int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
      final _CachedImage image = _CachedImage(result, imageSize);
      // 下面是緩存處理的邏輯
      if (maximumSizeBytes > 0 && imageSize > maximumSizeBytes) {
        _maximumSizeBytes = imageSize + 1000;
      }
      _currentSizeBytes += imageSize;
      final _PendingImage pendingImage = _pendingImages.remove(key);
      if (pendingImage != null) {
        pendingImage.removeListener();
      }


      _cache[key] = image;
      _checkCacheSize();
    }
    if (maximumSize > 0 && maximumSizeBytes > 0) {
      final ImageStreamListener streamListener = ImageStreamListener(listener);
      _pendingImages[key] = _PendingImage(result, streamListener);
      // Listener is removed in [_PendingImage.removeListener].
      result.addListener(streamListener);
    }
    return result;
  }


  // 當(dāng)緩存數(shù)量超過最大值或緩存的大小超過最大緩存容量,會調(diào)用此方法清理到緩存上限以內(nèi)
  void _checkCacheSize() {
   while (_currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
      final Object key = _cache.keys.first;
      final _CachedImage image = _cache[key];
      _currentSizeBytes -= image.sizeBytes;
      _cache.remove(key);
    }
    ... //省略無關(guān)代碼
  }
}

有緩存則使用緩存,沒有緩存則調(diào)用 load 方法加載圖片,加載成功后:

  1. 先判斷圖片數(shù)據(jù)有沒有緩存,如果有,則直接返回ImageStream
  2. 如果沒有緩存,則調(diào)用load(T key)方法從數(shù)據(jù)源加載圖片數(shù)據(jù),加載成功后先緩存,然后返回 ImageStream。

另外,我們可以看到ImageCache類中有設(shè)置緩存上限的setter,所以,如果我們可以自定義緩存上限:

 PaintingBinding.instance.imageCache.maximumSize=2000; //最多2000張
 PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20; //最大200M

現(xiàn)在我們看一下緩存的 key,因為 Map 中相同 key 的值會被覆蓋,也就是說 key 是圖片緩存的一個唯一標(biāo)識,只要是不同 key,那么圖片數(shù)據(jù)就會分別緩存(即使事實上是同一張圖片)。那么圖片的唯一標(biāo)識是什么呢?跟蹤源碼,很容易發(fā)現(xiàn) key 正是ImageProvider.obtainKey()方法的返回值,而此方法需要ImageProvider子類去重寫,這也就意味著不同的ImageProvider對key的定義邏輯會不同。其實也很好理解,比如對于NetworkImage,將圖片的 url 作為 key 會很合適,而對于AssetImage,則應(yīng)該將“包名+路徑”作為唯一的 key。下面我們以NetworkImage為例,看一下它的obtainKey()實現(xiàn):

@override
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
  return SynchronousFuture<NetworkImage>(this);
}

代碼很簡單,創(chuàng)建了一個同步的 future,然后直接將自身做為 key 返回。因為 Map 中在判斷 key(此時是NetworkImage對象)是否相等時會使用“==”運算符,那么定義 key 的邏輯就是NetworkImage的“==”運算符:

@override
bool operator ==(dynamic other) {
  ... //省略無關(guān)代碼
  final NetworkImage typedOther = other;
  return url == typedOther.url
      && scale == typedOther.scale;
}

很清晰,對于網(wǎng)絡(luò)圖片來說,會將其“url+縮放比例”作為緩存的 key。也就是說如果兩張圖片的 url 或 scale 只要有一個不同,便會重新下載并分別緩存。

另外,我們需要注意的是,圖片緩存是在內(nèi)存中,并沒有進(jìn)行本地文件持久化存儲,這也是為什么網(wǎng)絡(luò)圖片在應(yīng)用重啟后需要重新聯(lián)網(wǎng)下載的原因。

同時也意味著在應(yīng)用生命周期內(nèi),如果緩存沒有超過上限,相同的圖片只會被下載一次。

#總結(jié)

上面主要結(jié)合源碼,探索了ImageProvider的主要功能和原理,如果要用一句話來總結(jié)ImageProvider功能,那么應(yīng)該是:加載圖片數(shù)據(jù)并進(jìn)行緩存、解碼。在此再次提醒讀者,F(xiàn)lutter 的源碼是非常好的第一手資料,建議讀者多多探索,另外,在閱讀源碼學(xué)習(xí)的同時一定要有總結(jié),這樣才不至于在源碼中迷失。

#14.5.2 Image組件原理

前面章節(jié)中我們介紹過Image的基礎(chǔ)用法,現(xiàn)在我們更深入一些,研究一下Image是如何和ImageProvider配合來獲取最終解碼后的數(shù)據(jù),然后又如何將圖片繪制到屏幕上的。

本節(jié)換一個思路,我們先不去直接看Image的源碼,而根據(jù)已經(jīng)掌握的知識來實現(xiàn)一個簡版的“Image組件” MyImage,代碼大致如下:

class MyImage extends StatefulWidget {
  const MyImage({
    Key key,
    @required this.imageProvider,
  })
      : assert(imageProvider != null),
        super(key: key);


  final ImageProvider imageProvider;


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


class _MyImageState extends State<MyImage> {
  ImageStream _imageStream;
  ImageInfo _imageInfo;


  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 依賴改變時,圖片的配置信息可能會發(fā)生改變
    _getImage();
  }


  @override
  void didUpdateWidget(MyImage oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.imageProvider != oldWidget.imageProvider)
      _getImage();
  }


  void _getImage() {
    final ImageStream oldImageStream = _imageStream;
    // 調(diào)用imageProvider.resolve方法,獲得ImageStream。
    _imageStream =
        widget.imageProvider.resolve(createLocalImageConfiguration(context));
    //判斷新舊ImageStream是否相同,如果不同,則需要調(diào)整流的監(jiān)聽器
    if (_imageStream.key != oldImageStream?.key) {
      final ImageStreamListener listener = ImageStreamListener(_updateImage);
      oldImageStream?.removeListener(listener);
      _imageStream.addListener(listener);
    }
  }


  void _updateImage(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      // Trigger a build whenever the image changes.
      _imageInfo = imageInfo;
    });
  }


  @override
  void dispose() {
    _imageStream.removeListener(ImageStreamListener(_updateImage));
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {
    return RawImage(
      image: _imageInfo?.image, // this is a dart:ui Image object
      scale: _imageInfo?.scale ?? 1.0,
    );
  }
}

上面代碼流程如下:

  1. 通過imageProvider.resolve方法可以得到一個ImageStream(圖片數(shù)據(jù)流),然后監(jiān)聽ImageStream的變化。當(dāng)圖片數(shù)據(jù)源發(fā)生變化時,ImageStream會觸發(fā)相應(yīng)的事件,而本例中我們只設(shè)置了圖片成功的監(jiān)聽器_updateImage,而_updateImage中只更新了_imageInfo。值得注意的是,如果是靜態(tài)圖,ImageStream只會觸發(fā)一次時間,如果是動態(tài)圖,則會觸發(fā)多次事件,每一次都會有一個解碼后的圖片幀。
  2. _imageInfo 更新后會 rebuild,此時會創(chuàng)建一個RawImage Widget。RawImage最終會通過RenderImage來將圖片繪制在屏幕上。如果繼續(xù)跟進(jìn)RenderImage類,我們會發(fā)現(xiàn)RenderImagepaint 方法中調(diào)用了paintImage方法,而paintImage方法中通過CanvasdrawImageRect(…)drawImageNine(...)等方法來完成最終的繪制。
  3. 最終的繪制由RawImage來完成。

下面測試一下MyImage

class ImageInternalTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        MyImage(
          imageProvider: NetworkImage(
            "https://avatars2.githubusercontent.com/u/20411648?s=460&v=4",
          ),
        )
      ],
    );
  }
}

運行效果如圖14-4所示:

圖14-4

成功了! 現(xiàn)在,想必Image Widget 的源碼已經(jīng)沒必要在花費篇章去介紹了,讀者有興趣可以自行去閱讀。

#總結(jié)

本節(jié)主要介紹了 Flutter 圖片的加載、緩存和繪制流程。其中ImageProvider主要負(fù)責(zé)圖片數(shù)據(jù)的加載和緩存,而繪制部分邏輯主要是由RawImage來完成。 而Image正是連接起ImageProviderRawImage 的橋梁。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號