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

****前言****


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

image

该问题有两个解决方案:

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

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

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

****方案探索过程****


▐ 绘制图片的坐标信息

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

image

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 里,也一张一张加载并回收。如图,在底层打印纹理个数,并观察内存占用。

image

▐ 真实业务场景测试

然而在商品详情真实场景,图片完全加载不出来。调试发现,在 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

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

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

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

▐ 效果

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

image

****总结****


本文探索出的方案属于 AliFlutter 提供的外接图片库的功能之一。这个方案保障了淘宝商品图片详情这种场景下的稳定性。我们测试发现,使用官方的 Image.network 加载图片,并且不优化大 Cell 场景的话,一个较复杂的商品内存可能暴涨到 1GB,几乎 100% 造成低端机的 OOM。这种情况,业务是完全无法上线的。这个方案中图片在屏、离屏判断,未来会继续和官方人员讨论并进行优化。
https://mp.weixin.qq.com/s/Mcfj3lRR8VJACxsjjIiVsA

你可能感兴趣的:(细化 Flutter List 内存回收,解决大 Cell 问题)