缩略图生成进度对话框优化

January 1, 2026
4 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

我这边用的时候发现生成缩略图时,弹窗闪一下就消失,无法感知任务执行状态。需要改进交互体验:

  1. 单文件:显示”正在生成中…”加载动画
  2. 文件夹:显示实时进度 xx/xx

需求分析

原有实现问题

  • 确认后只显示 Toast 提示”正在生成缩略图…”
  • 后台异步执行,使用时感知不到进度
  • 批量任务时无法知道完成了多少

目标交互

场景确认后显示进度信息完成处理
单文件模态对话框 + 转圈”正在生成中…”自动关闭 + Toast 成功
文件夹模态对话框 + 转圈”正在生成图片/视频缩略图…\n1/10”短暂显示完成 + 自动关闭

方案设计

核心思路

使用 showDialog + StatefulBuilder 实现状态可更新的模态对话框:

  1. 对话框弹出时触发生成任务
  2. 任务进度实时更新对话框内容
  3. 任务完成后自动关闭对话框

关键技术点

1. 避免重复触发任务

StatefulBuilderbuilder 中启动任务,但 builder 会在每次 setState 时重新执行。使用标志位避免重复触发:

bool started = false;
 
builder: (ctx, setState) {
  if (!started) {
    started = true;
    Future(() async {
      // 执行任务...
    });
  }
  return AlertDialog(...);
}

2. 实时进度更新

文件夹场景需要显示 current/total

int current = 0;
int successCount = 0;
 
for (final f in targetFiles) {
  setState(() => current++);  // 更新进度
  try {
    await _generateThumbnail(f);
    successCount++;
  } catch (e) {
    // 继续处理下一个
  }
}

3. 完成状态短暂显示

完成后不立即关闭,先显示完成状态,再关闭:

bool completed = false;
 
// 任务完成后
if (ctx.mounted) {
  setState(() => completed = true);
  await Future.delayed(const Duration(milliseconds: 500));
  if (ctx.mounted) Navigator.pop(ctx);
}
 
// UI 显示
Text(
  completed
      ? '已完成 $successCount/$count'
      : '正在生成图片缩略图...\n$current/$count',
  textAlign: TextAlign.center,
),

4. 错误状态处理

单文件场景需要显示错误信息:

String? errorMsg;
 
try {
  await _generateThumbnail(file);
  Navigator.pop(ctx, true);
} catch (e) {
  setState(() => errorMsg = '生成失败');
  await Future.delayed(const Duration(seconds: 1));
  Navigator.pop(ctx, false);
}
 
// UI 显示
if (errorMsg == null) ...[
  CircularProgressIndicator(...),
  Text('正在生成中...'),
] else ...[
  Icon(TablerIcons.alert_circle, color: error),
  Text(errorMsg!),
],

实现细节

单文件缩略图生成对话框

修改前:

showAppToast(context, '正在生成缩略图...');
try {
  await _generateThumbnail(file);
  showAppToast(context, '缩略图生成成功');
} catch (e) {
  showAppToast(context, '缩略图生成失败');
}

修改后:

bool generating = true;
String? errorMsg;
 
showDialog(
  context: context,
  barrierDismissible: false,  // 禁止点击外部关闭
  builder: (ctx) => StatefulBuilder(
    builder: (ctx, setState) {
      if (generating) {
        generating = false;
        Future(() async {
          try {
            await _generateThumbnail(file);
            if (ctx.mounted) Navigator.pop(ctx, true);
          } catch (e) {
            if (ctx.mounted) {
              setState(() => errorMsg = '生成失败');
              await Future.delayed(const Duration(seconds: 1));
              if (ctx.mounted) Navigator.pop(ctx, false);
            }
          }
        });
      }
 
      return AlertDialog(
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (errorMsg == null) ...[
              const SizedBox(
                width: 40,
                height: 40,
                child: CircularProgressIndicator(strokeWidth: 3),
              ),
              const SizedBox(height: 16),
              const Text('正在生成中...'),
            ] else ...[
              Icon(
                TablerIcons.alert_circle,
                size: 40,
                color: Theme.of(ctx).colorScheme.error,
              ),
              const SizedBox(height: 16),
              Text(errorMsg!),
            ],
          ],
        ),
      );
    },
  ),
).then((success) {
  if (mounted && success == true) {
    showAppToast(context, '缩略图生成成功');
    onActionComplete?.call();
  }
});

