Flutter加载图片与Glide

5,975 阅读8分钟

相对于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。