Flutter 圖片控件適配之路

本文做者:段家順前端

背景

目前大部分應用都會使用大量的圖片,圖片成爲當前應用帶寬佔比最大的一種資源。在咱們接入 Flutter 的時候,發現 Flutter 的圖片控件緩存徹底由本身管理,同時尚未提供磁盤緩存(1.22版本),因此在性能以及體驗上均比較差,因此必須對其進一步優化。c++

圖片緩存

在目前不少 CDN 實現上,全部資源都是擁有惟一 uri 的,因此不少的客戶端實現,是忽略了 HTTP 協議中的 Caches 能力,而是直接將 uri 做爲惟一標識符來判斷圖片資源是否惟一的。這樣大大節省了向服務端確認 304 的時間與請求。git

而在客戶端,通常都會存在至少內存磁盤這兩級緩存,而咱們在接入 Flutter 圖片庫的時候,就但願可以將客戶端的緩存與 Flutter 中的緩存進行打通,從而減小內存和網絡的消耗。github

而目前複用緩存的方向大體有以下3種:緩存

  1. 複用視圖,徹底由客戶端來提供 Flutter 的圖片能力,就像 React Native 同樣。
  2. 複用磁盤緩存,不復用內存緩存,這種方案實現相對簡單,但會致使內存中存在兩份圖片數據。
  3. 複用內存緩存,由客戶端從磁盤加載到內存,並由客戶端來管理整個緩存的生命週期,好比和 SDWebImage 進行深度融合。該方案看似是最完美的複用,並且客戶端有能力對整個應用的圖片緩存大小進行精確的控制。

那麼下面咱們來看看這幾種方案的實現,哪些看似美好的方案,咱們都踩了哪些坑。安全

複用視圖

Flutter 提供了一種和客戶端原生視圖進行無縫拼接的方案,原始的動機實際上是爲了像地圖、WebView 這種場景,Flutter 不可能再去實現一套如此複雜的控件。那麼若是咱們用這個來作客戶端圖片橋接方案會怎麼樣呢?性能優化

首先,咱們要明白 PlatformView 是如何進行橋接的(如下討論的都是iOS實現)。在 Widget 中插入一層客戶端 View,此時並非咱們想的那樣,將此 View 簡單的draw到 Flutter Root Layer 上。由於 Flutter 的draw call並非發生在主線程上的,而是發生在raster線程上的,若是咱們想要將客戶端的 View 繪製到 Flutter 上,則必須先光柵化爲一張圖片,而後再進行繪製,這中間的性能開銷與延遲顯而易見是不可接受的,同時每幀都須要這麼作也是不現實的。markdown

因此,Flutter 採用了一種拆分 Flutter Layer 的形式。在插入一個客戶端 View 後,Flutter 會自動將本身拆爲2層:網絡

|-----| Flutter Overlay View 2
|-----| Native View
|-----| Flutter Root View 1
複製代碼

客戶端 View 就像夾心餅乾同樣被2個 Flutter view 夾住,此時位於 Platform View 上層以及後續的兄弟 Widget 都會被繪製到上層的 View 上,其餘的依舊繪製在底層。這樣雖然解決了客戶端視圖的接入,但也會致使一個問題,當上層視圖發生位置等變動的時候,須要從新建立對應的 Overlay View,爲了減小這種開銷,Flutter 採用了一種比較 trick 的作法,即 Overlay View 會鋪滿屏幕,而經過移動上面的 mask 來進行控制展現區域。app

// The overlay view wrapper masks the overlay view.
// This is required to keep the backing surface size unchanged between frames.
//
// Otherwise, changing the size of the overlay would require a new surface,
// which can be very expensive.
//
// This is the case of an animation in which the overlay size is changing in every frame.
//
// +------------------------+
// | overlay_view |
// | +--------------+ | +--------------+
// | | wrapper | | == mask => | overlay_view |
// | +--------------+ | +--------------+
// +------------------------+
複製代碼

