聊天消息分页加载机制

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

日期: 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 核心设计

+------------------+
| 最新消息 (底部) | ← 最先看到这里
+------------------+
| ... | ← 向上滚动查看历史
+------------------+
| 历史消息 (顶部) | ← 滚动到这里触发加载更多
+------------------+

关键决策

  1. 反向列表:使用 reverse: true 让最新消息在底部
  2. 懒加载触发点:距离顶部 200px 时自动加载更多
  3. 每页大小: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 做对的

  1. offset 分页足够:不要过度设计,个人应用场景不需要 cursor 分页
  2. 本地优先:先显示缓存,使用体验更好
  3. reverse: true 简化逻辑:滚动位置保持自动处理

6.2 需要注意

  1. 搜索时需加载全部:会话内搜索需要搜索全部消息,分页后需特殊处理
  2. 日历跳转需额外加载:跳转到历史日期可能需要加载中间消息
  3. 消息去重:本地消息和服务器消息合并时要处理重复

7. 相关文件

  • client/lib/ui/chat_page.dart - 分页加载核心实现
  • client/lib/core/api/api_client.dart - API 客户端
  • core/internal/api/server.go - 后端分页查询