Flutter 圖片載入

QiShare發表於2020-02-06

級別: ★★☆☆☆
標籤:「Flutter」「Image」
作者: 大道至簡
審校: QiShare團隊


前言:
閱讀本篇文章你將獲得:
1、Flutter 圖片載入方式?
2、Flutter 圖片載入原始碼實現流程?
3、Flutter 圖片載入優化點有什麼?

Flutter Image

在 Flutter 中 Image 是展示圖片的 widget ,用於從 ImageProvider 獲取影象。Image 支援的圖片格式有 JPEG、WebP、GIF、animated WebP/GIF 、PNG 、BMP、 and WBMP。

Image 結構如下:

Flutter 圖片載入

可以看到圖片上部有多個載入方式。

Flutter 圖片載入方式

1、Image.asset
使用 key 從AssetBundle獲得的圖片;

兩種方式如下:

Image(height: 100, width: 100, image: AssetImage(happy.png), )
複製程式碼
Image.asset( happy.png, width: 100, height: 100,)
複製程式碼

當然這一方式,需要在 pubspec.yaml 檔案中配置圖片路徑。

2、Image.network
從網路URL中獲取圖片;
Image.network('https://p0.ssl.qhimg.com/t0183421f63f84fccaf.gif',fit: BoxFit.fill);
複製程式碼
3、Image.file
從本地檔案中獲取圖片;
Image.file(File('/sdcard/happy.png')),
複製程式碼
4、Image.memory
用於從 Uint8List 獲取圖片;
new Image.memory(Uint8List bytes),
複製程式碼

bytes 指記憶體中的圖片資料,將其轉化為圖片物件。

Flutter 中 Unit8List 與其他語言資料結構類比:

flutter java swift C
Uint8List byte[] FlutterStandardTypedData char[]

其他相關常用的載入圖片的方式

5、CacheNetworkImage
快取的網路圖片,此類屬於 cached_network_image 庫;
new CachedNetworkImage(
    fit:BoxFit.fill,
    width:200,
    height:100,
    imageUrl:'https://p0.ssl.qhimg.com/t0183421f63f84fccaf.gif',
    placeholder:(context, url) => new ProgressView(),
    errorWidget:(context, url, error) => new Icon(Icons.error),
);
複製程式碼
6、FadeInImage.memoryNetwork
預設佔點陣圖和淡入效果
import 'package:transparent_image/transparent_image.dart';

FadeInImage.memoryNetwork(
    placeholder: kTransparentImage, //kTransparentImage 屬於 transparent_image 庫
    image: 'https://p0.ssl.qhimg.com/t0183421f63f84fccaf.gif',
);
複製程式碼
7、Icon Icons 圖片參考URL
new Icon(Icons.android,size: 200,);
複製程式碼

Flutter 載入 images 的解析度

Flutter 可以為當前裝置載入適合其解析度的影象。指定不同素裝置像比例的圖片可以這樣分配asset資料夾:

  • …icon/happy.png
  • …/2.0x/happy.png
  • …/3.0x/happy.png

主資源預設對應於 1.0 倍的解析度圖片;在裝置畫素比率為 1.8 的裝置上會選用 .../2.0x/happy.png ;對於在畫素比率 2.7 的裝置上 ,會選用 .../3.0x/happy.png

pubspec.yaml 中 asset 宣告中每一項都標識與實際檔案對應。但是主資源缺少時,會按解析度從低到高的順序尋找載入。這裡的載入方案,可以參考 Android 系統中圖片載入的邏輯作對比。

Flutter 打包應用時,資源會按照 key-value 的形式存入 apk 的 assets/flutter_assets/AssetManifest.json 檔案中,載入資源時先解析 json 檔案,選擇最適合的圖片進行載入顯示,其中 AssetManifest.json 的具體內容簡介如:

{
    "assets/happy.png":[
        "assets/2.0x/happy.png",
        "assets/3.0x/happy.png"
    ]
}
複製程式碼

Android

android 上可以通過 AssetManager 獲取 asset, 根據 key 查詢到 openFd 。

key 是由 PluginRegistry.Registrar 的 lookupKeyForAsset 與 FlutterView 的 getLookupKeyForAsset 得到;

