作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技術部
前言
何謂大 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]
關注「淘系技術」微信公眾號,一個有溫度有內容的技術社區~