Flutter載入圖片與Glide

大逗大人發表於2020-07-14

相對於Android而言。在Flutter中,載入網路圖片,是很方便的一件事。通過Flutter提供的API就可以來實現。如下。

Image.network("https://xxxxx");
複製程式碼

但使用後,很快就會發現一些問題,主要有以下幾點。

  1. Flutter載入網路圖片的API僅會將圖片快取在記憶體中,無法快取本地。當記憶體中圖片不存在時,又需要重新進行網路請求,這樣一來就比較耗費資源。
  2. 如果在已有專案中新增Flutter模組,那麼通過上面API就無法複用Android已有且成熟的網路圖片處理模組。
  3. 如果是混合開發專案,那麼針對同一張圖片,無法做到Flutter模組與Android的記憶體間共享。

針對上述問題,目前已經存在一些解決方案。如通過cached_network_image來解決圖片快取本地問題;通過外接texture來實現同一張圖片在Flutter模組與Android的記憶體間共享(可參考閒魚Flutter圖片框架架構演進(超詳細)一文)。

而本文主要就是介紹通過Android已有的網路圖片載入模組來實現Flutter中的網路圖片載入。該方案可以複用Android中現有的圖片處理模組及將圖片快取在本地,並且圖片在本地僅儲存一次。但要注意的是,該方案無法實現同一張圖片在Flutter模組與Android的記憶體間共享。

由於在Android開發中,通過Glide來載入網路圖片比較普遍。所以本文也就以Glide為例。

1、網路圖片的載入

整體實現方案很簡單,就是通過Glide來下載圖片,待下載成功後通過Platform Channel將圖片路徑傳遞給Flutter,最後再通過圖片路徑來載入。這樣圖片在本地僅會儲存一次。

先來看Flutter端程式碼的實現。

class ImageWidget extends StatefulWidget {
  final String url;
  final double width;
  final double height;

  const ImageWidget({Key key, @required this.url, this.width, this.height})
      : super(key: key);

  @override
  State<StatefulWidget> createState() => ImageWidgetState(url, width, height);
}

class ImageWidgetState extends State<ImageWidget> {
  final String url;//圖片網路路徑
  final double width;//widget的寬
  final double height;//widget的高

  String _imagePath;//圖片的本地路徑

  bool _visible = false;

  int _cacheWidth;//快取中圖片的寬
  int _cacheHeight;//快取中圖片的高

  ImageWidgetState(this.url, this.width, this.height);

  @override
  void initState() {
    super.initState();
    _getImage();
  }

  //從Native獲取圖片的本地路徑
  void _getImage() {
    //從Native獲取圖片路徑
    ChannelManager.instance.getImage(
        url, width * window.devicePixelRatio, height * window.devicePixelRatio,
        (data) {
      if (data == null || data == "") return;
      Map<String, dynamic> imageData = json.decode(data);
      _updateImageInfo(imageData);
      // 將圖片路徑存入記憶體中
      ImageInfoManager.instance.addImageInfo(url, imageData);
    });
  }

  _updateImageInfo(Map<String, dynamic> imageData) {
    setState(() {
      _visible = true;
      _cacheWidth = imageData['cacheWidth'];
      _cacheHeight = imageData['cacheHeight'];
      _imagePath = imageData['url'];
      print("_imagePath:$_imagePath");
    });
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedOpacity(//淡入淡出動畫
      opacity: _visible ? 1.0 : 0.0,
      duration: Duration(milliseconds: 500),
      child: _imagePath == null
          // 網路圖片載入前的預設圖片
          ? Container(
              width: width,
              height: height,
              color: Colors.transparent,
            )
          : Image.file(//根據圖片路徑來載入圖片
              File(_imagePath),
              width: width,
              height: height,
              cacheHeight: _cacheHeight,
              cacheWidth: _cacheWidth,
            ),
    );
  }
}

複製程式碼

再來看Android端程式碼的實現。

public class DDMethodChannel implements MethodChannel.MethodCallHandler{
    private static final String TAG = "DDMethodChannel";

    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private MethodChannel channel;

    public static DDMethodChannel registerWith(BinaryMessenger messenger) {
        MethodChannel channel = new MethodChannel(messenger, "native_http");
        DDMethodChannel ddMethodChannel = new DDMethodChannel(channel);
        channel.setMethodCallHandler(ddMethodChannel);
        return ddMethodChannel;
    }

    private DDMethodChannel(MethodChannel channel) {
        this.channel = channel;

    }

