flutter圖片元件原始碼解析

俞魚餘語發表於2021-05-22

導語

在使用flutter 自帶圖片元件的過程中,大家有沒有考慮過flutter是如何載入一張網路圖片的? 以及對自帶的圖片元件我們可以做些什麼優化?

問題

  1. flutter 網路圖片是怎麼請求的?

  2. 圖片請求成功後是這麼展示的? gif的每一幀是怎麼支援展示的?

  3. 如何支援圖片的磁碟快取?

接下來,讓我們帶著問題一起探究flutter 圖片元件的內部原理

本文原始碼分析以flutter-1.22版本為準,只涉及到dart端,c層圖片解碼不涉及

Image的核心類圖及其關係

盜用一張網上的uml圖

image.png

  • Image,是一個statefulWidget,flutter image的核心入口類,包含了network,file,assert,memory這幾個主要的功能,分包對應網路圖片,檔案圖片,APP內建assert圖片,從檔案流解析圖片
  • _ImageState,由於Image是statefulWidget,所以核心程式碼都在_ImageState
  • ImageStream ,處理圖片資源,ImageState和ImageStreamCompleter的橋樑
  • ImageInfo ,圖片原生資訊儲存者
  • ImageStreamCompleter,可以理解為一幀幀解析圖片,並把解析的資料回撥給展示方,主要有兩個實現類
    • OneFrameImageStreamCompleter單幀圖片解析器(貌似沒在用)
    • MultiFrameImageStreamCompleter多幀圖片解析器,原始碼裡所有圖片都是預設使用這個了
  • ImageProvider,圖片載入器,不同的載入方式有不同的實現
    • NetworkImage 網路載入圖片
    • MemoryImage 從二進位制流載入圖片
    • AssetImage 載入asset裡的image
    • FileImage 從檔案中載入圖片
  • ImageCache ,flutter自帶的圖片快取,只有記憶體快取,官方自帶cache ,最大個數100,最大記憶體100MB
  • ScrollAwareImageProvider,避免圖片在快速滑動中載入

網路圖片的載入過程

// 網路圖片
Image.network(imgUrl,  //圖片連結
      width: w, 
      height: h),
)
複製程式碼

上文中提到過,Image是個StatefulWidget,那核心邏輯看對應的ImageState,ImageState繼承自State,State的生命週期我們知道,首次初始化時按InitState()->didChangeDependencies->didUpdateWidget()-> build()順序執行

ImageState的InitState沒做什麼,圖片請求的發起是在didChangeDependencies裡做的

// ImageState->didChangeDependencies
@override
void didChangeDependencies() {
    // ios在輔助模式下的配置,不影響主流程,我們不分析
  _updateInvertColors(); 
  
  // 核心方法,開始請求解析圖片,從這裡開始,provier,stream,completer開始悉數登場
  _resolveImage();
    
    // 這個判斷可以認為是,當前widget 在tree中是否還是啟用狀態
  if (TickerMode.of(context))
    _listenToStream();
  else
    _stopListeningToStream();

  super.didChangeDependencies();
}
複製程式碼

再看ImageState裡的_resolveImage方法

void _resolveImage() {
    // ScrollAwareImageProvider代理模式,它本身也是繼承的ImageProvider,
    // 它的功能是防止在快速滾動時載入圖片
  final ScrollAwareImageProvider provider = ScrollAwareImageProvider<dynamic>(
    context: _scrollAwareContext,
    imageProvider: widget.image,
  );
  
  // 這裡呼叫了ImageProvider的resolve方法,圖片請求的主流程
  final ImageStream newStream =
    provider.resolve(createLocalImageConfiguration(
      context,
      size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null,
    ));
  assert(newStream != null);
  // 對resolve返回的Stream註冊監聽,這個監聽很重要,決定了後續的圖片展示(包括gif)
  // 重新整理當前圖片展示一次,例如幀數,載入狀態等等
  _updateSourceStream(newStream);
}
複製程式碼

我們接著看ImageProvider的resolve方法

