混合栈开发,看AliFlutter如何解决图片问题(完整方案)

简介: 在 Flutter 官方体系内,对混合栈开发支持不够友好。比如对于图片资源管理,以及如何对接 Native 图片库的问题,社区上已经有一些方案,但或多或少存在一些问题,或与 Flutter 图片加载流程背离较大,难以融合。为解决这些问题,AliFlutter 基础容器在 Flutter 官方的 Image Widget 体系里进行扩展,实现了一套完整的图片解决方案。本文带你一探究竟。

屏幕快照 2020-04-07 下午5.03.24.png

作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技术部

前言

在 Flutter 官方体系内,对混合栈开发支持不够友好。比如对于图片资源管理,以及如何对接 Native 图片库的问题,社区上已经有一些方案,但或多或少存在一些问题,或与 Flutter 图片加载流程背离较大,难以融合。

与此同时,在电商类应用中,使用 Flutter 实现的长列表多图页面,往往面临着严重的性能问题。例如滚动过程,过多的并发图片请求阻塞了网络,造成 CPU、内存飙高。在淘宝特价版 Flutter 商品详情页面里,还遇到了更棘手的大 Cell 问题,Flutter List 的回收机制对大 Cell 无能为力,造成内存疯涨极易 OOM。

为解决这些问题,AliFlutter 基础容器在 Flutter 官方的 Image Widget 体系里进行扩展,实现了一套完整的图片解决方案。具备的能力如下:

  1. 外接原生图片库,共享本地文件缓存、内存缓存。
  2. 图片请求取消功能,解决网络并发限制引起的排队加载缓慢,以及无效的解码、纹理上传造成资源浪费的情况。
  3. 图片解码并发管理,降低 CPU、内存峰值。
  4. 支持 GIF,在播放 GIF 时逐帧上传纹理,降低内存占用。
  5. 简单易用的 Placeholder。
  6. 允许将 Flutter 内置的各种图片解码库剥离,减小包大小。
  7. 业务无感的方式解决 List 滚动时,大 Cell 中的图片不能动态加载、回收的问题。解决 Native、Weex 体系下的顽疾。

关于大 Cell 问题的解决方案,下周将会推出文章:《细化 Flutter List 内存回收,解决大 Cell 问题》。

Flutter 的图片加载过程

首先介绍一下 Flutter 里图片相关的加载逻辑。显示图片使用 Image Widget。Image Widget 创建时,可以指定不同的图片来源:

  • Image.network
  • Image.file
  • Image.asset

这些方法创建了背后不同的 ImageProvider。当 Widget 构建并更新 State 时,调用相应的 ImageProvider 进行解析。ImageProvider 返回一个 ImageStream 对象,并让这些 Stream 对象共同监听一个 ImageStreamCompleter。与此同时,ImageProvider 为这个 Completer 提供不同的 load 方法加载来自网络、文件或资源中的图片数据(未解码)。当数据加载好后,调用 Engine 的 instantiateImageCodec 方法创建 C++ Codec(ui.Codec) 对象。由 Codec 负责解码,上传 GPU 纹理,生成 ui.Image。全部完成后,回调 Completer,以 Provider 作为 Key 将 Completer 加入缓存,并通知 Widget 重绘。

1.png

Flutter 自身提供的 ImageCache,以 ImageProvider 作为 Key 缓存了 ImageStreamCompleter。对于相同的图片,以及正在下载中的图片,不会重复加载。当图片上传 GPU 完成后,会以图片的 W H 4 更新缓存状态。所以实际缓存的是 GPU 纹理。使用 Flutter 原始 Image 组件开发时,将这个缓存大小设置为0,可以一定程度缓解内存压力(不多余缓存任何纹理,Widget 销毁,纹理释放),但是会造成图片的反复下载、解码、上传 GPU,系统开销较大。

AliFlutter 方案

Flutter 的图片加载流程抽象完备,我们自上而下进行定制化,在不修改原来链路任何代码的情况下,实现自己的 ImageProvider 和 Codec 对象,对接外部图片库。同时,图片纹理仍然可以保存到 Flutter 的 ImageCache 中,与 Flutter 原始方案完美融合。

1.png

▐ Flutter Widget 层扩展

扩展 Image Widget,指定使用外接图片库作为图片 Provider。

