背景
本次开发解决了聊天界面三个关联问题:
- 时间分割线渲染问题:分隔符出现在消息下方而非上方,逻辑错误
- 时间显示 UTC 时区问题:显示的是 UTC 时间而非本地时间
- 缩略图缓存频繁失效:每次进入视频聊天界面都会产生大量传输
- 聊天附件无法手动生成缩略图:skipMetadata 的文件不在文件列表中,没有生成入口
问题分析
问题一:时间分割线位置错误
在使用 reverse: true 的 ListView 中,最新消息在底部,旧消息在顶部。时间分割线应该表示”从这里开始是新的一天”,因此应该出现在消息上方。
原有代码问题:
Widget messageContent = Column(
key: ValueKey(message.id),
children: [
_buildMessageBubble(message),
// 日期分隔符在消息下方 — 错误位置!
if (showDateSeparator) DateSeparator(date: message.createdAt),
],
);视觉效果(错误):
[消息A - 1月1日]--- 1月2日 --- ← 分隔符在消息A下方,但意图是标记消息B[消息B - 1月2日]问题二:UTC 时区未转换
传给 DateSeparator 的日期是原始 UTC 时间,未调用 toLocal() 转换:
// 问题代码
DateSeparator(date: message.createdAt) // createdAt 是 UTC 时间我这边看到的时间与本地时区不一致,特别是跨日期边界时会导致分隔线显示在错误的位置。
问题三:缩略图缓存被系统清理
分析传输量大的原因:
- ThumbnailCacheManager 使用临时目录:
getTemporaryDirectory()返回的路径可能被系统随时清理 - 缓存被清理后:所有缩略图需要重新从 S3 下载
- 如果开启 autoGenVideo:还会触发视频流式下载来生成缩略图
// 问题代码
Future<void> init() async {
final dir = await getTemporaryDirectory(); // 临时目录,会被系统清理!
_baseDir = Directory(path.join(dir.path, 'thumbnail_cache'));
}问题四:聊天附件无生成入口
聊天发送的附件使用 skipMetadata: true,特点:
- 文件上传到 S3,有 fileId
- 不写入 meta.json 的 files 列表
- 在”文件”页面不可见
- 无法通过文件操作菜单生成缩略图
如果上传时缩略图生成失败(如视频编解码问题),使用时没有任何途径重新生成。
方案设计
方案一:时间分割线重新设计
位置修复
将分隔符移到消息上方:
Widget messageContent = Column(
key: ValueKey(message.id),
children: [
// 日期分隔符在消息上方(表示"从这里开始是新的一天")
if (showDateSeparator) DateSeparator(date: message.createdAt.toLocal()),
_buildMessageBubble(message),
],
);视觉效果(正确):
[消息A - 1月1日]--- 1月2日 --- ← 分隔符在消息B上方,表示从这里开始是1月2日[消息B - 1月2日]UI 重新设计
参考 WhatsApp/Telegram 设计,从蓝色圆角标签改为简洁的左线+文字+右线:
修改前:
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(formattedDate, style: TextStyle(color: Colors.blue)),
)修改后:
Row(
children: [
Expanded(child: Container(height: 1, color: lineColor)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(formattedDate, style: TextStyle(color: textColor, fontSize: 12)),
),
Expanded(child: Container(height: 1, color: lineColor)),
],
)日期格式化逻辑
需要处理多种场景:
| 场景 | 显示格式 | 示例 |
|---|---|---|
| 今天 | ”今天” | 今天 |
| 昨天 | ”昨天” | 昨天 |
| 本周内 | ”周X” | 周三 |
| 同年其他日期 | ”X月X日 周X” | 1月15日 周三 |
| 不同年 | ”XXXX年X月X日” | 2025年12月31日 |
String _formatDate(DateTime date) {
final now = DateTime.now();
final localDate = date.isUtc ? date.toLocal() : date;
final today = DateTime(now.year, now.month, now.day);
final dateOnly = DateTime(localDate.year, localDate.month, localDate.day);
final diff = today.difference(dateOnly).inDays;
if (diff == 0) return '今天';
if (diff == 1) return '昨天';
if (diff < 7 && localDate.weekday < now.weekday) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return weekdays[localDate.weekday - 1];
}
if (now.year == localDate.year) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return '${localDate.month}月${localDate.day}日 ${weekdays[localDate.weekday - 1]}';
}
return '${localDate.year}年${localDate.month}月${localDate.day}日';
}方案二:缩略图缓存持久化
问题根源
Android/iOS 系统会在以下情况清理临时目录:
- 存储空间不足
- 应用长时间未使用
- 系统维护清理
解决方案
将缓存目录从临时目录改为应用文档目录:
Future<void> init() async {
try {
// 使用应用文档目录而不是临时目录,避免系统清理缓存
final dir = await getApplicationDocumentsDirectory();
_baseDir = Directory(path.join(dir.path, 'thumbnail_cache'));
if (!await _baseDir!.exists()) {
await _baseDir!.create(recursive: true);
}
} catch (e) {
debugPrint('ThumbnailCacheManager init error: $e');
}
}权衡考虑
| 方案 | 优点 | 缺点 |
|---|---|---|
| 临时目录 | 不占用本地存储配额 | 会被系统清理,导致重复下载 |
| 应用文档目录 | 持久保存,不会被清理 | 占用本地存储空间 |
| 应用支持目录 | 持久保存,不备份 | 部分平台不支持 |
选择应用文档目录的理由:
- 缩略图是重要的本地数据派生产物,值得持久保存
- 缩略图通常较小(几十KB),不会占用太多空间
- 重新下载的网络成本和时间成本远高于存储成本
- 跨平台兼容性好
方案三:聊天消息缩略图生成菜单
方案调研
考虑了以下方案:
方案 A:将聊天附件也纳入文件列表
- 优点:复用现有的文件操作菜单
- 缺点:违背 skipMetadata 的设计初衷,污染文件列表
方案 B:在聊天消息长按菜单中添加”生成缩略图”选项
- 优点:入口明确,不影响现有架构
- 缺点:需要新增代码
方案 C:后端自动重试机制
- 优点:无需手动干预
- 缺点:实现复杂,可能浪费资源
选择方案 B:最简洁有效,提供明确的手动恢复途径。
图片 vs 视频的生成策略
| 类型 | 生成方式 | 网络消耗 | 处理时间 |
|---|---|---|---|
| 图片 | 后端从原图提取 | 低(后端操作) | 快 |
| 视频 | 前端从流式 URL 提取帧 | 高(需下载视频流) | 慢 |
图片缩略图生成流程
// 1. 删除可能存在的旧缩略图
await appState.api.deleteThumbnail(fileId);
// 2. 触发后端按需生成(autoGen: true)
final result = await appState.api.getThumbnailWithInfo(fileId, autoGen: true);
// 3. 更新本地缓存
if (result.imageData != null) {
final thumbData = Uint8List.fromList(result.imageData!);
await ThumbnailCacheManager().saveThumbnail(fileId, thumbData);
_thumbnailCache.put(fileId, thumbData);
}视频缩略图生成流程
视频需要特殊处理,因为要消耗流量下载视频流:
// 1. 确认对话框(提示会消耗流量)
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('生成视频缩略图'),
content: const Text('需要下载视频数据来生成缩略图,这可能会消耗一些流量。是否继续?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('继续')),
],
),
);
if (confirm != true) return;
// 2. 使用流式 URL 生成缩略图
final streamUrl = appState.api.getStreamUrl(fileId);
final headers = appState.api.getStreamHeaders();
thumbData = await VideoThumbnailService().generateFromUrl(streamUrl, headers);
// 3. 上传到后端
if (thumbData != null) {
await appState.api.uploadThumbnail(fileId, thumbData);
}
// 4. 更新本地缓存
await ThumbnailCacheManager().saveThumbnail(fileId, thumbData);
_thumbnailCache.put(fileId, thumbData);实现细节
修改的文件
-
chat_page.dart
- 修复 DateSeparator 位置(从消息下方移到上方)
- 添加
.toLocal()时区转换 - 在消息长按菜单添加”生成缩略图”选项
- 新增
_regenerateThumbnail方法
-
chat_avatar.dart(DateSeparator 组件所在文件)
- 重新设计 UI:从蓝色标签改为左线+文字+右线
- 改进
_formatDate方法的时区处理和格式化逻辑
-
thumbnail_cache.dart
- 将
getTemporaryDirectory()改为getApplicationDocumentsDirectory()
- 将
菜单项条件判断
只对图片和视频消息显示”生成缩略图”选项:
// 图片/视频消息:重新生成缩略图
if (message.fileId != null && message.fileName != null) {
final fileType = getFileType(message.fileName!);
if (fileType == AppFileType.image || fileType == AppFileType.video) {
items.add(
ContextMenuItem(
icon: TablerIcons.photo,
label: '生成缩略图',
onTap: () => _regenerateThumbnail(message),
),
);
}
}加载状态显示
生成过程中显示模态加载对话框:
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 40, height: 40, child: CircularProgressIndicator(strokeWidth: 3)),
const SizedBox(height: 16),
Text(isVideo ? '正在生成视频缩略图...' : '正在生成图片缩略图...'),
],
),
),
),
);错误处理
try {
// 生成逻辑...
} catch (e) {
if (mounted) {
Navigator.pop(context); // 关闭加载对话框
showAppToast(context, '缩略图生成失败: $e');
}
}涉及的缓存架构
缩略图使用两级缓存:
┌─────────────────────────────────────────────────────────┐│ 请求缩略图 │└─────────────────────────┬───────────────────────────────┘ │ ▼ ┌───────────────────────┐ │ 内存缓存 (LRU) │ ← ThumbnailMemoryCache │ 最大 100 条目 │ └───────────┬───────────┘ hit? │ miss ▼ ┌───────────────────────┐ │ 磁盘缓存 │ ← ThumbnailCacheManager │ 应用文档目录 │ (本次修改后) └───────────┬───────────┘ hit? │ miss ▼ ┌───────────────────────┐ │ 远程 S3 下载 │ ← API.getThumbnail() │ (可选 autoGen) │ └───────────────────────┘设计取舍总结
| 决策点 | 选择 | 原因 |
|---|---|---|
| 时间分割线位置 | 消息上方 | 符合”从这里开始是新的一天”的语义 |
| 时间分割线样式 | 左线+文字+右线 | 参考主流 IM 设计,简洁不抢眼 |
| 缓存存储位置 | 应用文档目录 | 持久保存,避免重复下载 |
| 视频缩略图生成 | 确认后执行 | 消耗流量,需要使用前知情同意 |
| 入口位置 | 消息长按菜单 | 直观明确,不影响现有架构 |
遗留问题和后续优化
-
缓存大小限制:当前磁盘缓存没有大小限制,长期使用可能占用较多空间。可考虑添加 LRU 淘汰或大小限制。
-
批量生成:当前只支持单条消息生成,可考虑批量选择多条消息生成缩略图。
-
生成失败自动重试:可在后台维护一个失败队列,定期重试。
-
缓存迁移:从临时目录改为文档目录后,旧缓存会丢失。可考虑首次启动时迁移旧缓存(非必需,因为会自动重新下载)。
缩略图缓存初始化时序问题修复
问题现象
我这边用的时候发现:每次进入有视频的聊天界面都会产生大量传输,缩略图磁盘缓存没有生效。
深度分析
1. 缓存加载流程
chat_page._loadThumbnailAsync() ↓fetchOrGenerateThumbnail() ↓ThumbnailCacheManager().loadThumbnail(fileId) ← 先查磁盘缓存 ↓ (如果为 null)api.getThumbnailWithInfo() ← 从后端下载 ↓ThumbnailCacheManager().saveThumbnail() ← 保存到磁盘2. 问题根源:初始化时序
发现点 1:ThumbnailCacheManager().init() 只在 files_page.dart 中被调用!
// files_page.dart
Future<void> _initThumbnailCache() async {
await ThumbnailCacheManager().init(); // 只有这里调用了 init()
}如果我直接进入 Chat 页面(不经过 Files 页面),缓存管理器可能还没有正确初始化。
发现点 2:setS3ConfigId() 和 init() 的时序问题
// app_state.dart 启动流程
_initCoreAndNetwork()
↓
_loadPreferences()
↓
_loadS3Config()
↓
_applyS3Config(config) // 调用 setS3ConfigId()
↓
ThumbnailCacheManager().setS3ConfigId(config.id)在 setS3ConfigId() 中:
Future<void> setS3ConfigId(String? s3ConfigId) async {
_currentS3ConfigId = s3ConfigId;
await _updateCacheDir(); // 调用 _updateCacheDir
}
Future<void> _updateCacheDir() async {
if (_baseDir == null) return; // 如果 init() 还没调用,_baseDir 为 null,直接返回!
// ... 设置 _cacheDir
}结果:_cacheDir 可能为 null 或指向错误的目录,导致每次 loadThumbnail 都返回 null,强制从后端重新下载。
发现点 3:并发初始化问题
Future<void> _ensureInit() async {
if (_initialized && _cacheDir != null) return;
await init(); // 没有并发保护!
}多个并发的 loadThumbnail 调用可能导致多次 init() 执行,产生竞争条件。
修复方案
修复 1:确保 init() 在 setS3ConfigId() 之前调用
// app_state.dart
Future<void> _loadS3Config() async {
// 确保缩略图缓存管理器先初始化,再设置 S3 配置 ID
// 否则 setS3ConfigId 调用时 _baseDir 为 null,导致缓存目录设置失败
await ThumbnailCacheManager().init();
final activeConfig = await db.getActiveS3Config();
if (activeConfig != null) {
_applyS3Config(activeConfig);
}
}修复 2:添加初始化锁防止并发
// thumbnail_cache.dart
Completer<void>? _initCompleter;
Future<void> init() async {
// 如果已经初始化完成,直接返回
if (_initialized && _baseDir != null) {
return;
}
// 如果正在初始化,等待完成
if (_initCompleter != null) {
await _initCompleter!.future;
return;
}
// 开始初始化
_initCompleter = Completer<void>();
try {
final dir = await getApplicationDocumentsDirectory();
_baseDir = Directory(path.join(dir.path, 'thumbnail_cache'));
// ...
} finally {
_initCompleter!.complete();
_initCompleter = null;
}
}修复 3:添加调试日志
Future<void> setS3ConfigId(String? s3ConfigId) async {
// ...
debugPrint('[ThumbnailCache] S3 config ID set to: $s3ConfigId, cacheDir: ${_cacheDir?.path}');
}
Future<void> init() async {
// ...
debugPrint('[ThumbnailCache] Initialized. baseDir: ${_baseDir?.path}, cacheDir: ${_cacheDir?.path}');
}缓存目录迁移说明
之前我们把缓存目录从临时目录改为应用文档目录:
- 旧路径:
getTemporaryDirectory()/thumbnail_cache/ - 新路径:
getApplicationDocumentsDirectory()/thumbnail_cache/
首次运行更新后的代码时,旧缓存会丢失(临时目录的文件找不到)。这是一次性问题,重新下载后就会缓存在新目录,之后不会再重复下载。
测试验证
flutter analyze
# 输出: No issues found!确认无编译错误和 lint 警告。