需求分析
我们希望客户端在登录后,从服务器拉取聊天信息,并且展示。常规的设计中,客户端本地也会有一个数据库,缓存上一次获取的最后的聊天信息,如果客户端下线了,再次登录,只需要从服务器拉取未接受的数据即可。
所以综合考虑过后将需求列出
- 客户端本地数据库缓存已经接受的消息(以后再做)
- 客户端登录后,将本地数据的最大的消息id发送给服务器,服务器根据这个id去数据库查找,找到比这个id大的消息,将消息回传给客户端
- 客户端登录后,先加载旧的数据,再差异加载未读取的数据即可。
客户端本地数据库存储放在之后实现,所以我们客户端目前只发送消息id为0即可。
数据模型设计
- 消息唯一标识
- 在服务器端的 MySQL 表里,为每条消息分配一个全局唯一的自增主键(
message_id),再配合时间戳(created_at)。 - 客户端本地用同样的
message_id做主键,这样就能很方便地做增量同步与去重。
- 在服务器端的 MySQL 表里,为每条消息分配一个全局唯一的自增主键(
- 会话/用户维度的索引
- 如果支持多对多(群聊),再维护一个会话表(
thread_id)和用户—会话关联表。 - 查询和分页时,都按
(thread_id, message_id)或(thread_id, created_at)建复合索引,加速筛选。
- 如果支持多对多(群聊),再维护一个会话表(
同步流程
客户端登录时
从本地 SQLite 加载最近 N 条消息(按
message_id或时间倒序),渲染到界面。读取本地记录的「每个会话已同步到的最大
message_id」,发送给服务器:1
2
3
4
5{
"action": "fetch_messages",
"thread_id": 123,
"since_id": 3456
}
服务器端响应
- 查询
WHERE thread_id=123 AND message_id>3456 ORDER BY message_id ASC LIMIT 1000 - 返回消息列表(可以分页返回,大量时前端可循环拉取,或返回
has_more标记)。
- 查询
客户端接收并保存
- 将服务器返回的消息批量插入本地 SQLite,注意用「主键冲突忽略(
INSERT OR IGNORE)」防止重复。 - 更新本地「已同步最大
message_id」。
- 将服务器返回的消息批量插入本地 SQLite,注意用「主键冲突忽略(
后续聊天时
- 新消息既推到服务器,也实时写入本地 SQLite。
- 如果走长连接(
Asio+ 自定义协议或使用 WebSocket),服务器收到新消息后直接广播给在线客户端,并提示客户端写到本地。 - 如果客户端离线,新消息积累在服务器,下一次登录再按 above 流程拉取。
聊天消息表
下面给出消息聊天表的字段和解释,包含了message_id, thread_id以及常见的其他字段
1 | CREATE TABLE `chat_message` ( |
字段说明
message_id:全局自增主键,唯一标识一条消息。thread_id:会话(单聊、群聊)ID,同一会话下的所有消息共用一个thread_id。sender_id:发送者用户 ID,指向用户表的主键。recv_id: 接收者用户ID,指向用户表主键content:消息正文,TEXT 类型,适合存储普通文字。created_at:消息创建时间,自动记录插入时刻。updated_at:消息更新时间,可用于标记“撤回”(status 变更)、编辑等操作。status:消息状态,用于标记未读/已读/撤回等(也可扩展更多状态)。
索引设计
- 主键索引:
PRIMARY KEY (message_id)用于唯一检索消息。 - 会话+时间索引:
KEY (thread_id, created_at)支持按会话分页、按时间范围查询。 - 会话+消息ID 索引:
KEY (thread_id, message_id)支持按message_id做增量拉取(WHERE thread_id=… AND message_id > since_id)。
可选扩展
- 群聊用户表:如果支持群聊,需要一个
thread_member表,记录每个thread_id下的成员及其角色。 - 附件支持:若要存储图片/文件,可额外建
message_attachment表,字段例如attachment_id、message_id、file_url、file_type。 - 已读回执:单独设计
message_read表,记录哪些用户在何时已读了该消息,字段如(message_id, user_id, read_at)。
会话消息表
全局聊天线程表
建立chat_thread主表,给它一个全局自增id,记录所有私聊/群聊的线程统一入口
1 | CREATE TABLE chat_thread ( |
单聊表设计
对于单聊,只有两个人,所以可以直接在private_chat表中定义两个字段存储user1_id和user2_id,这样能直接确定参与者
1 | CREATE TABLE `private_chat` ( |
- 通过
user1_id和user2_id唯一确定一个单聊会话 - 询某两个用户的单聊时,直接
SELECT即可。
群聊表设计
群聊相较于单聊要复杂一些,需要记录每个群聊的多名成员及其角色、权限等信息
先建一个独立的会话(线程)表:
1 | CREATE TABLE `group_chat` ( |
- 群聊会话表只存储群聊本身的信息(如群名称、创建时间等),
thread_id是唯一标识符
群聊成员表设计
- 群聊成员表用于存储群聊中各成员的信息(包括角色、加入时间、禁言等)。
1 | CREATE TABLE `group_chat_member` ( |
前端聊天框调整
回顾
我们先回顾一下之前设计的聊天框

对于我们自己发出的信息,我们可以实现这样一个网格布局管理

NameLabel用来显示用户的名字,Bubble用来显示聊天信息,Spacer是个弹簧,保证将NameLabel``,IconLabel,Bubble等挤压到右侧。
如果是别人发出的消息,我们设置这样一个网格布局

增加状态标签
因为自己发送的时候要增加发送状态(发送失败,未读,已读)三种,所以考虑将自己发送的消息改为如下

大体结构如下
1 | 列0 列1 列2 列3 |
代码修改如下
1 | ChatItemBase::ChatItemBase(ChatRole role, QWidget *parent) |
增加接口设置状态
1 | void ChatItemBase::setStatus(int status) { |
客户端同步流程
客户端本地会有sql记录该用户所有聊天记录最后收到的消息信息,包括message_id,thread_id等,每次客户端登录将本地最大messag_id和thread_id发送给服务器,服务器按照每个thread_id将信息恢复给客户端,可支持分页返回。
举例
比如第一次请求,客户端携带message_id为1001,thread_id为22,那么服务器就会去chat_message中升序查找,比message_id(1001)大且thread_id为22的消息,返回20条
客户端拿到20条消息后,可根据最后一个消息messag_id继续请求消息。
所以我们得出一个结论要拉取消息就要有thread_id以及message_id。
接下来的情形分为两种
情况1
本地有thread_id,但是在该用户A离线的时候B用户给他发消息,因为他们之前没有聊过天,所以此时B会通知服务器在private_chat表中创建新的thread_id,但是A本地数据库没有这个thread_id,所以A需要在登录时拉取.
拉取就传递目前A本地数据库中最大的thead_id以及自己的user_id给服务器,服务器去查找比这个thread_id大的会话列表返回即可,采取分页的方式,每次加载100个,并配合load_more字段通知客户端是否继续拉取
如果load_more字段为true则客户端继续拉取,传递上次服务器给它同步的最大的thread_id,服务器继续返回比thread_id大的会话列表。
直到load_more为false,客户端不再拉取。
情况2
如果客户端换了新机器,本地没有记录信息,那么就需要在用户登录后向服务器发送user_id和thread_id,thread_id 请求从 0 开始,服务器将返回该用户的所有聊天thread_id,必须分页返回,并且携带 load_more 字段,字段和上面类似。
一个服务器返回的数据格式如下
1 | { |
可采用如下sql语句查询
1 | -- 1) CTE 把私聊/群聊合并好 |
然后在服务端(伪代码)处理结果:
1 | def fetch_threads(me, last_id, page_size): |
说明
- 为什么要多取 1 条?
- 取
page_size + 1条后,如果结果确实多出那 1 条,就说明“在本页之后”还有数据; - 如果正好只有
page_size条或更少,就可以断定已经取尽。
- 取
- 游标(cursor)模式 vs OFFSET
- 用游标(
thread_id > last_id)可以保证性能,避免大 OFFSET 带来的全表扫描。 - 每次请求只跑新数据所在的索引范围。
- 用游标(
- 客户端流程
- 初次加载:传
last_id = 0; - 点「加载更多」:传上次接口返回的
next_last_id; - **收到
load_more = false**:表示已到末尾,不要再发更多请求。
- 初次加载:传
当然为了提升效率,可以在用户登录后,选择是否同步消息的勾选框
如果勾选则调用上述sql语句查询该用户所有chat_thread返回。
如果没勾选,就不用加载chat_thread。
重构聊天item
需要重构聊天左侧item列表结构,以支持聊天消息记录持久化存储。
默认情况下,会检索本地客户端是否有聊天记录信息,
如果没有则需要请求所有thread_id列表,然后更新左侧item列表。
如果有,也需要差异化加载 thread_id 列表,比如说 A 下线了,B 和 A 通信,A 之前没有收到过 B 的信息,所以也要拉取所有新建立的会话。
所以当务之急是先把这个聊天列表加载好
因为我们没有为客户端设置本地数据库,所以我们默认每次用户登录都请求一下所有thread_id列表,这样方便测试效果
Server返回聊天列表
Server需要根据用户uid返回他的聊天列表
1 注册消息
1 | _fun_callbacks[ID_LOAD_CHAT_THREAD_REQ] = std::bind(&LogicSystem::GetUserThreadsHandler, this, |
2 实现获取聊天记录逻辑
1 | void LogicSystem::GetUserThreadsHandler(std::shared_ptr<CSession> session, |
3 数据库加载聊天
1 | // 新增两个输出参数:loadMore, nextLastId |
客户端请求聊天列表
1 完善loading对话框
完善加载对话框,调整下布局,增加一个label和旋转gif的布局

布局界面

接下来调整下代码
1 |
|
具体实现
1 | LoadingDlg::LoadingDlg(QWidget *parent, QString tip): |
2 加载聊天记录
之前没有从数据库加载聊天记录,只是模拟从本地好友中加载为聊天记录了,现在需要将这部分从ChatDialog构造函数中移除
改为从服务器申请,并且此时展示LoadingDlg对话框,直到获取记录后,将LoadingDlg移除。
因为获取服务器记录是通过网络获取的,所以在客户端的TcpMgr中通过信号发送给ChatDialog界面
所以ChatDialog的构造函数改为如下
1 | ChatDialog::ChatDialog(QWidget* parent) : |
当用户登录成功后会切换到聊天页面,此时请求聊天列表
1 | void MainWindow::SlotSwitchChat() |
通过发送请求获取聊天记录
1 | void ChatDialog::loadChatList() |
TCPMgr注册从服务器获取回复的消息处理
1 | _handlers.insert(ID_LOAD_CHAT_THREAD_RSP, [this](ReqId id, int len, QByteArray data) { |
ChatDialog接收TcpMgr发送的sig_load_chat_thread消息,然后触发如下函数,该函数主要加载聊天列表并且消除加载动画
1 | void ChatDialog::slot_load_chat_thread(bool load_more, int last_thread_id, |
数据库构建
去navicat中执行上面数据模型设计中提到的几个sql语句
1 创建chat_thread
2 创建group_chat

成功后显示

3 创建group_member

成功后显示表

4 创建私聊表

成功后显示

注意: 创建后没有数据,数据是我自己添加的,为了方便测试
开启服务器,客户端登陆后加载数据
效果如下

首次单聊
A和B是好友,首次单聊,A发送给服务器创建聊天的请求。
服务器根据A的创建请求创建私聊,然后返回给客户端A。
注意
因为聊天服务是异步的,而且是分布式的,所以有可能对方B就在此时发送消息给A,服务器已经创建好了,或者服务器正在调用sql创建。
所以对于创建请求,sql需要先查询是否已经被其他人创建了thread_id, 我们可以制定一个规则,任何一方创建thread_id,在写入私聊表private_chat时都需要保证最小的uid为uid1_id, 大的在uid2_id, 这样查询的时候也方便。
这个查询要加行级锁,避免分布式造成数据混乱。
总结
所以创建单聊时,要先去private_chat表根据uid查询,如果查到了则返回这个thread_id, 这个查询要加行级锁。
如果没查到,则在chat_thread表创建thread_id并且插入private_chat表
思路
我们整理下思路
- 查询是否已存在私聊会话,如果存在则加锁行并返回 thread_id
1 | SELECT thread_id |
查询时使用 LEAST 和 GREATEST 来保证无论是 user1_id 还是 user2_id,都将较小的 ID 存放在 user1_id,较大的存放在 user2_id。这样可以避免不同的用户顺序导致查找不到匹配的记录。
FOR UPDATE 关键字会锁定这些查询行,确保在事务结束之前不会有其他并发的操作修改数据。
- 如果未找到数据(查询返回空),则插入新记录:
1 | -- 1. 在 chat_thread 表中创建新记录 |
- 将新生成的 thread_id 插入 private_chat 表
1 | INSERT INTO private_chat (thread_id, user1_id, user2_id, created_at) |
使用 INSERT INTO chat_thread 创建新的聊天记录,并使用 LAST_INSERT_ID() 获取新生成的 thread_id。
将新 thread_id 插入到 private_chat 表中,同时使用 LEAST 和 GREATEST 确保较小的 ID 存入 user1_id,较大的存入 user2_id。
问题分析
- 行级锁的生命周期:
行级锁(通过FOR UPDATE获得的锁)只在当前事务中有效。当查询结束后,锁会被释放。也就是说,如果我们查询了是否存在private_chat的记录并加了锁,但在查询完成后进行插入chat_thread和private_chat的操作时,其他并发请求可能会先插入新的私聊记录,从而造成数据冲突。 - 可能的并发问题:
例如:- 线程 A 执行查询,锁定了
private_chat表的行; - 线程 B 也执行了相同的查询,发现没有记录,于是开始插入
chat_thread; - 线程 A 完成插入
chat_thread和private_chat,但线程 B 也在此时完成了它的插入,导致private_chat表中出现两个重复的记录。
- 线程 A 执行查询,锁定了
解决方案
为了确保并发操作的安全性,我们可以使用 事务 来保证在查询、插入 chat_thread 和 private_chat 表的过程中,数据的一致性和原子性。具体步骤如下:
方案:使用事务(Atomic Transaction)
我们可以使用 事务 来确保操作的一致性,整个操作从查询到插入都在一个事务中进行。这样即使存在多个并发请求,也能保证同一时间只有一个请求可以成功创建 chat_thread 和 private_chat。
关键改动:
- 在查询时加行级锁。
- 确保所有的数据库操作(查询和插入)都在一个事务中进行,这样可以防止并发插入的问题。
- 使用事务提交(
commit)和回滚(rollback)确保数据一致性。
关键代码
1 | bool MysqlDao::CreatePrivateChat(int user1_id, int user2_id, int& thread_id) |
LogicSystem添加创建聊天的回调函数,并且注册
1 | void LogicSystem::CreatePrivateChat(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) |
客户端完善
在好友信息界面
1 | void FriendInfoPage::on_msg_chat_clicked() |
追踪这个信号,我们完善槽函数
1 | void ChatDialog::slot_jump_chat_item_from_infopage(std::shared_ptr<UserInfo> user_info) |
客户端注册服务器返回的消息ID_CREATE_PRIVATE_CHAT_RSP,进行处理
1 | _handlers.insert(ID_CREATE_PRIVATE_CHAT_RSP, [this](ReqId id, int len, QByteArray data) { |
编写槽函数和sig_create_private_chat连接,并且增加聊天条目
1 | //连接tcp返回的创建私聊的回复 |
具体处理的槽函数
1 | void ChatDialog::slot_create_private_chat(int uid, int other_id, int thread_id) |
聊天消息重构
ChaUserWid重构
之前我们的会话列表由一个一个的ChatUserWid构成

原来的ChatUserWid内部存储的是UserInfo结构,目前我们已经增加了ChatThread数据库内容,所以要将会话列表的每个ChatUserWid中存储ChatThreadData结构。
接下来我们定义这几个结构
1 | class ChatUserWid : public ListItemBase |
具体定义
1 | void ChatUserWid::SetChatData(std::shared_ptr<ChatThreadData> chat_data) { |
这样我们就将聊天会话的信息写入到了ChatUserWid这样一个个小条目了。
消息类抽象
因为我们将来要存储文本,文件以及图片不同类型的消息,那么就将原来的消息抽象出一个基类
1 | class ChatDataBase { |
然后基于上面的基类,我们可以定义不同类型的消息,如文本消息
1 | class TextChatData : public ChatDataBase { |
有了这个文本消息后,我们可以将基类指针ChatDataBase存储起来,将来通过实现虚函数,进行多态调用.
ChatThreadData聊天线程
聊天线程数据,重构和完善
1 | //客户端本地存储的聊天线程数据结构 |
具体实现
1 | void ChatThreadData::AddMsg(std::shared_ptr<ChatDataBase> msg) |
好友认证
对于好友认证时,如果双方通过,也要默认建立聊天消息,并且产生会话列表.
我们先从这块接入聊天消息列表,完善整体流程
proto协议修改
因为认证添加好友后,会生成两条聊天信息(比如,我们已经是好友了等),同时通知给对方,协议格式增加和修改如下
1 | message AddFriendMsg{ |
服务器接收好友申请
服务器收到A向B添加好友的请求,会更新数据库申请记录,同时转发给B
1 | void LogicSystem::AddFriendApply(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) |
如果不在一个服务器,则通过grpc通知对端所在服务器, 对端服务器收到后,组织消息转发
1 | Status ChatServiceImpl::NotifyAddFriend(ServerContext* context, const AddFriendReq* request, AddFriendRsp* reply) |
服务器收到同意申请
当B客户同意添加好友,会将请求发送给服务器
服务器收到后会执行
1 | void LogicSystem::AuthFriendApply(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) { |
数据库处理
1 | bool MysqlDao::AddFriend(const int& from, const int& to, std::string back_name, |
服务器收到同意通知
B同意A的申请,此时B所在的服务器会将同意的通知发送到A所在的服务器
下面是A所在的服务器收到请求后,发送通知给A的逻辑
1 | Status ChatServiceImpl::NotifyAuthFriend(ServerContext* context, const AuthFriendReq* request, |
客户端收到好友同意回复
当A申请B加好友,B同意后,服务器会回复给B消息,这样B的客户端要处理同意的回包
1 | _handlers.insert(ID_AUTH_FRIEND_RSP, [this](ReqId id, int len, QByteArray data) { |
界面和好友状态更新
1 | void ChatDialog::slot_auth_rsp(std::shared_ptr<AuthRsp> auth_rsp) |
客户端收到好友同意通知
A加B为好友,B同意后,服务器通知A,以下为A收到通知后的处理
1 | _handlers.insert(ID_NOTIFY_AUTH_FRIEND_REQ, [this](ReqId id, int len, QByteArray data) { |
界面添加好友会话状态更新
1 | void ChatDialog::slot_add_auth_friend(std::shared_ptr<AuthInfo> auth_info) { |
效果展示

GRPC同步认证消息认证
分布式认证就是让两个客户端分别登录不同的服务器,注意因为我们修改了连接检测和记录的方式,改为通过心跳定时更新,为了避免两个客户端同时登录到一个服务器的情况,可以在一个客户端登录服务器后,另一个客户端延迟一分钟登录。
同时要注意StatusServer要将getChatServer这个函数打开
1 | ChatServer StatusServiceImpl::getChatServer() { |
两个客户端登录后,确保后台看到两个用户登录不同的Server
1019用户登录Server2

1002用户登录Server1

这样二者都登陆成功了,然后任意一方向对方发送添加好友请求,另一方同意,看到的效果如下

聊天记录增量加载
客户端逻辑
聊天记录增量加载,可以在加载完聊天会话列表后,继续分页加载聊天信息。
因为qt支持信号和槽函数机制,所以我们可以加载完会话列表后发送, 在UserMgr中设置一个当前加载的_cur_load_chat_index用来记录将要加载的会话消息。
我们对外暴露两个接口,分别是获取当前要加载会话信息,和下次加载的会话信息
1 | std::shared_ptr<ChatThreadData> UserMgr::GetCurLoadData() |
然后封装加载消息的函数
1 | void ChatDialog::loadChatMsg() { |
接下来我们在加载完会话列表后调用这个函数
1 | void ChatDialog::slot_load_chat_thread(bool load_more, int last_thread_id, |
在收到服务器回复时处理消息
1 | _handlers.insert(ID_LOAD_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) { |
界面收到sig_load_chat_msg后添加消息,并且判断是否还有剩余消息加载
1 | void ChatDialog::slot_load_chat_msg(int thread_id, int msg_id, bool load_more, std::vector<std::shared_ptr<TextChatData>> msglists) |
服务器逻辑
注册消息
1 | _fun_callbacks[ID_LOAD_CHAT_MSG_REQ] = std::bind(&LogicSystem::LoadChatMsg, this, |
具体逻辑处理
1 | void LogicSystem::LoadChatMsg(std::shared_ptr<CSession> session, |
数据库新增根据thread_id和message_id返回分页数据
1 | std::shared_ptr<PageResult> MysqlMgr::LoadChatMsg(int threadId, int lastId, int pageSize) |
具体在MysqlDao层面实现分页加载
1 | std::shared_ptr<PageResult> MysqlDao::LoadChatMsg(int thread_id, int last_message_id, int page_size) |
效果展示

发送和接收消息同步
客户端缓存发送消息
我们需要在客户端缓存一下发送的消息,等到服务器回复后再将收到的消息放入ChatThreadData中。
为了标识消息的唯一性,我们需要在客户端生成唯一unique_id,构造成ChatTextData先放到ChatThreadData中存起来。
1 | //已发送的消息,还未收到回应的。 |
实现发送逻辑
1 | void ChatPage::on_send_btn_clicked() |
相比于之前,我们在json中增加了unique_id和thread_id字段,服务器收到后根据thread_id生成消息放入到数据库,并携带unique_id回传给客户端。
客户端缓存消息放入UserMgr中
1 | //将未回复的消息加入到未回复列表中,以便后续处理 |
此外,客户端需要设置聊天文本状态为未回复
1 | pChatItem->setStatus(0); |
切换聊天不丢失状态
如果此时切换页面,再切回来,也要保证之前服务器未回复的消息能重新加载
切换的逻辑在
1 | void ChatDialog::SetSelectChatPage(int thread_id) |
其中SetChatData是设置页面聊天信息列表
1 | void ChatPage::SetChatData(std::shared_ptr<ChatThreadData> chat_data) { |
这样我们可以加载服务器已经回复的和服务器未回复的。保证完全,具体添加逻辑
1 | void ChatPage::AppendChatMsg(std::shared_ptr<ChatDataBase> msg) |
其中_unrsp_item_map是聊天页面上的服务器未回复的聊天记录的,每次切换页面清掉,再重新创建加载。
这么做效率不高,后期给大家介绍Module View Delegate模式去优化聊天数据加载和管理。
这里先把持久化存储功能先实现再说。
客户端收到服务器回复
收到服务器回复后,需要组织数据发送给ChatDialog界面,将未回复的消息更新为已回复。
1 | _handlers.insert(ID_TEXT_CHAT_MSG_RSP, [this](ReqId id, int len, QByteArray data) { |
将信号和槽函数连接
1 | connect(TcpMgr::GetInstance().get(), &TcpMgr::sig_chat_msg_rsp, this, &ChatDialog::slot_add_chat_msg); |
会触发槽函数, 槽函数内部检测消息,将消息存储到已经回复列表中。
1 | void ChatDialog::slot_add_chat_msg(int thread_id, std::vector<std::shared_ptr<TextChatData>> msglists) { |
转移逻辑, 其实就是去未回复中查找对应消息,如果有就移动到已回复列表,如果没有就直接将回复的消息插入已回复列表中
1 | void ChatThreadData::MoveMsg(std::shared_ptr<ChatDataBase> msg) { |
对端收到消息通知
客户端对端收到通知消息
1 | _handlers.insert(ID_NOTIFY_TEXT_CHAT_MSG_REQ, [this](ReqId id, int len, QByteArray data) { |
这个消息连接槽函数
1 | //连接对端消息通知 |
因为被通知,可能此时不在对应的会话中
1 | void ChatDialog::slot_text_chat_msg(std::vector<std::shared_ptr<TextChatData>> msglists) |
服务器逻辑
服务器在收到聊天消息后要将消息入库,并且判断对方是否通服,如果不是一个服务器,则用grpc通知对方所在的服务器,再通过对方服务器的Session通知对方。
如果是同一个服务器,则直接通过Session通知对方
1 | void LogicSystem::DealChatTextMsg(std::shared_ptr<CSession> session, const short& msg_id, const string& msg_data) { |
数据库处理
1 | bool MysqlMgr::AddChatMsg(std::vector<std::shared_ptr<ChatMessage>>& chat_datas) { |
Dao层做了详细的数据库操作
1 | bool MysqlDao::AddChatMsg(std::vector<std::shared_ptr<ChatMessage>>& chat_datas) { |
grpc协议完善
1 | message TextChatMsgReq { |
对端服务器处理
如果客户不在本服,则通知对端服务处理
1 | Status ChatServiceImpl::NotifyTextChatMsg(::grpc::ServerContext* context, |
验证效果

待完善部分
目前切换页面会将之前的记录删掉,这样每次重新加载会影响效率。
考虑以后采用多页缓存机制。
以后用Model View Delegate改造数据存储模式。
使用 Model/View 架构(QListView + QAbstractListModel + Delegate)
- 思路:不要手动往布局里插 widget,而是把 “一条聊天消息” 抽象成一个数据结构,存到自定义的
QAbstractListModel。 - 在右侧放一个
QListView,并为它写一个QStyledItemDelegate,统一负责绘制消息气泡、头像、时间等。 - 优点:Qt 的视图会自动做 行缓存(view recycling)、懒加载 等优化,数据量大也能保持流畅。
- 切换用户:只需
model->setMessages(userMessages)(内部发beginResetModel()/endResetModel()),视图自动刷新。
方案一:在同一个 Model 里 reset 数据
维护一个消息列表
1
2
3
4
5
6
7
8
9
10
11class ChatModel : public QAbstractListModel {
QVector<Message> m_msgs;
public:
// 必要的 override:rowCount(), data(), roleNames()...
void setMessages(const QVector<Message>& msgs) {
beginResetModel();
m_msgs = msgs;
endResetModel();
}
};切换用户时
1
2
3
4
5
6
7
8// 假设你有一个 ChatModel* model 和 QListView* listView
// listView->setModel(model); // 已经在初始化时做过一次
void onUserClicked(const User& u) {
QVector<Message> msgs = loadMessagesFromDb(u.id);
model->setMessages(msgs);
// 可选:滚到最底部
listView->scrollToBottom();
}优点
- 结构简单,一处 model,view 自动刷新。
- 不需要销毁或创建 widget,性能最佳。
方案二:每个用户一个 Model,切换指针
如果你希望把每个用户的数据和 model 分开管理,也可以为每个用户维护独立的 ChatModel:
1 | QMap<UserId, ChatModel*> modelPool; |
- 优点:切换立刻就有缓存好的数据,不用每次都从数据库/网络加载。
- 缺点:如果用户特别多,内存开销会比较大。
更细粒度的更新
如果你不想一次 beginResetModel()/endResetModel() 重刷全表,还可以在 model 里实现增删改接口:
1 | void ChatModel::appendMessage(const Message& m) { |
- 切换用户时先
clearMessages(),然后循环appendMessage()。 - 这样 view 能做更细粒度的动画或局部刷新。
总结
- 最简单:一个 model,内部维护
QVector<Message>,切换时调用setMessages()。 - 缓存多用户:给每个用户分配一个 model,切换时调用
listView->setModel(...)。 - 增量更新:用
beginInsertRows/beginRemoveRows实现局部刷新。
选哪种方案,取决于你的聊天数据量和内存/加载开销:
- 少量用户、消息量大 → 方案一(reset)+ 分页加载
- 用户量多、切换频繁 → 方案二(model 池)
- 想要炫酷的动画或更精细性能 → 增量更新。