// File: lib/src/widgets/image.dart
Image.external_adapter(
  String src, {
  Key key,
....
  int targetWidth, // 请求的图片的宽
  int targetHeight, // 请求的图片的高
  Map<String, String> parameters, // 透传给图片库的参数
  Map<String, String> extraInfo,
  ImageProvider placeholderProvider, // placeholder 可以指定为其它 Provider
}) : image = ExternalAdapterImage(src, // 创建自定义的 ExternalAdapterImage Provider
        targetWidth: targetWidth, targetHeight: targetHeight,
        placeholderProvider: placeholderProvider,
        parameters: parameters, extraInfo: extraInfo),
     super(key: key);

这个方法中的 placeholderProvider 提供了更简单直观的方式为图片指定 placeholder。例如

// 使用本地资源作为 placeholder
Image.external_adapter(
  'https://gw.alicdn.com/tfs/TB1Aa0UcF67gK0jSZPfXXahhFXa-750-140.png',
  placeholderProvider: AssetImage("assets/placeholder.jpg"),
)
 
// 使用另一个网络资源作为 placeholder
Image.external_adapter(
  'https://gw.alicdn.com/tfs/TB1Aa0UcF67gK0jSZPfXXahhFXa-750-140.png',
  placeholderProvider: ExternalAdapterImage("https://alicdn.com/image1024.jpg"),
)

ExternalAdapterImage

该类继承自 ImageProvider,并在 @override load 方法中创建 ExternalAdapterImageStreamCompleter。load 方法由 ImageProvider 的 resolve 方法调用,返回图片数据流管理类。

ExternalAdapterImageStreamCompleter

该类负责图片的加载,回调逻辑,其主要职责如下:

  • 处理 placeholderProvider,在主图返回前,让 Image Widget 显示 placeholder 图片。
  • 创建 C++ 层 ExternalAdapterImageFrameCodec 对象,调用 getNextFrame 获取图片信息(是否为动图、帧数、播放时间),以及纹理对象 ui.Image 并通知 Widget 显示。
  • 对于 GIF 等多帧图片,循环调用 ExternalAdapterImageFrameCodec 对象的 getNextMultiframe 接口获取动图的每一帧 ui.Image 并通知 Widget 显示。
  • 当无监听者时,调用 ExternalAdapterImageFrameCodec 的 cancel 接口取消图片任务。

▐ Flutter Engine 层扩展

ExternalAdapterImageFrameCodec

该类为 C++ 实现,继承自 DartUI 库中的 Codec 类,被 Dart 类 ExternalAdapterImageStreamCompleter 持有、管理、调用。

该类与 ExternalAdapterImageProvider 进行交互。主要方法是 getNextFrame , getNextMultiframe,cancel。

ExternalAdapterImageProvider

该类为 Abstract C++ 接口类,定义了需要各平台适配层实现的接口。主要接口如下:

  • void request``(requestId, requestInfo, callback(platformImage, releaseFunc))
    该方法向图片库请求图片,图片库完成后,通过 callback 异步返回。platformImage 封装平台层的图片对象(如 UIImage),callback 同时返回一个 releaseFunc,Flutter 使用完成该图片后,调用该方法释放图片。
  • void cancel(requestId)
    通知图片库取消某个请求
  • Bitmap decode(platformImage, frameIndex)
    解码图片的某一帧,并返回 Bitmap 数据。
  • evaluateDeviceStatus(&cpuCount, &maxMemory)
    允许并发的图片解码任务数量,以及解码数据的内存使用量。这个方法会经常被 ExternalAdapterImageFrameCodec 调用,控制多图加载时的资源消耗。

其中 PlatformImage 结构体定义如下

struct PlatformImage {
  uintptr_t handle = 0;
  int width = 0;                        // width in pixel
  int height = 0;                       // height in pixel
  int frameCount = 1;                   // multiframe image such as GIF
  int repetitionCount = -1;             // infinite
  int durationInMs = 0;                 // in milliseconds
};

执行伪码如下,多次切换线程也是符合 Flutter 的纹理加载管线。多次判断 cancel,避免了大量无效操作,降低了列表滚动时的资源消耗。

class ExternalAdapterImageFrameCodec {
  ExternalAdapterImageProvider provider;
  void getNextFrame() {
    async(provider.request([](image) {
      if (cancelled) {
        return;
      }
      async(workerThread, {
        if (cancelled) {
          return;
        }
        bitmap = provider.decode(image);
        async(ioThread, {
          if (cancelled) {
            return;
          }
          ui.Image texture = uploadToGPU(bitmap);
          async(uiThread, {
            if (cancelled) {
              return;
            }
            callbackDart(texture);
          })
        })
      })
    }))
  }
  void cancel() {
    provider.cancel()
    cancelled = true
  }
}

执行时序图:

1.png

直接将 C++ 接口公开,理论上就可以直接对接手淘图片库了。但是 C++ 接口使用起来不太方便,且不符合 Flutter 规范(对 iOS/Mac 平台应该提供 ObjC 类,对 Android 平台应该只提供 Java 类),而且对于平台层图片对象的处理,由 Engine 提供统一实现更为安全。因此,在 Engine 内部,针对 iOS/Mac,以及 Android 平台各提供了一套封装。

以 iOS 为例,最终在 Flutter.framework 里对外公开的 ObjC 接口为:

@protocol FlutterExternalAdapterImageRequest <NSObject>
- (void)cancel;
@end
@protocol FlutterExternalAdapterImageProvider <NSObject>
- (id<FlutterExternalAdapterImageRequest>)request:(NSString*)url
    targetWidth:(NSInteger)targetWidth
    targetHeight:(NSInteger)targetHeight
    parameters:(NSDictionary<NSString*, NSString*>*)parameters
    extraInfo:(NSDictionary<NSString*, NSString*>*)extraInfo
    callback:(void(^)(UIImage* image))callback;
@end

由外部注册 id 类对接手淘图片库,在每次请求时,返回一个支持 cancel 方法的对象用于取消请求。完成后通过 callback 返回 UIImage 对象,可以为 GIF 图。

对于 Android,最终公开的也是非常简单的一个 Java 类供外部实现。

▐ AliFlutter 方案的优化

延迟加载

在 ExternalAdapterImageStreamCompleter 中,真正调用 Codec 加载图片前,会做短暂等待。如果此时 Widget 已经被回收,会将自己从 Completer 的 listeners 中移除(实际添加的 listener 为 Widget 的 State)。等待过后,如果监听者为空,不会做真实请求。

Flutter 最新代码(2020.1.30)中,貌似对快速滚动过程图片的加载也做了优化,避免一些不必要的图片请求。Commit 见 :点此阅读

图片取消

前面提到,当 ExternalAdapterImageStreamCompleter 无监听者时,会调用 ExternalAdapterImageFrameCodec 的 cancel 方法。

Codec 从平台图片库获取到图片并最终上传为纹理(ui.Image)的过程,需要切换多次线程。

在 cancel 方法中,不但会通知图片库取消网络请求,而且记录标志位。在切换线程的整个过程中,多次检查标记位。

经过实际测试,在列表快速滑动或网络、机器性能较慢时,可以避免大量无效图片下载、解码、上传 GPU 等动作。

UIImage 转 Bitmap 并发控制

iOS 平台上,将 UIImage 转换为 Bitmap 不可避免要进行像素的拷贝。一些时候,CGImageGetBitmapInfo(UIImage.CGImage) 获取到的位图格式需要进行转换才可以送给 OpenGL。完成纹理上传后,拷贝的内存会被释放。此时,如果过多的图片同时进行转换,难免产生内存尖刺。解码过程复用的 Flutter ConcurrentTaskRunner,该 Runner 并发数量仍然过高(6个左右)。

因此在解码时,Codec 会动态调用 ExternalAdapterImageProvider 的 evaluateDeviceStatus 接口评估内存状态,再次控制并发数量。实际使用发现,2~3个并发,图片的加载速度仍然非常快,同时可以较好地控制解码过程的临时内存占用。

GIF 逐帧上传

GIF/APNG 动图是内存消耗大户,AliFlutter 方案在显示动图时,通过 ExternalAdapterImageFrameCodec 的 getNextMultiframe 接口逐帧获取纹理对象。每个时刻,只会有一帧上传 GPU,达到节省内存的目的。

开发过程的插曲:Flutter 1.9.1版本的内存泄漏

在调试外接图片库的过程中,通过对底层纹理的计数,发现有内存泄漏的情况。淘宝特价版详情页面接入 Flutter,并且使用了 Boost。现象为

  • 进入详情页面,并退出,反复进入退出。无内存泄漏。(不进入二级详情)
  • 进入详情页面,点宝贝推荐再进入一个详情页面,返回,再返回。产生内存泄漏。
    也就是说使用 Boost 管理多个 Flutter 栈时,只要有二级 Flutter 页面,就会产生内存泄漏。看上去是整个 Widget 树泄漏,导致底层的 ui.Image 纹理对象不能释放。

这个问题排查过程比较困难,主要的方法是不断简化详情页面,并最终定位出问题的组件。最终发现业务代码里只要使用 RaisedButton 就会产生问题。通过一层层的剥去代码,最终发现了有 Bug 的组件是官方的 InkWell。RaisedButton 通过多层关系最终使用到了 InkWell 组件。

