Flutter 进阶:NCanvasImageLoader 让 Canvas 也能画网络图

一、需求来源

开发中遇到一个痛点:CustomPainter 里只能用 ui.Image 通过 paintImage 画图,但 Flutter 的图片加载是回调式的(ImageStreamListener),不是 Future。想把网图塞进 Canvas,得自己处理异步转同步、缓存、占位、错误。

Image widget 把这些全做了,可它进不了 paint 方法。所以封装了 NCanvasImageLoader 工具链:把网图加载成 ui.Image,再丢给 paintImage 绘制。

paintImage 需要的是一个 ui.Image:

less 复制代码
paintImage(
  canvas: canvas,
  rect: imgRect,
  image: image!, // 需要 ui.Image,不是 URL / Widget
  fit: BoxFit.contain,
);

而常规网图加载是回调式的:

scss 复制代码
final stream = CachedNetworkImageProvider(url).resolve(configuration);
stream.addListener(ImageStreamListener(
  (ImageInfo info, _) { /* 拿到 ui.Image */ },
  onError: (e, _) { /* 加载失败 */ },
));

问题很明显:

  1. 回调式 API :不是 Future,async 方法里没法直接 await;
  2. 多图并行难 :回调嵌套爆炸,没法 Future.wait;
  3. 占位 / 缓存 / 错误要自己管 :Image widget 白送的能力,到 Canvas 全没了。

二、使用示例

一句话把网图加载成 ui.Image,丢给 Canvas 绘制。工具链分两层,按需取用:

组件用法 ------ 想要占位图、生命周期自动管理,直接用组件:

less 复制代码
// 加载中显示占位 → 加载完用 CustomPaint 绘制
NCanvasNetworkImage(
  url: url,
  width: 200,
  height: 100,
  fit: BoxFit.cover,
);

工具用法 ------ 已经在自定义 CustomPainter 里,只要 ui.Image:

php 复制代码
final image = await NCanvasImageLoader.load(url, placeholder: myPlaceholder);
paintImage(canvas: canvas, rect: myRect, image: image, fit: BoxFit.contain);

三、源码讲解

1. 核心:Completer 把回调包成 Future

把回调转 Future 的标准工具是 Completer:拿到图 complete,出错 completeError。

ini 复制代码
static Future<ui.Image> _loadImage(
  ImageProvider provider, {
  ImageConfiguration configuration = const ImageConfiguration(),
}) async {
  final completer = Completer<ui.Image>();
  final stream = provider.resolve(configuration);

  late ImageStreamListener listener;
  listener = ImageStreamListener(
    (ImageInfo info, _) {
      completer.complete(info.image);
      stream.removeListener(listener); // 用完移除监听,防泄漏
    },
    onChunk: (event) {
      // 可选进度:cumulativeBytesLoaded / expectedTotalBytes
    },
    onError: (e, _) {
      completer.completeError(e);
      stream.removeListener(listener);
    },
  );

  stream.addListener(listener);
  return completer.future;
}

两个易忽略的细节:

  • removeListener :ImageStream 支持多监听者,成功/失败后必须移除,否则泄漏、重复回调;
  • late 声明 listener :回调里要引用 listener 自身来移除,存在循环引用,必须 late。

2. 入口:网图 + 占位兜底

load() 是对外唯一入口,策略:加载失败(或非 http)返回占位图,绝不抛异常:

dart 复制代码
static Future<ui.Image?> load(
  String? url, {
  required AssetImage placeholder,
  ImageConfiguration configuration = const ImageConfiguration(),
}) async {
  final placeholderImage = _loadImage(placeholder, configuration: configuration);
  try {
    if (url == null || url.startsWith("http") != true) {
      return placeholderImage;
    }
    final provider = CachedNetworkImageProvider(url); // 复用缓存
    return await _loadImage(provider, configuration: configuration);
  } catch (e) {
    return placeholderImage; // 任何异常都兜底
  }
}