// 這方法初次看比較繞,其實就幹了三個事
// 1. 建立了一個ImageStream
// 2. 建立一個Key,key由具體的provider自己實現,這個key用在後面ImageCache裡
// 3. 把接下來的流程封裝在一個Zone裡,捕獲了同步異常和非同步異常,不瞭解Zone的同學可以參考我另一篇文章
@nonVirtual
ImageStream resolve(ImageConfiguration configuration) {
  assert(configuration != null);
  final ImageStream stream = createStream(configuration);
  // 建立了key,把後續的流程封裝在zone裡,原始碼我不貼了,感興趣的同學自己看下
  _createErrorHandlerAndKey(
    configuration,
    (T key, ImageErrorListener errorHandler) {
      resolveStreamForKey(configuration, stream, key, errorHandler);
    },
    (T? key, dynamic exception, StackTrace? stack) async {
      await null; // wait an event turn in case a listener has been added to the image stream.
      final _ErrorImageCompleter imageCompleter = _ErrorImageCompleter();
      stream.setCompleter(imageCompleter);
      InformationCollector? collector;
      assert(() {
        collector = () sync* {
          yield DiagnosticsProperty<ImageProvider>('Image provider', this);
          yield DiagnosticsProperty<ImageConfiguration>('Image configuration', configuration);
          yield DiagnosticsProperty<T>('Image key', key, defaultValue: null);
        };
        return true;
      }());
      imageCompleter.setError(
        exception: exception,
        stack: stack,
        context: ErrorDescription('while resolving an image'),
        silent: true, // could be a network error or whatnot
        informationCollector: collector,
      );
    },
  );
  return stream;
}
複製程式碼

接著看resolveStreamForKey方法,在1.22裡,預設的provider都是ScrollAwareImageProvider,ScrollAwareImageProvider重寫了resolveStreamForKey,這裡有滾動控制載入的邏輯,但最終呼叫的還是ImageProvier的resolveStreamForKey

// ImageProvier -> resolveStreamForKey
@protected
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
    // streem中已經有completer了,從快取中拿,
  if (stream.completer != null) {
    final ImageStreamCompleter? completer = PaintingBinding.instance!.imageCache!.putIfAbsent(
      key,
      () => stream.completer!,
      onError: handleError,
    );
    assert(identical(completer, stream.completer));
    return;
  }
  
  // 如果是首次,新建一個completer,然後會執行load這個函式,就是putIfAbsent的第二個入參
  final ImageStreamCompleter? completer = PaintingBinding.instance!.imageCache!.putIfAbsent(
    key,
    () => load(key, PaintingBinding.instance!.instantiateImageCodec),
    onError: handleError,
  );
  // 賦值,注意這裡,後面講圖片展示的時候會說到這裡
  if (completer != null) {
    stream.setCompleter(completer);
  }
}
複製程式碼

接著看ImageProvider的load,load方法就是圖片的具體載入方法,不同的provider有不同的實現,此時我們關注NetworkImage的Provier裡的實現

// NetworkImage
@override
ImageStreamCompleter load(image_provider.NetworkImage key, image_provider.DecoderCallback decode) {
  // Ownership of this controller is handed off to [_loadAsync]; it is that
  // method's responsibility to close the controller's stream when the image
  // has been loaded or an error is thrown.
  final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
    
    // MultiFrameImageStreamCompleter是多幀解析器,預設使用的是就是這個,所以預設支援gif
  return MultiFrameImageStreamCompleter(
    codec: _loadAsync(key as NetworkImage, chunkEvents, decode), // 非同步載入圖片,我們接著看這個方法
    chunkEvents: chunkEvents.stream, //載入過程的回撥
    scale: key.scale,
    debugLabel: key.url,
    informationCollector: () {
      return <DiagnosticsNode>[
        DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
        DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
      ];
    },
  );
}
複製程式碼

接著看NetworkImage的_loadAsync

// 這裡就很清晰了吧,內建的HttpClient去載入
Future<ui.Codec> _loadAsync(
  NetworkImage key,
  StreamController<ImageChunkEvent> chunkEvents,
  image_provider.DecoderCallback decode,
) async {
  try {
    assert(key == this);
    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 image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved);
    }
    
    // 二進位制流資料回撥
    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');
    //解析二進位制流
    return decode(bytes);
  } catch (e) {
    // Depending on where the exception was thrown, the image cache may not
    // have had a chance to track the key in the cache at all.
    // Schedule a microtask to give the cache a chance to add the key.
    scheduleMicrotask(() {
      PaintingBinding.instance!.imageCache!.evict(key);
    });
    rethrow;
  } finally {
    chunkEvents.close();
  }
}
複製程式碼

