淺談Flutter web 圖片選擇器及圖片壓縮

Flutter小菜雞發表於2020-07-06

作者簡介:Flutter小菜雞,5年經驗移動端開發工程師,努力成為Flutter架構的小菜雞,現就職於某豐某大資料產品開發處,擔任移動端搬磚碼農,專注於移動端資料視覺化研究,目前沒有任何可以拿出來說的成績【手動狗頭】

木本水源篇

Flutter for web自發布以來,不少高等級的玩家已經對此進行了嚐鮮,評論也褒貶不一,有的說很難用,誰用誰知道。先不說好不好用,但從格局出發,Flutter的野心很大,想要在大前端領域實現 大一統,勇氣可嘉。Flutter for web總體上手感覺,對於一個沒有web開發經驗的人來說,稍稍有一點點難度,但難度本身並不是來自Flutter for web 框架,而是對web領域的未知,總體來說,做過Flutter app開發,上手web開發一些簡單的頁面是沒有問題的。但是也會有些很常用的進階一點的操作,比如運算元據庫,比如上傳圖片、或檔案,拍照、定位獲取GIS資訊等。其中在做圖片選擇上傳時,做圖片選擇並沒有什麼難度,圖片選擇庫支援web的也有幾個。但是在上傳後端時(起初方案不對,後面會介紹到)並沒有想象的那麼順利,以為拍照的問題,圖片轉碼後的base64字串會非常大,如果只是在本地做轉碼解碼顯示的操作,Flutter的效能是完全能夠支撐的,並且不會造成卡頓,但是在和後臺互動時,可能會因為圖片過大,而導致介面緩慢。因為Flutter web生態環境的問題,很多圖片選擇庫,並不支援web專案,且Flutter 官方的image_picker_for_web,也是標註了[UNIDENTIFIED],本菜雞也是嘗試了很多種方法。最終在Flutter_luban 圖片壓縮庫的原始碼中得到了答案。

不求甚解篇

囉嗦幾句,以後本菜雞寫文章都會分為四個階段,第一階段,木本水源,主要簡單介紹問題的來源,或技一項技術的背景。第二階段,就是不求甚解,旨在快速針對問題,給出解決方案,和實現步驟。第三階段,叫做格物致知,丁肇中先生《應有格物致知精神》一文中對本菜雞受益頗深,做自然科學都要有格物致知的精神,雖然本菜雞隻是個小碼農,但是作為一個理工男的職業素養還是要有的。本階段旨在通過舉例或檢視原始碼,進行原始碼解析,探究某項技術或功能的原理。第四階段,豹尾小結,少年時期老師作文講究鳳頭豹尾豬肚,沒錯本菜雞也是個講究人,最終總結是少不了的哈,也算是聊天吹水環節吧。

Flutter for Web 的圖片選擇庫目前本菜雞接觸到有四個,分別是

庫名 最新版本 -最後更新 GitHub Stars & Likes
image_picker_for_web 0.1.0+1 - [Jun 5, 2020] 官方維護
image_picker_web 1.0.9 - [May 16, 2020] 15
flutter_web_image_picker 0.0.2 - [Oct 8, 2019] 28
file_picker_cross 2.1.0 - [Jun 16, 2020] 15

一般本菜雞在為了完成某一項工作,又不想造輪子,那就只好拿來主義(站在巨人的肩膀上),選用好用的第三方庫,本菜雞在選用第三方庫時,一般有幾個原則,不會選用剛上線,且迭代版本或提交數量小於5次的庫;不會選用年久失修的庫;如若以上都滿足,優先選用官方維護庫,優先選擇==stars==或者==likes==比較多的庫。

所以在整合的過程中,除了第三個庫沒有嘗試以外,都做了嘗試,總結下來比較推薦前兩個庫,分別是image_picker_for_webimage_picker_web,下面也會重點對這兩個庫的使用和優缺點進行介紹。

image_picker_for_web

