【后端教程】细化 Flutter List 内存回收,解决大 Cell 问题
前言
何谓大 Cell 问题?在基于 Native List 的渲染方案中,都会遇到大 Cell 问题。比如 Weex 业务中,经常出现页面内存飙高,排查后发现多为前端写法导致的一个大 Cell 中存在过多图片,导致内存过高。在 Flutter 里同样有这个问题,本质原因都是因为 List 进行回收的单位是 Cell,而不是 Cell 中的图片。在浏览器体系下,不存在这个问题,想必是浏览器进行了额外的运算,可以正确回收出屏的图片。
在开发 Flutter 版本淘宝商品详情页面时,我们同样遇到了大 Cell 的问题。一个商品的详情由多张图片拼接而成,这些图片尺寸未知,需要进行高度自适应,图片被放在同一个 Cell 中。发现列表滚动到特定位置,大量图片同时加载并生成纹理,内存突然飙高。
该问题有两个解决方案:
重构业务层代码,把图片分散在多个 Cell 里。但是因为缺乏高度信息,Cell 仍然会一次性全部出现,带来内存问题。
细化 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,使图片获得一个大致的排版后的位置。整个图片的排版加载逻辑如下:
当 Image Widget 拥有确定宽、高时,依赖绘制阶段的在屏判断进行图片加载。
当 Image Widget 缺失宽、高信息时,如果有排版的虚拟尺寸,以这个虚拟尺寸进行预排版。排版后首次绘制时,如果在屏,进行图片真正加载。图片加载完成后,如果尺寸与虚拟尺寸不符合,会重新排版。
▐ 效果
经过优化后,图文详情部分仍然是一个大 Cell,里面罗列了一系列高度自适应的商品图片。我们的方案避免了 Cell 首次出现时,所有图片一次性全部加载,导致内存突然飙高造成 OOM。同时在列表滚动过程,同一个 Cell 中的图片可以按需回收,使内存水位保持在合理水平。
总结
本文探索出的方案属于 AliFlutter 提供的外接图片库的功能之一。这个方案保障了淘宝商品图片详情这种场景下的稳定性。我们测试发现,使用官方的 Image.network 加载图片,并且不优化大 Cell 场景的话,一个较复杂的商品内存可能暴涨到 1GB,几乎 100% 造成低端机的 OOM。这种情况,业务是完全无法上线的。
服务推荐
- 蜻蜓代理
- ip代理服务器
- 企业级代理ip
- 微信域名检测
- 微信域名拦截检测
【后端教程】细化 Flutter List 内存回收,解决大 Cell 问题相关推荐
- 细化 Flutter List 内存回收,解决大 Cell 问题
作者|王乾元(神漠) 出品|阿里巴巴新零售淘系技术部 前言 何谓大 Cell 问题?在基于 Native List 的渲染方案中,都会遇到大 Cell 问题.比如 Weex 业务中,经常出现页面内存飙 ...
- 内存不够解决大数据问题
在研究.应用机器学习算法的经历中,相信大伙儿经常遇到数据集太大.内存不够用的情况. 这引出一系列问题: 怎么加载十几.几十 GB 的数据文件? 运行数据集的时候算法崩溃了,怎么办? 怎么处理内存不足导 ...
- Linux内核:内存管理——内存回收
概述 当linux系统内存压力就大时,就会对系统的每个压力大的zone进程内存回收,内存回收主要是针对匿名页和文件页进行的.对于匿名页,内存回收过程中会筛选出一些不经常使用的匿名页,将它们写入到swa ...
- Redis系列教程(九):Redis的内存回收原理,及内存过期淘汰策略详解
Redis内存回收机制 Redis的内存回收主要围绕以下两个方面: 1.Redis过期策略:删除过期时间的key值 2.Redis淘汰策略:内存使用到达maxmemory上限时触发内存淘汰数据 Red ...
- 【Qt教程】1.5 - Qt5内存回收机制-对象树、窗口坐标系
1.Qt内存回收机制 - 对象树 (做了解,懂就可以,示例看视频) 当创建的对象在堆区时,如果指定的父亲是 QObject派生下来的类 或者 QObject子类派生下来的类,可以不用管理释放的操作,对 ...
- html5 image 内存溢出,解决内存溢出问题
webpack 运行 npm run build 内存溢出 JavaScript heap out of memory vue-cli3.0构建的项目,开发过程中,可能会遇到内存溢出的情况,改动一点代 ...
- java 全局变量 内存不回收_Java的内存 - 内存回收
这篇承接上一篇 <Java的内存 - 内存模型>,分析内存回收相关的知识点. 垃圾回收包含两个步骤,①标记哪些内存是垃圾 ②回收内存.下面分别说这两个步骤有哪些算法: 1. 垃圾标记 1. ...
- JVM内存回收算法简述
2019独角兽企业重金招聘Python工程师标准>>> 在第一代面向对象语言C++中,最让人头疼以及影响敏捷开发的无疑是内存的申请与回收 在程序运行时,使用享元设计使用的一些代码复用 ...
- Java内存回收机制基础[转]
原文链接:http://blog.jobbole.com/37273/ 在Java中,它的内存管理包括两方面:内存分配(创建Java对象的时候)和内存回收,这两方面工作都是由JVM自动完成的,降低了J ...
最新文章
- 使用Nginx搭建前端静态服务器+文件服务器
- 设置tomcat的默认jdk
- php 变量写入数据库,PHP基础/JS变量存入数据库 | 学步园
- boost::spirit模块演示语法和语义操作的计算器示例
- 10个奇葩的代码注释,笑出声!
- 【opencv 学习】使用tesseract-ocr机芯数字识别
- ios开发 访问mysql_iOS开发实战-时光记账Demo 网络版
- 唯一分解定理 详解(C++)
- 软件需求规格说明书范例
- 【MediaSoup】UDPSOCKET recv数据到rtcp包解析
- 极通EWEBS虚拟化平台牵手厦门大学
- Altova XMLSpy2011的破解出现的问题
- HDL4SE:软件工程师学习Verilog语言(七)
- 为啥外包喜欢php,为什么要面向对象?
- 3G模块SIM5360E拨号上网
- 时间管理经典书籍-《番茄工作法图解》
- 计算机网络体系结构及其简单通信
- git 清除本地远程被删除的分支
- 读写文件时缓冲区多大好呢?我来告诉大家哈
- fullcalendar 日历改造