開發與維運

混合棧開發,看AliFlutter如何解決圖片問題(完整方案)

屏幕快照 2020-04-07 下午5.03.24.png

作者|王乾元(神漠)
出品|阿里巴巴新零售淘系技術部

前言

在 Flutter 官方體系內,對混合棧開發支持不夠友好。比如對於圖片資源管理,以及如何對接 Native 圖片庫的問題,社區上已經有一些方案,但或多或少存在一些問題,或與 Flutter 圖片加載流程背離較大,難以融合。

與此同時,在電商類應用中,使用 Flutter 實現的長列表多圖頁面,往往面臨著嚴重的性能問題。例如滾動過程,過多的併發圖片請求阻塞了網絡,造成 CPU、內存飆高。在淘寶特價版 Flutter 商品詳情頁面裡,還遇到了更棘手的大 Cell 問題,Flutter List 的回收機制對大 Cell 無能為力,造成內存瘋漲極易 OOM。

為解決這些問題,AliFlutter 基礎容器在 Flutter 官方的 Image Widget 體系裡進行擴展,實現了一套完整的圖片解決方案。具備的能力如下:

  1. 外接原生圖片庫,共享本地文件緩存、內存緩存。
  2. 圖片請求取消功能,解決網絡併發限制引起的排隊加載緩慢,以及無效的解碼、紋理上傳造成資源浪費的情況。
  3. 圖片解碼併發管理,降低 CPU、內存峰值。
  4. 支持 GIF,在播放 GIF 時逐幀上傳紋理,降低內存佔用。
  5. 簡單易用的 Placeholder。
  6. 允許將 Flutter 內置的各種圖片解碼庫剝離,減小包大小。
  7. 業務無感的方式解決 List 滾動時,大 Cell 中的圖片不能動態加載、回收的問題。解決 Native、Weex 體系下的頑疾。

關於大 Cell 問題的解決方案,下週將會推出文章:《細化 Flutter List 內存回收,解決大 Cell 問題》。

Flutter 的圖片加載過程

首先介紹一下 Flutter 裡圖片相關的加載邏輯。顯示圖片使用 Image Widget。Image Widget 創建時,可以指定不同的圖片來源:

  • Image.network
  • Image.file
  • Image.asset

這些方法創建了背後不同的 ImageProvider。當 Widget 構建並更新 State 時,調用相應的 ImageProvider 進行解析。ImageProvider 返回一個 ImageStream 對象,並讓這些 Stream 對象共同監聽一個 ImageStreamCompleter。與此同時,ImageProvider 為這個 Completer 提供不同的 load 方法加載來自網絡、文件或資源中的圖片數據(未解碼)。當數據加載好後,調用 Engine 的 instantiateImageCodec 方法創建 C++ Codec(ui.Codec) 對象。由 Codec 負責解碼,上傳 GPU 紋理,生成 ui.Image。全部完成後,回調 Completer,以 Provider 作為 Key 將 Completer 加入緩存,並通知 Widget 重繪。

1.png

Flutter 自身提供的 ImageCache,以 ImageProvider 作為 Key 緩存了 ImageStreamCompleter。對於相同的圖片,以及正在下載中的圖片,不會重複加載。當圖片上傳 GPU 完成後,會以圖片的 W H 4 更新緩存狀態。所以實際緩存的是 GPU 紋理。使用 Flutter 原始 Image 組件開發時,將這個緩存大小設置為0,可以一定程度緩解內存壓力(不多餘緩存任何紋理,Widget 銷燬,紋理釋放),但是會造成圖片的反覆下載、解碼、上傳 GPU,系統開銷較大。

AliFlutter 方案

Flutter 的圖片加載流程抽象完備,我們自上而下進行定製化,在不修改原來鏈路任何代碼的情況下,實現自己的 ImageProvider 和 Codec 對象,對接外部圖片庫。同時,圖片紋理仍然可以保存到 Flutter 的 ImageCache 中,與 Flutter 原始方案完美融合。

1.png

▐ Flutter Widget 層擴展

