一、问题背景
在聊天界面上传文件时,如果我在上传完成前退出聊天页面,上传任务会直接终止失败。
原因分析:
- 上传逻辑在
ChatPage内直接执行 - 页面
dispose()时,异步任务随之销毁 - 使用体验差:必须等待上传完成才能退出
二、解决方案
2.1 设计原则
根据项目规范和之前 E2E 导入导出重构的经验教训:
“先问’能否复用’,再考虑’如何新建’”
关键决策:
- ✅ 复用现有的
uploadByPath和getUploadProgressAPI - ✅ 只在客户端层面做状态管理位置调整
- ❌ 不重新实现上传核心逻辑
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类_sendUploadProgressMapenqueueSendUpload()方法_processSendUpload()方法_handleSendUploadError()方法_generateSendVideoThumbnail()方法_generateSendImageThumbnail()方法getSendProgress()/removeSendProgress()方法
5.2 chat_page.dart
修改:
initState()- 添加 AppState 监听器dispose()- 移除监听器_sendFileMessage()- 简化为只创建临时消息 + 加入队列_buildMessageBubble()- 从 AppState 获取进度
删除:
_uploadProgress本地变量_generateAndUploadVideoThumbnail()方法_generateAndUploadImageThumbnail()方法
六、经验总结
- 页面级任务 vs 全局任务:需要跨页面生命周期的任务应放在全局状态管理
- 复用优先:先考虑复用现有接口,而非重新实现
- 监听模式:使用
addListener/removeListener实现页面与全局状态的解耦 - 乐观更新:先显示临时消息,后台异步处理,提升使用体验