目前已經解決了客戶端視圖接入 Flutter 的能力,但能夠看到,當插入一張客戶端 View,Flutter 須要額外建立2個 View 進行分區域繪製。當一個頁面存在多張圖片的時候,此時額外產生的開銷顯然也是不可接受的,性能更是不可接受。

下面是 Flutter 官方在 Platform View 上描述的關於性能的考慮。

Platform views in Flutter come with performance trade-offs.

For example, in a typical Flutter app, the Flutter UI is composed on a dedicated raster thread. This allows Flutter apps to be fast, as the main platform thread is rarely blocked.

While a platform view is rendered with Hybrid composition, the Flutter UI is composed from the platform thread, which competes with other tasks like handling OS or plugin messages, etc.

Prior to Android 10, Hybrid composition copies each Flutter frame out of the graphic memory into main memory, and then copies it back to a GPU texture. In Android 10 or above, the graphics memory is copied twice. As this copy happens per frame, the performance of the entire Flutter UI may be impacted.

Virtual display, on the other hand, makes each pixel of the native view flow through additional intermediate graphic buffers, which cost graphic memory and drawing performance.
複製代碼

複用磁盤緩存

讓咱們都退一步,咱們首先解決網絡帶寬的問題,那麼一個簡單的方案即是複用磁盤緩存。

複用磁盤緩存的方案相對能夠作的很是簡單,而且擁有極低的侵入性。咱們只須要設計一套 channel 接口,來同步雙方緩存的狀態和緩存的地址。

getCacheInfo({ 
    String url,
    double width,
    double height,
    double scale,
    BoxFit fit}) 
-> {String path, bool exists}
複製代碼

那麼在使用的時候,咱們僅須要定製一套新的 ImageProvider,將網絡、本地兩種 Provider 統一塊兒來便可。

_CompositeImageStreamCompleter({
    String url,
    double width,
    double height
}) {
    getCacheInfo({url: url, width: width, height:height})
        .then((info) {
        if (info != null && info.path != null && info.path.length > 0) {
        var imageProvider;
        var decode = this.decode;
        if (info.exists) {
            final imageFile = File(info.path);
            imageProvider = FileImage(imageFile, scale: this.scale);
        } else {
            imageProvider = NetworkImage(info.fixUrl ?? this.url,
                scale: this.scale, headers: this.headers);
            decode = (Uint8List bytes,
                {int cacheWidth, int cacheHeight, bool allowUpscaling}) {
            final cacheFile = File(info.path);
            // 緩存到磁盤
            cacheFile.writeAsBytes(bytes).then((value) => { });
            return this.decode(bytes,
                cacheWidth: cacheWidth,
                cacheHeight: cacheHeight,
                allowUpscaling: allowUpscaling);
            };
        }
        _childCompleter = imageProvider.load(imageProvider, decode);
        final listener =
            ImageStreamListener(_onImage, onChunk: _onChunk, onError: _onError);
        _childCompleter.addListener(listener);
        }
    }).catchError((err, stack) {
        print(err);
    });
}
複製代碼

這裏須要注意的是,當不存在磁盤緩存的時候,這裏採用了 Flutter 來下載圖片,此時須要咱們手動將其保存到磁盤上,以保證磁盤緩存的一致性。

複用內存緩存

複用磁盤緩存是風險較低的一種改動,可是代價是沒法複用內存緩存,不只僅須要分別讀取,同時會保存多分內存緩存,由於雙方的內存緩存部分是徹底獨立存在的。

那麼若是咱們想進一步優化,則須要採用複用內存緩存的方案,目前同步內存緩存大體有以下幾種方案:

  • 利用 channel 通訊,將內存傳輸給 Flutter
  • 利用新特性 ffi 通道,將內存直接傳遞給 Flutter
  • 利用 Texture 控件,從紋理層面進行復用

Channel

Flutter 官方穩定的消息通訊方案,兼容性和穩定性都很是高。當咱們須要展現緩存圖片的時候,只須要將圖片數據經過 BinaryMessenger 形式傳遞到 Flutter 便可。

