在Flutter上封装一套类似电报的图片组件

前言

最近项目需要封装一个图片加载组件,boss让我们实现类似电报的那种效果,直接上图:

就像把大象装入冰箱一样,图片加载拢共有三种状态:loading、success、fail。

首先是loading,电报的实现效果是底部展示blur image, 上面盖了个progress indicator。blur image有三方库可以实现:flutter_thumbhash | Flutter Package (pub.dev),但是这个库有个bug: 它使用到了MemoryImage, 并且MemoryImage的bytes参数每次都是重新生成的,因而无法使用缓存。所以上面的progress刷新时底部的blur image都会不停闪烁。

dart 复制代码
//MemoryImage
@override
bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is MemoryImage
        && other.bytes == bytes
        && other.scale == scale;
}

@override
int get hashCode => Object.hash(bytes.hashCode, scale);

笔者覆写了equals和hashcode方法,通过listEquals方法来比较bytes,考虑到thumb_hash一般数据量都比较小估计不会有性能问题。 也有人给了个一次性比较8个byte的算法【StackOverflow摘抄】😄

dart 复制代码
/// Compares two [Uint8List]s by comparing 8 bytes at a time.
bool memEquals(Uint8List bytes1, Uint8List bytes2) {
  if (identical(bytes1, bytes2)) {
    return true;
  }

  if (bytes1.lengthInBytes != bytes2.lengthInBytes) {
    return false;
  }

  // Treat the original byte lists as lists of 8-byte words.
  var numWords = bytes1.lengthInBytes ~/ 8;
  var words1 = bytes1.buffer.asUint64List(0, numWords);
  var words2 = bytes2.buffer.asUint64List(0, numWords);

  for (var i = 0; i < words1.length; i += 1) {
    if (words1[i] != words2[i]) {
      return false;
    }
  }

  // Compare any remaining bytes.
  for (var i = words1.lengthInBytes; i < bytes1.lengthInBytes; i += 1) {
    if (bytes1[i] != bytes2[i]) {
      return false;
    }
  }

  return true;
}

图片加载和取消重试

电报在loading的时候可以手动取消下载,这个在Flutter官方Image组件和cached_network_iamge组件都是不支持的,因为在设计者看来既然图片加载失败了,那重试也肯定还是失败(By design)。 extended_image库对cancel和retry做了支持,这里要给作者点赞👍🏻

取消加载

加载图片是通过官方http库来实现的, 核心逻辑是:

dart 复制代码
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 (timeLimit != null) {
  response.timeout(
    timeLimit!,
  );
}
return response;

返回的response是个Stream对象,通过它来获取图片数据

dart 复制代码
final Uint8List bytes = await consolidateHttpClientResponseBytes(
        response,
        onBytesReceived: chunkEvents != null
            ? (int cumulative, int? total) {
                chunkEvents.add(ImageChunkEvent(
                  cumulativeBytesLoaded: cumulative,
                  expectedTotalBytes: total,
                ));
              }
            : null,
      );

图片加载进度就是通过ImageChunkEvent来获取的,cumulative代表当前已加载的长度,total是总长度,所有图片加载库都是通过它来显示进度的。所以,如何取消呢?这里就需要用到Flutter异步的一个API了:

dart 复制代码
Future.any(<Future<T>>[Future cancelTokenFuture, Future<Uint8List> imageLoadingFuture])

在加载的时候除了加载图片数据的Future,我们再额外生成一个Future,当需要取消加载的时候只需要后者抛出Error那加载就会直接终止,extended_image就是这么做的:

dart 复制代码
class CancellationTokenSource {
  CancellationTokenSource._();
  
  static Future<T> register<T>(
      CancellationToken? cancelToken, Future<T> future) {
    if (cancelToken != null && !cancelToken.isCanceled) {
      final Completer<T> completer = Completer<T>();
      cancelToken._addCompleter(completer);
      
      ///CancellationToken负责管理cancel completer
      return Future.any(<Future<T>>[completer.future, future])
          .then<T>((T result) async {
        cancelToken._removeCompleter(completer);
        return result;
      }).catchError((Object error) {
        cancelToken._removeCompleter(completer);
        throw error;
      });
    } else {
      return future;
    }
  }
}

这种取消机制有个问题:虽然上层会捕获抛出的异常终止加载,但是网络请求还是会继续下去直到加载完图片所有数据,我于是翻看了Flutter的API,发现上面提到的解析HttpResponse的方法consolidateHttpClientResponseBytes有个注释:

arduino 复制代码
/// The `onBytesReceived` callback, if specified, will be invoked for every
/// chunk of bytes that is received while consolidating the response bytes.
/// If the callback throws an error, processing of the response will halt, and
/// the returned future will complete with the error that was thrown by the
/// callback. For more information on how to interpret the parameters to the
/// callback, see the documentation on [BytesReceivedCallback].

即onBytesReceived方法如果抛出异常那么就会终止数据传输,所以可以根据chunkEvents是否alive来判断是否需要继续传输,如果不需要就直接抛出异常,从而终止http请求。

重试

图片加载有两种重试:第一种是自动重试,笔者遇到了一个connection closed before full header was received错误,而且是高概率出现,目前没有好的解决办法,加上自动重试机制后好了很多。

第二种就是手动重试,自动重试达到阈值后还是失败,手动触发加载。我这里主要讲第二种,在电报里的展示效果是这样:

