December 27, 2025
3 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.

一、问题背景

在聊天界面上传文件时,如果我在上传完成前退出聊天页面,上传任务会直接终止失败。

原因分析

  • 上传逻辑在 ChatPage 内直接执行
  • 页面 dispose() 时,异步任务随之销毁
  • 使用体验差:必须等待上传完成才能退出

二、解决方案

2.1 设计原则

根据项目规范和之前 E2E 导入导出重构的经验教训:

“先问’能否复用’,再考虑’如何新建’”

关键决策

  • ✅ 复用现有的 uploadByPathgetUploadProgress API
  • ✅ 只在客户端层面做状态管理位置调整
  • ❌ 不重新实现上传核心逻辑

2.2 架构对比

改动前

ChatPage._sendFileMessage()
├── 创建临时消息
├── 调用 api.uploadByPath() ← 页面销毁时终止
├── 轮询 api.getUploadProgress()
├── 调用 api.addSendMessage()
└── 生成缩略图

改动后

ChatPage._sendFileMessage()
├── 创建临时消息(乐观更新)
└── appState.enqueueSendUpload() ← 加入全局队列,页面销毁不影响
AppState._processSendUpload() ← 后台独立执行
├── 调用 api.uploadByPath() ← 复用现有接口
├── 轮询 api.getUploadProgress() ← 复用现有接口
├── 调用 api.addSendMessage()
└── 生成缩略图
ChatPage 通过 addListener 监听进度更新

三、实现细节

3.1 AppState 新增数据结构

/// 发送任务(后台执行,不依赖页面生命周期)
class _SendUploadJob {
  final String tempId;       // 临时消息 ID
  final String sessionId;    // 会话 ID
  final String filePath;     // 本地文件路径
  final String fileName;     // 文件名
  final int fileSize;        // 文件大小
  final CancelToken cancelToken;
}
 
/// 发送状态枚举
enum SendUploadStatus { 
  uploading,   // 上传中
  sending,     // 发送消息中
  generating,  // 生成缩略图中
  success,     // 成功
  failed       // 失败
}
 
/// 发送进度跟踪
class SendUploadProgress {
  final String tempId;
  final String sessionId;
  final String fileName;
  final double progress;      // 0.0-1.0
  final SendUploadStatus status;
  final String? error;
  final String? fileId;
}

3.2 核心处理流程

Future<void> _processSendUpload(_SendUploadJob job) async {
  // 1. 上传文件(复用现有接口)
  final taskResult = await api.uploadByPath(
    filePath: job.filePath,
    fileName: job.fileName,
    remotePath: '/sender/',
    skipMetadata: true,  // 发送附件不写入元数据索引
  );
 
  // 2. 轮询上传进度(复用现有接口)
  while (true) {
    await Future.delayed(const Duration(milliseconds: 500));
    final progress = await api.getUploadProgress(taskId);
    
    _sendUploadProgress[job.tempId] = progress.copyWith(...);
    notifyListeners();  // 通知监听者
    
    if (progress.isDone) break;
  }
 
  // 3. 发送消息
  await api.addSendMessage(...);
 
  // 4. 更新数据库
  await db.deleteSendMessage(job.tempId);
  await db.upsertSendMessage(result.data!);
 
  // 5. 生成缩略图(根据文件类型)
  if (isVideoFile(job.fileName)) {
    await _generateSendVideoThumbnail(job.filePath, uploadedFileId);
  } else if (isImageFile(job.fileName)) {
    await _generateSendImageThumbnail(job.filePath, uploadedFileId);
  }
 
  // 6. 完成
  _sendUploadProgress[job.tempId] = progress.copyWith(
    status: SendUploadStatus.success,
  );
  notifyListeners();
}

3.3 ChatPage 监听机制

@override
void initState() {
  super.initState();
  // 监听 AppState 变化(发送进度更新)
  WidgetsBinding.instance.addPostFrameCallback((_) {
    context.read<AppState>().addListener(_onAppStateChanged);
  });
}
 
void _onAppStateChanged() {
  if (!mounted) return;
  final appState = context.read<AppState>();
  
  for (final entry in appState.sendUploadProgress.entries) {
    if (entry.value.sessionId != widget.session.id) continue;
    
    if (progress.status == SendUploadStatus.success) {
      _refreshMessages();  // 刷新消息列表
      appState.removeSendProgress(tempId);
      return;
    } else if (progress.status == SendUploadStatus.failed) {
      _failedMessageIds.add(tempId);
      appState.removeSendProgress(tempId);
    }
  }
  setState(() {});
}
 
@override
void dispose() {
  context.read<AppState>().removeListener(_onAppStateChanged);
  super.dispose();
}

四、与过度设计的区别

参考 20251221-235200-e2e-import-export-refactor.md 中的教训:

维度E2E 过度设计这次实现
上传核心逻辑❌ 重新实现✅ 复用 uploadByPath
进度追踪❌ 独立系统✅ 复用 getUploadProgress
新增代码用途重复造轮子生命周期管理 + 通知机制

这次改动的本质:只是把任务管理从页面级移到全局,底层完全复用现有 API。


五、修改文件清单

5.1 app_state.dart

新增:

  • _SendUploadJob
  • SendUploadStatus 枚举
  • SendUploadProgress
  • _sendUploadProgress Map
  • enqueueSendUpload() 方法
  • _processSendUpload() 方法
  • _handleSendUploadError() 方法
  • _generateSendVideoThumbnail() 方法
  • _generateSendImageThumbnail() 方法
  • getSendProgress() / removeSendProgress() 方法

5.2 chat_page.dart

修改:

  • initState() - 添加 AppState 监听器
  • dispose() - 移除监听器
  • _sendFileMessage() - 简化为只创建临时消息 + 加入队列
  • _buildMessageBubble() - 从 AppState 获取进度

删除:

  • _uploadProgress 本地变量
  • _generateAndUploadVideoThumbnail() 方法
  • _generateAndUploadImageThumbnail() 方法

六、经验总结

  1. 页面级任务 vs 全局任务:需要跨页面生命周期的任务应放在全局状态管理
  2. 复用优先:先考虑复用现有接口,而非重新实现
  3. 监听模式:使用 addListener / removeListener 实现页面与全局状态的解耦
  4. 乐观更新:先显示临时消息,后台异步处理,提升使用体验