Flutter中ui.Image載入探索

張風捷特烈發表於2019-09-12

想必大家Image元件都玩得挺6的,那麼如何在Canvas上畫一個圖片,實現圖片的放大等變換又該如何操呢?如何去監聽一個圖片流。這些Image元件就無法完成了。

Flutter中ui.Image載入探索

Flutter中ui.Image載入探索


import 'dart:ui' as ui;
class ImagePage extends StatefulWidget {
  ImagePage({Key key,}):super(key:key);
  @override
  _ImagePageState createState() => _ImagePageState();
}

class _ImagePageState extends State<ImagePage> {
  
  @override
  Widget build(BuildContext context) {
    return Container(
      child: CustomPaint(painter: ImagePainter(),),
    );
  }
}

class ImagePainter extends CustomPainter {

  Paint mainPaint;
  ImagePainter(){
    mainPaint=Paint()..isAntiAlias=true;
  }
  
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImage(Image.asset("images/wy_300x200.jpg"), //報錯
        Offset(0,0), mainPaint);
  }
  
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    return true;
  }
}
複製程式碼

1.如何使用Canvas繪製圖片

上面在Canvas的drawImage中,你會看到一個Image引數,你會想,這不好辦嗎?Image傳唄!
但是你傳入一個Image元件它會神奇般地報錯:意思是說人家要的是ui/painting檔案的Image。

Flutter中ui.Image載入探索

