前言


何谓大 Cell 问题?在基于 Native List 的渲染方案中,都会遇到大 Cell 问题。比如 Weex 业务中,经常出现页面内存飙高,排查后发现多为前端写法导致的一个大 Cell 中存在过多图片,导致内存过高。在 Flutter 里同样有这个问题,本质原因都是因为 List 进行回收的单位是 Cell,而不是 Cell 中的图片。在浏览器体系下,不存在这个问题,想必是浏览器进行了额外的运算,可以正确回收出屏的图片。
在开发 Flutter 版本淘宝商品详情页面时,我们同样遇到了大 Cell 的问题。一个商品的详情由多张图片拼接而成,这些图片尺寸未知,需要进行高度自适应,图片被放在同一个 Cell 中。发现列表滚动到特定位置,大量图片同时加载并生成纹理,内存突然飙高。

该问题有两个解决方案:

  1. 重构业务层代码,把图片分散在多个 Cell 里。但是因为缺乏高度信息,Cell 仍然会一次性全部出现,带来内存问题。

  2. 细化 Flutter List 的回收能力,在 Cell 回收的基础上,可以做到以图片为单位进行回收。

方案1只能说治标不治本,而且成本较高。根据 Weex 的经验,业务开发同学难免会因为不注意而造成大 Cell 的实际存在导致线上内存问题。而方案2就是本文要探索的方法,在 Flutter 体系内增强图片回收能力,降低内存占用。

方案探索过程


▐ 绘制图片的坐标信息

Flutter 里,图片的绘制在 Dart 层调用到 RenderImage.paint 方法。在里面打日志,发现绘制的时候,可以近似认为 offset 参数的值就是图片相对页面左上角的距离。(如果页面层级更复杂,比如 List 非全屏,上面有 TabBar 等,该偏移值可能不准确。)

`2020-02-06 Runner[45049:2962074] flutter: [AA] Render offset: Offset(0.0, 74.4)``2020-02-06 Runner[45049:2962074] flutter: [AA] Render offset: Offset(0.0, 449.4)``2020-02-06 Runner[45049:2962074] flutter: [AA] Render offset: Offset(0.0, 824.4)``2020-02-06 Runner[45049:2962074] flutter: [AA] Render offset: Offset(0.0, 1199.4)``2020-02-06 Runner[45049:2962074] flutter: [AA] Render offset: Offset(0.0, 1574.4)``....`

▐ 提根据坐标判断图片是否在屏幕内

有了坐标信息,也就有了一个粗略的方法判断图片是否在屏幕内。在实际代码中,我使用下面的方法来判断。这个方法只能判断是否在屏幕内,不能判断是否滑出 List 或被 NavigationBar 遮盖等场景。

`void paint(PaintingContext context, Offset offset) {` `// Check if Rect(offset & size) intersects with screen bounds.` `final double screenWidth = ui.window.physicalSize.width / ui.window.devicePixelRatio;` `final double screenHeight = ui.window.physicalSize.height / ui.window.devicePixelRatio;` `if (offset.dy >= screenHeight - 1 || offset.dy <= -size.height + 1 ||` `offset.dx >= screenWidth - 1 || offset.dx <= -size.width + 1) {` `// 在屏幕外` `}` `....``}`

▐ 强制每帧重新绘制该 Cell

打日志发现,即使是个超长的 Cell,Flutter 也只会绘制一次,生成一个大的纹理。之后在滚动过程中便不会有 RenderImage.paint 调用了。研究代码发现,在 sliver.dart 文件中,每个 Cell 被强制包裹在 RepaintBoundary 中。而这个 addRepaintBoundaries 参数默认是 true。根据 Flutter 代码里的注释,将 Cell 加到 RepaintBoundary 中是为了获得更好的滚动性能。

`// Class SliverChildBuilderDelegate``/// Whether to wrap each child in a [RepaintBoundary].``///``/// Typically, children in a scrolling container are wrapped in repaint``/// boundaries so that they do not need to be repainted as the list scrolls.``/// If the children are easy to repaint (e.g., solid color blocks or a short``/// snippet of text), it might be more efficient to not add a repaint boundary``/// and simply repaint the children during scrolling.``///``/// Defaults to true.``final bool addRepaintBoundaries;`

这里,我们想办法对特定的 Cell 屏蔽 RepaintBoundary 功能,添加一个空的纯虚类 NoRepaintBoundaryHint。

`/// A widget that tells sliver not to create repaint boundary for a cell content.``abstract class NoRepaintBoundaryHint {``}`