先說第一個,官方維護的image_picker_for_web 這個庫在介紹文件中也有提到,需要配合另一個官方庫image_picker

This package is an ==unendorsed== web platform implementation of image_picker. In order to use this, you'll need to depend in image_picker: ^0.6.7 (which was the first version of the plugin that allowed federation), and image_picker_for_web: ^0.1.0.

首先在pubspec.yaml檔案中的dependencies中新增依賴,匯入這兩個庫

...
dependencies:
  ...
  image_picker: ^0.6.7
  image_picker_for_web: ^0.1.0
  ...
...

複製程式碼

在使用的地方匯入檔案

import 'package:image_picker/image_picker.dart';
複製程式碼

==需要注意的是,image_picker從0.6.7之後使用方法有所變更,拋棄了之前ImagePicker().getImage 類似這樣的寫法==

這裡就只寫出最新的寫法:

File _image;
//需要先構造一個ImagePicker物件
final picker = ImagePicker();
//獲取圖片方法
Future getImage() async {
    //返回一個pickedFile物件
    final pickedFile = await picker.getImage(source: ImageSource.camera);
    //更新狀態
    setState(() {
      _image = File(pickedFile.path);
    });
}


複製程式碼

需要注意的是getImage方法返回的是PickedFile型別的物件,跟File的關係可以看一下官方給PickedFile的解釋

The interface for a PickedFile.

A PickedFile is a container that wraps the path of a selected file by the user and (in some platforms, like web) the bytes with the contents of the file.

This class is a very limited subset of dart:io [File], so all the methods should seem familiar.

根據字面意思很好理解,說PickedFile是Flie的一個有限子集,並且Flie class常用的屬性有path,常用的方法有readAsBytes()、openRead()等,在PickedFile中都有實現。

image_picker_web

此庫推薦的原因是因為支援選擇返回型別,相比之前的庫多了一層封裝,暴露了一個ImgaeType給到我們已於呼叫。這也是本菜雞認為這個庫比較人性化的地方

用法:

首先在pubspec.yaml檔案中的dependencies中新增依賴,匯入這個庫

dependencies:
  image_picker_web: ^1.0.9

複製程式碼

在使用的地方匯入檔案

import 'package:image_picker_web/image_picker_web.dart';
複製程式碼
Image pickedImage;
pickImage() async {
    //ImageType一共有三種型別可選
    //file
    //bytes
    //widget
    Image fromPicker = await ImagePickerWeb.getImage(outputType: ImageType.widget);

    if (fromPicker != null) {
      setState(() {
        pickedImage = fromPicker;
      });
    }
  }

複製程式碼

上面兩個庫不僅是支援PC環境的web(目前只測試了Chrome瀏覽器) 圖片選擇,而且web專案run在手機上時,也是可以訪問到相簿和相機,整合使用並無難度,所以用法介紹就到此結束了。

dart圖片壓縮

ok,整合完成之後,就要考慮適用性和優化的問題了。現在的手機畫素都很高,拍一張無損高清照片,3-5M算是正常,但是即使再高清的圖片在微信的傳輸中是非常絲滑的,一方面是微信的圖片優化無疑是非常棒的,還有就是縮圖和原圖分時非同步載入,微信的原圖只有當你點選下載原圖才會從伺服器下載,所以平時看到的都是微信已經壓縮過的圖片,記憶體已經非常小了,當然在圖片壓縮處理方面也是有很多優秀的第三方演算法或者已經整合過的Flutter pub庫,比如luban(魯班)壓縮演算法flutter_luban等。

當然了Flutter官方也會考慮到這個事情,所以在image_picker_for_web庫也是繼承了image_picker的屬性,能夠傳入maxWidth maxHeight imageQuality三個屬性來約束圖片的大小和質量,但是不知為何,在web專案上,這幾個屬性並沒有生效。已經在Github上的Flutter專案中提交了Issuee。或者有知道的巨佬也可以告知一下本菜雞,還望不吝賜教。

