作者:新宿
一、 背景
去年,闲鱼技术团队新一代图片库PowerImage在经过一系列灰度、问题修复、代码调优后,已全量稳定应用于闲鱼。相对于上一代IFImage,PowerImage经过进一步的演进,适应了更多的业务场景与最新的flutter特性,解决了一系列痛点。
比如,因为完全抛弃了原生的ImageCache,在与原生图片混用的场景下,会让一些低频的图片反而占用了缓存;比如,我们在模拟器上无法展示图片;比如,我们在相册中,需要在图片库之外再搭建图片通道。
二、 简介
PowerImage是一个充分利用native原生图片库能力、高扩展性的flutter图片库。我们巧妙地将外接纹理与ffi方案组合,以更贴近原生的设计,解决了一系列业务痛点。
能力特点:
• 支持加载ui.Image能力。在基于外接纹理的方案中,使用方无法拿到真正的ui.Image去使用,这导致图片库在这种特殊的使用场景下无能为力。
• 支持图片预加载能力。正如原生precacheImage一样。这在某些对图片展示速度要求较高的场景下非常有用。
• 新增纹理缓存,与原生图片库缓存打通!统一图片缓存,避免原生图片混用带来的内存问题。
• 支持模拟器。在flutter-1.23.0-18.1.pre之前的版本,模拟器无法展示Texture Widget。
• 完善自定义图片类型通道。解决业务自定义图片获取诉求。
• 完善的异常捕获与收集。
• 支持动图。(来自淘特的PR)
三、 Flutter原生方案
在介绍新方案开始之前,先简单回忆一下flutter原生图片方案。
原生Image Widget先通过ImageProvider得到ImageStream,通过监听它的状态,进行各种状态的展示。比如frameBuilder、loadingBuilder,最终在图片加载成功后,会rebuild出RawImage,RawImage会通过RenderImage来绘制,整个绘制的核心是ImageInfo中的ui.Image。
• Image:负责图片加载的各个状态的展示,如加载中、失败、加载成功展示图片等。
• ImageProvider:负责ImageStream的获取,比如系统内置的NetworkImage、AssetImage等。
• ImageStream:图片资源加载的对象。
在梳理flutter原生图片方案之后,我们发现是不是有机会在某个环节将flutter图片和native以原生的方式打通?
四、 新一代方案
我们巧妙地将FFi方案与外接纹理方案组合,解决了一系列业务痛点。
1. FFI
正如开头说的那些问题,Texture方案有些做不到的事情,这需要其他方案来互补,这其中核心需要的就是ui.Image。我们把native内存地址、长度等信息传递给flutter侧,用于生成ui.Image。
首先native侧先获取必要的参数(以iOS为例):
_rowBytes = CGImageGetBytesPerRow(cgImage); CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage); CFDataRef rawDataRef = CGDataProviderCopyData(dataProvider); _handle = (long)CFDataGetBytePtr(rawDataRef); NSData *data = CFBridgingRelease(rawDataRef); self.data = data; _length = data.length;
dart侧拿到后
@override FutureOr createImageInfo(Map map) { Completer completer = Completer(); int handle = map['handle']; int length = map['length']; int width = map['width']; int height = map['height']; int rowBytes = map['rowBytes']; ui.PixelFormat pixelFormat = ui.PixelFormat.values[map['flutterPixelFormat'] ?? 0]; Pointer pointer = Pointer.fromAddress(handle); Uint8List pixels = pointer.asTypedList(length); ui.decodeImageFromPixels(pixels, width, height, pixelFormat, (ui.Image image) { ImageInfo imageInfo = ImageInfo(image: image); completer.complete(imageInfo); //释放 native 内存 PowerImageLoader.instance.releaseImageRequest(options); }, rowBytes: rowBytes); return completer.future; }
我们可以通过ffi拿到native内存,从而生成ui.Image。这里有个问题,虽然通过ffi能直接获取native内存,但是由于decodeImageFromPixels会有内存拷贝,在拷贝解码后的图片数据时,内存峰值会更加严重。
这里有两个优化方向:
• 解码前的图片数据给flutter,由flutter提供的解码器解码,从而削减内存拷贝峰值。
• 与flutter官方讨论,尝试从内部减少这次内存拷贝。
FFI这种方式适合轻度使用、特殊场景使用,支持这种方式可以解决无法获取ui.Image的问题,也可以在模拟器上展示图片(flutter <= 1.23.0-18.1.pre),并且图片缓存将完全交给ImageCache管理。
2. Texture
Texture方案与原生结合有一些难度,这里涉及到没有ui.Image只有textureId。这里有几个问题需要解决:
• 问题一:Image Widget需要ui.Image去build RawImage从而绘制,这在本文前面的Flutter原生方案介绍中也提到了。
• 问题二:ImageCache依赖ImageInfo中ui.Image的宽高进行cache大小计算以及缓存前的校验;问题三:native侧texture生命周期管理。
分别都有解决方案:
• 问题一:通过自定义Image解决,透出imageBuilder来让外部自定义图片widget。
• 问题二:为Texture自定义ui.image,如下:
import 'dart:typed_data'; import 'dart:ui' as ui show Image; import 'dart:ui'; class TextureImage implements ui.Image { int _width; int _height; int textureId; TextureImage(this.textureId, int width, int height) : _width = width, _height = height; @override void dispose() { // TODO: implement dispose } @override int get height => _height; @override Future toByteData( {ImageByteFormat format = ImageByteFormat.rawRgba}) { // TODO: implement toByteData throw UnimplementedError(); } @override int get width => _width; }
这样的话,TextureImage实际上就是个壳,仅仅用来计算cache大小。实际上,ImageCache计算大小,完全没必要直接接触到ui.Image,可以直接找ImageInfo取,这样的话就没有这个问题了。
• 问题三:关于native侧感知flutter image释放时机的问题。
修改的ImageCache释放如下(部分代码):
typedef void HasRemovedCallback(dynamic key, dynamic value); class RemoveAwareMap implements Map { HasRemovedCallback hasRemovedCallback; ... } //------ final RemoveAwareMap _pendingImages = RemoveAwareMap(); //------ void hasImageRemovedCallback(dynamic key, dynamic value) { if (key is ImageProviderExt) { waitingToBeCheckedKeys.add(key); } if (isScheduledImageStatusCheck) return; isScheduledImageStatusCheck = true; //We should do check in MicroTask to avoid if image is remove and add right away scheduleMicrotask(() { waitingToBeCheckedKeys.forEach((key) { if (!_pendingImages.containsKey(key) && !_cache.containsKey(key) && !_liveImages.containsKey(key)) { if (key is ImageProviderExt) { key.dispose(); } } }); waitingToBeCheckedKeys.clear(); isScheduledImageStatusCheck = false; }); }
接下篇:https://developer.aliyun.com/article/1225986?groupCode=idlefish