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

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

屏幕快照 2020-04-13 下午4.07.14.png
作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技术部

前言

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

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

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

1.jpg

该问题有两个解决方案:

  1. 重构业务层代码,把图片分散在多个 Cell 里。但是因为缺乏高度信息,Cell 仍然会一次性全部出现,带来内存问题。
  2. 细化 Flutter List 的回收能力,在 Cell 回收的基础上,可以做到以图片为单位进行回收。

方案1只能说治标不治本,而且成本较高。根据 Weex 的经验,业务开发同学难免会因为不注意而造成大 Cell 的实际存在导致线上内存问题。

而方案2就是本文要探索的方法,在 Flutter 体系内增强图片回收能力,降低内存占用。

方案探索过程

▐ 绘制图片的坐标信息

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

1.jpg

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

1.png

▐ 真实业务场景测试

然而在商品详情真实场景,图片完全加载不出来。调试发现,在 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 的官方图片组件添加了一个给排版用的虚拟尺寸参数。

1.png

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

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

▐ 效果

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

1.png

总结

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

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

We are hiring

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

关注「淘系技术」微信公众号,一个有温度有内容的技术社区~

公众号二维码.jpg

相关文章
|
1月前
|
程序员 开发者
分代回收和手动内存管理相比有何优势
分代回收和手动内存管理相比有何优势
|
1月前
|
存储 Dart 索引
flutter_鸿蒙next_Dart基础②List
本文介绍了 Dart 编程语言中的列表操作,包括创建、打印、强类型列表、可扩展空列表、填充列表、列表扩展、可选展开操作符、获取列表长度、列表反转、添加和移除元素、插入和清空列表等基本操作。通过代码示例详细解析了每一步的操作方法,帮助读者更好地理解和应用 Dart 列表。
134 2
|
2月前
|
算法 Java 程序员
内存回收
【10月更文挑战第9天】
58 5
|
6月前
|
NoSQL Java Redis
Redis系列学习文章分享---第十八篇(Redis原理篇--网络模型,通讯协议,内存回收)
Redis系列学习文章分享---第十八篇(Redis原理篇--网络模型,通讯协议,内存回收)
89 0
|
2月前
|
Java 测试技术 Android开发
让星星⭐月亮告诉你,强软弱虚引用类型对象在内存足够和内存不足的情况下,面对System.gc()时,被回收情况如何?
本文介绍了Java中四种引用类型(强引用、软引用、弱引用、虚引用)的特点及行为,并通过示例代码展示了在内存充足和不足情况下这些引用类型的不同表现。文中提供了详细的测试方法和步骤,帮助理解不同引用类型在垃圾回收机制中的作用。测试环境为Eclipse + JDK1.8,需配置JVM运行参数以限制内存使用。
39 2
|
7月前
|
Web App开发 缓存 前端开发
【Flutter前端技术开发专栏】Flutter中的性能优化与内存管理
【4月更文挑战第30天】本文探讨了Flutter应用的性能优化和内存管理。关键点包括:减少布局重绘(使用`const`构造函数和最小化依赖),选择合适的动画实现,懒加载和按需加载以提升性能。同时,强调了避免内存泄漏和优化内存使用,利用Flutter提供的性能分析工具。实践案例展示了如何优化ListView,包括使用`ListView.builder`和缓存策略。通过这些方法,开发者可以提升应用的响应性、流畅性和稳定性。
293 0
【Flutter前端技术开发专栏】Flutter中的性能优化与内存管理
|
4月前
|
存储 NoSQL 算法
Redis内存回收
Redis 基于内存存储,性能卓越,但单节点内存不宜过大,以免影响持久化或主从同步。可通过配置 `maxmemory` 限制最大内存。内存达到上限时,Redis采用两种策略:内存过期策略和内存淘汰策略。过期策略包括惰性删除和周期删除,后者分为 SLOW 和 FAST 模式。内存淘汰策略有八种,如 LRU、LFU 和随机淘汰等,用于在内存不足时释放空间。官方推荐使用 LFU 算法。
Redis内存回收
|
2月前
|
算法 Java
JVM进阶调优系列(3)堆内存的对象什么时候被回收?
堆对象的生命周期是咋样的?什么时候被回收,回收前又如何流转?具体又是被如何回收?今天重点讲对象GC,看完这篇就全都明白了。
|
4月前
|
JavaScript 前端开发 算法
js 内存回收机制
【8月更文挑战第23天】js 内存回收机制
43 3
|
3月前
|
数据安全/隐私保护 虚拟化
基于DAMON的内存能回收 【ChatGPT】
基于DAMON的内存能回收 【ChatGPT】

热门文章

最新文章