其實圖片壓縮,本身並不是很複雜的東西,在APP專案中使用MethodChannel呼叫native的壓縮api,其中flutter_image_compress庫就是這麼做的,當然還有藉助dart:Image的壓縮方法來實現的,此方式在web端和app端同樣適用,所以我在做圖片壓縮時,借鑑了flutter_luban庫中的原始碼,使用dart自帶的壓縮方法,只在質量上進行了壓縮。(只壓縮了質量,圖片會失真)


import 'dart:convert';
import 'package:image/image.dart';

class ImageDelegate {
    //...
    //圖片壓縮部分程式碼
    String compressImgage(List<int> data) {
        Image image = decodeImage(data);
        List<int> result = encodeJpg(image, quality: 70);
        String imageStr = base64.encode(result);
        return imageStr;
    }
}
複製程式碼

需要注意的是,這裡用到的Image型別,是package:image/image.dart中的型別,並非我們常用的widget元件package:flutter/src/widgets/image.dart型別,所以建議把壓縮方法單獨寫一個工具類。這種方式就是運用的dart系統方法對圖片進行壓縮。

還有人會有疑問了,為什麼不直接用flutter_image_compress類似的壓縮庫直接壓縮即可,在這琢磨什麼dart自帶的壓縮方法有什麼意義,需要了解的是flutter_image_compress等圖片壓縮庫目前所支援的平臺依舊是Android&iOS,所以web平臺是沒有辦法通過目前除了flutter_luban之外的庫進行壓縮的,因為目前圖片壓縮庫一般都是methodChannel呼叫原生API進行的壓縮,iOS程式碼上一般呼叫的的是SDWebImgae的圖片壓縮方法,在Android程式碼使用Android系統api。

格物致知篇

接下來的部分我們就來詳細剖析一下,Flutter的圖片選擇器和上圖片壓縮的問題吧。本菜雞也是查閱了海量資料,寫了很多demo,有了一些自己的理解,接下來就講一下我自己的理解,大家一起來探究一下圖片選擇器的一些細節問題和圖片壓縮的原理吧。發現問題的或者有不同意見的都可以私聊本菜雞微信,或者在留言,歡迎交流。

圖片選擇器的細節問題

先說一下圖片選擇器的流程,我們手機中的圖片是怎麼轉換為Image物件或者位元組流而上傳到後臺呢?

image_picker為例:

淺談Flutter web 圖片選擇器及圖片壓縮

原始碼解析

///method_channel_image_picker.dart

//...

@override
  Future<PickedFile> pickImage({
    @required ImageSource source,
    double maxWidth,
    double maxHeight,
    int imageQuality,
    CameraDevice preferredCameraDevice = CameraDevice.rear,
  }) async {
    String path = await pickImagePath(
      source: source,
      maxWidth: maxWidth,
      maxHeight: maxHeight,
      imageQuality: imageQuality,
      preferredCameraDevice: preferredCameraDevice,
    );
    return path != null ? PickedFile(path) : null;
  }


//核心方法
//maxWidth 返回圖片的最大寬度
//maxHeight 返回圖片的最大高度
//imageQuality 返回圖片的質量
// 在image_picker_for_web中,上面三個屬性失效(原因未查明)
//通過MerthodChannel發起_channel.invokeMethod()呼叫 method name = 'pickImage'的通道方法。

  @override
  Future<String> pickImagePath({
    @required ImageSource source,
    double maxWidth,
    double maxHeight,
    int imageQuality,
    CameraDevice preferredCameraDevice = CameraDevice.rear,
  }) {
    assert(source != null);
    if (imageQuality != null && (imageQuality < 0 || imageQuality > 100)) {
      throw ArgumentError.value(
          imageQuality, 'imageQuality', 'must be between 0 and 100');
    }

    if (maxWidth != null && maxWidth < 0) {
      throw ArgumentError.value(maxWidth, 'maxWidth', 'cannot be negative');
    }

    if (maxHeight != null && maxHeight < 0) {
      throw ArgumentError.value(maxHeight, 'maxHeight', 'cannot be negative');
    }

    return _channel.invokeMethod<String>(
      'pickImage',
      <String, dynamic>{
        'source': source.index,
        'maxWidth': maxWidth,
        'maxHeight': maxHeight,
        'imageQuality': imageQuality,
        'cameraDevice': preferredCameraDevice.index
      },
    );
  }