擴展 Image Widget,指定使用外接圖片庫作為圖片 Provider。

// File: lib/src/widgets/image.dart
Image.external_adapter(
  String src, {
  Key key,
....
  int targetWidth, // 請求的圖片的寬
  int targetHeight, // 請求的圖片的高
  Map<String, String> parameters, // 透傳給圖片庫的參數
  Map<String, String> extraInfo,
  ImageProvider placeholderProvider, // placeholder 可以指定為其它 Provider
}) : image = ExternalAdapterImage(src, // 創建自定義的 ExternalAdapterImage Provider
        targetWidth: targetWidth, targetHeight: targetHeight,
        placeholderProvider: placeholderProvider,
        parameters: parameters, extraInfo: extraInfo),
     super(key: key);

這個方法中的 placeholderProvider 提供了更簡單直觀的方式為圖片指定 placeholder。例如

// 使用本地資源作為 placeholder
Image.external_adapter(
  'https://gw.alicdn.com/tfs/TB1Aa0UcF67gK0jSZPfXXahhFXa-750-140.png',
  placeholderProvider: AssetImage("assets/placeholder.jpg"),
)
 
// 使用另一個網絡資源作為 placeholder
Image.external_adapter(
  'https://gw.alicdn.com/tfs/TB1Aa0UcF67gK0jSZPfXXahhFXa-750-140.png',
  placeholderProvider: ExternalAdapterImage("https://alicdn.com/image1024.jpg"),
)

ExternalAdapterImage

該類繼承自 ImageProvider,並在 @override load 方法中創建 ExternalAdapterImageStreamCompleter。load 方法由 ImageProvider 的 resolve 方法調用,返回圖片數據流管理類。

ExternalAdapterImageStreamCompleter

該類負責圖片的加載,回調邏輯,其主要職責如下:

  • 處理 placeholderProvider,在主圖返回前,讓 Image Widget 顯示 placeholder 圖片。
  • 創建 C++ 層 ExternalAdapterImageFrameCodec 對象,調用 getNextFrame 獲取圖片信息(是否為動圖、幀數、播放時間),以及紋理對象 ui.Image 並通知 Widget 顯示。
  • 對於 GIF 等多幀圖片,循環調用 ExternalAdapterImageFrameCodec 對象的 getNextMultiframe 接口獲取動圖的每一幀 ui.Image 並通知 Widget 顯示。
  • 當無監聽者時,調用 ExternalAdapterImageFrameCodec 的 cancel 接口取消圖片任務。

▐ Flutter Engine 層擴展

ExternalAdapterImageFrameCodec

該類為 C++ 實現,繼承自 DartUI 庫中的 Codec 類,被 Dart 類 ExternalAdapterImageStreamCompleter 持有、管理、調用。

該類與 ExternalAdapterImageProvider 進行交互。主要方法是 getNextFrame , getNextMultiframe,cancel。

ExternalAdapterImageProvider

該類為 Abstract C++ 接口類,定義了需要各平臺適配層實現的接口。主要接口如下:

  • void request``(requestId, requestInfo, callback(platformImage, releaseFunc))
    該方法向圖片庫請求圖片,圖片庫完成後,通過 callback 異步返回。platformImage 封裝平臺層的圖片對象(如 UIImage),callback 同時返回一個 releaseFunc,Flutter 使用完成該圖片後,調用該方法釋放圖片。
  • void cancel(requestId)
    通知圖片庫取消某個請求
  • Bitmap decode(platformImage, frameIndex)
    解碼圖片的某一幀,並返回 Bitmap 數據。
  • evaluateDeviceStatus(&cpuCount, &maxMemory)
    允許併發的圖片解碼任務數量,以及解碼數據的內存使用量。這個方法會經常被 ExternalAdapterImageFrameCodec 調用,控制多圖加載時的資源消耗。

其中 PlatformImage 結構體定義如下

struct PlatformImage {
  uintptr_t handle = 0;
  int width = 0;                        // width in pixel
  int height = 0;                       // height in pixel
  int frameCount = 1;                   // multiframe image such as GIF
  int repetitionCount = -1;             // infinite
  int durationInMs = 0;                 // in milliseconds
};