精妙点:占位图在进 try 前就启动 _loadImage,与网图并发加载,即使网图失败占位图也大概率就绪,不二次等待。

3. NImagePainter:薄封装 paintImage

CustomPainter 把 paintImage 的参数原样暴露,照搬 Image widget 能力:

arduino 复制代码
class NImagePainter extends CustomPainter {
  NImagePainter({this.image, this.fit, this.opacity = 1.0, this.colorFilter,
    this.blendMode = BlendMode.srcOver, this.alignment = Alignment.center, /* ... */});

  @override
  void paint(Canvas canvas, Size size) {
    if (image == null) return;
    paintImage(
      canvas: canvas,
      rect: Rect.fromLTWH(0, 0, size.width, size.height),
      image: image!,
      fit: fit, alignment: alignment, opacity: opacity,
      colorFilter: colorFilter, blendMode: blendMode, // ...
    );
  }

  @override
  bool shouldRepaint(covariant NImagePainter oldDelegate) {
    return oldDelegate.image != image || oldDelegate.fit != fit /* ... */;
  }
}

4. NCanvasNetworkImage:加载 + 绘制收成一个 Widget

StatefulWidget 管理生命周期:加载中显示 Image 占位,加载完切换 CustomPaint。

arduino 复制代码
class _NCanvasNetworkImageState extends State<NCanvasNetworkImage> {
  ui.Image? image;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => _load()); // 首帧后加载
  }

  Future<void> _load() async {
    image = await NCanvasImageLoader.load(
      widget.url,
      placeholder: widget.placeholder,
      configuration: ImageConfiguration(
        size: Size(widget.width, widget.height),
        devicePixelRatio: 3,
      ),
    );
    if (mounted) setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    if (image == null) {
      return Image(image: widget.placeholder, width: widget.width, height: widget.height);
    }
    return CustomPaint(
      size: Size(widget.width, widget.height),
      painter: NImagePainter(image: image, fit: widget.fit),
    );
  }
}

工程细节:

  • addPostFrameCallback 再加载:首帧先渲染占位图,避免 build 里触发网络请求阻塞首帧;
  • mounted 再 setState:异步加载可能跨销毁,防止对已销毁组件 setState;
  • didUpdateWidget 里 URL 变了要重载 :列表滚动复用时 URL 变化触发 _load(),其它属性只重绘;
  • devicePixelRatio: 3:按 3x 屏配置,高清屏下图不糊。

四、总结

NCanvasImageLoader 做的事很朴素:用 Completer 把回调式图片加载包装成 Future<ui.Image>,并内置占位兜底 。配合 NImagePainter(薄封装 paintImage)和 NCanvasNetworkImage(生命周期管理),组成完整「Canvas 画网图」工具链。

核心价值:

  • 异步转同步 :一句 await 拿到 ui.Image;
  • 占位兜底:任何失败返回占位图,且并发预加载;
  • 能力对齐 Image :fit / opacity / colorFilter / blendMode 全透传;
  • 工程细节 :removeListener 防泄漏、mounted 防崩溃、didUpdateWidget 重载、devicePixelRatio 保清晰。

一句话:把 Image widget 的能力,用 Completer + paintImage 搬进 Canvas,让自定义绘制也能优雅地消费网络图。


本文源码参考:

相关推荐
huakoh4 小时前
MCP 工具报错走哪条通道:三条探针的最小复现检查
前端
不爱说话郭德纲5 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app
Lstone73645 小时前
从 Jetpack Compose 到 CMP:跨平台开发学习笔记
前端
deli0075 小时前
多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测
前端
涛涛ing5 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端
__sjfzllv___5 小时前
在职前端Leader学习/转行 AI Agent -DAY73
前端
用户1733598075375 小时前
纯前端 PDF 压平避坑指南:压平后表单字段变了?
前端·javascript·vue.js
nyaomaru5 小时前
将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown
前端·typescript
胡写代码5 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
沐言人生5 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github