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

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


前言

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

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

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

  1. 外接原生圖片庫,共享本地文件緩存、內存緩存。git

  1. 圖片請求取消功能,解決網絡併發限制引發的排隊加載緩慢,以及無效的解碼、紋理上傳形成資源浪費的狀況。github

  1. 圖片解碼併發管理,下降 CPU、內存峯值。緩存

  1. 支持 GIF,在播放 GIF 時逐幀上傳紋理,下降內存佔用。安全

  1. 簡單易用的 Placeholder。bash

  1. 容許將 Flutter 內置的各類圖片解碼庫剝離,減少包大小。網絡

  1. 業務無感的方式解決 List 滾動時,大 Cell 中的圖片不能動態加載、回收的問題。解決 Native、Weex 體系下的頑疾。併發


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


Flutter 的圖片加載過程

首先介紹一下 Flutter 裏圖片相關的加載邏輯。顯示圖片使用 Image Widget。Image Widget 建立時,能夠指定不一樣的圖片來源:

  • Image.networkapp

  • 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 重繪。async


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


    AliFlutter 方案

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


    ▐ 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
      }
    }複製代碼

    執行時序圖:


    直接將 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<FlutterExternalAdapterImageProvider> 類對接手淘圖片庫,在每次請求時,返回一個支持 cancel 方法的對象用於取消請求。完成後經過 callback 返回 UIImage 對象,能夠爲 GIF 圖。

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

    ▐ AliFlutter 方案的優化

    延遲加載


    在 ExternalAdapterImageStreamCompleter 中,真正調用 Codec 加載圖片前,會作短暫等待。若是此時 Widget 已經被回收,會將本身從 Completer 的 listeners 中移除(實際添加的 listener 爲 Widget 的 State)。等待事後,若是監聽者爲空,不會作真實請求。

    Flutter 最新代碼(2020.1.30)中,貌似對快速滾動過程圖片的加載也作了優化,避免一些沒必要要的圖片請求。Commit 見 https://github.com/flutter/flutter/commit/169529c37064568a17b634968c73a7ff79029dfb

    圖片取消


    前面提到,當 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 有關。



    總結


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

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

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