关于前端:Flutter-图片控件适配之路

41次阅读

共计 7841 个字符,预计需要花费 20 分钟才能阅读完成。

本文作者:段家顺

背景

目前大部分利用都会应用大量的图片,图片成为以后利用带宽占比最大的一种资源。在咱们接入 Flutter 的时候,发现 Flutter 的图片控件缓存齐全由本人治理,同时还没有提供磁盘缓存(1.22 版本),所以在性能以及体验上均比拟差,所以必须对其进一步优化。

图片缓存

在目前很多 CDN 实现上,所有资源都是领有惟一 uri 的,所以很多的客户端实现,是疏忽了 HTTP 协定中的 Caches 能力,而是间接将 uri 作为惟一标识符来判断图片资源是否惟一的。这样大大节俭了向服务端确认 304 的工夫与申请。

而在客户端,个别都会存在至多 内存 磁盘 这两级缓存,而咱们在接入 Flutter 图片库的时候,就心愿可能将客户端的缓存与 Flutter 中的缓存进行买通,从而缩小内存和网络的耗费。
而目前复用缓存的方向大抵有如下 3 种:

  1. 复用视图,齐全由客户端来提供 Flutter 的图片能力,就像 React Native 一样。
  2. 复用磁盘缓存,不复用内存缓存,这种计划实现绝对简略,但会导致内存中存在两份图片数据。
  3. 复用内存缓存,由客户端从磁盘加载到内存,并由客户端来治理整个缓存的生命周期,比方和 SDWebImage 进行深度交融。该计划看似是最完满的复用,而且客户端有能力对整个利用的图片缓存大小进行准确的管制。

那么上面咱们来看看这几种计划的实现,哪些看似美妙的计划,咱们都踩了哪些坑。

复用视图

Flutter 提供了一种和客户端原生视图进行无缝拼接的计划,原始的动机其实是为了像地图、WebView 这种场景,Flutter 不可能再去实现一套如此简单的控件。那么如果咱们用这个来做客户端图片桥接计划会怎么样呢?

首先,咱们要明确 PlatformView 是如何进行桥接的(以下探讨的都是 iOS 实现)。在 Widget 中插入一层客户端 View,此时并不是咱们想的那样,将此 View 简略的 draw 到 Flutter Root Layer 上。因为 Flutter 的 draw call 并不是产生在主线程上的,而是产生在 raster 线程上的,如果咱们想要将客户端的 View 绘制到 Flutter 上,则必须先光栅化为一张图片,而后再进行绘制,这两头的性能开销与提早不言而喻是不可承受的,同时每帧都须要这么做也是不事实的。

所以,Flutter 采纳了一种拆分 Flutter Layer 的模式。在插入一个客户端 View 后,Flutter 会主动将本人拆为 2 层:

|-----| Flutter Overlay View 2
|-----| Native View
|-----| Flutter Root View 1

客户端 View 就像夹心饼干一样被 2 个 Flutter view 夹住,此时位于 Platform View 下层以及后续的兄弟 Widget 都会被绘制到下层的 View 上,其余的仍旧绘制在底层。这样尽管解决了客户端视图的接入,但也会导致一个问题,当下层视图产生地位等变更的时候,须要从新创立对应的 Overlay View,为了缩小这种开销,Flutter 采纳了一种比拟 trick 的做法,即 Overlay View 会铺满屏幕,而通过挪动下面的 mask 来进行管制展现区域。

// The overlay view wrapper masks the overlay view.
// This is required to keep the backing surface size unchanged between frames.
//
// Otherwise, changing the size of the overlay would require a new surface,
// which can be very expensive.
//
// This is the case of an animation in which the overlay size is changing in every frame.
//
// +------------------------+
// |   overlay_view         |
// |    +--------------+    |              +--------------+
// |    |    wrapper   |    |  == mask =>  | overlay_view |
// |    +--------------+    |              +--------------+
// +------------------------+

目前曾经解决了客户端视图接入 Flutter 的能力,但能够看到,当插入一张客户端 View,Flutter 须要额定创立 2 个 View 进行分区域绘制。当一个页面存在多张图片的时候,此时额定产生的开销显然也是不可承受的,性能更是不可承受。

上面是 Flutter 官网在 Platform View 上形容的对于性能的思考。

Platform views in Flutter come with performance trade-offs.
For example, in a typical Flutter app, the Flutter UI is composed on a dedicated raster thread. This allows Flutter apps to be fast, as the main platform thread is rarely blocked.
While a platform view is rendered with Hybrid composition, the Flutter UI is composed from the platform thread, which competes with other tasks like handling OS or plugin messages, etc.
Prior to Android 10, Hybrid composition copies each Flutter frame out of the graphic memory into main memory, and then copies it back to a GPU texture. In Android 10 or above, the graphics memory is copied twice. As this copy happens per frame, the performance of the entire Flutter UI may be impacted.
Virtual display, on the other hand, makes each pixel of the native view flow through additional intermediate graphic buffers, which cost graphic memory and drawing performance.

复用磁盘缓存

让咱们都退一步,咱们首先解决网络带宽的问题,那么一个简略的计划便是复用磁盘缓存。

