Flutter中網絡圖片加載和緩存

前言

應用開發中常常會碰到網絡圖片的加載,一般咱們會對圖片進行緩存,以便下次加載同一張圖片時不用再從新下載,在包含有大量圖片的應用中,會大幅提升圖片展示速度、提高用戶體驗且爲用戶節省流量。Flutter自己提供的Image Widget已經實現了加載網絡圖片的功能,且具有內存緩存的機制,接下來一塊兒看一下Image的網絡圖片加載的實現。android

重溫小部件Image

經常使用小部件Image中實現了幾種構造函數,已經足夠咱們平常開發中各類場景下建立Image對象使用了。編程

  • 有參構造函數:緩存

    Image(Key key, @required this.image, ...)bash

    開發者可根據自定義的ImageProvider來建立Image。微信

  • 命名構造函數:網絡

    • Image.network(String src, ...)框架

      src便是根據網絡獲取的圖片url地址。異步

    • Image.file(File file, ...)async

      file指本地一個圖片文件對象,安卓中須要android.permission.READ_EXTERNAL_STORAGE權限。ide

    • Image.asset(String name, ...)

      name指項目中添加的圖片資源名,事先在pubspec.yaml文件中有聲明。

    • Image.memory(Uint8List bytes, ...)

      bytes指內存中的圖片數據,將其轉化爲圖片對象。

其中Image.network就是咱們本篇分享的重點 -- 加載網絡圖片。

Image.network源碼分析

下面經過源碼咱們來看下Image.network加載網絡圖片的具體實現。

Image.network(String src, {
    Key key,
    double scale = 1.0,
    .
    .
  }) : image = NetworkImage(src, scale: scale, headers: headers),
       assert(alignment != null),
       assert(repeat != null),
       assert(matchTextDirection != null),
       super(key: key);

  /// The image to display.
  final ImageProvider image;
複製代碼

首先,使用Image.network命名構造函數建立Image對象時,會同時初始化實例變量image,image是一個ImageProvider對象,該ImageProvider就是咱們所須要的圖片的提供者,它自己是一個抽象類,子類包括NetworkImageFileImageExactAssetImageAssetImageMemoryImage等,網絡加載圖片使用的就是NetworkImage

Image做爲一個StatefulWidget其狀態由_ImageState控制,_ImageState繼承自State類,其生命週期方法包括initState()didChangeDependencies()build()deactivate()dispose()didUpdateWidget()等。咱們重點來_ImageState中函數的執行。

因爲插入渲染樹時會先調用initState()函數,而後調用didChangeDependencies()函數,_ImageState中並無重寫initState()函數,因此didChangeDependencies()函數會執行,看下didChangeDependencies()裏的內容

@override
  void didChangeDependencies() {
    _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
      ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
    _resolveImage();

    if (TickerMode.of(context))
      _listenToStream();
    else
      _stopListeningToStream();

    super.didChangeDependencies();
  }
複製代碼

_resolveImage()會被調用,函數內容以下

void _resolveImage() {
    final ImageStream newStream =
      widget.image.resolve(createLocalImageConfiguration(
          context,
          size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
      ));
    assert(newStream != null);
    _updateSourceStream(newStream);
  }
複製代碼

函數中先建立了一個ImageStream對象,該對象是一個圖片資源的句柄,其持有着圖片資源加載完畢後的監聽回調和圖片資源的管理者。而其中的ImageStreamCompleter對象就是圖片資源的一個管理類,也就是說,_ImageState經過ImageStreamImageStreamCompleter管理類創建了聯繫。

再回頭看一下ImageStream對象是經過widget.image.resolve方法建立的,也就是對應NetworkImageresolve方法,咱們查看NetworkImage類的源碼發現並無resolve方法,因而查找其父類,在ImageProvider類中找到了。

ImageStream resolve(ImageConfiguration configuration) {
    assert(configuration != null);
    final ImageStream stream = ImageStream();
    T obtainedKey;
    Future<void> handleError(dynamic exception, StackTrace stack) async {
      .
      .
    }
    obtainKey(configuration).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;
  }
複製代碼

ImageStream中的圖片管理者ImageStreamCompleter經過PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key), onError: handleError);方法建立,imageCache是Flutter框架中實現的用於圖片緩存的單例,查看其中的putIfAbsent方法

ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(), { ImageErrorListener onError }) {
    assert(key != null);
    assert(loader != null);
    ImageStreamCompleter result = _pendingImages[key]?.completer;
    // Nothing needs to be done because the image hasn't loaded yet. if (result != null) return result; // Remove the provider from the list so that we can move it to the // recently used position below. 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) { // Images that fail to load don't contribute to cache size.
      final int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
      final _CachedImage image = _CachedImage(result, imageSize);
      // If the image is bigger than the maximum cache size, and the cache size
      // is not zero, then increase the cache size to the size of the image plus
      // some change.
      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) {
      _pendingImages[key] = _PendingImage(result, listener);
      result.addListener(listener);
    }
    return result;
  }
