Flutter 圖片庫高燃新登場

背景

去年,閒魚圖片庫在大規模的應用下取得了不錯的成績,但也遇到了一些問題和訴求,需要進一步的演進,以適應更多的業務場景與最新的 flutter 特性。比如,因爲完全拋棄了原生的 ImageCache,在與原生圖片混用的場景下,會讓一些低頻的圖片反而佔用了緩存;比如,我們在模擬器上無法展示圖片;比如我們在相冊中,需要在圖片庫之外再搭建圖片通道。

這次,我們巧妙地將外接紋理與 FFi 方案組合,以更貼近原生的設計,解決了一系列業務痛點。沒錯,Power 系列將新增一員,我們將新的圖片庫命名爲 「PowerImage」!

我們將新增以下核心能力:

去年圖片方案可以參考《閒魚 Flutter 圖片框架架構演進(超詳細)》

Flutter 原生方案

在我們新方案開始之前,先簡單回憶一下 flutter 原生圖片方案。

原生 Image Widget 先通過 ImageProvider 得到 ImageStream,通過監聽它的狀態,進行各種狀態的展示。比如frameBuilderloadingBuilder,最終在圖片加載成功後,會 rebuild 出 RawImageRawImage 會通過 RenderImage 來繪製,整個繪製的核心是 ImageInfo 中的 ui.Image

在梳理 flutter 原生圖片方案之後,我們發現是不是有機會在某個環節將 flutter 圖片和 native 以原生的方式打通?

新的方案

我們巧妙地將 FFi 方案與外接紋理方案組合,解決了一系列業務痛點。

FFI

正如開頭說的那些問題,Texture 方案有些做不到的事情,這需要其他方案來互補,這其中核心需要的就是 ui.Image。我們把 native 內存地址、長度等信息傳遞給 flutter 側,用於生成 ui.Image

首先 native 側先獲取必要的參數(以 iOS 爲例):

    _rowBytes = CGImageGetBytesPerRow(cgImage);

    CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage);
    CFDataRef rawDataRef = CGDataProviderCopyData(dataProvider);
    _handle = (long)CFDataGetBytePtr(rawDataRef);

    NSData *data = CFBridgingRelease(rawDataRef);
    self.data = data;
    _length = data.length;

dart 側拿到後

@override
  FutureOr<ImageInfo> createImageInfo(Map map) {
    Completer<ImageInfo> completer = Completer<ImageInfo>();
    int handle = map['handle'];
    int length = map['length'];
    int width = map['width'];
    int height = map['height'];
    int rowBytes = map['rowBytes'];
    ui.PixelFormat pixelFormat =
        ui.PixelFormat.values[map['flutterPixelFormat'] ?? 0];
    Pointer<Uint8> pointer = Pointer<Uint8>.fromAddress(handle);
    Uint8List pixels = pointer.asTypedList(length);
    ui.decodeImageFromPixels(pixels, width, height, pixelFormat,
        (ui.Image image) {
      ImageInfo imageInfo = ImageInfo(image: image);
      completer.complete(imageInfo);
      //釋放 native 內存
      PowerImageLoader.instance.releaseImageRequest(options);
    }, rowBytes: rowBytes);
    return completer.future;
  }

我們可以通過 ffi 拿到 native 內存,從而生成 ui.Image。這裏有個問題,雖然通過 ffi 能直接獲取 native 內存,但是由於 decodeImageFromPixels 會有內存拷貝,在拷貝解碼後的圖片數據時,內存峯值會更加嚴重。

這裏有兩個優化方向:

FFI 這種方式適合輕度使用、特殊場景使用,支持這種方式可以解決無法獲取 ui.Image 的問題,也可以在模擬器上展示圖片(flutter <= 1.23.0-18.1.pre),並且圖片緩存將完全交給 ImageCache 管理。

Texture

Texture 方案與原生結合有一些難度,這裏涉及到沒有 ui.Image 只有 textureId。這裏有幾個問題需要解決:

問題一:Image Widget 需要 ui.Image 去 build RawImage 從而繪製,這在本文前面的 Flutter 原生方案介紹中也提到了。

問題二:ImageCache 依賴 ImageInfo 中 ui.Image 的寬高進行 cache 大小計算以及緩存前的校驗。

問題三:native 側 texture 生命週期管理

都有解決方案:

問題一:通過自定義 Image 解決,透出 imageBuilder 來讓外部自定義圖片 widget

問題二:爲 Texture 自定義 ui.image,如下:

import 'dart:typed_data';
import 'dart:ui' as ui show Image;
import 'dart:ui';

class TextureImage implements ui.Image {
  int _width;
  int _height;
  int textureId;
  TextureImage(this.textureId, int width, int height)
      : _width = width,
        _height = height;

  @override
  void dispose() {
    // TODO: implement dispose
  }

  @override
  int get height => _height;

  @override
  Future<ByteData> toByteData(
      {ImageByteFormat format = ImageByteFormat.rawRgba}) {
    // TODO: implement toByteData
    throw UnimplementedError();
  }

  @override
  int get width => _width;
}

這樣的話,TextureImage 實際上就是個殼,僅僅用來計算 cache 大小。實際上,ImageCache 計算大小,完全沒必要直接接觸到 ui.Image,可以直接找 ImageInfo 取,這樣的話就沒有這個問題了。這個問題可以具體看 @皓黯 的 ISSUE[1] 與 PR[2]。