執行偽碼如下,多次切換線程也是符合 Flutter 的紋理加載管線。多次判斷 cancel,避免了大量無效操作,降低了列表滾動時的資源消耗。

class ExternalAdapterImageFrameCodec {
  ExternalAdapterImageProvider provider;
  void getNextFrame() {
    async(provider.request([](image) {
      if (cancelled) {
        return;
      }
      async(workerThread, {
        if (cancelled) {
          return;
        }
        bitmap = provider.decode(image);
        async(ioThread, {
          if (cancelled) {
            return;
          }
          ui.Image texture = uploadToGPU(bitmap);
          async(uiThread, {
            if (cancelled) {
              return;
            }
            callbackDart(texture);
          })
        })
      })
    }))
  }
  void cancel() {
    provider.cancel()
    cancelled = true
  }
}

執行時序圖:

1.png

直接將 C++ 接口公開,理論上就可以直接對接手淘圖片庫了。但是 C++ 接口使用起來不太方便,且不符合 Flutter 規範(對 iOS/Mac 平臺應該提供 ObjC 類,對 Android 平臺應該只提供 Java 類),而且對於平臺層圖片對象的處理,由 Engine 提供統一實現更為安全。因此,在 Engine 內部,針對 iOS/Mac,以及 Android 平臺各提供了一套封裝。

以 iOS 為例,最終在 Flutter.framework 裡對外公開的 ObjC 接口為:

@protocol FlutterExternalAdapterImageRequest <NSObject>
- (void)cancel;
@end
@protocol FlutterExternalAdapterImageProvider <NSObject>
- (id<FlutterExternalAdapterImageRequest>)request:(NSString*)url
    targetWidth:(NSInteger)targetWidth
    targetHeight:(NSInteger)targetHeight
    parameters:(NSDictionary<NSString*, NSString*>*)parameters
    extraInfo:(NSDictionary<NSString*, NSString*>*)extraInfo
    callback:(void(^)(UIImage* image))callback;
@end

由外部註冊 id 類對接手淘圖片庫,在每次請求時,返回一個支持 cancel 方法的對象用於取消請求。完成後通過 callback 返回 UIImage 對象,可以為 GIF 圖。

對於 Android,最終公開的也是非常簡單的一個 Java 類供外部實現。

▐ AliFlutter 方案的優化

延遲加載

在 ExternalAdapterImageStreamCompleter 中,真正調用 Codec 加載圖片前,會做短暫等待。如果此時 Widget 已經被回收,會將自己從 Completer 的 listeners 中移除(實際添加的 listener 為 Widget 的 State)。等待過後,如果監聽者為空,不會做真實請求。

Flutter 最新代碼(2020.1.30)中,貌似對快速滾動過程圖片的加載也做了優化,避免一些不必要的圖片請求。Commit 見 :點此閱讀

圖片取消

前面提到,當 ExternalAdapterImageStreamCompleter 無監聽者時,會調用 ExternalAdapterImageFrameCodec 的 cancel 方法。

Codec 從平臺圖片庫獲取到圖片並最終上傳為紋理(ui.Image)的過程,需要切換多次線程。

在 cancel 方法中,不但會通知圖片庫取消網絡請求,而且記錄標誌位。在切換線程的整個過程中,多次檢查標記位。

經過實際測試,在列表快速滑動或網絡、機器性能較慢時,可以避免大量無效圖片下載、解碼、上傳 GPU 等動作。

UIImage 轉 Bitmap 併發控制

iOS 平臺上,將 UIImage 轉換為 Bitmap 不可避免要進行像素的拷貝。一些時候,CGImageGetBitmapInfo(UIImage.CGImage) 獲取到的位圖格式需要進行轉換才可以送給 OpenGL。完成紋理上傳後,拷貝的內存會被釋放。此時,如果過多的圖片同時進行轉換,難免產生內存尖刺。解碼過程複用的 Flutter ConcurrentTaskRunner,該 Runner 併發數量仍然過高(6個左右)。

