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

作者|王乾元(神漠)

出品|阿里巴巴新零售淘系技术部

前言


何谓大 Cell 问题?在基于 Native List 的渲染方案中,都会遇到大 Cell 问题。比如 Weex 业务中,经常出现页面内存飙高,排查后发现多为前端写法导致的一个大 Cell 中存在过多图片,导致内存过高。

在 Flutter 里同样有这个问题,本质原因都是因为 List 进行回收的单位是 Cell,而不是 Cell 中的图片。在浏览器体系下,不存在这个问题,想必是浏览器进行了额外的运算,可以正确回收出屏的图片。

在开发 Flutter 版本淘宝商品详情页面时,我们同样遇到了大 Cell 的问题。一个商品的详情由多张图片拼接而成,这些图片尺寸未知,需要进行高度自适应,图片被放在同一个 Cell 中。发现列表滚动到特定位置,大量图片同时加载并生成纹理,内存突然飙高。

该问题有两个解决方案:

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

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

▐  Demo 测试运行

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

Widget build(BuildContext context) {  if (widget.index % 10 == 0) {    final images = <Widget>[];    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 RenderImageSize _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 _ImageStatevoid 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 拥有确定宽、高时,依赖绘制阶段的在屏判断进行图片加载。

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

▐  效果

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

总结


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

这个方案中图片在屏、离屏判断,未来会继续和官方人员讨论并进行优化。

We are hiring

淘系技术部依托淘系丰富的业务形态和海量的用户,我们持续以技术驱动产品和商业创新,不断探索和衍生颠覆型互联网新技术,以更加智能、友好、普惠的科技深度重塑产业和用户体验,打造新商业。我们不断吸引用户增长、机器学习、视觉算法、音视频通信、数字媒体、移动技术、端侧智能等领域全球顶尖专业人才加入,让科技引领面向未来的商业创新和进步。

请投递简历至邮箱:ruoqi.zlj@taobao.com

END

更多好文

点击下方图片即可阅读

必看|阿里集团内如何进行Flutter体系化建设?

打破重重阻碍,Flutter 和 Web 生态如何对接?

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值