复用磁盘缓存的计划绝对能够做的非常简单,并且领有极低的侵入性。咱们只须要设计一套 channel 接口,来同步单方缓存的状态和缓存的地址。

getCacheInfo({ 
 String url,
 double width,
 double height,
 double scale,
 BoxFit fit}) 
-> {String path, bool exists}

那么在应用的时候,咱们仅须要定制一套新的 ImageProvider,将网络、本地两种 Provider 对立起来即可。

_CompositeImageStreamCompleter({
 String url,
 double width,
 double height
}) {getCacheInfo({url: url, width: width, height:height})
 .then((info) {if (info != null && info.path != null && info.path.length > 0) {
 var imageProvider;
 var decode = this.decode;
 if (info.exists) {final imageFile = File(info.path);
 imageProvider = FileImage(imageFile, scale: this.scale);
 } else {
 imageProvider = NetworkImage(info.fixUrl ?? this.url,
 scale: this.scale, headers: this.headers);
 decode = (Uint8List bytes,
 {int cacheWidth, int cacheHeight, bool allowUpscaling}) {final cacheFile = File(info.path);
 // 缓存到磁盘
 cacheFile.writeAsBytes(bytes).then((value) => {});
 return this.decode(bytes,
 cacheWidth: cacheWidth,
 cacheHeight: cacheHeight,
 allowUpscaling: allowUpscaling);
 };
 }
 _childCompleter = imageProvider.load(imageProvider, decode);
 final listener =
 ImageStreamListener(_onImage, onChunk: _onChunk, onError: _onError);
 _childCompleter.addListener(listener);
 }
 }).catchError((err, stack) {print(err);
 });
}

这里须要留神的是,当不存在磁盘缓存的时候,这里采纳了 Flutter 来下载图片,此时须要咱们手动将其保留到磁盘上,以保障磁盘缓存的一致性。

复用内存缓存

复用磁盘缓存是危险较低的一种改变,然而代价是无奈复用内存缓存,不仅仅须要别离读取,同时会保留多份内存缓存,因为单方的内存缓存局部是齐全独立存在的。

那么如果咱们想进一步优化,则须要采纳复用内存缓存的计划,目前同步内存缓存大抵有如下几种计划:

  • 利用 channel 通信,将内存传输给 Flutter
  • 利用新个性 ffi 通道,将内存间接传递给 Flutter
  • 利用 Texture 控件,从纹理层面进行复用

Channel

Flutter 官网稳固的音讯通信计划,兼容性和稳定性都十分高。当咱们须要展现缓存图片的时候,只须要将图片数据通过 BinaryMessenger 模式传递到 Flutter 即可。

因为 Channel 自身就必须是异步过程,所以该形式通信会有肯定开销。
同时因为 Channel 在客户端是在主线程进行解决,所以也须要留神防止在主线程间接做加载与解码等耗时操作。

而 Channel 在数据传递过程中,因为机制(从平安角度来看也必须这么做)起因,二进制数据必然会被拷贝一份,这样导致的后果是 Flutter 这边保护的内存缓存和客户端本身的缓存仍然是两份,并没有完满的达到咱们上述的复用成果。

ffi

从音讯通信开销以及音讯的内存拷贝问题来看,ffi 的呈现仿佛可能完满解决 Channel 中所有的问题。

原理和实现过程与 Channel 完全一致,此时只须要替换为 ffi 通道即可。ffi 并没有像 Channel 那么长的通信过程,不须要进行音讯序列化与解析,也不须要切换线程解决,就像一个 HTTP 申请和一个简略的 API 调用的区别一样。

这里咱们须要留神的是 ffi 接口是同步执行的,也就是说客户端执行的时候是处于 flutter.ui 线程,咱们必须留神线程平安问题。而对于 Flutter 来说,因为是在 UI 线程执行,所以该办法必须尽量快的返回,不能执行一些耗时比拟长的操作。

然而咱们采纳 ffi 就真的可能解决上述问题吗?认真钻研发现,其实还是不能解决内存复用的根本性问题,上面能够看下 ffi 转换的过程。
当咱们把客户端图片加载到内存的时候,是通过 Buffer 的模式传递给 Flutter 的,比方是这样一个构造:

struct Buffer {
 int8    *ptr;
 size_t  length;
}

对应于 Dart 中的数据类型为 Int8PointerInt64,而 Image 控件所须要的数据类型为Uint8List,那么咱们必须进行一步数据格式转换:

Pointer<UInt8> bufferPtr;
int length;
Uint8List buffer = bufferPtr.asTypedList(length);

而在这次转换过程中,会产生一次内存拷贝(Uint8List 底层保持数据应用的是 std::vector)。

所以,从最终后果来看,并不比 Channel 有更高的缓存复用能力。

Texture

另一种是共享 PixelBuffer,也就是解码后的图片数据,在 Flutter 这里能够采纳 Texture 来实现复用。

具体实现计划阿里曾经钻研的十分透彻,这里就不再复述了,咱们次要剖析下其性能与复用能力。