    @Override
    public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
        Map<String, String> args = (Map<String, String>) call.arguments;
        String url = args.get("url");
        switch (call.method) {
            case "getImage":
                double width = TextUtils.isEmpty(args.get("width")) ? 0.0 : Double.parseDouble(args.get("width"));
                double height = TextUtils.isEmpty(args.get("height")) ? 0.0 : Double.parseDouble(args.get("height"));
                Log.i(TAG, "url:" + url + ",width:" + width + ",height:" + height);
                //Glide下載圖片
                Glide.with(Constants.getAppContext())
                        .downloadOnly()//僅下載
                        .load(url)
                        .override((int) width, (int) height)
                        .skipMemoryCache(true)//由於僅下載圖片,所以可以跳過記憶體快取
                        .dontAnimate()//由於僅下載圖片,所以可以取消動畫
                        .listener(new RequestListener<File>() {//監聽圖片是否下載完畢
                            @Override
                            public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<File> target, boolean isFirstResource) {
                                Log.i(TAG, "image下載失敗,error:" + e.getMessage());
                                //必須切換回主執行緒,否則報錯
                                mainHandler.post(new Runnable() {
                                    @Override
                                    public void run() {
                                        if (result != null) {
                                            result.error(-1 + "", e.getMessage(), "");
                                        }
                                    }
                                });

                                return false;
                            }

                            @Override
                            public boolean onResourceReady(File resource, Object model, Target<File> target, DataSource dataSource, boolean isFirstResource) {
                                String data = "";
                                //圖片下載成功,通過一個json將路徑傳遞給Flutter
                                JSONObject object = new JSONObject();
                                try {
                                    object.put("url", resource.getAbsolutePath());
                                    object.put("cacheWidth", outWidth);
                                    object.put("cacheHeight", outHeight);
                                    data = object.toString();
                                } catch (JSONException e) {
//                                    e.printStackTrace();
                                    Log.i(TAG, "error:" + e.getMessage());
                                }

                                String finalData = data;
                                //必須切換回主執行緒,否則報錯
                                mainHandler.post(new Runnable() {
                                    @Override
                                    public void run() {
                                        MethodChannel.Result result = resultMap.remove(url);
                                        if (result != null) {
                                            result.success(finalData);
                                        }
                                    }
                                });
                                return false;
                            }
                        }).submit();
                break;
        }
    }
}
複製程式碼

經過上面程式碼,就實現了Flutter通過Glide來載入網路圖片。

上面程式碼中省略了Platform Channel使用的程式碼,但如果對於Platform Channel的使用不熟悉,可以參考Flutter與Android間通訊一文。

2、圖片記憶體佔用優化

再來看上面程式碼中使用的cacheWidthcacheHeight欄位,它們在文件中的說明如下。

If [cacheWidth] or [cacheHeight] are provided, it indicates to the engine that the image must be decoded at the specified size. The image will be rendered to the constraints of the layout or [width] and [height] regardless of these parameters. These parameters are primarily intended to reduce the memory usage of [ImageCache].

簡單翻譯下,cacheWidthcacheHeight是圖片在記憶體快取中的寬與高,設定該值可以減小圖片在記憶體中的佔用。因此我們可以根據widget的寬高與圖片的實際寬高來進行縮放,從而減小圖片在記憶體中的佔用。

因此,我們就可以根據cacheWidthcacheHeight來優化上面程式碼。

    @Override
    public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
        Map<String, String> args = (Map<String, String>) call.arguments;
        String url = args.get("url");
        switch (call.method) {
            case "getImage":
                double width = TextUtils.isEmpty(args.get("width")) ? 0.0 : Double.parseDouble(args.get("width"));
                double height = TextUtils.isEmpty(args.get("height")) ? 0.0 : Double.parseDouble(args.get("height"));
                Log.i(TAG, "url:" + url + ",width:" + width + ",height:" + height);
                Glide.with(Constants.getAppContext())
                        .downloadOnly()
                        .load(url)
                        .override((int) width, (int) height)
                        .skipMemoryCache(true)
                        .dontAnimate()
                        .listener(new RequestListener<File>() {
                            @Override
                            public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<File> target, boolean isFirstResource) {...}

                            @Override
                            public boolean onResourceReady(File resource, Object model, Target<File> target, DataSource dataSource, boolean isFirstResource) {
                                Log.i(TAG, "image下載成功,path:" + resource.getAbsolutePath());
                                BitmapFactory.Options options = new BitmapFactory.Options();
                                options.inJustDecodeBounds = true;//這個引數設定為true才有效,
                                Bitmap bmp = BitmapFactory.decodeFile(resource.getAbsolutePath(), options);//這裡的bitmap是個空
                                if (bmp == null) {
                                    Log.e(TAG, "通過options獲取到的bitmap為空 ===");
                                }
                                //獲取圖片的真實高度
                                int outHeight = options.outHeight;
                                //獲取圖片的真實寬度
                                int outWidth = options.outWidth;
                                //計算寬高的縮放比例
                                int inSampleSize = calculateInSampleSize(outWidth, outHeight, (int) width, (int) height);
                                Log.i(TAG, "outWidth:" + outWidth + ",outHeight:" + outHeight + ",inSampleSize:" + inSampleSize);
                                String data = "";
                                JSONObject object = new JSONObject();
                                try {
                                    object.put("url", resource.getAbsolutePath());
                                    //縮放後的cacheWidth
                                    object.put("cacheWidth", outWidth / inSampleSize);
                                    //縮放後的cacheHeight
                                    object.put("cacheHeight", outHeight / inSampleSize);
                                    data = object.toString();
                                } catch (JSONException e) {
//                                    e.printStackTrace();
                                    Log.i(TAG, "error:" + e.getMessage());
                                }

                                String finalData = data;
                                mainHandler.post(new Runnable() {
                                    @Override
                                    public void run() {
                                        if (result != null) {
                                            result.success(finalData);
                                        }
                                    }
                                });
                                return false;
                            }
                        }).submit();
                break;
        }
    }
    
    //獲取圖片的縮放比
    private int calculateInSampleSize(int outWidth, int outHeight, int reqWidth, int reqHeight) {
        int inSampleSize = 1;
        if (outWidth > reqWidth || outHeight > reqHeight) {
            int halfWidth = outWidth / 2;
            int halfHeight = outHeight / 2;
            while ((halfWidth / inSampleSize) >= reqWidth && (halfHeight / inSampleSize) >= reqHeight) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }
複製程式碼

經過上面程式碼的優化,Flutter通過Glide來載入網路圖片基本上就沒啥大問題了。

3、列表載入圖片優化

再來看一個非常常見的應用場景,列表中載入網路圖片。在Android中,Glide針對列表有專門的優化,在快速滑動時,不會進行圖片的載入。那麼這在Flutter中該怎麼實現尼?

其實在Flutter中已經幫我們做了關於快速滑動時的處理,下面來看Image元件的實現程式碼。

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ...

  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);
  }

  ...

  @override
  Widget build(BuildContext context) {...}

  ...
}