問題三:關於 native 側感知 flutter image 釋放時機的問題

修改的 ImageCache 釋放如下 (部分代碼):

typedef void HasRemovedCallback(dynamic key, dynamic value);

class RemoveAwareMap<K, V> implements Map<K, V> {
  HasRemovedCallback hasRemovedCallback;
  ...
}
//------
  final RemoveAwareMap<Object, _PendingImage> _pendingImages = RemoveAwareMap<Object, _PendingImage>();
//------
void hasImageRemovedCallback(dynamic key, dynamic value) {
    if (key is ImageProviderExt) {
      waitingToBeCheckedKeys.add(key);
    }
    if (isScheduledImageStatusCheck) return;
    isScheduledImageStatusCheck = true;
    //We should do check in MicroTask to avoid if image is remove and add right away
    scheduleMicrotask(() {
      waitingToBeCheckedKeys.forEach((key) {
        if (!_pendingImages.containsKey(key) &&
            !_cache.containsKey(key) &&
            !_liveImages.containsKey(key)) {
          if (key is ImageProviderExt) {
            key.dispose();
          }
        }
      });
      waitingToBeCheckedKeys.clear();
      isScheduledImageStatusCheck = false;
    });
  }

整體架構

我們將兩種解決方案非常優雅地結合在了一起:

我們抽象出了 PowerImageProvider ,對於 external(ffi)、texture,分別生產自己的 ImageInfo 即可。它將通過對 PowerImageLoader 的調用,提供統一的加載與釋放能力。

藍色實線的 ImageExt 即爲自定義的 Image Widget,爲 texture 方式透出了 imageBuilder。

藍色虛線 ImageCacheExt 即爲 ImageCache 的擴展,僅在 flutter < 2.2.0 版本才需要,它將提供 ImageCache 釋放時機的回調。

這次,我們也設計了超強的擴展能力。除了支持網絡圖、本地圖、flutter 資源、native 資源外,我們提供了自定義圖片類型的通道,flutter 可以傳遞任何自定義的參數組合給 native,只要 native 註冊對應類型 loader,比如「相冊」這種場景,使用方可以自定義 imageType 爲 album ,native 使用自己的邏輯進行加載圖片。有了這個自定義通道,甚至圖片濾鏡都可以使用 PowerImage 進行展示刷新。

除了圖片類型的擴展,渲染類型也可進行自定義。比如在上面 ffi 中說的,爲了降低內存拷貝帶來的峯值問題,使用方可以在 flutter 側進行解碼,當然這需要 native 圖片庫提供解碼前的數據。

數據對比

FFI vs Texture:

機型:iPhone 11 Pro,圖片:300 張網絡圖,行爲:在listView中手動滾動到底部再滾動到頂部,native Cache:100MB,flutter Cache:100MB

這裏有兩個現象:

Texture:395MB波動,內存較平滑
FFI:480MB波動,內存有毛刺

Texture 方案在內存方面表現優於 FFI,在內存水位與毛刺兩方面:

結論:

  1. Texture 適用於日常場景,優先選擇;

  2. FFI 更適用於

  3. flutter <= 1.23.0-18.1.pre 版本中,在模擬器上顯示圖片

  4. 獲取 ui.Image 圖片數據

  5. flutter 側解碼,解碼前的數據拷貝影響較小。(比如集團 Hummer 的外接解碼庫)

滾動流暢性分析:

設備: Android OnePlus 8t,CPU和GPU進行了鎖頻。
case: GridView每行4張圖片,300張圖片,從上往下,再從下往上,滑動幅度從500,1000,1500,2000,2500,5輪滑動。重複20次。
方式: for i in {1..20}; do flutter drive --target=test_driver/app.dart --profile; done 跑數據,獲取TimeLine數據並分析。

結論:

更精簡的代碼:

dart 側代碼有較大幅度的減少,這歸功於技術方案貼合 flutter 原生設計,我們與原生圖片共用較多代碼。

FFI 方案補全了外接紋理的不足,遵循原生 Image 的設計規範,不僅讓我們享受到 ImageCache 帶來的統一管理,也帶來了更精簡的代碼。

未來

相信很多人注意到了,上文中少了動圖部分。當前動圖部分正在開發中,內部的 Pre Release 版本中,在 load 的時候返回的實際上是 OneFrameImageStreamCompleter,對於動圖,我們將替換爲 MultiFrameImageStreamCompleter,後面如何做,只是一些策略問題,並不難。順便拋個另一種方案:可以把動圖解碼前的數據給 flutter 側解碼與渲染,但支持的格式不如原生豐富。

我們希望能將 PowerImage 貢獻給社區,爲了實現這一目標,我們提供了詳細的設計文檔、接入文檔、性能報告,另外我們也在完善單元測試,在代碼提交後或者 CR 時,都會進行單元測試。

最後,也是大家最關心的:我們計劃在今年十二月底將代碼開源在 「XianyuTech[3]」。

References

[1] ISSUE: https://github.com/flutter/flutter/issues/86402
[2] PR: https://github.com/flutter/flutter/pull/86555
[3] XianyuTech: https://github.com/XianyuTech

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TdTGK21S-Yd3aD-yZDoYyQ