1.1.Canvas繪製圖片原始碼及Image原始碼
---->[sky_engine/lib/ui/painting.dart:Canvas#drawImage]----
void drawImage(Image image, Offset p, Paint paint) {
  assert(image != null); // image is checked on the engine side
  assert(_offsetIsValid(p));
  assert(paint != null);
  _drawImage(image, p.dx, p.dy, paint._objects, paint._data);
}
複製程式碼

當跳入Image中是發現是ui/painting的Image,而且該類被私有化構造
就說明無法被直接建立,更有意思的是幾乎都是native方法。

---->[sky_engine/lib/ui/painting.dart:Image]----
@pragma('vm:entry-point')
class Image extends NativeFieldWrapperClass2 {
  @pragma('vm:entry-point')
  Image._();//私有化構造
  
  int get width native 'Image_width';//獲取寬
  int get height native 'Image_height';//獲取高
  
  Future<ByteData> toByteData({ImageByteFormat format = ImageByteFormat.rawRgba}) {//轉換成位元組資料
    return _futurize((_Callback<ByteData> callback) {
      return _toByteData(format.index, (Uint8List encoded) {
        callback(encoded?.buffer?.asByteData());
      });
    });
  }
  String _toByteData(int format, _Callback<Uint8List> callback) native 'Image_toByteData';

  void dispose() native 'Image_dispose';//釋放圖片

  @override
  String toString() => '[$width\u00D7$height]';
}
複製程式碼

1.2.通過instantiateImageCodec獲取圖片編解碼器

既然無法建立物件,那怎麼玩?原始碼中為我們指明道路:使用instantiateImageCodec 那instantiateImageCodec又是什麼鬼。它是返回一個Future的方法,而且傳入一個Uint8List
也許這時你會說: 好複雜,臣妾做不到。我不畫了還不行嗎。稍安勿躁,先看Codec何許人也...

To obtain an [Image] object, use [instantiateImageCodec].

---->[sky_engine/lib/ui/painting.dart:instantiateImageCodec]----
Future<Codec> instantiateImageCodec(Uint8List list, {
  int targetWidth,
  int targetHeight,
}) {
  return _futurize(
    (_Callback<Codec> callback) => _instantiateImageCodec(list, callback, null, targetWidth ?? _kDoNotResizeDimension, targetHeight ?? _kDoNotResizeDimension)
  );
}
複製程式碼

Codec是一個圖片編解碼器的控制程式碼,這還了得,簡直是極品紅裝啊。它也是私有化構造
所以顯得instantiateImageCodec是多麼重要。其中getNextFrame方法返回FrameInfo的未來物件
看到Frame你應該立刻聯想到圖片幀,於是看到在FrameInfo中Image物件就在那等著你。

---->[sky_engine/lib/ui/painting.dart:Codec]----
@pragma('vm:entry-point')
class Codec extends NativeFieldWrapperClass2 {
  @pragma('vm:entry-point')
  Codec._();
  
Future<FrameInfo> getNextFrame() {
  return _futurize(_getNextFrame);
}

---->[sky_engine/lib/ui/painting.dart:FrameInfo]----
@pragma('vm:entry-point')
class FrameInfo extends NativeFieldWrapperClass2 {
  @pragma('vm:entry-point')
  FrameInfo._();

  Duration get duration => Duration(milliseconds: _durationMillis);
  int get _durationMillis native 'FrameInfo_durationMillis';

  Image get image native 'FrameInfo_image';//獲取Image物件。
}
複製程式碼

好了,現在似乎一條路已經走通了,唯一一點就是Uint8List的圖片資料如何獲取
如果你不知道,那麼至少可以先寫出下面的這個方法:

//通過[Uint8List]獲取圖片
Future<ui.Image> loadImageByUint8List(Uint8List list) async{
  ui.Codec codec= await ui.instantiateImageCodec(list);
  ui.FrameInfo frame= await codec.getNextFrame();
  return frame.image;
}
複製程式碼

1.3.繪製你的第一張圖

這就要考驗基本功了,記得在File中有一個方法可以將檔案讀成Uint8List

//通過 檔案讀取Image
Future<ui.Image> loadImageByFile(String path) async{
  var list =await File(path).readAsBytes();
  return loadImageByUint8List(list);
}
複製程式碼

這裡將一張圖片放入快取資料夾。再用FutureBuilder優雅地將未來的Image物件傳入畫板中
在畫板中當_image非空時就可以將Image物件繪製出來。

Flutter中ui.Image載入探索

---->[ImagePage.dart:_ImagePageState#build]----
class _ImagePageState extends State<ImagePage> {

  @override
  Widget build(BuildContext context) {
    return Container(
      child: FutureBuilder<ui.Image>(
        future: loadImageByFile("/data/data/com.toly1994.flutter_image/cache/wy_300x200.jpg"),
        builder:(context,snapshot)=>CustomPaint(
          painter: ImagePainter(snapshot.data),
        ),
      ),
    );
  }
}

---->[ImagePage.dart:ImagePainter#paint]----
@override
void paint(Canvas canvas, Size size) {
  if (_image != null) {
    canvas.drawImage(_image, Offset(0, 0), mainPaint);
  }
}
複製程式碼

也許細心的你可以看到instantiateImageCodec中有兩個鍵值引數,可以確定圖片載入出來的寬高
未了使用方便,這裡提取一個ImageLoader用於載入圖片,使用單例模式:使用 ImageLoader.loader.loadImageByFile("the path",width: 150,height: 100),就可以指定編解碼圖片的尺寸
實驗表明尺寸越大,載入的速度就越慢,超過一定的尺寸image_decoder.cc會不允許載入

Flutter中ui.Image載入探索

---->[ImageLoader.dart#ImageLoader]----
class ImageLoader {

  ImageLoader._();//私有化構造
  static final ImageLoader loader= ImageLoader._();//單例模式

  //通過 檔案讀取Image
  Future<ui.Image> loadImageByFile(
    String path, {
    int width,
    int height,
  }) async {
    var list = await File(path).readAsBytes();
    return loadImageByUint8List(list, width: width, height: height);
  }

//通過[Uint8List]獲取圖片,預設寬高[width][height]
  Future<ui.Image> loadImageByUint8List(
    Uint8List list, {
    int width,
    int height,
  }) async {
    ui.Codec codec = await ui.instantiateImageCodec(list,
        targetWidth: width, targetHeight: height);
    ui.FrameInfo frame = await codec.getNextFrame();
    return frame.image;
  }
}
複製程式碼

2.從ImageProvider獲取及Image

如果是Asset圖片資源或是網路圖片如何獲取Image呢?
ImageProvider有一個resolve方法可以返回一個圖片流ImageStream
作為它孩子的幾種圖片載入方式都會有該方法,切入點便在此處:

Flutter中ui.Image載入探索

2.1 :ImageProvider相關原始碼
---->[src/painting/image_provider.dart:ImageProvider#resolve]----
ImageStream resolve(ImageConfiguration configuration) {
    //略...
}
複製程式碼

ImageStream可以新增一個監聽器,其中傳入ImageStreamListener物件

---->[src/painting/image_stream.dart:ImageStream#addListener]----
void addListener(ImageStreamListener listener) {
  if (_completer != null)
    return _completer.addListener(listener);
  _listeners ??= <ImageStreamListener>[];
  _listeners.add(listener);
}
複製程式碼

ImageStreamListener種有三個回撥函式:onChunk在接收到一塊位元組觸發監聽
onError在錯誤時觸發監聽,onImage在完成時觸發監聽,如果只是想獲取Image,onImage即可

---->[src/painting/image_stream.dart:#ImageStreamListener]----
class ImageStreamListener {

  const ImageStreamListener(
    this.onImage, {
    this.onChunk,
    this.onError,
  }) : assert(onImage != null);
  
  final ImageListener onImage;
  final ImageChunkListener onChunk;
  final ImageErrorListener onError;
複製程式碼

onImage對應的是ImageListener物件,在回撥中可以獲取ImageInfo物件
Image物件就在這裡靜靜地等著你來。

typedef ImageListener = void Function(ImageInfo image, bool synchronousCall);

---->[src/painting/image_stream.dart:17]----
class ImageInfo {
  const ImageInfo({ @required this.image, this.scale = 1.
    : assert(image != null),
      assert(scale != null);

  final ui.Image image;
  final double scale;
}
複製程式碼

2.2 :ImageProvider獲取Image方法封裝

這樣的話,完全可以先封裝一個通過ImageProvider獲取Image的方法

//通過ImageProvider讀取Image
Future<ui.Image> loadImageByProvider(
  ImageProvider provider, {
  ImageConfiguration config = ImageConfiguration.empty,
}) async {
  Completer<ui.Image> completer = Completer<ui.Image>(); //完成的回撥
  ImageStreamListener listener;
  ImageStream stream = provider.resolve(config); //獲取圖片流
  listener = ImageStreamListener((ImageInfo frame, bool sync) {
    //監聽
    final ui.Image image = frame.image;
    completer.complete(image); //完成
    stream.removeListener(listener); //移除監聽
  });
  stream.addListener(listener); //新增監聽
  return completer.future; //返回
}
複製程式碼

2.3 :ImageProvider載入圖片

現在使用網路圖片測試一下:

Flutter中ui.Image載入探索

  @override
  Widget build(BuildContext context) {
//var futureFile=ImageLoader.loader.loadImageByFile("/data/data/com.toly1994.flutter_image/cache/wy_300x200.jpg",width: 150,height: 100);

  //從資源部獲取Image
  var futureAsset= ImageLoader.loader.loadImageByProvider(AssetImage("images/wy_300x200.jpg"));
  //從網路獲取Image
  var imageUrl='https://user-gold-cdn.xitu.io/2018/7/9/1647cc06a3e9e9c4?imageView2'
      '/1/w/180/h/180/q/85/format/webp/interlace/1';
  var futureNet= ImageLoader.loader.loadImageByProvider(NetworkImage(imageUrl));
   //從檔案獲取Image
  var path="/data/data/com.toly1994.flutter_image/cache/wy_300x200.jpg";
  var futureFile= ImageLoader.loader.loadImageByProvider(FileImage(File(path)));
  
    return Container(
      child: FutureBuilder<ui.Image>(
        future:futureNet,
        builder:(context,snapshot)=>CustomPaint(
          painter: ImagePainter(snapshot.data),
        ),
      ),
    );
  }
}
複製程式碼

不過發現ImageConfiguration的Size並不能改變圖片的展示大小,那該怎麼辦?
網路圖片太大的,想要在本地儲存一個縮圖,如何實現?


3.儲存網路圖片的縮圖

主要通過PictureRecorder對Canvas進行錄製,使用Canvas對圖片進行重定尺寸。

Flutter中ui.Image載入探索

///對圖片重定義寬高尺寸[dstWidth],[dstHeight]
Future<ui.Image> _resize(ui.Image image, int dstWidth,int dstHeight) {
  var recorder = ui.PictureRecorder();//使用PictureRecorder對圖片進行錄製
  Paint paint = Paint();
  Canvas canvas = Canvas(recorder);
  double srcWidth = image.width.toDouble();
  double srcHeight = image.height.toDouble();
  canvas.drawImageRect(image, //使用drawImageRect對圖片進行定尺寸填充
      Rect.fromLTWH(0, 0, srcWidth, srcHeight),
      Rect.fromLTWH(0, 0, dstWidth.toDouble() ,
          dstHeight.toDouble()), paint);
  return recorder.endRecording().toImage(dstWidth, dstHeight);//返回圖片
}
複製程式碼

這樣就可以定義出重設尺寸的載入方式

///縮放載入[provider],縮放比例[scale]
Future<ui.Image> scaleLoad(ImageProvider provider, double scale) async {
  var img = await loadImageByProvider(provider);
  return _resize(img, (img.width*scale).toInt(),(img.height*scale).toInt());
}

///縮放載入[provider],縮放比例[scale]
Future<ui.Image> resizeLoad(ImageProvider provider, int dstWidth,int dstHeight) async {
  var img = await loadImageByProvider(provider);
  return _resize(img, dstWidth,dstHeight);
}
複製程式碼

如何將一個Image物件儲存到本地?Image物件可以轉化成位元組流,再通過檔案寫入。

Flutter中ui.Image載入探索

//儲存一個Image 
Future<File> saveImage(ui.Image image,String path,{format=ui.ImageByteFormat.png}) async{
  var file= File(path);
  if(!await file.exists()){
    await file.create(recursive: true);
  }
  ByteData byteData = await image.toByteData(format:format);
  Uint8List pngBytes = byteData.buffer.asUint8List();
  return file.writeAsBytes(pngBytes);
}
複製程式碼

通過ImageLoader.loader.saveImage便可以將,縮小0.3倍的圖片儲存到本地。

var imageUrl='https://user-gold-cdn.xitu.io/2018/7/9/1647cc06a3e9e9c4?imageView2'
var path="/data/data/com.toly1994.flutter_image/cache/net/wy_300x200_mini.png";

ImageLoader.loader.scaleLoad(NetworkImage(imageUrl),0.3)
        .then((img)=>ImageLoader.loader.saveImage(img,path));
複製程式碼

4.網路圖片的載入及快取檔案的有效期

對於快取檔案的期限,可以用一個追蹤檔案進行記錄,在訪問網路圖片時首先看有沒有快取檔案
然後看快取檔案有沒有過期,如果過期,則刪除,重新載入並快取到本地。
當然你也可以更高階一點使用Json對或資料庫,或xml配置來記錄快取的失效期。

Flutter中ui.Image載入探索

//通過ImageProvider讀取Image
Future<ui.Image> loadNetImage(String url,
    {bool cache = true, scale = 1.0, int deathSecond = 60}) async {
  ui.Image image;
  var dir = await getTemporaryDirectory();
  var name = md5.convert(utf8.encode(url)).toString();
  var imgPath = File(path.join(dir.path, name));
  var fileDeath = File(imgPath.path + "._cache_death");
  
  if (cache && await imgPath.exists() && !await isCacheDeath(fileDeath)) {//表示有快取且快取有效
    //設定快取,並且有快取檔案,並且快取失效時,寫入快取
    image= await loadImageByProvider(FileImage(imgPath));
    print("使用快取");
  }else{
    image = await loadImageByProvider(NetworkImage(url));
    var death = DateTime.now().millisecondsSinceEpoch + deathSecond + 1000 * 60; //過期時間
    await fileDeath.writeAsString("$death");
    await saveImage(image, imgPath.path);
    print("使用網路圖片---快取已重置");
  }
 return _scale(image, scale);
}

/// 檢查快取是否過期
Future<bool> isCacheDeath(File fileDeath) async {
  if(!await fileDeath.exists()){
    return true;
  }
  var death = await fileDeath.readAsString();
  print("$death ==== ${DateTime.now().millisecondsSinceEpoch}--${int.parse(death) > DateTime.now().millisecondsSinceEpoch}");
  return int.parse(death) < DateTime.now().millisecondsSinceEpoch;
}
複製程式碼

文章到這就結束了,也許你是被開頭的圖片吸引來的,這裡為了不掃你的興,原始碼在此:

/// 圖片放大鏡的配置類,將圖片提供器中的[image],
/// 在半徑為[radius]的[outlineColor]色圓中區域性放大比例[rate]倍,
class BiggerConfig {
  double rate;
  ImageProvider image;
  double radius;
  Color outlineColor;
  bool isClip;

  BiggerConfig(
      {this.rate = 3,
      @required this.image,
      this.isClip = true,
      this.radius = 30,
      this.outlineColor = Colors.white});
}

class BiggerView extends StatefulWidget {
  BiggerView({
    Key key,
    @required this.config,
  }) : super(key: key);

  final BiggerConfig config;

  @override
  _BiggerViewState createState() => _BiggerViewState();
}

class _BiggerViewState extends State<BiggerView> {
  var posX = 0.0;
  var posY = 0.0;
  bool canDraw = false;
  var width =0.0;
  var height =0.0;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<ui.Image>(
      future: ImageLoader.loader.loadImageByProvider(widget.config.image),
      builder: (context, snapshot) {
        if(snapshot.connectionState==ConnectionState.done){
           width = snapshot.data.width.toDouble() / widget.config.rate;
           height = snapshot.data.height.toDouble() / widget.config.rate;
        }

        return GestureDetector(
          onPanDown: (detail) {
            posX = detail.localPosition.dx;
            posY = detail.localPosition.dy;
            canDraw = true;
            setState(() {});
          },
          onPanUpdate: (detail) {
            posX = detail.localPosition.dx;
            posY = detail.localPosition.dy;
            if (judgeRectArea(posX, posY, width + 2, height + 2)) {
              setState(() {});
            }
          },
          onPanEnd: (detail) {
            canDraw = false;
            setState(() {});
          },
          child: Container(
            width: width,
            height: height,
            child: CustomPaint(
              painter: BiggerPainter(snapshot.data, posX, posY, canDraw,
                  widget.config.radius, widget.config.rate, widget.config.isClip),
            ),
          ),
        );
      },
    );
  }

  //判斷落點是否在矩形區域
  bool judgeRectArea(double dstX, double dstY, double w, double h) {
    return (dstX - w / 2).abs() < w / 2 && (dstY - h / 2).abs() < h / 2;
  }
}

class BiggerPainter extends CustomPainter {
  final ui.Image _img; //圖片
  Paint mainPaint; //主畫筆
  Path circlePath; //圓路徑
  double _x; //觸點x
  double _y; //觸點y
  double _radius; //圓形放大區域
  double _rate; //放大倍率
  bool _canDraw; //是否繪製放大圖
  bool _isClip; //是否是裁切模式
  BiggerPainter(this._img, this._x, this._y, this._canDraw, this._radius, this._rate, this._isClip) {
    mainPaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;
    circlePath = Path();
  }

  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Offset.zero & size;
    canvas.clipRect(rect); //裁剪區域
    if (_img != null) {
      Rect src =
          Rect.fromLTRB(0, 0, _img.width.toDouble(), _img.height.toDouble());
      canvas.drawImageRect(_img, src, rect, mainPaint);
      if (_canDraw) {
        var tempY = _y;
        _y = _y > 2 * _radius ? _y - 2 * _radius : _y + _radius;
        circlePath
            .addOval(Rect.fromCircle(center: Offset(_x, _y), radius: _radius));
        if (_isClip) {

          canvas.clipPath(circlePath);
          canvas.drawImage(
              _img, Offset(-_x * (_rate - 1), -tempY * (_rate - 1)), mainPaint);
          canvas.drawPath(circlePath, mainPaint);
       
        } else {
          canvas.drawImage(
              _img, Offset(-_x * (_rate - 1), -tempY * (_rate - 1)), mainPaint);
        }
      }
    }
  }
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

/// 測試
var showBiggerView = Center(
  child: BiggerView(
    config: BiggerConfig(
        image: AssetImage("images/sabar.jpg"), rate: 3, isClip: true),
  ),
);
複製程式碼

本文到此接近尾聲了,如果想快速嚐鮮Flutter,《Flutter七日》會是你的必備佳品;如果想細細探究它,那就跟隨我的腳步,完成一次Flutter之旅。
另外本人有一個Flutter微信交流群,歡迎小夥伴加入,共同探討Flutter的問題,本人微訊號:zdl1994328,期待與你的交流與切磋。

相關文章