至此,第一個問題回答完畢,那當圖片資料請求成功後,是怎麼回撥到ImageState並展示到介面中的呢?

網路圖片資料的回撥和展示過程

要看回撥和展示,我們從終點ImageState的build方法開始看

// 很容易發現RawImage,RawImage是實際渲染圖片的widget,這麼說其實也不對,RenderImage才是最終渲染的
// 可以看到RawImage的第一個引數_imageInfo?.image,那_imageInfo?.image是什麼時候賦值的?
Widget result = RawImage(
  image: _imageInfo?.image,
  debugImageLabel: _imageInfo?.debugLabel,
  width: widget.width,
  height: widget.height,
  scale: _imageInfo?.scale ?? 1.0,
  color: widget.color,
  colorBlendMode: widget.colorBlendMode,
  fit: widget.fit,
  alignment: widget.alignment,
  repeat: widget.repeat,
  centerSlice: widget.centerSlice,
  matchTextDirection: widget.matchTextDirection,
  invertColors: _invertColors,
  isAntiAlias: widget.isAntiAlias,
  filterQuality: widget.filterQuality,
);
複製程式碼

還記得第一部分提到的_updateSourceStream(newStream);方法嗎?在這個方法裡對ImageStrem設定了一個監聽

// 設定了監聽
_imageStream.addListener(_getListener());

// ImageStreamListener
ImageStreamListener _getListener({bool recreateListener = false}) {
  if(_imageStreamListener == null || recreateListener) {
    _lastException = null;
    _lastStack = null;
    _imageStreamListener = ImageStreamListener(
      _handleImageFrame, // 每一幀圖片解析完,代表可以展示這一幀了
      onChunk: widget.loadingBuilder == null ? null : _handleImageChunk, // 圖片載入互調
      onError: widget.errorBuilder != null // 圖片載入錯誤互調
          ? (dynamic error, StackTrace stackTrace) {
              setState(() {
                _lastException = error;
                _lastStack = stackTrace;
              });
            }
          : null,
    );
  }
  return _imageStreamListener;
}
複製程式碼

接著看ImageState的_handleImageFrame

// 很簡單,就是setState,可以看到這裡賦值了_imageInfo
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
  setState(() {
    _imageInfo = imageInfo;
    _loadingProgress = null;
    _frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
    _wasSynchronouslyLoaded |= synchronousCall;
  });
}
複製程式碼

那麼這個_imageStreamListener 是什麼時候回撥的呢? 還記得第一步載入過程最後一步的MultiFrameImageStreamCompleter嗎?

// MultiFrameImageStreamCompleter就是支援gif的多幀解析器,還有一個OneFrameImageStreamCompleter,但已經不用了
MultiFrameImageStreamCompleter({
  required Future<ui.Codec> codec,
  required double scale,
  String? debugLabel,
  Stream<ImageChunkEvent>? chunkEvents,
  InformationCollector? informationCollector,
}) : assert(codec != null),
     _informationCollector = informationCollector,
     _scale = scale {
  this.debugLabel = debugLabel;
  // _handleCodecReady就是圖片載入完的回撥,我們看看他內部幹了什麼
  codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) {
    // 捕獲錯誤並上報
  });
  // 監聽回撥
  if (chunkEvents != null) {
    chunkEvents.listen(reportImageChunkEvent,
      onError: (dynamic error, StackTrace stack) {
        reportError(
          context: ErrorDescription('loading an image'),
          exception: error,
          stack: stack,
          informationCollector: informationCollector,
          silent: true,
        );
      },
    );
  }
}

複製程式碼

這裡回答了第二個問題,gif的每幀是怎麼支援的,關鍵就是MultiFrameImageStreamCompleter這個類, 接著看MultiFrameImageStreamCompleter的_handleCodecReady

void _handleCodecReady(ui.Codec codec) {
  _codec = codec;
  assert(_codec != null);

  if (hasListeners) {
      // 看函式名就知道了,解析下一幀並執行
    _decodeNextFrameAndSchedule();
  }
}
複製程式碼

