作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技术部
前言
何谓大 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,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 中,每隔十个 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,使图片获得一个大致的排版后的位置。整个图片的排版加载逻辑如下:
当 Image Widget 拥有确定宽、高时,依赖绘制阶段的在屏判断进行图片加载。
当 Image Widget 缺失宽、高信息时,如果有排版的虚拟尺寸,以这个虚拟尺寸进行预排版。排版后首次绘制时,如果在屏,进行图片真正加载。图片加载完成后,如果尺寸与虚拟尺寸不符合,会重新排版。
经过优化后,图文详情部分仍然是一个大 Cell,里面罗列了一系列高度自适应的商品图片。我们的方案避免了 Cell 首次出现时,所有图片一次性全部加载,导致内存突然飙高造成 OOM。同时在列表滚动过程,同一个 Cell 中的图片可以按需回收,使内存水位保持在合理水平。
本文探索出的方案属于 AliFlutter 提供的外接图片库的功能之一。这个方案保障了淘宝商品图片详情这种场景下的稳定性。我们测试发现,使用官方的 Image.network 加载图片,并且不优化大 Cell 场景的话,一个较复杂的商品内存可能暴涨到 1GB,几乎 100% 造成低端机的 OOM。这种情况,业务是完全无法上线的。
这个方案中图片在屏、离屏判断,未来会继续和官方人员讨论并进行优化。