複製程式碼

前面有提到PickedFile型別的返回值,不管是Flie還是PickedFile都有一個path屬性具體列印值為: blob:http://localhost:62137/b7239cc7-ec85-40fc-a4c7-07f5b920771e,可以看到是一個blob物件,前端範疇,為此本菜雞特意百度了一下,此處的path為什麼不是圖片的絕對路徑,且blob到底是什麼玩意兒,本菜雞的理解為,blob是基於瀏覽器內部對絕對路徑的一個封裝,防止爬蟲爬取資料,並且此連結只能在瀏覽器內部進行訪問,那就沒什麼問題了。

圖片壓縮原理及理解

這個話題說起來就很大了,先從圖片型別入手,列舉幾個圖片格式

  • ==“JPEG”格式==

JPEG格式,也叫做JPG或JPE格式,是最常用的一種檔案格式,Photoshop“儲存為”命令中預設的圖片格式就是JPEG,大部分手機相機拍照的照片也是JPE格式。

JPEG格式的壓縮技術十分先進,能夠將影像壓縮在很小的儲存空間,不過這種壓縮是有損耗的,過度壓縮會降低圖片的質量。JPEG格式壓縮的主要是高頻資訊,對色彩的資訊保留較好,因此特別適合應用於網際網路,可減少影像的傳輸和載入時間。

  • ==“PNG”格式==

PNG也是常見的一種圖片格式,它最重要的特點是支援 alpha 通道透明度,也就是說,PNG圖片支援透明背景。比如在使用Photoshop製作透明背景的圓形logo時,如果使用JPG格式,則圖片背景會預設地存為白色,使用PNG格式則可以存為透明背景圖片。

PNG格式圖片也支援有損耗壓縮,雖然PNG 提供的壓縮量比JPG少,但PNG圖片卻比JPEG圖片有更小的文件尺寸,因此現在越來越多的網路影像開始採用PNG格式。

  • ==“GIF”格式==

GIF也是一種壓縮的圖片格式,分為動態GIF和靜態GIF兩種。

GIF格式的最大特點是支援動態圖片,並且支援透明背景。網路上絕大部分動圖、表情包都是GIF格式的,相比與動畫,GIF動態圖片佔用的儲存空間小,載入速度快,因此非常流行。

除了羅列的三種,還有==BMP、PSD、SVG==等圖片格式。

圖片壓縮的技術原理層面本菜雞在此就不做太多解釋了,感興趣的可以看一下

影像壓縮原理

JPEG壓縮原理與DCT離散餘弦變換

小蝌蚪傳記:PNG圖片壓縮原理--屌絲的眼淚 #1 (==關於圖片、色彩基礎理論、視訊等,在這篇文章最後有連結==)

我們在此只關心Flutter端的圖片壓縮處理在dart層的展現,由於前面說到flutter_image_compress是藉助native api實現的圖片壓縮,且目前只支援在APP端執行,最近一直在看dart原始碼層面的東西,所以我們還是拿flutter_luban庫來進行原始碼解析,因為只有flutter_luban庫是實現了web端的圖片壓縮。

其實flutter_luban庫並沒有很複雜的專案結構,只有一個flutter_luban.dart檔案,只是魯班壓縮演算法在Flutter端的移植,所以我們直接貼出關鍵原始碼逐句分析即可。