Texture 复用采纳的是 TextureId,这是一个 int 值,所以在两端通信上不存在数据量上的性能开销。其次要过程是:

  1. 客户端将纹理注册到 Flutter,同时会返回一个 id 作为惟一标识符(i++)。这个过程产生在 Platform 线程,也就是客户端主线程,而真正注册到 TextureRegistry 中则是在 raster 线程中实现的。
  2. 在 flutter.ui 线程解决 paint 事件的时候,会将该 id 传递给 TextureLayer。
  3. 并在 raster 线程,凭借 TextureId 从 TextureRegistry 中取出并生成 draw call。

从整体流程来看,Flutter 在两头流转过程全程只应用了 TextureId,并不会操作内存与纹理,并不存在多份缓存的问题。所以这种计划比拟完满的解决了上述两个问题。

内存优化

尽管从上述剖析中,缓存利用率最高的是 Texture,然而从内存上来剖析,则呈现了一个意想不到的后果。

上图是应用 Flutter Image 控件,加载几张大图的一个内存图,总共减少了 10M 内存耗费。

上图是应用 Texture 计划,加载同样图片所产生的内存耗费,达到了 37M,相差微小。

同时能够看到原生 Flutter 图片在初始阶段有一个比拟大的波峰,同样纹理也有,但绝对平缓一些。
产生这样大的区别次要还是要从 Flutter Image 控件的渲染流程中说起。

  1. ImageProvider 将图片加载到内存后,首先会进行解码,而这个事件是在 flutter.io 线程实现的。
  2. 图片数据解码之后,会造成一个十分大的内存耗费,因为此时的图片数据是以 pixel buffer 的模式存储的。而 Flutter 在这一过程会进行一个优化,此时解码的数据将不是 100% 大小的,而是会以后 widget size 进行调整,计算出一个最优的大小,而后在这一大小上进行解码,所以原生的 Image 反而在内存占用这个方面会比客户端更优良。
  3. 在图片移除后,Flutter 会立即回收解码后的内存,即 Flutter 仅对图片的原始压缩数据进行存储,并不缓存 pixel buffer。而咱们客户端(SDWebImage)则会缓存解码后的全副数据,这也是另一个 Flutter 内存体现比客户端要优的中央。

那么 Flutter 这种策略在内存占用上完胜客户端,是否就必然是好的呢?

其实从渲染流程中看,Flutter 仅仅是用解码工夫换取了内存空间。在理论 Demo 中,列表疾速滑动时,Flutter Image 控件的图片展示会有显著的提早,而采纳 Texture 计划,肉眼简直无奈分辨。所以从整体的体现上来说,Texture 计划并不是没有长处。

图片尺寸

从上述中能够看进去,Texture 计划在内存的体现上比拟差,那么咱们如何去进一步优化呢?

对于很多场景,比方用户头像等,都是有一个固定大小的,那么咱们能够将该大小作为参数,传给 CDN,在 CDN 上就进行裁剪成咱们须要的大小,这样也会节俭大量流量。

然而同样有很多场景,咱们是无奈失去其控件大小的,比方充斥容器大小这种场景。咱们如何主动在所有图片上加上 Size 参数呢?

从渲染过程中,Layout之后会触发Paint,而此时该控件的大小必然曾经是齐全确定的了,那么咱们能够在这里做一个假的占位控件,在计算出大小后,再替换为真正的图片。

typedef ImageSizeResolve = void Function(Size size);
class ImageSizeProxyWidget extends SingleChildRenderObjectWidget {const ImageSizeProxyWidget({Key key, Widget child, this.onResolve})
 : super(key: key, child: child);
 final ImageSizeResolve onResolve;
 @override
 ImageSizeProxyElement createElement() => ImageSizeProxyElement(this);
 @override
 ImageSizeRenderBox createRenderObject(BuildContext context) =>
 ImageSizeRenderBox(onResolve);
 @override
 void updateRenderObject(BuildContext context, covariant ImageSizeRenderBox renderObject) {super.updateRenderObject(context, renderObject);
 renderObject.onResolve = onResolve;
 }
}
class ImageSizeProxyElement extends SingleChildRenderObjectElement {ImageSizeProxyElement(RenderObjectWidget widget) : super(widget);
}
class ImageSizeRenderBox extends RenderProxyBox with RenderProxyBoxMixin {ImageSizeRenderBox(ImageSizeResolve onResolve, [RenderBox child])
 : onResolve = onResolve,
 super(child);
 ImageSizeResolve onResolve;
 @override
 void paint(PaintingContext context, ui.Offset offset) {if (hasSize) {if (onResolve != null) onResolve(size);
 }
 super.paint(context, offset);
 }
}

这样,咱们就能强制所有图片都必须带上 Size 参数了。

通过这样的优化解决后,内存占用降落到了 2M 左右(因为我用的测试图都是高清图,所以成果看上去会比拟显著)。

总结

Flutter 的很多思路和策略和客户端有着显著的区别,从图片这一个能力来看,就能够从各个方面进行适配与优化,如果须要达到完满可用的一个状态,看来还是须要一直的投入与摸索。

附录

Texture 实现计划能够参考《Alibaba.com Flutter 摸索之路:Flutter 图片性能优化》。

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0