Flutter ImageCache之LiveImage

914 阅读4分钟

LiveImage介绍

Flutter的图片缓存,是通过ImageCache类实现的,到达图片内缓存大小上限(Flutter默认是整个App的图片缓存100张和100M),就根据LRU算法回收。除此之外,Flutter在2020年初专门设计了一个LiveImage缓存。这个LiveImage缓存是在原有的image cache之外的,不受缓存大小限制。

设计文档
源代码

LiveImage是为了fix 3个issue:
#48731
#49456
#45406\

简要说,就是当Image widge仍在WigetTree时,不能因为到达设定的图片内存缓存上限,而被清除。源码的主要设计是:
1、_ImageState的didChangeDependencies根据状态增加和停止对ImageStream的listener(keepStreamAlive参数很有用)

  @override
  void didChangeDependencies() {
    _updateInvertColors();
    _resolveImage();

    if (TickerMode.of(context))
      _listenToStream();
    else
      _stopListeningToStream(keepStreamAlive: true);

    super.didChangeDependencies();
  }

2、ImageStreamCompleter增加一个回调addOnLastListenerRemovedCallback,LiveImage在listener全部移除后,移除缓存,缓存以map的结构,存在于ImageCache的属性_liveImages

class _LiveImage extends _CachedImageBase {
  _LiveImage(ImageStreamCompleter completer, VoidCallback handleRemove, {int? sizeBytes})
      : super(completer, sizeBytes: sizeBytes) {
    _handleRemove = () {
      handleRemove();
      dispose();
    };
    completer.addOnLastListenerRemovedCallback(_handleRemove);
  }

  late VoidCallback _handleRemove;

  @override
  void dispose() {
    completer.removeOnLastListenerRemovedCallback(_handleRemove);
    super.dispose();
  }

  @override
  String toString() => describeIdentity(this);
}

问题

假定有2个page:A(普通页面)、B(商品列表页)

因为LiveImage仅追踪当前页面,打开A,加载图片,图片会同时缓存到image cache和LiveImage,A push B,A页面的图片在LiveImage的缓存,会被清除,但image cache还有缓存。B持续滚动,加载了超多的商品图,这时,会导致A页面使用的图片缓存被回收。然后pop B回到A,A页面的图片会重新加载。

重现视频:user-images.githubusercontent.com/2723525/147…

因为_stopListeningToStream有keepStreamAlive,本来Image widget的state是持有image stream的,就是缓存被清,Image widget自己还持有,应该不用重新加载。但是这里有一个bug,_ImageState的didChangeDependencies会执行updateStream,自己把已有的ImageStream干掉了,只能重新加载。

具体我在github提了2个issue:
_liveImages only track widget tree for current page
keepStreamAlive not working

这对电商App是影响很大的,用户在商品列表滑动,很轻易就能超100张,那么继续滑动加载图片,App进程内,之前页面(是之前的所有页面)的图片缓存就逐渐被清除了,再回去页面的图片都重新加载,太不能接受了。

解决

改Flutter源码

那么2个思路,都是修改_ImageState:

1、删除didChangeDependencies的_stopListeningToStream,只在dispose方法调用stop,这样LiveImage就不仅仅追踪当前页面,而是整个App的live image
2、_resolveImage方法增加一个判断,这样避免因为_stopListeningToStream有keepStreamAlive的ImageStream被重置

if (_completerHandle) {
  return;
}

不改Flutter源码

那么思路是:想办法增加一个对ImageStream的listener,这样避免removeOnLastListenerRemovedCallback过早的回调,同样,LiveImage就不仅仅追踪当前页面,而是整个App的live image

比如我这里,自定义一个CustomImageProvider,将ImageStream抛出来,用一个自定义的ImageWarpper接收,增加一个listener,并在dispose时移除listener