PluginRegistry.Registrar 用於開發外掛,而 FlutterView 則用於開發平臺 app 的 view。

pubspec.yaml
flutter:
  assets:
    - icons/happy.png
複製程式碼
Java plugin code
AssetManager assetManager = registrar.context().getAssets();
String key = registrar.lookupKeyForAsset("icons/happy.png");
AssetFileDescriptor fd = assetManager.openFd(key);
複製程式碼

iOS

iOS 開發使用 mainbundle 獲取 assets。

使用 FlutterPluginRegistrar 的 lookupKeyForAsset 和 lookupKeyForAsset:fromPackage: 方法獲取檔案路徑 ;FlutterViewController 的 lookupKeyForAsset 和lookupKeyForAsset:fromPackage: 方法獲取檔案路徑 ;

然後 FlutterPluginRegistrar 用於開發外掛,而 FlutterViewController 則用於開發平臺 app 的 view 。

Objective-C plugin
NSString* key = [registrar lookupKeyForAsset:@"icons/happy.png"];
NSString* path = [[NSBundle mainBundle] pathForResource:key ofType:nil];
複製程式碼

當然 pubspec.yaml 配置都是一致的。

原始碼分析

圖片載入方式中有四種方式,接下來我們一起看看 framework 層載入圖片是如何實現的。我們就以 Image.network 為例,跟進一下相關原始碼實現。

Image.network 的方法如下:

Image.network(
   String src, {
   Key key,
   double scale = 1.0,
   this.frameBuilder,
   this.loadingBuilder,
   this.semanticLabel,
   this.excludeFromSemantics = false,
   this.width,
   this.height,
   this.color,
   this.colorBlendMode,
   this.fit,
   this.alignment = Alignment.center,
   this.repeat = ImageRepeat.noRepeat,
   this.centerSlice,
   this.matchTextDirection = false,
   this.gaplessPlayback = false,
   this.filterQuality = FilterQuality.low,
   Map<String, String> headers,
 }) : image = NetworkImage(src, scale: scale, headers: headers),
      assert(alignment != null),
      assert(repeat != null),
      assert(matchTextDirection != null),
      super(key: key);
複製程式碼

這方法的作用就是建立一個 用於顯示從網路得到的 ImageStream 的 image 小部件,載入網路圖片的 image 是由 NetworkImage 建立出來的,其中引數 src, scale, headers 是不能為空的,其他的引數可以不做要求。NetworkImage 又是繼承自 ImageProvider,所以 image 就是 ImageProvider 。ImageProvider 是個抽象類,它的實現類包括:NetworkImage、FileImage、ExactAssetImage、AssetImage、MemoryImage、AssetBundleImageProvider。

Flutter 圖片載入

Image 原始碼部分如下:

class Image extends StatefulWidget {
/// 用於顯示的 image
  final ImageProvider image;
  
  ..........

  @override
  _ImageState createState() => _ImageState();
}
複製程式碼

_ImageState 類

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ImageStream _imageStream;
  ImageInfo _imageInfo;
    
  .......

@override
void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
}
  
@override
void didChangeDependencies() {
    _updateInvertColors();
    _resolveImage();//解析圖片從這裡開始
    //設定和移除監聽圖片變化的回撥
    if (TickerMode.of(context))
      _listenToStream();
    else
      _stopListeningToStream();
    super.didChangeDependencies();
}
  
void _resolveImage() {
    //根據 ImageConfiguration 呼叫 ImageProvider 的 resolve 函式獲得 ImageStream 物件
    final ImageStream newStream = widget.image.resolve(createLocalImageConfiguration(
        context,
        size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null,
      ));
    _updateSourceStream(newStream);
}
  ......
}
複製程式碼

它的生命週期方法方法包括initState()didChangeDependencies()build()deactivate()dispose()didUpdateWidget() 等等。當它插入到渲染樹時,先呼叫initState()函式,再呼叫 didChangeDependencies()。程式碼中可以看到呼叫了方法 _resolveImage(),這個方法中建立了 ImageStream 的新物件 newStream 。widget.image 就是 ImageProvider,呼叫resolve方法,程式碼如下:

