作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技术部
前言
何谓大 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<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 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。这种情况,业务是完全无法上线的。
这个方案中图片在屏、离屏判断,未来会继续和官方人员讨论并进行优化。
We are hiring
淘系技术部依托淘系丰富的业务形态和海量的用户,我们持续以技术驱动产品和商业创新,不断探索和衍生颠覆型互联网新技术,以更加智能、友好、普惠的科技深度重塑产业和用户体验,打造新商业。我们不断吸引用户增长、机器学习、视觉算法、音视频通信、数字媒体、移动技术、端侧智能等领域全球顶尖专业人才加入,让科技引领面向未来的商业创新和进步。
请投递简历至邮箱:ruoqi.zlj@taobao.com
关注「淘系技术」微信公众号,一个有温度有内容的技术社区~