雲計算

Flutter 圖片解碼與緩存管理研究

圖片解碼和緩存管理是渲染引擎的一個重要模塊,這是因為圖片解碼的耗時很長,特別是對於設計為跨平臺的通用渲染引擎來說,依賴於CPU來做圖片解碼,會消耗大量的CPU時間,並且圖片解碼後佔用的內存很大,一張 1024x1024 分辨率的圖片解碼後就需要 4M 內存(除非硬件支持實時生成無損壓縮格式紋理,通常這也不在通用渲染引擎的考慮範圍之內)。所以一個設計良好的圖片解碼和緩存管理模塊需要平衡很多不同的因素,包括內存佔用,CPU佔用,解碼任務調度的及時性等。

在對 Flutter 的圖片解碼和緩存管理模塊進行研究後,發現它跟 Chromium 有很大的差別。一方面它實現比較簡單,給予了應用更直接的控制權,引擎本身只提供了最基本的支持,更契合 Native UI 的實際使用場景,另外一方面因為引擎本身缺少控制權,如果應用生成的 UI 界面較為極端,可能會導致比較災難性的結果。

在這篇文章,我會先對 Flutter 的圖片解碼和緩存管理機制進行說明。然後再說明這種機制存在的一些問題。

Image Widget and Provider

class Image extends StatefulWidget {
  ...
  /// The image to display.
  final ImageProvider image;
}

abstract class NetworkImage extends ImageProvider<NetworkImage> {
  ...
}

Flutter 通過 Image.asset,Image.file,Image.network 等方法創建一個 Image Widget 來顯示圖片,方法名字說明了圖片數據的來源,他們實際上是為 Image Widget 提供了不同的 ImageProvider,比如說 Image.network 創建的 Image Widget,它的 ImageProvider 就是 NetworkImage。因為 Image Widget 是一個 StatefulWidget,所以它核心的狀態處理邏輯代碼是位於 _ImageState 對象中,由它來創建真正顯示圖片的 RawImage Widget。

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ...
  @override
  void didChangeDependencies() {
    _updateInvertColors();
    _resolveImage();

    if (TickerMode.of(context))
      _listenToStream();
    else
      _stopListeningToStream();

    super.didChangeDependencies();
  }

  void _resolveImage() {
    final ScrollAwareImageProvider provider = ScrollAwareImageProvider<dynamic>(
      context: _scrollAwareContext,
      imageProvider: widget.image,
    );
    final ImageStream newStream =
      provider.resolve(createLocalImageConfiguration(
        context,
        size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null,
      ));
    assert(newStream != null);
    _updateSourceStream(newStream);
  }

  void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _imageInfo = imageInfo;
      _loadingProgress = null;
      _frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
      _wasSynchronouslyLoaded |= synchronousCall;
    });
  }
}

ScrollAwareImageProvider 是新版本新增的優化,它包裝了最初的 ImageProvider,用來避免在快速滾動的過程中加載圖片,也就是說快速滾動過程新增的 Image Widget,它加載圖片的時機會被延遲,如果它在滾動過程中移除屏幕然後被移除,就完全不會觸發加載。

當 Image Widget 被加入到 UI 的 Widget 樹時,Flutter 就會調用 _ImageState.didChangeDependencies,然後 _ImageState._resolveImage 被調用,最後調用 ImageProvider.resolve 來加載圖片。ImageProvider.resolve 觸發了一連串的事情發生,它會先在 ImageCache 中生成 Entry,然後開始加載數據(異步方法,由 ImageProvider 的子類提供),加載完數據後生成相應的 Codec 開始請求解碼(異步方法,由 Native Engine 提供),解碼完成後最終通知 _ImageState._handleImageFrame 改變狀態,產生新的 child Widget 顯示圖片。

Flutter 單幀圖片的解碼是運行在 worker 線程池(可以併發),解碼後的 GPU 紋理上傳是 io 線程,多幀圖片的解碼和紋理上傳都是在 io 線程。

也就是說:

  1. Flutter 圖片解碼的調度和圖片緩存的管理都在 Widget 層,由 Image Widget 關聯的 _ImageState 對象和 ImageProvider 對象負責;
  2. 圖片緩存的實現是 ImageCache 對象,通過 PaintingBinding.instance.imageCache 訪問,ImageProvider 封裝了 ImageCache 的訪問;
  3. 當 Image Widget 被加入 Widget 樹,就會觸發圖片的加載,加載完後就會自動請求解碼,加載和解碼是連在一起不可分割的;
  4. 解碼完成後 Image Widget 才會產生 RawImage 作為 child Widget 真正顯示圖片。

ImageCache 圖片緩存

class ImageCache {
  final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
  final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
  final Map<Object, _LiveImage> _liveImages = <Object, _LiveImage>{};
  
  
  ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(),
      {ImageErrorListener onError}) {
    ...
  }
}

當 ImageProvide.resolve 被調用時,它會去調用 ImageCache.putIfAbsent 生成 Cache Entry(Key 由 ImageProvider 產生),返回一個 ImageStreamCompleter 對象用於監聽圖片加載和解碼完成的情況,如果已經有緩存的 Entry,則直接返回。