MultiFrameImageStreamCompleter的_decodeNextFrameAndSchedule()

Future<void> _decodeNextFrameAndSchedule() async {
  try {
      // 獲得下一幀,這一步在C中處理
    _nextFrame = await _codec!.getNextFrame();
  } catch (exception, stack) {
    reportError(
      context: ErrorDescription('resolving an image frame'),
      exception: exception,
      stack: stack,
      informationCollector: _informationCollector,
      silent: true,
    );
    return;
  }
  // 幀數不等於1,說明圖片有多幀
  if (_codec!.frameCount == 1) {
    // This is not an animated image, just return it and don't schedule more
    // frames.
    _emitFrame(ImageInfo(image: _nextFrame!.image, scale: _scale, debugLabel: debugLabel));
    return;
  }
  // 如果只有一幀,_scheduleAppFrame最終也會走到_emitFrame
  _scheduleAppFrame();
}
複製程式碼

接著看MultiFrameImageStreamCompleter的_emitFrame

// 呼叫了setImage
void _emitFrame(ImageInfo imageInfo) {
  setImage(imageInfo);
  _framesEmitted += 1;
}
複製程式碼

ImageStreamCompleter的setImage

@protected
void setImage(ImageInfo image) {
  _currentImage = image;
  if (_listeners.isEmpty)
    return;
  // Make a copy to allow for concurrent modification.
  final List<ImageStreamListener> localListeners =
      List<ImageStreamListener>.from(_listeners);
  for (final ImageStreamListener listener in localListeners) {
    try {
        // 在這裡回撥了onImage,那這個回撥是哪裡註冊的呢? 回到ImageStream的addLister裡
      listener.onImage(image, false);
    } catch (exception, stack) {
    }
  }
}
複製程式碼

ImageStream的addLister裡

void addListener(ImageStreamListener listener) {
    // 這裡破案了,_completer 不為null的時候,註冊了回撥,而ImageStream的completer在ImageStream被建立的還是就賦值了
    // 所以前面的listener.onImage(image, false);最終會回撥到ImageState裡的_imageStreamListener
  if (_completer != null)
    return _completer!.addListener(listener);
  _listeners ??= <ImageStreamListener>[];
  _listeners!.add(listener);
}
複製程式碼

至此,圖片的是展示流程也分析完畢,第二個問題也回答完了。

補上圖片記憶體快取的原始碼分析

首先要說明的是,flutter記憶體快取預設只有記憶體快取,也就意味著如果殺程式重啟,圖片就需要重新載入了。

1.22的記憶體快取主要分三部分,相比1.17增加了一部分

  • _pendingImages 正在載入中的快取,這個有什麼作用呢? 假設Widget1載入了圖片A,Widget2也在這個時候載入了圖片A,那這時候Widget就複用了這個載入中的快取
    複製程式碼
  • _cache 已經載入成功的圖片快取,這個很好理解
    複製程式碼
  • _liveImages 存活的圖片快取,看程式碼主要是在CacheImage之外再加一層快取,在CacheImage被清楚後,
    複製程式碼
    對於一張圖片,當首次載入時,首先會在_pendingImages中,注意此時圖片還未載入成功,所以如果有複用的情況,會命中_pendingImages,當圖片請求成功後,在_cache和_liveImages都會儲存一份,此時_pendingImages會移除。 當超過快取中的最大數時,會從_cache裡按照LRU的規則刪除

如何支援圖片的磁碟快取

在看完整個流程後,對磁碟快取應該也有思路了。第一個是可以自定義ImageProvider,在圖片資料請求成功後寫入磁碟快取,不過對於混合專案來說,更好的方式應該是替換圖片的網路請求方式,利用channel和原生(Android ,ios)的圖片庫載入圖片,這樣可以複用原生圖片庫的磁碟快取,但也有缺陷,在效率上會有降低,畢竟多了記憶體的多次拷貝和channel通訊。

總結

本文只是分析了Image.Network的載入和展示過程,而且也只是涉及到了dart端程式碼。總的來說,整個流程並不複雜,其他諸如Image.Memory,Image.File 原理都是一樣的,區別只是各自的ImageProvider不一樣,我們也可以自定義ImageProvider實現自己想要的效果。

相關文章