因此在解碼時,Codec 會動態調用 ExternalAdapterImageProvider 的 evaluateDeviceStatus 接口評估內存狀態,再次控制併發數量。實際使用發現,2~3個併發,圖片的加載速度仍然非常快,同時可以較好地控制解碼過程的臨時內存佔用。

GIF 逐幀上傳

GIF/APNG 動圖是內存消耗大戶,AliFlutter 方案在顯示動圖時,通過 ExternalAdapterImageFrameCodec 的 getNextMultiframe 接口逐幀獲取紋理對象。每個時刻,只會有一幀上傳 GPU,達到節省內存的目的。

開發過程的插曲:Flutter 1.9.1版本的內存洩漏

在調試外接圖片庫的過程中,通過對底層紋理的計數,發現有內存洩漏的情況。淘寶特價版詳情頁面接入 Flutter,並且使用了 Boost。現象為

  • 進入詳情頁面,並退出,反覆進入退出。無內存洩漏。(不進入二級詳情)
  • 進入詳情頁面,點寶貝推薦再進入一個詳情頁面,返回,再返回。產生內存洩漏。
    也就是說使用 Boost 管理多個 Flutter 棧時,只要有二級 Flutter 頁面,就會產生內存洩漏。看上去是整個 Widget 樹洩漏,導致底層的 ui.Image 紋理對象不能釋放。

這個問題排查過程比較困難,主要的方法是不斷簡化詳情頁面,並最終定位出問題的組件。最終發現業務代碼裡只要使用 RaisedButton 就會產生問題。通過一層層的剝去代碼,最終發現了有 Bug 的組件是官方的 InkWell。RaisedButton 通過多層關係最終使用到了 InkWell 組件。

在 _InkResponseState 類中,didChangeDependencies 方法未從 focusManager 裡移除 listener(其實也就是自己)。導致在 Boost 管理的堆棧中,二級 Flutter 頁面返回時,前一個頁面組件的該方法多次執行,造成洩漏。

// Class _InkResponseState
void didChangeDependencies() {
  super.didChangeDependencies();
  _focusNode?.removeListener(_handleFocusUpdate);
  _focusNode = Focus.of(context, nullOk: true);
  _focusNode?.addListener(_handleFocusUpdate);
  // 原來的代碼缺少這一行,導致多次添加 listener 造成組件洩漏。
  WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
  WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}

該問題在 Flutter 新版中已經修復了,整個代碼完全變了,官方用其它方式避免了這種情況。

這裡走了一些彎路。事後,通過 Dart 調試工具,可以看到出問題的時候 FocusManager 對象不斷增長。提前用 Dart 工具,應該可以更早到定位到問題與使用 FocusManager 有關。

1.png

總結

這個方案完整探索瞭如何遵循 Flutter 官方的圖片加載邏輯,對接外接圖片庫。同時整體方案對官方代碼只添加、不修改,並提供了 ObjC、Java 語言的接口。方案完整度較高,後續可以與官方溝通合入主幹。在圖片加載的完整過程中,多次介入判斷,較好地避免了無效的圖片下載、解碼、上傳紋理工作,減少了系統資源的消耗。

為了避免對手淘圖片庫進行修改,且複用其內存緩存,目前的方案接收平臺層解碼後的 UIImage、AndroidBitmap 對象,再獲取其位圖數據上傳紋理。後續可以讓圖片庫返回未解碼的文件數據,交給 Flutter 解碼,整體流程可以再簡化一些。不過目前的方案可以將所有圖片解碼庫從 Flutter 裡剝離,減小包大小,各有利弊。

基於該方案,同時探索瞭如何在 Flutter 中解決大 Cell 中多張圖片同時加載產生的內存飆高問題,下週將會推出:《細化 Flutter List 內存回收,解決大 Cell 問題》敬請期待。

關注「淘系技術」微信公眾號,一個有溫度有內容的技術社區~

公眾號二維碼.jpg

Leave a Reply

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