You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中FutureBuilder构建的Widget真机正常但测试不渲染问题

Flutter Widget测试中IconButton未渲染问题的解决方法

问题背景

开发的Thumbnail组件通过FutureBuilder结合Completer和Image.resolve监听获取图片尺寸,动态设置IconButton的大小,真机运行正常,但Widget测试中IconButton始终不渲染,导致测试失败。

组件代码

class Thumbnail extends StatelessWidget {
  final Uint8List thumbnail;
  final ImagePage mainImage;
  final Image thumbnailImage;
  final bool editMode;
  final completer = Completer<ui.Image>();

  Thumbnail(
      {super.key,
      required this.thumbnail,
      required this.mainImage,
      this.editMode = false})
      : thumbnailImage = Image.memory(thumbnail);

  @override
  Widget build(BuildContext context) {
    thumbnailImage.image
        .resolve(const ImageConfiguration())
        .addListener(ImageStreamListener((image, synchronousCall) {
      completer.complete(image.image);
      image.dispose();
    }));
    return Stack(alignment: Alignment.center, children: [
      thumbnailImage,
      FutureBuilder<ui.Image>(
          future: completer.future,
          builder: (context, snapshot) {
            return Offstage(
                offstage: !snapshot.hasData,
                child: makeIconButton(context, snapshot.data));
          }),
    ]);
  }

  static int minSize(ui.Image? image) {
    if (image == null) {
      return 2;
    }
    return image.width < image.height ? image.width : image.height;
  }

  Widget makeIconButton(BuildContext context, ui.Image? image) {
    final imageMinSize = minSize(image);
    return IconButton(
      onPressed: () {
        if (!editMode) {
          Navigator.of(context).push(MaterialPageRoute(
              builder: (context) => page.Page.fromWidget(mainImage)));
        }
      },
      icon: editMode ? const Icon(Icons.edit) : const Icon(Icons.zoom_in),
      iconSize: imageMinSize >= 64 ? 48.0 : imageMinSize / 2,
      splashRadius: imageMinSize / 2,
      color: Colors.blueGrey.withOpacity(0.8),
      hoverColor: Colors.lightBlue.withOpacity(0.3),
    );
  }
}

测试代码