这里卡了我好久,主要是我对Flutter的ImageCache了解不深入导致的,首先看几个问题:

1. 页面有一张图片加载失败,退出页面重新进来图片会自动重新加载吗?

答案是不一定,Flutter图片缓存存储的是ImageStreamController对象,这个对象里有一个FlutterErrorDetails? _currentError;属性,当加载图片失败后_currentError会被赋值,所以退出后重进页面虽然会导致页面重新加载,但是获取到的缓存对象有Error,那就会直接进入fail状态。 缓存的清理是个很复杂的问题, ImageStreamCompleter的清理逻辑主要靠两个属性:_listeners_keepAliveHandles

dart 复制代码
List<ImageStreamListener> _listeners = [];

@mustCallSuper
void _maybeDispose() {
    if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
      return;
    }

    _currentImage?.dispose();
    _currentImage = null;
    _disposed = true;
}

_listerners的add和remove时机和Image组件有关

dart 复制代码
/// image.dart
/// 加载图片
void _resolveImage() {
    ......
    final ImageStream newStream =
      provider.resolve(createLocalImageConfiguration(
        context,
        size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
      ));
    _updateSourceStream(newStream);
}

void _updateSourceStream(ImageStream newStream) {
    ......
    /// 向ImageStreamCompleter注册Listener
    _imageStream!.addListener(_getListener());
  }

既然有了_listeners那为什么还需要_keepAliveHandles属性呢,原因就是在image组件所在页面不在前台时会移除注册的listerner,如果没有_keepAliveHandles属性那缓存可能会被错误清理:

dart 复制代码
@override
void didChangeDependencies() {
    _updateInvertColors();
    _resolveImage();

    if (TickerMode.of(context)) {
       ///页面在前台的时候获取最新的ImageStreamCompleter对象
      _listenToStream();
    } else {
      ///页面不在前台移除Listener
      _stopListeningToStream(keepStreamAlive: true);
    }
    super.didChangeDependencies();
}

回到最开始的问题:如果加载失败的图片组件在其他页面不存在,那image组件dispose的时候就会清理掉缓存,第二次进入该页面的时候就会重新加载。反之,如果其他页面也在使用该缓存,那二次进入的时候就会直接fail。

一个很好玩的现象是,假如两个页面在加载同一张图片,那么其中一个页面图片加载失败另外一个页面也会同步失败。

2. 判定加载的是同一张图片

这里的相同很重要,因为它决定了ImageCache的存储,比如笔者自定义一个NetworkImage:

dart 复制代码
class _NetworkImage extends ImageProvider<_NetworkImage> {

  _NetworkImage(this.url);

  final String url;
  
  @override
  ImageStreamCompleter loadImage(NetworkImage key, ImageDecoderCallback decode);

  @override
  Future<ExtendedNetworkImageProvider> obtainKey();
}

obtainKey一般都会返回SynchronousFuture<_NetworkImage>(this),它代表的是ImageCache使用的键,ImageCache判断当前是否存在缓存的时候会拿Key和缓存的所有键进行比对,这个时候equals和hashcode就开始起作用了:

dart 复制代码
@override
bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is _NetworkImage
        && other.url == url;
}

@override
int get hashCode => Object.hash(url);

因为我们需要支持取消加载,所以最初我考虑加上cancel token到相同逻辑的判定,但是这会导致同一张图片被不停重复加载,缓存完全失效。

为了解决上面的问题,我对ImageCache起了歪脑筋:能不能在没有加载成功的时候允许并行下载,但是只要有一张图片成功,那后续都可以复用缓存? 如果要实现这个效果,那就必须缓存下所有下载成功的ImageProvider或者对应的CancelToken。下载成功的监听好办,在MultiFrameImageStreamCompleter加个监听就完事。难的是缓存消除的时机判断,ImageCache的缓存机制很复杂(_pendingImages,_cacheImage,_liveImages),并且没有缓存移除的回调。

最终,我放弃了这个方案,不把cancel token放入ImageProvider的比较逻辑中。

3. 实现图片重新加载

首先,我给封装的图片组件加了个reloadFlag参数,当需要重新加载的时候+1即可:

dart 复制代码
@override
void didUpdateWidget(OldWidget old) {
    if(old.reloadFlag != widget.reloadFlag) {
        _resolveImage();
    }
}

但是,这个时候不会起作用,因为之前失败的缓存没被清理,ImageProvider的evict方法可实现清理操作。

4. 多图状态管理

我在适配折叠屏的时候发现了一个场景:多页面下载相同图片时有时无法联动,首先看cancel:

  • A页面加载图片时使用CancelToken A,新建缓存
  • B页面使用CancelToken B, 复用缓存

B的CancelToken完全没用到,所以是cancel不了的。为了解决这个问题,我创建了一个CancelTokenManager,按需生成CancelToken,并在加载成功或失败时清理掉。

然后是重试,多图无法同时触发重试,虽然可以复用同一个ImageStreamCompleter对象,但ImageStream对象却是Image组件单独生成的,所以只能借助状态管理框架或者事件总线来实现同步刷新。

相关推荐
瓜子三百克1 小时前
七、性能优化
flutter·性能优化
雨白5 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹7 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空8 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭9 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日10 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安10 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑10 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟14 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡15 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio