Linux 之父出席、干货分享、圆桌讨论,精彩尽在 OpenCloudOS 社区开放日,报名戳 了解详情
写点什么

混合栈开发,AliFlutter 如何解决图片问题

  • 2020 年 4 月 13 日
  • 本文字数:5822 字

    阅读完需:约 19 分钟

混合栈开发,AliFlutter如何解决图片问题

前言

在 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 重绘。


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


AliFlutter 方案

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



Flutter Widget 层扩展

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

// File: lib/src/widgets/image.dartImage.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。例如


// 使用本地资源作为 placeholderImage.external_adapter(  'https://gw.alicdn.com/tfs/TB1Aa0UcF67gK0jSZPfXXahhFXa-750-140.png',  placeholderProvider: AssetImage("assets/placeholder.jpg"),) // 使用另一个网络资源作为 placeholderImage.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  }}
复制代码


执行时序图:



直接将 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<FlutterExternalAdapterImageProvider> 类对接手淘图片库,在每次请求时,返回一个支持 cancel 方法的对象用于取消请求。完成后通过 callback 返回 UIImage 对象,可以为 GIF 图。


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


AliFlutter 方案的优化

延迟加载

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


Flutter 最新代码(2020.1.30)中,貌似对快速滚动过程图片的加载也做了优化,避免一些不必要的图片请求。Commit 见 https://github.com/flutter/flutter/commit/169529c37064568a17b634968c73a7ff79029dfb


图片取消

前面提到,当 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 _InkResponseStatevoid 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 有关。



总结

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


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


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


本文转载自公众号淘系技术(ID:AlibabaMTT)。


原文链接


https://mp.weixin.qq.com/s/0dJzviKLYXT4j46u3oOwGg


2020 年 4 月 13 日 10:002216

评论

发布
暂无评论
发现更多内容

阿里P8手敲出来这份565页凤凰架构分布式手册,惨遭GitHub直接封杀

热爱java的分享家

Java 面试 编程语言 经验分享 凤凰架构

等级保护对象是指什么?是指整个单位吗?

行云管家

云计算 网络安全 等保 等级保护 等保2.0

前端避坑指南丨辛辛苦苦开发的APP竟然被判定为简单网页打包?

APICloud

Flux 源码之reactor 核心原理及概述

漫游指南

reactor Flux

TDengine助力顺丰科技大数据监控改造

TDengine

tdengine 时序数据库

人脸识别实战:使用Python OpenCV 和深度学习进行人脸识别

AI浩

人脸识别

前端如何低门槛开发iOS、Android、小程序多端应用

APICloud

【语言】Java 日期 API 的使用技巧

恒生LIGHT云社区

Java 编程语言

ArkUI 3.0让多设备开发更简单|HDC2021技术分论坛

HarmonyOS开发者社区

HarmonyOS

第四范式x英特尔“AI应用与异构内存编程挑战赛”圆满收官

第四范式开发者社区

CSS布局(二)之多列布局

Augus

CSS 11月日更

云账户是什么意思?有什么用?

行云管家

云计算 公有云 私有云 混合云 云资源

我们是如何使用 PingCode Flow 实现研发自动化管理的?

PingCode

项目管理 敏捷开发 PingCode

百万关注的CSRF攻击是什么意思?

喀拉峻

黑客 网络安全 安全 信息安全

架构师书籍推荐:2021年必看的架构师图书

华章IT

架构师

渗透实战:内网域渗透

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

Docker心经

卫先生

Python 编译器 编译器远程连接docker docker常用命令 docker总结

业务流程建模,你真的做对了吗

明道云

Go语言学习查缺补漏ing Day4

Regan Yue

Go 语言 11月日更

Shopee 末端物流智能提效之路

Shopee技术团队

人工智能 算法 后端 供应链 物流

保持清洁的Git提交记录,三招就够了

Geek_1df311

Java 开源 架构 git 学习

Alibaba 新产 SpringCloud Aliababa(全彩第四版)开源

Geek_1df311

Java 编程 架构 微服务

混沌工程:分布式系统稳定性的“疫苗”

中原银行

微服务 云原生 混沌工程

WeTest.net全球能力开放:锻造高品质产品,构建全球竞争力

WeTest

这一次,飞书改变「飞书」

ToB行业头条

热爱代码且发量惊人,一名反“内卷”研发工程师的日常

尔达Erda

程序员 开发者 技术人生 成长笔记

React性能优化

CRMEB

打造数字化软件工厂 —— 一站式 DevOps 平台全景解读

CODING DevOps

DevOps 研发管理 CODING

太顶了!华为高工用一份423页的网络协议笔记把计算机网络讲清了

热爱java的分享家

Java 面试 程序人生 网络协议 经验分享

一文解析数据库的三生三世

Zilliz

数据库 oracle 数据库设计 Milvus

修复SecurityException: getDataNetworkTypeForSubscriber问题

Changing Lin

11月日更

GPU容器虚拟化:用户态和内核态的技术和实践详解

GPU容器虚拟化:用户态和内核态的技术和实践详解

混合栈开发,AliFlutter如何解决图片问题_容器_王乾元(神漠)_InfoQ精选文章