Flutter中网络图片加载和缓存

9,560

前言

应用开发中经常会碰到网络图片的加载,通常我们会对图片进行缓存,以便下次加载同一张图片时不用再重新下载,在包含有大量图片的应用中,会大幅提高图片展现速度、提升用户体验且为用户节省流量。Flutter本身提供的Image Widget已经实现了加载网络图片的功能,且具备内存缓存的机制,接下来一起看一下Image的网络图片加载的实现。

重温小部件Image

常用小部件Image中实现了几种构造函数,已经足够我们日常开发中各种场景下创建Image对象使用了。

  • 有参构造函数:

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

    开发者可根据自定义的ImageProvider来创建Image。

  • 命名构造函数:

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

      src即是根据网络获取的图片url地址。

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

      file指本地一个图片文件对象,安卓中需要android.permission.READ_EXTERNAL_STORAGE权限。

    • 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相关技术文章打开微信扫描二维码关注微信公众号获取。