日期: 2024-12-28 状态: 已完成 涉及文件: client/lib/ui/chat_page.dart, core/internal/api/server.go
1. 背景与问题
1.1 原始实现
最初的聊天消息加载是一次性全量加载:
final result = await appState.api.listSendMessages(widget.session.id);1.2 问题分析
- 性能问题:当消息量大(数百/数千条)时,首次加载慢
- 内存问题:全量消息占用大量内存
- 体验问题:等待时间长,白屏时间久
2. 方案设计
2.1 技术选型
评估了几种分页方案:
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| offset 分页 | 使用 offset + limit | 实现简单,兼容现有 API | 数据变化时可能重复/跳过 |
| cursor 分页 | 使用游标(如时间戳) | 数据一致性好 | 实现复杂,API 改动大 |
| keyset 分页 | 基于排序键分页 | 性能最优 | 实现最复杂 |
最终选择:offset 分页
- 理由:我们的场景是个人单向发送消息,数据变化少,offset 足够
- 优势:最小改动,复用现有 API 结构
2.2 核心设计
+------------------+| 最新消息 (底部) | ← 最先看到这里+------------------+| ... | ← 向上滚动查看历史+------------------+| 历史消息 (顶部) | ← 滚动到这里触发加载更多+------------------+关键决策:
- 反向列表:使用
reverse: true让最新消息在底部 - 懒加载触发点:距离顶部 200px 时自动加载更多
- 每页大小:50 条(平衡性能和体验)
3. 实现细节
3.1 状态变量
// 分页加载状态
static const int _pageSize = 50; // 每页加载数量
int _currentOffset = 0; // 当前加载位置
bool _hasMoreHistory = true; // 是否有更多历史
bool _loadingMore = false; // 是否正在加载更多3.2 滚动监听
void _onScroll() {
// 检测是否滚动到历史顶部(reverse: true 时,近 maxScrollExtent)
if (_hasMoreHistory && !_loadingMore && _scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
// 当距离顶部小于 200px 时加载更多
if (currentScroll >= maxScroll - 200) {
_loadMoreHistory();
}
}
}关键点解释:
reverse: true时,maxScrollExtent是列表”顶部”(最早消息位置)- 向上滚动时
pixels增加,接近maxScrollExtent
3.3 加载更多历史
Future<void> _loadMoreHistory() async {
if (_loadingMore || !_hasMoreHistory) return;
setState(() => _loadingMore = true);
final result = await appState.api.listSendMessages(
widget.session.id,
limit: _pageSize,
offset: _currentOffset,
);
if (result.isSuccess && result.data != null) {
final olderMessages = pageResult.messages.toList();
if (olderMessages.isNotEmpty) {
// 将更早的消息插入到列表,然后排序
final allMessages = [...olderMessages, ..._messages];
allMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
setState(() {
_messages = allMessages;
_currentOffset += olderMessages.length;
_hasMoreHistory = pageResult.hasMore;
_loadingMore = false;
});
}
}
}3.4 后端 API 响应
// 响应结构包含分页信息
type PagedMessages struct {
Messages []SendMessage `json:"messages"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
HasMore bool `json:"hasMore"`
}4. 特殊场景处理
4.1 日历跳转兼容
从历史日历选择某天跳转时,目标消息可能不在已加载范围内:
Future<void> _jumpToDate(DateTime date) async {
// 获取当天第一条消息
final result = await appState.api.listSendMessages(
sessionId,
startDate: date,
limit: 1,
);
if (result.isSuccess && result.data!.messages.isNotEmpty) {
final targetId = result.data!.messages.first.id;
// 如果目标消息不在当前列表,需要重新加载
if (!_messages.any((m) => m.id == targetId)) {
await _loadMessagesAroundDate(date);
}
_scrollToMessage(targetId);
}
}4.2 搜索跳转兼容
搜索模式需要搜索全部消息,在进入搜索模式前先加载所有:
void _enterSearchMode() {
// 搜索前确保加载所有消息
while (_hasMoreHistory) {
await _loadMoreHistory();
}
setState(() => _isSearchMode = true);
_performSearch();
}4.3 本地优先 + 合并
首次加载优先使用本地缓存,避免白屏:
Future<void> _loadMessages() async {
// 1. 先加载本地缓存
final cachedRecords = await appState.db.getSessionMessages(
widget.session.id,
limit: 100,
);
if (cachedRecords.isNotEmpty) {
setState(() {
_messages = cachedMessages;
_loading = false;
});
_scrollToBottom(instant: true); // 首次加载瞬间滚动
}
// 2. 后台从服务器加载最新
final result = await appState.api.listSendMessages(sessionId);
if (result.isSuccess) {
// 合并:保留本地 pending/failed 消息
final serverIds = serverMessages.map((m) => m.id).toSet();
final localOnlyMessages = _messages.where(
(m) => !serverIds.contains(m.id) &&
(_failedMessageIds.contains(m.id) || _pendingMessageIds.contains(m.id))
).toList();
final allMessages = [...serverMessages, ...localOnlyMessages];
allMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
setState(() {
_messages = allMessages;
_hasMoreHistory = pageResult.hasMore;
_currentOffset = serverMessages.length;
});
}
}5. 使用体验优化
5.1 加载指示器
顶部显示加载更多指示器:
if (_loadingMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),5.2 滚动位置保持
加载更多后保持当前滚动位置,通过 reverse: true 列表自动实现:
- 新消息插入到列表开头
- 当前查看的位置自动保持
5.3 新消息提示
我不在底部时收到新消息,显示悬浮按钮:
if (_showNewMessageButton)
Positioned(
bottom: 80,
right: 16,
child: FloatingActionButton.small(
onPressed: _scrollToBottom,
child: const Icon(Icons.arrow_downward),
),
),6. 经验总结
6.1 做对的
- offset 分页足够:不要过度设计,个人应用场景不需要 cursor 分页
- 本地优先:先显示缓存,使用体验更好
- reverse: true 简化逻辑:滚动位置保持自动处理
6.2 需要注意
- 搜索时需加载全部:会话内搜索需要搜索全部消息,分页后需特殊处理
- 日历跳转需额外加载:跳转到历史日期可能需要加载中间消息
- 消息去重:本地消息和服务器消息合并时要处理重复
7. 相关文件
client/lib/ui/chat_page.dart- 分页加载核心实现client/lib/core/api/api_client.dart- API 客户端core/internal/api/server.go- 后端分页查询