【AICon】AI 基础设施、LLM运维、大模型训练与推理,一场会议,全方位涵盖! >>> 了解详情
写点什么

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

  • 2020-04-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-04-13 10:002666

评论

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

防治“虚假种草”,小红书技术团队干了这几件大事

小红书技术REDtech

架构 AI 小红书

利用RunnerGo简化性能测试流程

爱研究代码的极客人

软件测试 Jmeter 性能测试 压力测试 runnergo

Android技术分享 | 一行代码实现屏幕、声音采集

anyRTC开发者

音视频 移动开发 Andriod 屏幕采集 声音采集

阿里P8面试官让我吃透这份10W字Java面试题,终于拿下Java高级岗Offer

做梦都在改BUG

Java java面试 Java八股文 Java面试题 Java面试八股文

Redis源码之SDS简单动态字符串

Java你猿哥

Java redis ssm Java工程师

面对向多模态发展的趋势,为什么这些业界和学界专家说“不必追热点”

小红书技术REDtech

深度学习 专家 活动回顾

电商流量分析怎么做?试试这款数据工具DataLeap!

字节跳动数据平台

大数据 用户增长 数据产品 电商 企业号 4 月 PK 榜

ChatGPT背后的AI背景、技术门道和商业应用(万字长文,建议收藏)

京东科技开发者

人工智能 AI ChatGPT 人工智能ChatGPT 吗? 企业号 4 月 PK 榜

投放视频广告时,如何快速与第三方播放器兼容?

HMS Core

HMS Core

关于Blender你想了解的都在这里

Finovy Cloud

blender 3D软件

基于IM的企业移动应用平台,支持企业定制化

WorkPlus

SpringCloud 网关实现线程池异步批量保存请求日志

做梦都在改BUG

Java Spring Cloud 网关设计

java性能优化实战:高并发系统的法宝之缓存设计

Java你猿哥

高并发 缓存并发 缓存设计 Java高并发 Java性能优化

Github最新开源!Alibaba 亿级并发系统架构(2023全彩版小册)

Java你猿哥

Java 数据库 缓存 分布式 高并发

SpringCloud 网关实现线程池异步批量保存请求日志

Java你猿哥

spring Spring Cloud Java工程师 日志表

私有化视频会议系统, WorkPlus Meet助力企业 “面对面”安全开会!

WorkPlus

高兼容低成本,开箱即用的首页性能优化方式被我们找到了

小红书技术REDtech

前端 Andriod

京东技术专家首推:微服务架构深度解析,GitHub星标120K

Java你猿哥

数据库 架构 微服务 ssm Java微服务

硬核!互联网资深大佬手码高并发编程速成笔记(2023版)限时开源

Java你猿哥

性能优化 系统架构 ssm 高并发 Java高并发

【等保小知识】等保一级需要备案吗?

行云管家

等级保护 等保备案 等保一级 一级等保

数据解析NFT Q1市场表现:NFT生态正向Polygon聚拢,蓝筹项目"保值"难

NFT Research

数据分析 NFT

3000字13张图详细介绍RAID0、1、5、6、10、50、60,非常值得收藏!

wljslmz

raid 存储技术 三周年连更

人工智能基础数据服务,第一!

百度开发者中心

人工智能 数据标注 元宇宙

AI与打工人:相互补充,共同进步 | 社区征文

海拥(haiyong.site)

三周年征文

跟随项曙明走进中兴通讯,探索企业开源风险治理优秀实践

开源雨林

开源治理 中兴通讯

Kubernetes 中容器跨主机网络是怎么样的?

做梦都在改BUG

Java Kubernetes 云原生

数据库原理及MySQL应用 | 日志管理

TiAmo

数据库 MySQL数据库 日志管理 三周年连更

ONES × 中国信通院《中国企业软件研发管理白皮书》即将发布

万事ONES

2023 BAT最强Java岗面试题 !底气来源"java面试手册2023"轻松上岸

Java你猿哥

Java JVM 多线程 面经 java基础

【堡垒机小知识】堡垒机有主机监控功能吗?

行云管家

网络安全 堡垒机 主机监控

【机器学习入门与实践】数据挖掘-二手车价格交易预测(含EDA探索、特征工程、特征优化、模型融合等)

汀丶人工智能

人工智能 数据挖掘 机器学习 深度学习 模型融合

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