//魯班壓縮庫核心程式碼
  static String _lubanCompress(CompressObject object) {
    //根據CompressObject物件中的File通過Uint8List的readAsBytesSync()方法獲取到List<int>陣列
    //通過Image中的decodeImage()初始化image物件
    //注意:此Imgae物件為'package:image/image.dart'中的物件,並非我們常用的Widget物件
    Image image = decodeImage(object.imageFile.readAsBytesSync());
    //獲取file長度並列印
    var length = object.imageFile.lengthSync();
    print(object.imageFile.path);

    bool isLandscape = false;
    //jpg型別陣列
    const List<String> jpgSuffix = ["jpg", "jpeg", "JPG", "JPEG"];
    //png型別陣列
    const List<String> pngSuffix = ["png", "PNG"];

    //呼叫_parseType()方法判斷圖片型別
    bool isJpg = _parseType(object.imageFile.path, jpgSuffix);
    bool isPng = false;

    if (!isJpg) isPng = _parseType(object.imageFile.path, pngSuffix);

    //初始化size width height
    double size;
    int fixelW = image.width;
    int fixelH = image.height;
    //
    double thumbW = (fixelW % 2 == 1 ? fixelW + 1 : fixelW).toDouble();
    double thumbH = (fixelH % 2 == 1 ? fixelH + 1 : fixelH).toDouble();
    //橫縱比
    double scale = 0;
    if (fixelW > fixelH) {
      scale = fixelH / fixelW;
      var tempFixelH = fixelW;
      var tempFixelW = fixelH;
      fixelH = tempFixelH;
      fixelW = tempFixelW;
      isLandscape = true;
    } else {
      scale = fixelW / fixelH;
    }
    var decodedImageFile;
    //目前只支援jpg和png的壓縮,否則丟擲異常提示
    if (isJpg)
      decodedImageFile = new File(
          object.path + '/img_${DateTime.now().millisecondsSinceEpoch}.jpg');
    else if (isPng)
      decodedImageFile = new File(
          object.path + '/img_${DateTime.now().millisecondsSinceEpoch}.png');
    else
      throw Exception("flutter_luban don't support this image type");
    //同步檢查decodedImageFile檔案是否存在
    if (decodedImageFile.existsSync()) {
      //同步刪除decodedImageFile檔案
      decodedImageFile.deleteSync();
    }
    //根據圖片的橫縱比例和傳入的圖片大小重新計算圖片size
    var imageSize = length / 1024;
    if (scale <= 1 && scale > 0.5625) {
      if (fixelH < 1664) {
        if (imageSize < 150) {
          decodedImageFile
              .writeAsBytesSync(encodeJpg(image, quality: object.quality));
          return decodedImageFile.path;
        }
        size = (fixelW * fixelH) / pow(1664, 2) * 150;
        size = size < 60 ? 60 : size;
      } else if (fixelH >= 1664 && fixelH < 4990) {
        thumbW = fixelW / 2;
        thumbH = fixelH / 2;
        size = (thumbH * thumbW) / pow(2495, 2) * 300;
        size = size < 60 ? 60 : size;
      } else if (fixelH >= 4990 && fixelH < 10240) {
        thumbW = fixelW / 4;
        thumbH = fixelH / 4;
        size = (thumbW * thumbH) / pow(2560, 2) * 300;
        size = size < 100 ? 100 : size;
      } else {
        int multiple = fixelH / 1280 == 0 ? 1 : fixelH ~/ 1280;
        thumbW = fixelW / multiple;
        thumbH = fixelH / multiple;
        size = (thumbW * thumbH) / pow(2560, 2) * 300;
        size = size < 100 ? 100 : size;
      }
    } else if (scale <= 0.5625 && scale >= 0.5) {
      if (fixelH < 1280 && imageSize < 200) {
        decodedImageFile
            .writeAsBytesSync(encodeJpg(image, quality: object.quality));
        return decodedImageFile.path;
      }
      int multiple = fixelH / 1280 == 0 ? 1 : fixelH ~/ 1280;
      thumbW = fixelW / multiple;
      thumbH = fixelH / multiple;
      size = (thumbW * thumbH) / (1440.0 * 2560.0) * 200;
      size = size < 100 ? 100 : size;
    } else {
      int multiple = (fixelH / (1280.0 / scale)).ceil();
      thumbW = fixelW / multiple;
      thumbH = fixelH / multiple;
      size = ((thumbW * thumbH) / (1280.0 * (1280 / scale))) * 500;
      size = size < 100 ? 100 : size;
    }
    //如果原始圖片size小於計算完畢後圖片size
    //則呼叫Image encodeJpg()方法進行質量壓縮,並同步寫入快取且返回路徑
    if (imageSize < size) {
      decodedImageFile
          .writeAsBytesSync(encodeJpg(image, quality: object.quality));
      return decodedImageFile.path;
    }
    //如果原始圖片size大於計算完畢後圖片size
    //根據橫豎方向,呼叫copyResize()方法重設寬高屬性給smallerImage賦值
    Image smallerImage;
    if (isLandscape) {
      smallerImage = copyResize(image,
          width: thumbH.toInt(),
          height: object.autoRatio ? null : thumbW.toInt());
    } else {
      smallerImage = copyResize(image,
          width: thumbW.toInt(),
          height: object.autoRatio ? null : thumbH.toInt());
    }

    if (decodedImageFile.existsSync()) {
      decodedImageFile.deleteSync();
    }
    //根據傳入的CompressMode列舉型別,呼叫對應的CompressImage()方法
    //本質都是呼叫Image encodeJpg()方法進行質量壓縮,只是在image size上做了調整
    if (object.mode == CompressMode.LARGE2SMALL) {
      _large2SmallCompressImage(
        image: smallerImage,
        file: decodedImageFile,
        quality: object.quality,
        targetSize: size,
        step: object.step,
        isJpg: isJpg,
      );
    } else if (object.mode == CompressMode.SMALL2LARGE) {
      _small2LargeCompressImage(
        image: smallerImage,
        file: decodedImageFile,
        quality: object.step,
        targetSize: size,
        step: object.step,
        isJpg: isJpg,
      );
    } else {
      if (imageSize < 500) {
        _large2SmallCompressImage(
          image: smallerImage,
          file: decodedImageFile,
          quality: object.quality,
          targetSize: size,
          step: object.step,
          isJpg: isJpg,
        );
      } else {
        _small2LargeCompressImage(
          image: smallerImage,
          file: decodedImageFile,
          quality: object.step,
          targetSize: size,
          step: object.step,
          isJpg: isJpg,
        );
      }
    }
    return decodedImageFile.path;
  }

