
作者|王乾元(神漠)
出品|阿裡巴巴新零售淘系技術部
前言
何謂大 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
淘系技術部依托淘系豐富的業務形态和海量的使用者,我們持續以技術驅動産品和商業創新,不斷探索和衍生颠覆型網際網路新技術,以更加智能、友好、普惠的科技深度重塑産業和使用者體驗,打造新商業。我們不斷吸引使用者增長、機器學習、視覺算法、音視訊通信、數字媒體、移動技術、端側智能等領域全球頂尖專業人才加入,讓科技引領面向未來的商業創新和進步。
請投遞履歷至郵箱:[email protected]
關注「淘系技術」微信公衆号,一個有溫度有内容的技術社群~