文件夹缩略图生成对话框

修改前:

showAppToast(context, '正在生成 $count 个图片缩略图...');
 
int successCount = 0;
for (final f in targetFiles) {
  try {
    await _generateThumbnail(f);
    successCount++;
  } catch (e) {}
}
 
showAppToast(context, '已生成 $successCount/$count 个缩略图');

修改后:

bool started = false;
int current = 0;
int successCount = 0;
bool completed = false;
 
await showDialog(
  context: context,
  barrierDismissible: false,
  builder: (ctx) => StatefulBuilder(
    builder: (ctx, setState) {
      if (!started) {
        started = true;
        Future(() async {
          for (final f in targetFiles) {
            if (!ctx.mounted) break;
            setState(() => current++);
            try {
              await _generateThumbnail(f);
              successCount++;
            } catch (e) {}
          }
 
          if (ctx.mounted) {
            setState(() => completed = true);
            await Future.delayed(const Duration(milliseconds: 500));
            if (ctx.mounted) Navigator.pop(ctx);
          }
        });
      }
 
      return AlertDialog(
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const SizedBox(
              width: 40,
              height: 40,
              child: CircularProgressIndicator(strokeWidth: 3),
            ),
            const SizedBox(height: 16),
            Text(
              completed
                  ? '已完成 $successCount/$count'
                  : '正在生成图片缩略图...\n$current/$count',
              textAlign: TextAlign.center,
            ),
          ],
        ),
      );
    },
  ),
);
 
showAppToast(context, '已生成 $successCount/$count 个缩略图');

修改的文件

1. file_operation_service.dart

  • _showSingleFileThumbnailDialog: 单文件进度对话框
  • _showFolderThumbnailDialog: 文件夹进度对话框(末尾执行部分)

2. file_tiles.dart

  • _showSingleFileThumbnailDialog: 同上
  • _showFolderThumbnailDialog: 同上

设计取舍

为什么使用 StatefulBuilder 而不是独立组件?

  • 进度对话框逻辑简单,不需要独立状态管理
  • StatefulBuilder 可直接访问外层变量(targetFiles、count 等)
  • 代码内聚,修改方便

为什么不用 showDialog 的返回值传递进度?

  • showDialog 返回 Future<T?>,只能在关闭时获取最终结果
  • 需要实时更新显示,必须在对话框内部用 setState

为什么完成后延迟 500ms 再关闭?

  • 让我这边看到完成状态,确认任务确实完成了
  • 防止进度从 “9/10” 直接闪到下一页面,使用时可能没注意到完成

单文件错误显示 1 秒 vs 文件夹无错误显示

  • 单文件:失败就是最终结果,需要明确提示
  • 文件夹:批量任务允许部分失败,最终 Toast 显示成功/总数即可

相关规范记录

这次修改确立了批量任务进度反馈的规范:

  • 批量操作需在弹窗中显示 xx/xx 进度
  • 配合加载动画确保能感知任务状态
  • 单文件操作显示加载动画 + 简单状态提示

遗留的 Lint 警告

修改后存在 3 个 use_build_context_synchronously info 级别警告:

  • 这是 Flutter 中使用异步上下文的常见提示
  • 由于我们已经检查了 ctx.mounted,实际使用是安全的
  • 不影响功能运行

测试验证

  1. 单文件图片:确认后显示转圈 → 成功自动关闭 → Toast 成功
  2. 单文件视频:确认后显示转圈 → 成功自动关闭 → Toast 成功
  3. 文件夹图片:确认后显示 “0/10” → “1/10” → … → “已完成 10/10” → 关闭
  4. 文件夹视频:同上
  5. 失败场景:单文件显示”生成失败”1秒后关闭