在 _InkResponseState 类中,didChangeDependencies 方法未从 focusManager 里移除 listener(其实也就是自己)。导致在 Boost 管理的堆栈中,二级 Flutter 页面返回时,前一个页面组件的该方法多次执行,造成泄漏。

// Class _InkResponseState
void didChangeDependencies() {
  super.didChangeDependencies();
  _focusNode?.removeListener(_handleFocusUpdate);
  _focusNode = Focus.of(context, nullOk: true);
  _focusNode?.addListener(_handleFocusUpdate);
  // 原来的代码缺少这一行,导致多次添加 listener 造成组件泄漏。
  WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
  WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}

该问题在 Flutter 新版中已经修复了,整个代码完全变了,官方用其它方式避免了这种情况。

这里走了一些弯路。事后,通过 Dart 调试工具,可以看到出问题的时候 FocusManager 对象不断增长。提前用 Dart 工具,应该可以更早到定位到问题与使用 FocusManager 有关。

1.png

总结

这个方案完整探索了如何遵循 Flutter 官方的图片加载逻辑,对接外接图片库。同时整体方案对官方代码只添加、不修改,并提供了 ObjC、Java 语言的接口。方案完整度较高,后续可以与官方沟通合入主干。在图片加载的完整过程中,多次介入判断,较好地避免了无效的图片下载、解码、上传纹理工作,减少了系统资源的消耗。

为了避免对手淘图片库进行修改,且复用其内存缓存,目前的方案接收平台层解码后的 UIImage、AndroidBitmap 对象,再获取其位图数据上传纹理。后续可以让图片库返回未解码的文件数据,交给 Flutter 解码,整体流程可以再简化一些。不过目前的方案可以将所有图片解码库从 Flutter 里剥离,减小包大小,各有利弊。

基于该方案,同时探索了如何在 Flutter 中解决大 Cell 中多张图片同时加载产生的内存飙高问题,下周将会推出:《细化 Flutter List 内存回收,解决大 Cell 问题》敬请期待。

关注「淘系技术」微信公众号,一个有温度有内容的技术社区~

公众号二维码.jpg

相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
相关文章
|
22天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
109 9
|
13天前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
21 1
|
15天前
|
存储 算法 Java
数据结构的栈
栈作为一种简单而高效的数据结构,在计算机科学和软件开发中有着广泛的应用。通过合理地使用栈,可以有效地解决许多与数据存储和操作相关的问题。
|
18天前
|
存储 JavaScript 前端开发
执行上下文和执行栈
执行上下文是JavaScript运行代码时的环境,每个执行上下文都有自己的变量对象、作用域链和this值。执行栈用于管理函数调用,每当调用一个函数,就会在栈中添加一个新的执行上下文。
|
20天前
|
存储
系统调用处理程序在内核栈中保存了哪些上下文信息?
【10月更文挑战第29天】系统调用处理程序在内核栈中保存的这些上下文信息对于保证系统调用的正确执行和用户程序的正常恢复至关重要。通过准确地保存和恢复这些信息,操作系统能够实现用户模式和内核模式之间的无缝切换,为用户程序提供稳定、可靠的系统服务。
47 4
|
1月前
|
算法 程序员 索引
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
栈的基本概念、应用场景以及如何使用数组和单链表模拟栈,并展示了如何利用栈和中缀表达式实现一个综合计算器。
33 1
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
|
30天前
|
机器学习/深度学习 存储 人工智能
数据结构在实际开发中的广泛应用
【10月更文挑战第20天】数据结构是软件开发的基础,它们贯穿于各种应用场景中,为解决实际问题提供了有力的支持。不同的数据结构具有不同的特点和优势,开发者需要根据具体需求选择合适的数据结构,以实现高效、可靠的程序设计。
69 7
|
25天前
|
算法 安全 NoSQL
2024重生之回溯数据结构与算法系列学习之栈和队列精题汇总(10)【无论是王道考研人还是IKUN都能包会的;不然别给我家鸽鸽丢脸好嘛?】
数据结构王道第3章之IKUN和I原达人之数据结构与算法系列学习栈与队列精题详解、数据结构、C++、排序算法、java、动态规划你个小黑子;这都学不会;能不能不要给我家鸽鸽丢脸啊~除了会黑我家鸽鸽还会干嘛?!!!
|
1月前
初步认识栈和队列
初步认识栈和队列
61 10
|
1月前
数据结构(栈与列队)
数据结构(栈与列队)
20 1
下一篇
无影云桌面