複製程式碼

上面程式碼中的ScrollAwareImageProvider就是Image在快速滑時的處理,再來看該類的實現。

@optionalTypeArgs
class ScrollAwareImageProvider<T> extends ImageProvider<T> {
  const ScrollAwareImageProvider({
    @required this.context,
    @required this.imageProvider,
  });

  @override
  void resolveStreamForKey(
    ImageConfiguration configuration,
    ImageStream stream,
    T key,
    ImageErrorListener handleError,
  ) {
    
    ...
    //檢測當前是否在快速滑動
    if (Scrollable.recommendDeferredLoadingForContext(context.context)) {
        SchedulerBinding.instance.scheduleFrameCallback((_) {
          //新增到微任務
          scheduleMicrotask(() => resolveStreamForKey(configuration, stream, key, handleError));
        });
        return;
    }
    //正常載入圖片
    imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
  }

  ...
}
複製程式碼

上面程式碼很簡單,重點就是判斷當前是否在快速滑動。如果在快速滑動就等待下一幀,否則就將圖片展示在介面上。

由於Flutter在快速滑動時做了處理,所以基本上不需要再次進行優化,就可以把上面的圖片載入方案使用在列表中。但在使用時,還是發現了存在的一個小問題,就是當快速滑動時,每幀的繪製時間會超過16ms。經過仔細排查,主要是由於快速滑動時,每個item的淡入淡出動畫還需要執行,從而導致了每幀繪製時間的延長。所以需要列表快速滑動時取消item的淡入淡出動畫。具體實現程式碼如下。

  void _getImage() {
    //由於Platform Channel是非同步的,所以通過Platform Channel來獲取路徑會產生淡入淡出動畫。這裡從記憶體中獲取圖片路徑,可以取消在快速滑動時的淡入淡出動畫,也可以減少Flutter與Native間的互動。
    Map<String, dynamic> imageInfo =
        ImageInfoManager.instance.getImageInfo(url);
    if (imageInfo != null) {
      print("直接從Map中獲取路徑");
      _visible = true;
      _updateImageInfo(imageInfo);
      return;
    }

    //判斷列表是否在快速滑動
    if (Scrollable.recommendDeferredLoadingForContext(context)) {
      SchedulerBinding.instance.scheduleFrameCallback((_) {
        scheduleMicrotask(() => _getImage());
      });
      return;
    }

    //從Native獲取圖片路徑(目前僅支援Android平臺)
    ChannelManager.instance.getImage(
        url, width * window.devicePixelRatio, height * window.devicePixelRatio,
        (data) {
      if (data == null || data == "") return;
      Map<String, dynamic> imageData = json.decode(data);
      _updateImageInfo(imageData);
      // 將圖片路徑存入記憶體中
      ImageInfoManager.instance.addImageInfo(url, imageData);
    });
  }
複製程式碼

4、總結

前面兩小結中優化過後的程式碼就是本文方案的最終實現,做到了混合專案中複用已有的圖片載入模組及圖片僅在本地儲存一次。但還是無法做到圖片在Flutter與Native間的記憶體共享,也無法做到圖片在多Engine的記憶體間共享,而關於閒魚通過外接texture方案來實現圖片的記憶體間共享有一定實現複雜度,所以這種實現方案待後面再來分享。

此外,FlutterImage元件可以很方便的載入gif與webp,所以上述方案的實現也是能夠載入gif與webp。

相關文章