Flutter中FutureBuilder构建的Widget真机正常但测试不渲染问题
问题背景
开发的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

