聊天时间分割线优化与缩略图生成菜单

January 1, 2026
7 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. 时间显示 UTC 时区问题:显示的是 UTC 时间而非本地时间
  3. 缩略图缓存频繁失效:每次进入视频聊天界面都会产生大量传输
  4. 聊天附件无法手动生成缩略图: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 时间

我这边看到的时间与本地时区不一致,特别是跨日期边界时会导致分隔线显示在错误的位置。

问题三:缩略图缓存被系统清理

分析传输量大的原因:

  1. ThumbnailCacheManager 使用临时目录getTemporaryDirectory() 返回的路径可能被系统随时清理
  2. 缓存被清理后:所有缩略图需要重新从 S3 下载
  3. 如果开启 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');
  }
}

权衡考虑

方案优点缺点
临时目录不占用本地存储配额会被系统清理,导致重复下载
应用文档目录持久保存,不会被清理占用本地存储空间
应用支持目录持久保存,不备份部分平台不支持

选择应用文档目录的理由

  1. 缩略图是重要的本地数据派生产物,值得持久保存
  2. 缩略图通常较小(几十KB),不会占用太多空间
  3. 重新下载的网络成本和时间成本远高于存储成本
  4. 跨平台兼容性好

方案三:聊天消息缩略图生成菜单

方案调研

考虑了以下方案:

方案 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);

实现细节

修改的文件

  1. chat_page.dart

    • 修复 DateSeparator 位置(从消息下方移到上方)
    • 添加 .toLocal() 时区转换
    • 在消息长按菜单添加”生成缩略图”选项
    • 新增 _regenerateThumbnail 方法
  2. chat_avatar.dart(DateSeparator 组件所在文件)

    • 重新设计 UI:从蓝色标签改为左线+文字+右线
    • 改进 _formatDate 方法的时区处理和格式化逻辑
  3. 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 设计,简洁不抢眼
缓存存储位置应用文档目录持久保存,避免重复下载
视频缩略图生成确认后执行消耗流量,需要使用前知情同意
入口位置消息长按菜单直观明确,不影响现有架构

遗留问题和后续优化

  1. 缓存大小限制:当前磁盘缓存没有大小限制,长期使用可能占用较多空间。可考虑添加 LRU 淘汰或大小限制。

  2. 批量生成:当前只支持单条消息生成,可考虑批量选择多条消息生成缩略图。

  3. 生成失败自动重试:可在后台维护一个失败队列,定期重试。

  4. 缓存迁移:从临时目录改为文档目录后,旧缓存会丢失。可考虑首次启动时迁移旧缓存(非必需,因为会自动重新下载)。


缩略图缓存初始化时序问题修复

问题现象

我这边用的时候发现:每次进入有视频的聊天界面都会产生大量传输,缩略图磁盘缓存没有生效。

深度分析

1. 缓存加载流程

chat_page._loadThumbnailAsync()
fetchOrGenerateThumbnail()
ThumbnailCacheManager().loadThumbnail(fileId) ← 先查磁盘缓存
↓ (如果为 null)
api.getThumbnailWithInfo() ← 从后端下载
ThumbnailCacheManager().saveThumbnail() ← 保存到磁盘

2. 问题根源:初始化时序

发现点 1ThumbnailCacheManager().init() 只在 files_page.dart 中被调用!

// files_page.dart
Future<void> _initThumbnailCache() async {
  await ThumbnailCacheManager().init();  // 只有这里调用了 init()
}

如果我直接进入 Chat 页面(不经过 Files 页面),缓存管理器可能还没有正确初始化。

发现点 2setS3ConfigId()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 警告。