因爲 Channel 自己就必須是異步過程,因此該方式通訊會有必定開銷。

同時因爲 Channel 在客戶端是在主線程進行處理,因此也須要注意避免在主線程直接作加載與解碼等耗時操做。

而 Channel 在數據傳遞過程當中,因爲機制(從安全角度來看也必須這麼作)緣由,二進制數據必然會被拷貝一份,這樣致使的結果是 Flutter 這邊維護的內存緩存和客戶端自身的緩存依然是兩份,並無完美的達到咱們上述的複用效果。

ffi

從消息通訊開銷以及消息的內存拷貝問題來看,ffi 的出現彷佛可以完美解決 Channel 中全部的問題。

原理和實現過程與 Channel 徹底一致,此時只須要替換爲 ffi 通道便可。ffi 並無像 Channel 那麼長的通訊過程,不須要進行消息序列化與解析,也不須要切換線程處理,就像一個 HTTP 請求和一個簡單的 API 調用的區別同樣。

這裏咱們須要注意的是 ffi 接口是同步執行的,也就是說客戶端執行的時候是處於 flutter.ui 線程,咱們必須注意線程安全問題。而對於 Flutter 來講,因爲是在 UI 線程執行,因此該方法必須儘可能快的返回,不能執行一些耗時比較長的操做。

可是咱們採用 ffi 就真的可以解決上述問題嗎?仔細研究發現,其實仍是不能解決內存複用的根本性問題,下面能夠看下 ffi 轉換的過程。

當咱們把客戶端圖片加載到內存的時候,是經過 Buffer 的形式傳遞給 Flutter 的,好比是這樣一個結構:

struct Buffer {
    int8    *ptr;
    size_t  length;
}
複製代碼

對應於 Dart 中的數據類型爲Int8PointerInt64,而 Image 控件所須要的數據類型爲Uint8List,那麼咱們必須進行一步數據格式轉換:

Pointer<UInt8> bufferPtr;
int length;
Uint8List buffer = bufferPtr.asTypedList(length);
複製代碼

而在此次轉換過程當中,會發生一次內存拷貝(Uint8List 底層保持數據使用的是 std::vector)。

因此,從最終結果來看,並不比 Channel 有更高的緩存複用能力。

Texture

另外一種是共享 PixelBuffer,也就是解碼後的圖片數據,在 Flutter 這裏能夠採用 Texture 來實現複用。

具體實現方案阿里已經研究的很是透徹,這裏就再也不復述了,咱們主要分析下其性能與複用能力。

Texture 複用採用的是 TextureId,這是一個 int 值,因此在兩端通訊上不存在數據量上的性能開銷。其主要過程是:

  1. 客戶端將紋理註冊到 Flutter,同時會返回一個 id 做爲惟一標識符(i++)。這個過程發生在 Platform 線程,也就是客戶端主線程,而真正註冊到 TextureRegistry 中則是在 raster 線程中完成的。
  2. 在 flutter.ui 線程處理 paint 事件的時候,會將該 id 傳遞給 TextureLayer。
  3. 並在 raster 線程,憑藉 TextureId 從 TextureRegistry 中取出並生成 draw call。

從總體流程來看,Flutter 在中間流轉過程全程只使用了 TextureId,並不會操做內存與紋理,並不存在多份緩存的問題。因此這種方案比較完美的解決了上述兩個問題。

內存優化

雖然從上述分析中,緩存利用率最高的是 Texture,可是從內存上來分析,則出現了一個意想不到的結果。

上圖是使用 Flutter Image 控件,加載幾張大圖的一個內存圖,總共增長了 10M 內存消耗。

上圖是使用 Texture 方案,加載一樣圖片所產生的內存消耗,達到了 37M,相差巨大。

同時能夠看到原生 Flutter 圖片在初始階段有一個比較大的波峯,一樣紋理也有,但相對平緩一些。