并修改 SliverChildBuilderDelegate 和 SliverChildListDelegate 类的 build 方法。当child 继承自 NoRepaintBoundaryHint 时,不要添加 RepaintBoundary。

`if (addRepaintBoundaries && (child is! NoRepaintBoundaryHint)) {` `child = RepaintBoundary(child: child);``}`

这样,我们自定义的 Widget 只需要假装实现一下 NoRepaintBoundaryHint 接口即可,这也是本方案唯一需要业务层配合修改的地方。

`class MyListItem extends StatefulWidget implements NoRepaintBoundaryHint {``}`

▐ 添加通知进行图片加载与回收

对于 _ImageState 类,其会创建 RawImage 组件,RawImage 又会创建 RenderImage。对这个链路添加回调方法,同时新建子类 AutoreleaseRawImage 和 AutoreleaseRenderImage。

`/// On drawing image, AutoreleaseRenderImage will notify image moving inside or outside screen event to owner.``typedef SetNeedsImageCallback = void Function(bool value);`

在出屏时,调用 SetNeedsImageCallback(false),并将各自持有的 ui.Image 置 null,释放纹理。
在入屏时,调用 SetNeedsImageCallback(true),重新请求图片。代码大致如下(省略了一部分):

`// Class _ImageState``void didChangeDependencies() {` `_updateInvertColors();` `if (_releaseImageWhenOutsideScreen) {` `return; // 如果有标记,不再加载图片,等待绘制指令` `}` `.... 请求图片` `super.didChangeDependencies();``}``void __setNeedsImage(bool value) {` `if (value) {` `if (_imageStream == null) {` `请求图片` `}` `}` `else {` `清空图片` `}``}``void _setNeedsImage(bool value) { // AutoreleaseRenderImage 回调该方法` `Future(() {` `__setNeedsImage(value); // 在 paint 过程,不允许 setState,所以需要异步一下` `});``}`

▐ Demo 测试运行

在 Demo 中,每隔十个 Cell 添加一个大 Cell,大 Cell 中有十张图片。代码如下:

`Widget build(BuildContext context) {` `if (widget.index % 10 == 0) {` `final images = [];` `for (var i = 0; i < 10; i++) {` `images.add(new Image.external_adapter(` `'https://i.picsum.photos/id/' + (widget.index + i).toString() + '/1000/1000.jpg',` `height: 375,` `width: 375,` `));` `}` `return Column(` `children: images` `);` `}` `else {` `return Container(` `width: 375,` `height: 375,` `child: Text(widget.index.toString()),` `);` `}``}`

在 Demo 中效果非常好,原先滚动到图片时,一次性十张图片全部被加载;修改后,即使十张图片放在同一个 Cell 里,也一张一张加载并回收。如图,在底层打印纹理个数,并观察内存占用。

▐ 真实业务场景测试

然而在商品详情真实场景,图片完全加载不出来。调试发现,在 Demo 里我为每个 Image 指定了宽高,Image 可以正常排版。而在业务场景里,解析 HTML 产生的图片组件,缺少宽高信息,需要等到图片真正加载完成,RenderImage 才能获取到图片尺寸信息并进行排版。

`// Class RenderImage``Size _sizeForConstraints(BoxConstraints constraints) {` `constraints = BoxConstraints.tightFor(` `width: _width, // 为 null` `height: _height, // 为 null` `).enforce(constraints);` `if (_image == null)` `return constraints.smallest; // 图片也没有加载完成时,该 Widget 根本没有尺寸` `return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(` `_image.width.toDouble() / _scale,` `_image.height.toDouble() / _scale,` `));``}`

这里似乎陷入一个悖论:

  • 图片不存在,无法排版,无法显示。

  • 加载图片,导致本应在屏幕外的图片纹理全部上传到 GPU;然后才能完成排版,再次绘制时发现在屏幕外,再删除纹理。

如果按照这个流程,图片必须完成加载才能排版,优化效果大打折扣了。其实,排版需要的只是图片的尺寸,并不需要 GPU 纹理,这里给了我们优化的余地。

▐ 提前获取图片尺寸

在 AliFlutter 的图片方案中,实现了自定义的 ExternalAdapterImageFrameCodec,它提供的 getNextFrame 接口用于获取图片,上传纹理后返回可用的 ui.Image。为了提前获取图片尺寸,我们添加一个接口 getImageInfo。这个接口从图片库获取图片后(比如 UIImage),只取其基本信息,并不上传纹理。在 _ImageState 中,判断 widget 的宽高是否被指定。如果任一个参数未被指定,请求图片时携带参数,只获取图片的基本信息,不上传纹理。