ImageCache 實際上有三個 Pool,分別是 Pending,Cache 和 Live Pool,一個新的 Entry 一開始會被加入到這三個 Pool 中。Pending Pool 用來跟蹤正在加載和解碼的圖片,當圖片加載和解碼完成後,ImageCache 會自動移除 Pending Pool 相應的 Entry。Live Pool 是用來跟蹤使用中的圖片,當 Image Widget 移除或者更換圖片,或者 Image Widget 自身被移除,ImageCache 會從 Live Pool 移除相應的 Entry。如果圖片緩存的數量和內存佔用大小沒有超過 ImageCache 的上限,Cache Pool 就會一直保留 Cache Entry,如果超過則按 LRU 進行釋放。只有 ImageCache 從所有 Pool 都釋放了同一個圖片的 Entry,該圖片解碼後生成的紋理內存才會真正被釋放

我們可以通過一個實際的場景來說明 ImageCache 的處理邏輯。假設 ImageCache 緩存的限制是 100M(100M 也是 Flutter 的默認值),我們的 UI 陸續加入 200 個 Image Widget,每個 Image Widget 顯示一個 512x512 的圖片,每個圖片解碼後的紋理內存佔用為 1M。

  1. 當 UI 加入 100 個 Image Widget 的時候,Live Pool 和 Cache Pool 都有對應 100 個 Entey,假設圖片都已經加載和解碼完畢,Pending Pool 裡面的 Entry 被全部移除,當前總的圖片紋理緩存佔用為 100M;
  2. 當加入 101 個 Image Widget,並且圖片加載和解碼完畢的時候,Live Pool 裡面有 101 個 Entry,但是 Cache Pool 因為超過上限,最初的 Entry 被移除,只保留了後面 100 個 Entry,當前總的圖片紋理緩存佔用為 101M;
  3. 當加入 200 個 Image Widget,並且圖片加載和解碼完畢的時候,Live Pool 裡面有 200 個 Entry,但是 Cache Pool 因為超過上限,最初的 100 個 Entry 被移除,只保留了後面 100 個 Entry,當前總的圖片紋理緩存佔用為 200M;
  4. 我們移除這 200 個 Image Widget,Live Pool 的 Entry 被完全移除,但是 Cache Pool 沒有超過上限,仍然保留,當前總的圖片紋理緩存佔用為 100M;
  5. 我們使用後 100 張同樣的圖片重新加入 100 個 Image Widget,因為圖片已經存在於 Cache Pool,所以不需要重新加載和解碼,ImageCache 會從 Cache Pool 裡面取出對應 Entry,並且重新在 Live Pool 生成對應的 Entry,最後 Live Pool 和 Cache Pool 都包含同樣的 100 個 Entry,當前總的圖片紋理緩存佔用為 100M;
  6. 我們繼續使用前 100 張圖片再加入 100 個 Image Widget,因為 Cache Pool 已經移除了對應的 Entry,所以需要重新加載和解碼,最終 Live Pool 包含了 200 個 Entry,Cache Pool 包含 100 對應前 100 張圖片的 Entry,當前總的圖片紋理緩存佔用為 200M;
  7. 我們再次移除所有的 Image Widget,並且手動設置 ImageCache 的內存上限為 0,這樣 ImageCache 會移除 Live Pool 和 Cache Pool 的所有 Entry,當前總的圖片紋理緩存佔用為 0M;

Flutter 圖片緩存設計的一些問題

應該說 Flutter 的圖片緩存設計還是比較契合 Native UI 的使用場景的,但是對於一些設計比較糟糕的 UI,或者是自動生成的類 Web 的長頁面,這樣的設計可能會造成一些災難性的後果。

  1. Flutter 解碼的時機非常靠前,如果一次性加入大量的 Image Widget 對象,會馬上產生相應數量的加載和解碼任務,這可能造成系統較為嚴重的阻塞,並且部分 Image Widget 實際上可能距離可見區域較遠,解碼後產生的紋理暫時不會被使用,這造成了內存浪費;
  2. ImageCache 實際上是沒有真正封頂的(Live Pool 是無上限的),如果當前的 Widget 樹同時包含了大量的 Image Widget,內存峰值可能會非常誇張,很容易造成 OOM;
  3. ImageCache 是在 Framework 層的實現而不是 Engine 層,它的實例由 Widget 層產生,通過 PaintingBinding.instance.imageCache 訪問,這意味著每個 FlutterView,每個 Root Isolate 都有一個不同的 ImageCache,如果是混合應用,同時展現多個 FlutterView,不但 ImageCache 的 Live Pool 沒法控制,Cache Pool 也會處於疊加的狀態,導致內存的峰值會更難以控制;

目前已經有不少嘗試是先生成 DOM 樹,然後再用不同的後端將 DOM 樹轉換成適合不同渲染引擎的產物,交給對應的引擎去渲染。比如生成真正的 Web DOM 交給 Web 引擎渲染,或者生成 Flutter Widget 樹交給 Flutter 渲染。這種代碼自動生成的 Widget 樹可能存在的一個問題就是可能會一次性生成大量的 Widget,並且同時加入 Widget 樹。

Leave a Reply

Your email address will not be published. Required fields are marked *