產生這樣大的區別主要仍是要從 Flutter Image 控件的渲染流程中提及。

  1. ImageProvider 將圖片加載到內存後,首先會進行解碼,而這個事情是在 flutter.io 線程完成的。
  2. 圖片數據解碼以後,會形成一個很是大的內存消耗,由於此時的圖片數據是以 pixel buffer 的形式存儲的。而 Flutter 在這一過程會進行一個優化,此時解碼的數據將不是 100% 大小的,而是會當前 widget size進行調整,計算出一個最優的大小,而後在這一大小上進行解碼,因此原生的 Image 反而在內存佔用這個方面會比客戶端更優秀。
  3. 在圖片移除後,Flutter 會馬上回收解碼後的內存,即 Flutter 僅對圖片的原始壓縮數據進行存儲,並不緩存 pixel buffer。而咱們客戶端(SDWebImage)則會緩存解碼後的所有數據,這也是另外一個 Flutter 內存表現比客戶端要優的地方。

那麼 Flutter 這種策略在內存佔用上完勝客戶端,是否就必然是好的呢?

其實從渲染流程中看,Flutter 僅僅是用解碼時間換取了內存空間。在實際 Demo 中,列表快速滑動時,Flutter Image 控件的圖片展現會有明顯的延遲,而採用 Texture 方案,肉眼幾乎沒法分辨。因此從總體的表現上來講,Texture 方案並非沒有優勢。

圖片尺寸

從上述中能夠看出來,Texture 方案在內存的表現上比較差,那麼咱們如何去進一步優化呢?

對於不少場景,好比用戶頭像等,都是有一個固定大小的,那麼咱們能夠將該大小做爲參數,傳給 CDN,在 CDN 上就進行裁剪成咱們須要的大小,這樣也會節省大量流量。

可是一樣有不少場景,咱們是沒法獲得其控件大小的,好比充滿容器大小這種場景。咱們如何自動在全部圖片上加上 Size 參數呢?

從渲染過程當中,Layout以後會觸發Paint,而此時該控件的大小必然已是徹底肯定的了,那麼咱們能夠在這裏作一個假的佔位控件,在計算出大小後,再替換爲真正的圖片。

typedef ImageSizeResolve = void Function(Size size);

class ImageSizeProxyWidget extends SingleChildRenderObjectWidget {
  const ImageSizeProxyWidget({Key key, Widget child, this.onResolve})
      : super(key: key, child: child);

  final ImageSizeResolve onResolve;

  @override
  ImageSizeProxyElement createElement() => ImageSizeProxyElement(this);

  @override
  ImageSizeRenderBox createRenderObject(BuildContext context) =>
      ImageSizeRenderBox(onResolve);

  @override
  void updateRenderObject(
      BuildContext context, covariant ImageSizeRenderBox renderObject) {
    super.updateRenderObject(context, renderObject);
    renderObject.onResolve = onResolve;
  }
}

class ImageSizeProxyElement extends SingleChildRenderObjectElement {
  ImageSizeProxyElement(RenderObjectWidget widget) : super(widget);
}

class ImageSizeRenderBox extends RenderProxyBox with RenderProxyBoxMixin {
  ImageSizeRenderBox(ImageSizeResolve onResolve, [RenderBox child])
      : onResolve = onResolve,
        super(child);

  ImageSizeResolve onResolve;

  @override
  void paint(PaintingContext context, ui.Offset offset) {
    if (hasSize) {
      if (onResolve != null) onResolve(size);
    }
    super.paint(context, offset);
  }
}
複製代碼

這樣,咱們就能強制全部圖片都必須帶上 Size 參數了。

通過這樣的優化處理後,內存佔用降低到了 2M 左右(因爲我用的測試圖都是高清圖,因此效果看上去會比較明顯)。

總結

Flutter 的不少思路和策略和客戶端有着明顯的區別,從圖片這一個能力來看,就能夠從各個方面進行適配與優化,若是須要達到完美可用的一個狀態,看來仍是須要不斷的投入與探索。

附錄

Texture 實現方案能夠參考《Alibaba.com Flutter 探索之路:Flutter 圖片性能優化》

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!

相關文章
相關標籤/搜索