`// Class _ImageState``void didChangeDependencies() {` `if (_releaseImageWhenOutsideScreen) {` `if (widget.width == null || widget.height == null) {` `_resolveImage(true); // 只获取图片尺寸,不上传纹理` `_listenToStream();` `}` `}` `.... 以下略``}``void _handleImageInfo(int width, int height, int frameCount, int durationInMs, int repetitionCount) {` `setState(() { // 获取到图片尺寸后,记录下来,并更新给 RenderObject` `_imageWidth = width;` `_imageHeight = height;` `});``}`

其中 _resolveImage(true); 告知 ExternalAdapterImageStreamCompleter 调用 getImageInfo 而不是 getNextFrame 接口。 在获取到图片尺寸后,记录下来,并通过 setState 告知给 AutoreleaseRenderImage。重写 AutoreleaseRenderImage 方法的 _sizeForConstraints 方法,处理图片纹理不存在,但是图片的尺寸已经得知的场景,保证排版顺利进行。这里我们优先仍然使用 _image 来获取宽高,当 _image 为空时,使用上层指定的 _imageWidth 和 _imageHeight 来计算排版。

`Size _sizeForConstraints(BoxConstraints constraints) {` `constraints = BoxConstraints.tightFor(` `width: _width,` `height: _height,` `).enforce(constraints);` `// No intrinsic from image itself or image pixel dimension info.` `if (_image == null && (_imageWidth == null || _imageHeight == null))` `return constraints.smallest;` `// Use _image if not null` `if (_image != null) {` `return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(` `_image.width.toDouble() / _scale,` `_image.height.toDouble() / _scale,` `));` `}` `// Or else use image dimension info.` `return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(` `_imageWidth.toDouble(),` `_imageHeight.toDouble(),` `));``}`

▐ 进一步优化

通过给 ExternalAdapterImageFrameCodec 添加 getImageInfo 接口,我们可以避免了离屏纹理的上传。但是因为图片缺乏高度信息,因此一进入页面时,仍然是堆叠在一起,产生了大量图片请求。这些图片请求通过外接图片库返回 UIImage(或 Android Bitmap) 对象,即使没有上传成纹理,仍然是较大的内存开销。商品详情业务的特点是多张图片拼接而成,我们只能指定图片的宽度,需要图片高度自适应。因此针对这种场景,我们给 Flutter 的官方图片组件添加了一个给排版用的虚拟尺寸参数。

根据详情业务特点,指定 Image Widget 的宽度为页面宽度,虚拟高度与图片宽度相同。在 ImageWidgetState 的 build 方法中,创建底层的 RenderObject 时,将这个虚拟尺寸传给底层的 RenderObject,使图片获得一个大致的排版后的位置。整个图片的排版加载逻辑如下:

  1. 当 Image Widget 拥有确定宽、高时,依赖绘制阶段的在屏判断进行图片加载。

  2. 当 Image Widget 缺失宽、高信息时,如果有排版的虚拟尺寸,以这个虚拟尺寸进行预排版。排版后首次绘制时,如果在屏,进行图片真正加载。图片加载完成后,如果尺寸与虚拟尺寸不符合,会重新排版。

▐ 效果

经过优化后,图文详情部分仍然是一个大 Cell,里面罗列了一系列高度自适应的商品图片。我们的方案避免了 Cell 首次出现时,所有图片一次性全部加载,导致内存突然飙高造成 OOM。同时在列表滚动过程,同一个 Cell 中的图片可以按需回收,使内存水位保持在合理水平。

总结


本文探索出的方案属于 AliFlutter 提供的外接图片库的功能之一。这个方案保障了淘宝商品图片详情这种场景下的稳定性。我们测试发现,使用官方的 Image.network 加载图片,并且不优化大 Cell 场景的话,一个较复杂的商品内存可能暴涨到 1GB,几乎 100% 造成低端机的 OOM。这种情况,业务是完全无法上线的。

服务推荐

  • 蜻蜓代理
  • ip代理服务器
  • 企业级代理ip
  • 微信域名检测
  • 微信域名拦截检测