void main() {
  testWidgets('Thumbnail', (WidgetTester tester) async {
      var thumbnailPage = page.Page('Thumbnail', const [],
          thumbnail_package.Thumbnail(
              thumbnail: image_samples.thumbnail_64x64,
              mainImage: thumbnail_package.ImagePage(
                  'MainImage',
                  MemoryImage(image_samples.thumbnail_64x64)
              )
          ));
      await tester.pumpWidget(
          MaterialApp(home: thumbnailPage)
      );

      expect(find.byType(thumbnail_package.Thumbnail), findsOneWidget);

      expect(find.byType(Image), findsOneWidget);
      for (final image in find.byType(Image).evaluate().map((element) =>
      element.widget as Image)) {
        expect(
            Uint8ListCompareByValue((image.image as MemoryImage).bytes),
            Uint8ListCompareByValue(image_samples.thumbnail_64x64));
      }

      await tester.tap(find.byType(IconButton));  // <--- 此处调用失败
      await tester.pumpAndSettle();

      expect(find.byType(thumbnail_package.Thumbnail), findsNothing);
      expect(find.byType(Image), findsOneWidget);
      for (final image in find.byType(Image).evaluate().map((element) =>
      element.widget as Image)) {
        expect(
            Uint8ListCompareByValue((image.image as MemoryImage).bytes),
            Uint8ListCompareByValue(image_samples.thumbnail_64x64));
      }
  });

测试报错

The following assertion was thrown running a test:
The finder "Found 0 widgets with type "IconButton": []" (used in a call to "tap()") could not find any matching widgets.

问题原因

测试环境使用FakeAsync,不会自动处理图片加载的异步逻辑,Image.resolve的监听器无法被触发,导致Completer始终未完成,FutureBuilder因snapshot.hasData为false,将IconButton通过Offstage隐藏。

解决方法

1. 优化组件代码(避免重复添加监听器)

将StatelessWidget改为StatefulWidget,确保只添加一次图片加载监听器,避免build重复调用时多次注册:

class Thumbnail extends StatefulWidget {
  final Uint8List thumbnail;
  final ImagePage mainImage;
  final bool editMode;

  const Thumbnail({
    super.key,
    required this.thumbnail,
    required this.mainImage,
    this.editMode = false,
  });

  @override
  State<Thumbnail> createState() => _ThumbnailState();
}

class _ThumbnailState extends State<Thumbnail> {
  late final Image thumbnailImage;
  final completer = Completer<ui.Image>();
  bool _listenerAdded = false;

  @override
  void initState() {
    super.initState();
    thumbnailImage = Image.memory(widget.thumbnail);
  }

  @override
  Widget build(BuildContext context) {
    if (!_listenerAdded) {
      thumbnailImage.image
          .resolve(const ImageConfiguration())
          .addListener(ImageStreamListener((image, synchronousCall) {
        completer.complete(image.image);
        image.dispose();
      }));
      _listenerAdded = true;
    }
    return Stack(alignment: Alignment.center, children: [
      thumbnailImage,
      FutureBuilder<ui.Image>(
          future: completer.future,
          builder: (context, snapshot) {
            return Offstage(
                offstage: !snapshot.hasData,
                child: makeIconButton(context, snapshot.data));
          }),
    ]);
  }

  static int minSize(ui.Image? image) {
    if (image == null) {
      return 2;
    }
    return image.width < image.height ? image.width : image.height;
  }

  Widget makeIconButton(BuildContext context, ui.Image? image) {
    final imageMinSize = minSize(image);
    return IconButton(
      onPressed: () {
        if (!widget.editMode) {
          Navigator.of(context).push(MaterialPageRoute(
              builder: (context) => page.Page.fromWidget(widget.mainImage)));
        }
      },
      icon: widget.editMode ? const Icon(Icons.edit) : const Icon(Icons.zoom_in),
      iconSize: imageMinSize >= 64 ? 48.0 : imageMinSize / 2,
      splashRadius: imageMinSize / 2,
      color: Colors.blueGrey.withOpacity(0.8),
      hoverColor: Colors.lightBlue.withOpacity(0.3),
    );
  }
}

2. 修改测试代码(手动触发图片加载回调)

在测试中手动获取ImageStream并触发监听器,让Completer完成,从而渲染IconButton:

void main() {
  testWidgets('Thumbnail', (WidgetTester tester) async {
    var thumbnailPage = page.Page('Thumbnail', const [],
        thumbnail_package.Thumbnail(
          thumbnail: image_samples.thumbnail_64x64,
          mainImage: thumbnail_package.ImagePage(
            'MainImage',
            MemoryImage(image_samples.thumbnail_64x64),
          ),
        ));
    await tester.pumpWidget(MaterialApp(home: thumbnailPage));

    expect(find.byType(thumbnail_package.Thumbnail), findsOneWidget);
    expect(find.byType(Image), findsOneWidget);

    // 验证图片数据
    for (final image in find.byType(Image).evaluate().map((element) => element.widget as Image)) {
      expect(
        Uint8ListCompareByValue((image.image as MemoryImage).bytes),
        Uint8ListCompareByValue(image_samples.thumbnail_64x64),
      );
    }

    // 手动触发图片加载完成的回调
    final thumbnailWidget = tester.widget<thumbnail_package.Thumbnail>(find.byType(thumbnail_package.Thumbnail));
    final imageStream = thumbnailWidget.thumbnailImage.image.resolve(const ImageConfiguration());
    final imageInfo = await imageStream.first;
    // 触发所有已注册的监听器
    for (final listener in imageStream.listeners) {
      listener(imageInfo, true);
    }

    // 触发UI更新,让FutureBuilder重新构建
    await tester.pump();

    // 现在IconButton已渲染,可执行点击操作
    await tester.tap(find.byType(IconButton));
    await tester.pumpAndSettle();

    expect(find.byType(thumbnail_package.Thumbnail), findsNothing);
    expect(find.byType(Image), findsOneWidget);

    // 验证跳转后的图片数据
    for (final image in find.byType(Image).evaluate().map((element) => element.widget as Image)) {
      expect(
        Uint8ListCompareByValue((image.image as MemoryImage).bytes),
        Uint8ListCompareByValue(image_samples.thumbnail_64x64),
      );
    }
  });
}

原理说明

测试环境中没有真实的图片加载引擎,无法自动完成图片解码流程。通过手动获取ImageStream的ImageInfo并触发监听器,模拟图片加载完成的场景,让Completer完成Future,FutureBuilder就能获取到图片尺寸数据,从而显示IconButton。

内容的提问来源于stack exchange,提问作者Arks

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 09:59:54