ImageStream resolve(ImageConfiguration configuration) {
  final ImageStream stream = ImageStream();
  T obtainedKey;
  bool didError = false;
  Future<void> handleError(dynamic exception, StackTrace stack) async {
    if (didError) {
      return;
    }
    didError = true;
    await null; // 等待事件輪詢,以防偵聽器被新增到影象流中。
  
    final _ErrorImageCompleter imageCompleter = _ErrorImageCompleter();
    stream.setCompleter(imageCompleter);
    ......
  }
    
  ......
      
      Future<T> key;
      try {
        key = obtainKey(configuration);
      } catch (error, stackTrace) {
        return;
      }
      key.then<void>((T key) {
        obtainedKey = key;
        final ImageStreamCompleter completer = 
        PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key), onError: handleError);
        if (completer != null) {
          stream.setCompleter(completer);
        }
      }).catchError(handleError);
    
return stream;
複製程式碼

ImageStreamCompleter 用於管理 dart:ui 載入的類的基類。ImageStreams 的物件很少直接構造,而是由 ImageStreamCompleter 自動配置它。ImageStream 中的圖片管理者 ImageStreamCompleter 通過方法建立,imageCache 是 Flutter 框架中實現的用於圖片快取的單例,它這 Dart 虛擬機器載入時就已經建立。imageCache 最多可快取 1000 張影象和 100MB 記憶體空間。可以使用 [maximumSize] 和 [maximumSizeBytes]調整最大大小。

PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key), onError: handleError);
複製程式碼

根據原始碼可以看到兩個關鍵方法 :putIfAbsent 和 load。

putIfAbsent
ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(), {ImageErrorListener onError }) {
    ImageStreamCompleter result = _pendingImages[key]?.completer;
    // 因為影象還沒有載入,不需要做任何事情。
    if (result != null)
      return result;
    // 從快取列表中根據Key刪除對應的 imageprovider,便於將它移動到下面最近使用位置。
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _cache[key] = image;
      return image.completer;
    }
    try {
      result = loader();
    } catch (error, stackTrace) {
      ......
    }
    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);
      // 如果影象大於最大快取大小,且快取大小不為零,則將快取大小增加到影象大小加上 1000。
      // 思考點:一直這麼加什麼時候引起崩潰?
      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);
      // 移除 [_PendingImage.removeListener] 上的監聽
      result.addListener(streamListener);
    }
    return result;
  }
複製程式碼
load
/// 拉取網路圖片的 image_provider.NetworkImage 具體實現.
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage {
 ......................
  @override
  ImageStreamCompleter load(image_provider.NetworkImage key) {
     
    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
     
    return MultiFrameImageStreamCompleter(
        
      codec: _loadAsync(key, chunkEvents),
        
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      informationCollector: () {
        return <DiagnosticsNode>[
          DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
          DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
        ];
      },
    );
  }
複製程式碼
loadAsync
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 image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved);
        //將網路返回的 response 資訊,轉換成記憶體中的 Uint8List bytes。這裡面有解壓 gzip 的邏輯。
      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 PaintingBinding.instance.instantiateImageCodec(bytes);
    } finally {
      chunkEvents.close();
    }
  }
複製程式碼

將網路返回的response資訊,轉換成記憶體中的 Uint8List bytes,最終返回一個例項化影象編解碼器物件Codec,此處 Codec 可以移步到 painting.dart 檔案的 _instantiateImageCodec 看出來它是呼叫了native方法去處理了。

MultiFrameImageStreamCompleter

這個物件就是 ImageStreamCompleter 的具體實現,見名知意,多幀圖片流管理,作用管理影象幀的解碼和排程。

這個類處理兩種型別的幀:

  • 影象幀 :動畫影象的影象幀。

  • app 幀 :Flutter 引擎繪製到螢幕的幀,顯示到應用程式 GUI。

這就不貼所有程式碼了,在 image_stream.dart 檔案中 可見 class MultiFrameImageStreamCompleter。

MultiFrameImageStreamCompleter({
    @required Future<ui.Codec> codec,
    @required double scale,
    Stream<ImageChunkEvent> chunkEvents,
    InformationCollector informationCollector,
  }) : assert(codec != null),
       _informationCollector = informationCollector,
       _scale = scale {
    codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) {
     ..........
    });
複製程式碼
_handleCodecReady