ps: 1、无法在CustomImageProvider通过override createStream方法来抛出ImageStream,因为在_imageState的_resolveImage方法内,provider都会被wrap一个ScrollAwareImageProvider(这个provider是专门为了优化快速滑动时,取消还没来及加载就滑出屏幕的Image),所以自己写的provider根本没机会被调用createStream,其实是所有的都没机会调用,之所以resolveStreamForKey能执行,是因为ScrollAwareImageProvider做了转发。真是偷懒,多写个转发啊。2、为了避免重复build导致缓存出问题,需要通过Stream的key判断

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class ImageWarpper extends StatefulWidget {
  final String url;
  const ImageWarpper({Key? key, required this.url}) : super(key: key);

  @override
  State<ImageWarpper> createState() => _ImageWarpperState();
}

class _ImageWarpperState extends State<ImageWarpper> {
  ImageStream? _imageStream;

  @override
  void initState() {
    if (kDebugMode) {
      // print("[ImageWarpper] init image: ${widget.url}");
    }
    super.initState();
  }

  @override
  void dispose() {
    if (kDebugMode) {
      // print("[ImageWarpper] dispose image: ${widget.url}");
    }
    _stopListeningToStream();
    super.dispose();
  }

  ImageStreamListener? _imageStreamListener;
  ImageStreamListener _getListener({bool recreateListener = false}) {
    _imageStreamListener ??= ImageStreamListener(
      (ImageInfo imageInfo, bool synchronousCall) {},
    );
    return _imageStreamListener!;
  }

  _stopListeningToStream() {
    _imageStream?.removeListener(_getListener());
  }

  @override
  Widget build(BuildContext context) {
    return Image(
      image: CustomImageProvider(
          imageProvider: NetworkImage(widget.url),
          onResolveStreamForKey: (ImageConfiguration configuration,
              ImageStream stream, dynamic key, ImageErrorListener handleError) {
            print("onResolveStreamForKey");
            if (_imageStream == null) {
              _imageStream = stream;
              stream.addListener(_getListener(recreateListener: true));
              return;
            }
            if (_imageStream?.key != stream.key) {
              // stream不一样,重置
              _stopListeningToStream();
              _imageStream = stream;
              stream.addListener(_getListener(recreateListener: true));
            } else {
              // 避免重复build有问题,do nothing
            }
          }),
      height: 100,
      fit: BoxFit.cover,
    );
  }
}

typedef OnResolveStreamForKey = Function(ImageConfiguration configuration,
    ImageStream stream, dynamic key, ImageErrorListener handleError);

@optionalTypeArgs
class CustomImageProvider<T extends Object> extends ImageProvider<T> {
  const CustomImageProvider(
      {required this.imageProvider, this.onResolveStreamForKey})
      : assert(imageProvider != null);

  /// The wrapped image provider to delegate [obtainKey] and [load] and [resolveStreamForKey] to.
  final ImageProvider<T> imageProvider;

  final OnResolveStreamForKey? onResolveStreamForKey;

  @override
  void resolveStreamForKey(
    ImageConfiguration configuration,
    ImageStream stream,
    T key,
    ImageErrorListener handleError,
  ) {
    imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
    // 下面的代码必须后执行,才能让stream带上key
    if (onResolveStreamForKey != null) {
      onResolveStreamForKey!(configuration, stream, key, handleError);
    }
  }

  @override
  ImageStreamCompleter load(T key, DecoderCallback decode) =>
      imageProvider.load(key, decode);

  @override
  Future<T> obtainKey(ImageConfiguration configuration) =>
      imageProvider.obtainKey(configuration);
}

后续

1、我不知道为什么Flutter原本的LiveImage设计,只追踪当前页面,原生不是这样的,iOS的SDWebImage,也是18年,通过强弱引用的数据结构,专门处理了UIImageView持有的图片内存,保障缓存不被乱清
2、LiveImage独立用一个map,导致图片缓存的大小设置,等同虚设了,不应该啊

文章有错误的地方请指出(错误太多我就删了:)