複製程式碼

==圖片壓縮步驟總結:==

  1. 傳入圖片CompressObject物件(此物件為魯班庫自定義物件型別),主要理解為傳入圖片path即可

  2. 根據圖片路徑獲取Uint8List並轉換為'package:image/image.dart'庫對應的Image物件。

  3. 魯班壓縮演算法,主要用於圖片size計算,縱橫比比例分為四個區間,分別計算出結果size

  4. 利用copyResize()方法傳入計算結果size生成smallImage物件

  5. 利用dart原生api encodeJpg()方法進行質量壓縮

豹尾小結篇

這篇文章主要是針對Flutter for web的圖片選擇及壓縮,通過對比圖片選擇庫,圖片壓縮庫,進行了原始碼分析,並列出了圖片壓縮的大概步驟。總體來說還是比較詳細的分析了圖片選擇和壓縮的過程及步驟,包括dart層面的實現。在做圖片轉碼的過程,是曲折又辛酸的,確實找了很多資料,看了很多部落格軟文,還是資料太少,不【science network】的話,侷限性太大了。雖然Flutter的入門文章教程很多,但是有深度、有質量的文章還是少了一些,特別是Flutter for web,可能大家都是在嘗試的原因。Flutter能否一統前端,就要看大家的努力了,讓我們一起為Flutter生態建設添磚加瓦吧~

我是努力成為Flutter架構的Flutter小菜雞,我為自己帶鹽!

相關文章