複製代碼

經過以上代碼能夠看到會經過key來查找緩存中是否存在,若是存在則返回,若是不存在則會經過執行loader()方法建立圖片資源管理者,然後再將緩存圖片資源的監聽方法註冊到新建的圖片管理者中以便圖片加載完畢後作緩存處理。

根據上面的代碼調用PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key), onError: handleError);看出load()方法由ImageProvider對象實現,這裏就是NetworkImage對象,看下其具體實現代碼

@override
  ImageStreamCompleter load(NetworkImage key) {
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key),
      scale: key.scale,
      informationCollector: (StringBuffer information) {
        information.writeln('Image provider: $this');
        information.write('Image key: $key');
      }
    );
  }
複製代碼

代碼中其就是建立一個MultiFrameImageStreamCompleter對象並返回,這是一個多幀圖片管理器,代表Flutter是支持GIF圖片的。建立對象時的codec變量由_loadAsync方法的返回值初始化,查看該方法內容

static final HttpClient _httpClient = HttpClient();

  Future<ui.Codec> _loadAsync(NetworkImage key) async {
    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 Exception('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');

    final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
    if (bytes.lengthInBytes == 0)
      throw Exception('NetworkImage is an empty file: $resolved');

    return PaintingBinding.instance.instantiateImageCodec(bytes);
  }
複製代碼

這裏纔是關鍵,就是經過HttpClient對象對指定的url進行下載操做,下載完成後根據圖片二進制數據實例化圖像編解碼器對象Codec,而後返回。

那麼圖片下載完成後是如何顯示到界面上的呢,下面看下MultiFrameImageStreamCompleter的構造方法實現

MultiFrameImageStreamCompleter({
    @required Future<ui.Codec> codec,
    @required double scale,
    InformationCollector informationCollector
  }) : assert(codec != null),
       _informationCollector = informationCollector,
       _scale = scale,
       _framesEmitted = 0,
       _timer = null {
    codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) {
      reportError(
        context: 'resolving an image codec',
        exception: error,
        stack: stack,
        informationCollector: informationCollector,
        silent: true,
      );
    });
  }
複製代碼

看,構造方法中的代碼塊,codec的異步方法執行完成後會調用_handleCodecReady函數,函數內容以下

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

    _decodeNextFrameAndSchedule();
  }
複製代碼

方法中會將codec對象保存起來,而後解碼圖片幀

Future<void> _decodeNextFrameAndSchedule() async {
    try {
      _nextFrame = await _codec.getNextFrame();
    } catch (exception, stack) {
      reportError(
        context: 'resolving an image frame',
        exception: exception,
        stack: stack,
        informationCollector: _informationCollector,
        silent: true,
      );
      return;
    }
    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)); return; } SchedulerBinding.instance.scheduleFrameCallback(_handleAppFrame); } 複製代碼

若是圖片是png或jpg只有一幀,則執行_emitFrame函數,從幀數據中拿到圖片幀對象根據縮放比例建立ImageInfo對象,而後設置顯示的圖片信息

void _emitFrame(ImageInfo imageInfo) {
    setImage(imageInfo);
    _framesEmitted += 1;
  }
  
  /// Calls all the registered listeners to notify them of a new image.
  @protected
  void setImage(ImageInfo image) {
    _currentImage = image;
    if (_listeners.isEmpty)
      return;
    final List<ImageListener> localListeners = _listeners.map<ImageListener>(
      (_ImageListenerPair listenerPair) => listenerPair.listener
    ).toList();
    for (ImageListener listener in localListeners) {
      try {
        listener(image, false);
      } catch (exception, stack) {
        reportError(
          context: 'by an image listener',
          exception: exception,
          stack: stack,
        );
      }
    }
  }
複製代碼

這時就會根據添加的監聽器來通知一個新的圖片須要渲染。那麼這個監聽器是何時添加的呢,咱們回頭看一下_ImageState類中的didChangeDependencies()方法內容,執行完_resolveImage();後會執行_listenToStream();方法

void _listenToStream() {
    if (_isListeningToStream)
      return;
    _imageStream.addListener(_handleImageChanged);
    _isListeningToStream = true;
  }
複製代碼

該方法就向ImageStream對象中添加了監聽器_handleImageChanged,監聽方法以下

void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _imageInfo = imageInfo;
    });
  }
複製代碼

最終就是調用setState方法來通知界面刷新,將下載到的圖片渲染到界面上來了。