【后端教程】细化 Flutter List 内存回收,解决大 Cell 问题相关推荐

  1. 细化 Flutter List 内存回收,解决大 Cell 问题

    作者|王乾元(神漠) 出品|阿里巴巴新零售淘系技术部 前言 何谓大 Cell 问题?在基于 Native List 的渲染方案中,都会遇到大 Cell 问题.比如 Weex 业务中,经常出现页面内存飙 ...

  2. 内存不够解决大数据问题

    在研究.应用机器学习算法的经历中,相信大伙儿经常遇到数据集太大.内存不够用的情况. 这引出一系列问题: 怎么加载十几.几十 GB 的数据文件? 运行数据集的时候算法崩溃了,怎么办? 怎么处理内存不足导 ...

  3. Linux内核:内存管理——内存回收

    概述 当linux系统内存压力就大时,就会对系统的每个压力大的zone进程内存回收,内存回收主要是针对匿名页和文件页进行的.对于匿名页,内存回收过程中会筛选出一些不经常使用的匿名页,将它们写入到swa ...

  4. Redis系列教程(九):Redis的内存回收原理,及内存过期淘汰策略详解

    Redis内存回收机制 Redis的内存回收主要围绕以下两个方面: 1.Redis过期策略:删除过期时间的key值 2.Redis淘汰策略:内存使用到达maxmemory上限时触发内存淘汰数据 Red ...

  5. 【Qt教程】1.5 - Qt5内存回收机制-对象树、窗口坐标系

    1.Qt内存回收机制 - 对象树 (做了解,懂就可以,示例看视频) 当创建的对象在堆区时,如果指定的父亲是 QObject派生下来的类 或者 QObject子类派生下来的类,可以不用管理释放的操作,对 ...

  6. html5 image 内存溢出,解决内存溢出问题

    webpack 运行 npm run build 内存溢出 JavaScript heap out of memory vue-cli3.0构建的项目,开发过程中,可能会遇到内存溢出的情况,改动一点代 ...

  7. java 全局变量 内存不回收_Java的内存 - 内存回收

    这篇承接上一篇 <Java的内存 - 内存模型>,分析内存回收相关的知识点. 垃圾回收包含两个步骤,①标记哪些内存是垃圾 ②回收内存.下面分别说这两个步骤有哪些算法: 1. 垃圾标记 1. ...

  8. JVM内存回收算法简述

    2019独角兽企业重金招聘Python工程师标准>>> 在第一代面向对象语言C++中,最让人头疼以及影响敏捷开发的无疑是内存的申请与回收 在程序运行时,使用享元设计使用的一些代码复用 ...

  9. Java内存回收机制基础[转]

    原文链接:http://blog.jobbole.com/37273/ 在Java中,它的内存管理包括两方面:内存分配(创建Java对象的时候)和内存回收,这两方面工作都是由JVM自动完成的,降低了J ...

最新文章

  1. 使用Nginx搭建前端静态服务器+文件服务器
  2. 设置tomcat的默认jdk
  3. php 变量写入数据库,PHP基础/JS变量存入数据库 | 学步园
  4. boost::spirit模块演示语法和语义操作的计算器示例
  5. 10个奇葩的代码注释,笑出声!
  6. 【opencv 学习】使用tesseract-ocr机芯数字识别
  7. ios开发 访问mysql_iOS开发实战-时光记账Demo 网络版
  8. 唯一分解定理 详解(C++)
  9. 软件需求规格说明书范例
  10. 【MediaSoup】UDPSOCKET recv数据到rtcp包解析
  11. 极通EWEBS虚拟化平台牵手厦门大学
  12. Altova XMLSpy2011的破解出现的问题
  13. HDL4SE:软件工程师学习Verilog语言(七)
  14. 为啥外包喜欢php,为什么要面向对象?
  15. 3G模块SIM5360E拨号上网
  16. 时间管理经典书籍-《番茄工作法图解》
  17. 计算机网络体系结构及其简单通信
  18. git 清除本地远程被删除的分支
  19. 读写文件时缓冲区多大好呢?我来告诉大家哈
  20. fullcalendar 日历改造

热门文章

  1. [人工智能AI]之推理
  2. android开发是java语言吗_android开发是用java语言吗?
  3. 序列号及序列号生成器(号段模式,数据库模式)详细介绍(建议收藏)
  4. Comodo Positive SSL证书简要介绍
  5. Vue中 对Table表格中的输入项进行校验
  6. IronOCR,Crack支持全球125种语言
  7. 产品外观设计成本的组成,你知道吗?
  8. mysql的二进制安装与备份与密码破解!!
  9. STM32菜鸟成长记录---系统滴答定时器(systick)应用
  10. webpack和vue热更新