這裡 codec 非同步回撥次方法

void _handleCodecReady(ui.Codec codec) {
    _codec = codec;
    if (hasListeners) {
      _decodeNextFrameAndSchedule();
    }
  }
複製程式碼
_decodeNextFrameAndSchedule

codec 解碼獲取到圖片的幀數,判斷圖片是隻有一幀的話,就是png、jpg這樣靜態圖片。

Future<void> _decodeNextFrameAndSchedule() async {
    try {
      _nextFrame = await _codec.getNextFrame();
    } catch (exception, stack) {
      ........
      return;
    }
    if (_codec.frameCount == 1) { // 此處判斷圖片是隻有一幀的邏輯.
      _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale));
      return;
    }
    _scheduleAppFrame();
  }

  void _scheduleAppFrame() {
    if (_frameCallbackScheduled) {
      return;
    }
    _frameCallbackScheduled = true;
    SchedulerBinding.instance.scheduleFrameCallback(_handleAppFrame);
  }
複製程式碼
_emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale));

void _emitFrame(ImageInfo imageInfo) {
    setImage(imageInfo);
    _framesEmitted += 1;
  }
  

  @protected
  void setImage(ImageInfo image) {
    _currentImage = image;
    if (_listeners.isEmpty)
      return;
    // 複製一份以允許併發修改。
final List<ImageStreamListener> localListeners = List<ImageStreamListener>.from(_listeners);
      
    for (ImageStreamListener listener in localListeners) {
      try {
        listener.onImage(image, false);
      } catch (exception, stack) {
        ..........
      }
    }
  }
複製程式碼

setImage 核心邏輯就是通知所有註冊上的監聽,表示圖片發生了變化可以更新啦。此時我們回到 開始提到的_ImageState 類中 didChangeDependencies 方法呼叫的 _listenToStream 方法,最終呼叫方法 _handleImageFrame ,改變 圖片資訊 _imageInfo 和 圖片幀數變化 _frameNumber ,最終執行 setState(() {}) 來重新整理了 UI。

void _listenToStream() {
    if (_isListeningToStream)
      return;
    _imageStream.addListener(_getListener());
    _isListeningToStream = true;
  }
  
ImageStreamListener _getListener([ImageLoadingBuilder loadingBuilder]) {
    loadingBuilder ??= widget.loadingBuilder;
    return ImageStreamListener(
      _handleImageFrame,
      onChunk: loadingBuilder == null ? null : _handleImageChunk,
    );
  }

  void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _imageInfo = imageInfo;
      _loadingProgress = null;
      _frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
      _wasSynchronouslyLoaded |= synchronousCall;
    });
  }
複製程式碼

這樣就結束了一個網路圖片的載入過程。

此處應該有流程圖就更加簡潔明瞭的表達啦。

總結

圖片載入顯示的方式 framework 提供了多種方式,我們就圖片網路載入進行了分析。從原始碼角度對網路圖片載入過程有了大致的瞭解。發現的可以優化點,這裡先提出來優化的點:

1、看到網路圖片只是在 ImageCache 管理類中進行了記憶體快取,當應用程式重新啟動後還是要重新下載圖片,此處是可以優化的,比如儲存到本地磁碟外存。

2、拿到圖片載入到記憶體裡面的時候,是否有對圖片進行壓縮處理,這種處理最好既適應當前平臺又不過分地改變圖片的清晰度。

期待下一篇的迭代優化點。


小編微信:可加並拉入《QiShare技術交流群》。

Flutter 圖片載入

關注我們的途徑有:
QiShare(簡書)
QiShare(掘金)
QiShare(知乎)
QiShare(GitHub)
QiShare(CocoaChina)
QiShare(StackOverflow)
QiShare(微信公眾號)

推薦文章:
用 Swift 進行貝塞爾曲線繪製
iOS GCD訊號量dispatch_semaphore_t
Swift 5.1 (11) - 方法
Swift 5.1 (10) - 屬性
iOS App後臺保活
Swift 中使用 CGAffineTransform
iOS 效能監控(一)—— CPU功耗監控
iOS 效能監控(二)—— 主執行緒卡頓監控
iOS 效能監控(三)—— 方法耗時監控
初識Flutter web
奇舞週刊

相關文章