在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都会不停闪烁。
//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摘抄】😄
/// 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库来实现的, 核心逻辑是:
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对象,通过它来获取图片数据
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了:
Future.any(<Future<T>>[Future cancelTokenFuture, Future<Uint8List> imageLoadingFuture])
在加载的时候除了加载图片数据的Future,我们再额外生成一个Future,当需要取消加载的时候只需要后者抛出Error那加载就会直接终止,extended_image就是这么做的:
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
有个注释:
/// 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
:
List<ImageStreamListener> _listeners = [];
@mustCallSuper
void _maybeDispose() {
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
return;
}
_currentImage?.dispose();
_currentImage = null;
_disposed = true;
}
_listerners的add和remove时机和Image组件有关
/// 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属性那缓存可能会被错误清理:
@override
void didChangeDependencies() {
_updateInvertColors();
_resolveImage();
if (TickerMode.of(context)) {
///页面在前台的时候获取最新的ImageStreamCompleter对象
_listenToStream();
} else {
///页面不在前台移除Listener
_stopListeningToStream(keepStreamAlive: true);
}
super.didChangeDependencies();
}
回到最开始的问题:如果加载失败的图片组件在其他页面不存在,那image组件dispose的时候就会清理掉缓存,第二次进入该页面的时候就会重新加载。反之,如果其他页面也在使用该缓存,那二次进入的时候就会直接fail。
一个很好玩的现象是,假如两个页面在加载同一张图片,那么其中一个页面图片加载失败另外一个页面也会同步失败。
2. 判定加载的是同一张图片
这里的相同
很重要,因为它决定了ImageCache的存储,比如笔者自定义一个NetworkImage:
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就开始起作用了:
@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即可:
@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组件单独生成的,所以只能借助状态管理框架或者事件总线来实现同步刷新。
来源:juejin.cn/post/7290732297427107895