實際問題

從以上源碼分析,咱們應該清楚了整個網絡圖片從加載到顯示的過程,不過使用這種原生的方式咱們發現網絡圖片只是進行了內存緩存,若是殺掉應用進程再從新打開後仍是要從新下載圖片,這對於用戶而言,每次打開應用仍是會消耗下載圖片的流量,不過咱們能夠從中學習到一些思路來本身設計網絡圖片加載框架,下面做者就簡單的基於Image.network來進行一下改造,增長圖片的磁盤緩存。

解決方案

咱們經過源碼分析可知,圖片在緩存中未找到時,會經過網絡直接下載獲取,而下載的方法是在NetworkImage類中,因而咱們能夠參考NetworkImage來自定義一個ImageProvider。

代碼實現

拷貝一份NetworkImage的代碼到新建的network_image.dart文件中,在_loadAsync方法中咱們加入磁盤緩存的代碼。

static final CacheFileImage _cacheFileImage = CacheFileImage();

  Future<ui.Codec> _loadAsync(NetworkImage key) async {
    assert(key == this);

/// 新增代碼塊start
/// 從緩存目錄中查找圖片是否存在
    final Uint8List cacheBytes = await _cacheFileImage.getFileBytes(key.url);
    if(cacheBytes != null) {
      return PaintingBinding.instance.instantiateImageCodec(cacheBytes);
    }
/// 新增代碼塊end

    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('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');

/// 新增代碼塊start
/// 將下載的圖片數據保存到指定緩存文件中
    await _cacheFileImage.saveBytesToFile(key.url, bytes);
/// 新增代碼塊end

    return PaintingBinding.instance.instantiateImageCodec(bytes);
  }
複製代碼

代碼中註釋已經代表了基於原有代碼新增的代碼塊,CacheFileImage是本身定義的文件緩存類,完整代碼以下

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';

class CacheFileImage {

  /// 獲取url字符串的MD5值
  static String getUrlMd5(String url) {
    var content = new Utf8Encoder().convert(url);
    var digest = md5.convert(content);
    return digest.toString();
  }

  /// 獲取圖片緩存路徑
  Future<String> getCachePath() async {
    Directory dir = await getApplicationDocumentsDirectory();
    Directory cachePath = Directory("${dir.path}/imagecache/");
    if(!cachePath.existsSync()) {
      cachePath.createSync();
    }
    return cachePath.path;
  }

  /// 判斷是否有對應圖片緩存文件存在
  Future<Uint8List> getFileBytes(String url) async {
    String cacheDirPath = await getCachePath();
    String urlMd5 = getUrlMd5(url);
    File file = File("$cacheDirPath/$urlMd5");
    print("讀取文件:${file.path}");
    if(file.existsSync()) {
      return await file.readAsBytes();
    }

    return null;
  }

  /// 將下載的圖片數據緩存到指定文件
  Future saveBytesToFile(String url, Uint8List bytes) async {
    String cacheDirPath = await getCachePath();
    String urlMd5 = getUrlMd5(url);
    File file = File("$cacheDirPath/$urlMd5");
    if(!file.existsSync()) {
      file.createSync();
      await file.writeAsBytes(bytes);
    }
  }
}
複製代碼

這樣就增長了文件緩存的功能,思路很簡單,就是在獲取網絡圖片以前先檢查一下本地文件緩存目錄中是否有緩存文件,若是有則不用再去下載,不然去下載圖片,下載完成後當即將下載到的圖片緩存到文件中供下次須要時使用。

工程的pubspec.yaml中須要增長如下依賴庫

dependencies:
	path_provider: ^0.4.1
	crypto: ^2.0.6
複製代碼

自定義ImageProvider使用

在建立圖片Widget時使用帶參數的非命名構造函數,指定image參數爲自定義ImageProvider對象便可,代碼示例以下

import 'imageloader/network_image.dart' as network;

  Widget getNetworkImage() {
    return Container(
      color: Colors.blue,
      width: 200,
      height: 200,
      child: Image(image: network.NetworkImage("https://flutter.dev/images/flutter-mono-81x100.png")),
    );
  }
複製代碼

寫在最後

以上對Flutter中自帶的Image小部件的網絡圖片加載流程進行了源碼分析,瞭解了源碼的設計思路以後,咱們新增了簡單的本地文件緩存功能,這使咱們的網絡圖片加載同時具有了內存緩存和文件緩存兩種能力,大大提高了用戶體驗,若是其餘同窗有更好的方案能夠給做者留言交流。

說明:

文章轉載自對應的「Flutter編程指南」微信公衆號,更多Flutter相關技術文章打開微信掃描二維碼關注微信公衆號獲取。

相關文章
相關標籤/搜索