目录
一.数据类型的定义(data.h)
1.1.用户信息类型的定义
其实有的数据在我们的服务端和客户端进行流通的,我们客户端本地就需要保存好一些信息。
首先,我们需要存储用户信息,我们在客户端这边不使用protobuf生成好的那个UserInfo数据结构,我们选择自己另外定义一个新的类,然后我们这个类还支持将protobuf里面的UserInfo数据来进行传输。
cpp
class UserInfo {
public:
QString userId = ""; // 用户编号
QString nickname = ""; // 用户昵称
QString description = ""; // 用户签名
QString email = ""; // 邮箱号码
QIcon avatar; // 用户头像
// 从 protobuffer 的 UserInfo 对象, 转成当前代码的 UserInfo 对象
void load(const IMS::UserInfo& userInfo) {
this->userId = userInfo.userId();
this->nickname = userInfo.nickname();
this->email = userInfo.email();
this->description = userInfo.description();
if (userInfo.avatar().isEmpty()) {
// 使用默认头像即可
this->avatar = QIcon(":/resource/image/defaultAvatar.png");
} else {
this->avatar = makeIcon(userInfo.avatar());
}
}
};
1.2.消息类型的定义
事实上呢,我们这个消息类型的定义也是仿照我们的base.proto里面的消息定义来实现的
cpp
// 消息类型枚举,定义不同类型的消息内容
enum MessageType
{
STRING = 0; // 纯文本消息
IMAGE = 1; // 图片消息
FILE = 2; // 文件消息
SPEECH = 3; // 语音消息
}
// 纯文本消息的具体内容
message StringMessageInfo
{
string content = 1; // 文字聊天内容
}
// 图片消息的具体内容
message ImageMessageInfo
{
// 图片文件ID,客户端发送时不需要设置,由transmit服务器生成后交给storage服务时设置
optional string file_id = 1;//optional表示该字段是可选的
// 图片数据,在ES中存储消息时只需保留ID,不需要存储文件数据;服务端转发时需要原样转发
optional bytes image_content = 2;//optional表示该字段是可选的
}
// 文件消息的具体内容
message FileMessageInfo
{
optional string file_id = 1; // 文件ID,客户端发送时不用设置,由服务端生成
optional int64 file_size = 2; // 文件大小(字节)
optional string file_name = 3; // 文件名称
// 文件数据,在ES中存储消息时只需保留ID和元信息,不需要存储文件数据;服务端转发时也不需要填充
optional bytes file_contents = 4;
}
// 语音消息的具体内容
message SpeechMessageInfo
{
optional string file_id = 1; // 语音文件ID,客户端发送时不用设置,由服务端生成
// 文件数据,在ES中存储消息时只需保留ID,不需要存储文件数据;服务端转发时也不需要填充
optional bytes file_contents = 2;
}
// 消息内容的联合体,根据消息类型包含不同的具体消息内容,本质就是 消息类型+消息内容 的组合体
message MessageContent
{
MessageType message_type = 1; // 消息类型,指明具体是哪种消息
oneof msg_content { // 具体消息内容,oneof表明每次只能有一种类型
StringMessageInfo string_message = 2; // 文字消息
FileMessageInfo file_message = 3; // 文件消息
SpeechMessageInfo speech_message = 4; // 语音消息
ImageMessageInfo image_message = 5; // 图片消息
};
}
//作为一条消息,只有消息类型+消息内容是不行的
// 消息结构,表示一条完整的聊天消息
message MessageInfo
{
string message_id = 1; // 消息ID,全局唯一
string chat_session_id = 2; // 消息所属聊天会话ID
int64 timestamp = 3; // 消息产生的时间戳(Unix时间戳,秒)
UserInfo sender = 4; // 消息发送者的用户信息
MessageContent message = 5; // 消息内容体
}
那么我们其实很容易就写出下面这段代码
cpp
enum MessageType
{
TEXT_TYPE, // 文本消息
IMAGE_TYPE, // 图片消息
FILE_TYPE, // 文件消息
SPEECH_TYPE // 语音消息
};
class Message
{
public:
QString messageId = ""; // 消息的编号
QString chatSessionId = ""; // 消息所属会话的编号
QString time = ""; // 消息的时间. 通过 "格式化" 时间的方式来表示. 形如 06-07 12:00:00
MessageType messageType = TEXT_TYPE; // 消息类型
UserInfo sender; // 发送者的信息
QByteArray content; // 消息的正文内容
QString fileId = ""; // 文件的身份标识. 当消息类型为 文件, 图片, 语音 的时候, 才有效. 当消息类型为 文本, 则为 ""
QString fileName = ""; // 文件名称. 只是当消息类型为 文件 消息, 才有效. 其他消息均为 ""
// 从 protobuf 的 MessageInfo 对象加载数据到当前 Message 对象
void load(const IMS::MessageInfo &messageInfo)
{
this->messageId = messageInfo.messageId();
this->chatSessionId = messageInfo.chatSessionId();
this->time = formatTime(messageInfo.timestamp());
this->sender.load(messageInfo.sender());
// 设置消息类型
auto type = messageInfo.message().messageType();
if (type == IMS::MessageTypeGadget::MessageType::STRING)
{
this->messageType = TEXT_TYPE;
this->content = messageInfo.message().stringMessage().content().toUtf8();
}
else if (type == IMS::MessageTypeGadget::MessageType::IMAGE)
{
this->messageType = IMAGE_TYPE;
if (messageInfo.message().imageMessage().hasImageContent())
{
this->content = messageInfo.message().imageMessage().imageContent();
}
if (messageInfo.message().imageMessage().hasFileId())
{
this->fileId = messageInfo.message().imageMessage().fileId();
}
}
else if (type == IMS::MessageTypeGadget::MessageType::FILE)
{
this->messageType = FILE_TYPE;
if (messageInfo.message().fileMessage().hasFileContents())
{
this->content = messageInfo.message().fileMessage().fileContents();
}
if (messageInfo.message().fileMessage().hasFileId())
{
this->fileId = messageInfo.message().fileMessage().fileId();
}
this->fileName = messageInfo.message().fileMessage().fileName();
}
else if (type == IMS::MessageTypeGadget::MessageType::SPEECH)
{
this->messageType = SPEECH_TYPE;
if (messageInfo.message().speechMessage().hasFileContents())
{
this->content = messageInfo.message().speechMessage().fileContents();
}
if (messageInfo.message().speechMessage().hasFileId())
{
this->fileId = messageInfo.message().speechMessage().fileId();
}
}
else
{
// 错误的类型, 啥都不做了, 只是打印一个日志
LOG() << "非法的消息类型! type=" << type;
}
}
};
首先,特别注意,四种消息类型使用的正文字段是不一样的。
- STRING 类型:content(文本内容)
- IMAGE 类型:file_id(文件ID)、image_content(图片二进制数据)
- FILE 类型:file_id(文件ID)、file_size(文件大小)、file_name(文件名)、file_contents(文件内容)
- SPEECH 类型:file_id(文件ID)、file_contents(语音文件内容)
我们这里就选择将消息正文完全展开成下面四个成员变量
cpp
MessageType messageType = TEXT_TYPE; // 消息类型,默认设置为纯文本类型的消息
QByteArray content; // 消息的正文内容
QString fileId = ""; // 文件的身份标识. 当消息类型为 文件, 图片, 语音 的时候, 才有效. 当消息类型为 文本, 则为 ""
QString fileName = ""; // 文件名称. 只是当消息类型为 文件 消息, 才有效. 其他消息均为 ""
这个和base.proto里面是一模一样的。
但是到这里还没有结束,我们需要去写一个工厂类
cpp
// 静态工厂方法:根据消息类型创建 Message 对象
// extraInfo 在 FILE_TYPE 时用作文件名,其他类型忽略
// 此处 extraInfo 目前只是在消息类型为文件消息时, 作为 "文件名" 补充.
static Message makeMessage(MessageType messageType, const QString &chatSessionId, const UserInfo &sender,
const QByteArray &content, const QString &extraInfo)
{
if (messageType == TEXT_TYPE)
{
return makeTextMessage(chatSessionId, sender, content);
}
else if (messageType == IMAGE_TYPE)
{
return makeImageMessage(chatSessionId, sender, content);
}
else if (messageType == FILE_TYPE)
{
return makeFileMessage(chatSessionId, sender, content, extraInfo);
}
else if (messageType == SPEECH_TYPE)
{
return makeSpeechMessage(chatSessionId, sender, content);
}
else
{
// 触发了未知的消息类型
return Message();
}
}
那么这里构建消息的函数其实我们都已经写好了。
cpp
// 通过这个方法生成唯一的 messageId
static QString makeId()
{
return "M" + QUuid::createUuid().toString().sliced(25, 12);
}
static Message makeTextMessage(const QString &chatSessionId, const UserInfo &sender, const QByteArray &content)
{
Message message;
// 此处需要确保, 设置的 messageId 是 "唯一" 的
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime()); // 生成一个格式化时间
message.content = content;
message.messageType = TEXT_TYPE;
// 对于文本消息来说, 这俩属性不使用, 设为 ""
message.fileId = "";
message.fileName = "";
return message;
}
static Message makeImageMessage(const QString &chatSessionId, const UserInfo &sender, const QByteArray &content)
{
Message message;
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = IMAGE_TYPE;
// fileId 后续使用的时候再进一步设置
message.fileId = "";
// fileName 不使用, 直接设为 ""
message.fileName = "";
return message;
}
static Message makeFileMessage(const QString &chatSessionId, const UserInfo &sender, const QByteArray &content, const QString &fileName)
{
Message message;
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = FILE_TYPE;
// fileId 后续使用的时候进一步设置
message.fileId = "";
message.fileName = fileName;
return message;
}
static Message makeSpeechMessage(const QString &chatSessionId, const UserInfo &sender, const QByteArray &content)
{
Message message;
message.messageId = makeId();
message.chatSessionId = chatSessionId;
message.sender = sender;
message.time = formatTime(getTime());
message.content = content;
message.messageType = SPEECH_TYPE;
// fileId 后续使用的时候进一步设置
message.fileId = "";
// fileName 不使用, 直接设为 ""
message.fileName = "";
return message;
}
1.3.会话类型的定义
我们这个会话类型的定义还是取决于这个base.proto里面的定义
cpp
// 聊天会话信息,表示一个聊天会话(单聊或群聊)
message ChatSessionInfo
{
// 群聊会话不需要此字段,单聊会话设置为对方用户ID
optional string single_chat_friend_id = 1; // 单聊对方的用户ID,optional表示该字段是可选的
string chat_session_id = 2; // 会话ID,全局唯一
string chat_session_name = 3; // 会话名称,例如群名称或对方昵称
// 会话中最新的一条消息,新建的会话没有最新消息
optional MessageInfo prev_message = 4; // 上一条消息信息
// 会话头像------群聊会话不需要,直接由前端固定渲染;单聊就是对方的头像
optional bytes avatar = 5; // 会话头像图片数据
}
那么我们其实也很快就能写出来
cpp
class ChatSessionInfo
{
public:
QString chatSessionId = ""; // 会话编号
QString chatSessionName = ""; // 会话名字, 如果会话是单聊, 名字就是对方的昵称; 如果是群聊, 名字就是群聊的名称.
Message lastMessage; // 表示最新的消息.
QIcon avatar; // 会话头像. 如果会话是单聊, 头像就是对方的头像; 如果是群聊, 头像群聊的头像.
QString userId = ""; // 对于单聊来说, 表示对方的用户 id, 对于群聊设为 ""
// 从 protobuf 的 ChatSessionInfo 对象加载数据到当前 ChatSessionInfo 对象
void load(const IMS::ChatSessionInfo &chatSessionInfo)
{
this->chatSessionId = chatSessionInfo.chatSessionId();
this->chatSessionName = chatSessionInfo.chatSessionName();
if (chatSessionInfo.hasSingleChatFriendId())
{
this->userId = chatSessionInfo.singleChatFriendId();
}
if (chatSessionInfo.hasPrevMessage())
{
lastMessage.load(chatSessionInfo.prevMessage());
}
if (chatSessionInfo.hasAvatar() && !chatSessionInfo.avatar().isEmpty())
{
// 已经有头像了, 直接设置这个头像
this->avatar = makeIcon(chatSessionInfo.avatar());
}
else
{
// 如果没有头像, 则根据当前会话是单聊还是群聊, 使用不同的默认头像.
if (userId != "")
{
// 单聊
this->avatar = QIcon(":/resource/image/defaultAvatar.png");
}
else
{
// 群聊
this->avatar = QIcon(":/resource/image/groupAvatar.png");
}
}
}
};
二.数据中心的定义(datacenter.h)
2.1.成员变量
我们需要思考一些问题,我们客户端的数据中心应该有哪些成员变量
-
当前登录会话 ID
客户端与服务器建立登录会话后,会获得一个唯一的会话标识。后续几乎所有需要鉴权的请求(如获取好友列表、发送消息等)都要携带这个 ID,服务器才能识别是哪个用户在操作。因此必须把它保存在内存中,随时取用。
-
当前用户自己的信息
登录后界面左上角要显示当前登录用户的昵称、头像、个性签名,个人资料页要展示完整信息,修改资料后要立即刷新显示------这些都需要一个"我"的数据副本。把它单独存起来,可以避免每次用到都去服务器请求,提升响应速度。
-
好友列表
好友面板需要展示所有好友的头像、昵称等,好友搜索、删除好友、发起聊天等操作也依赖这个列表。客户端启动后先去从服务器拉取一次,之后增删好友时再更新本地列表,能减少网络开销,让界面更流畅。
-
会话列表
聊天主界面左侧展示的是所有会话(单聊和群聊)。每个会话包含最近一条消息、未读数、最后活跃时间等信息。客户端需要维护这个列表以便按时间排序、置顶、删除会话,并快速定位当前选中的会话。
-
当前选中的会话 ID
当用户点击某个会话时,右侧聊天区域需要展示该会话的历史消息、群成员(如果是群聊)等。记录当前选中的会话 ID,可以让程序知道应该显示哪个会话的数据,同时也能用于标记未读消息已读。
-
群聊成员列表(按会话 ID 索引)
对于群聊,用户可能需要查看群成员列表或者添加/移除成员。这个信息不是每个会话都需要,但对于群聊是必要的。用一个哈希表(键为会话 ID,值为成员列表)来存储,可以按需加载并缓存,避免重复请求。
-
待处理的好友申请列表
当有人申请添加好友时,客户端会收到通知,用户需要在"申请列表"界面中看到这些申请,并可以选择同意或拒绝。如果不在内存中保存这份列表,每次打开申请界面都要重新请求服务器,体验差且浪费流量。
-
每个会话的最近消息列表
聊天区域显示的消息就是从这个列表来的。用户滚动查看历史消息时,客户端会从本地已有消息中读取,同时按需从服务器拉取更早的消息。把消息按会话 ID 缓存起来,可以实现即时展示、离线浏览,并减少网络传输。
-
每个会话的未读消息数量
会话列表项上显示的小红点数字就是由这个数量决定的。**新消息到达时增加未读数,用户点进会话时清零。**如果没有这个计数,每次都要遍历消息列表去统计,效率很低;而且未读数是独立于消息列表的状态,需要单独存储。
-
用户搜索结果列表
当用户在"添加好友"界面输入关键词搜索时,服务器会返回匹配的用户列表。这个结果需要暂时保存,以便搜索结果界面分页显示、重复利用。下一次搜索会覆盖这个列表。
-
历史消息搜索结果列表
类似用户搜索,当用户搜索聊天记录中的关键词或时间段时,服务器返回匹配的消息列表。保存这个结果可以让搜索界面展示结果,并支持点击跳转到具体消息位置。
-
邮箱验证码的验证 ID
在修改邮箱、邮箱登录/注册等场景中,用户会先收到验证码,服务器会返回一个验证 ID。用户提交验证码时必须带上这个 ID,服务器才能正确校验。因此客户端需要暂存这个 ID,直到验证完成。
-
网络客户端(NetClient)实例
所有与服务器的通信(HTTP 请求、WebSocket 收发消息)都通过这个客户端完成。把它作为 DataCenter 的成员,可以统一管理网络连接、重连逻辑、消息收发回调,避免在多个窗口或控制器中重复创建连接。
那么我们很快就能定义出这些成员变量来。
cpp
namespace model {
class DataCenter : public QObject
{
Q_OBJECT
public:
static DataCenter* getInstance();
~DataCenter();
private:
DataCenter();
static DataCenter* instance;
// 列出 DataCenter 中要组织管理的所有的数据
// 当前客户端登录到服务器对应的登录会话 id
QString loginSessionId = "";
// 当前的用户信息
UserInfo* myself = nullptr;
// 好友列表
QList<UserInfo>* friendList = nullptr;
// 会话列表
QList<ChatSessionInfo>* chatSessionList = nullptr;
// 记录当前选中的会话是哪个~~
QString currentChatSessionId = "";
// 记录每个会话中, 都有哪些成员(主要针对群聊). key 为 chatSessionId, value 为成员列表
QHash<QString, QList<UserInfo>>* memberList = nullptr;
// 待处理的好友申请列表
QList<UserInfo>* applyList = nullptr;
// 每个会话的最近消息列表, key 为 chatSessionId, value 为消息列表
QHash<QString, QList<Message>>* recentMessages = nullptr;
// 存储每个会话, 未读消息的个数. key 为 chatSessionId, value 为未读消息的个数.
QHash<QString, int>* unreadMessageCount = nullptr;
// 用户的好友搜索结果.
QList<UserInfo>* searchUserResult = nullptr;
// 历史消息搜索结果.
QList<Message>* searchMessageResult = nullptr;
// 短信验证码的验证 id
QString currentVerifyCodeId = "";
// 让 DataCenter 持有 NetClient 实例.
network::NetClient netClient;
};
} // end namespace
#endif // DATACENTER_H
但是我们对于这个网络客户端(NetClient)实例,这个可是我们自己写的类哦。
我们后续再去讲解这个网络客户端类的定义。
2.2.成员函数的设计
2.2.1.核心设计
我们这个类可是数据中心啊,那么我们这个类的核心功能是不是就是去服务器里面获取对应的数据,来填充我们上面定义好的那些成员变量啊!!!!
那么这里势必会涉及到我们客户端与服务器的网络连接啊,那么我们这里就会需要先涉及NetClient.h里面的那些函数实现,只有这样子,我们才能讲清楚我们的这个数据中心的实现。
我们一类一类的来讲解
我们先看看最核心的一个设计:
我们以获取当前用户信息为例
cpp
// 通过网络获取到用户的个人信息, 该函数是一个 "异步" 的函数, 只负责把 HTTP 请求发出去就不管了.
// 异步获取当前登录用户的个人信息。该函数立即返回,不阻塞界面。
// 请求成功后,服务器返回的数据会通过 resetMyself 更新到内存中,并发出 getMyselfDone 信号。
void getMyselfAsync();
// 同步获取当前缓存的用户个人信息(即内存中的 myself 对象)。
// 该函数不发起网络请求,仅返回本地已有数据。若尚未获取过,可能返回空指针。
UserInfo *getMyself();
// 将服务器返回的用户信息(resp)填充到本地缓存 myself 中。
// 该函数由网络层回调触发,通常不在 UI 线程直接调用。
void resetMyself(std::shared_ptr<IMS::GetUserInfoRsp> resp);
我们可以看看这3个函数是怎么进行配合的
cpp
void DataCenter::getMyselfAsync()
{
// 注意! DataCenter 只是负责 "处理数据", 真正访问网络进行通信, 需要通过 NetClient
netClient.getMyself(loginSessionId);
}
UserInfo *DataCenter::getMyself()
{
return myself;
}
void DataCenter::resetMyself(std::shared_ptr<IMS::GetUserInfoRsp> resp)
{
if (myself == nullptr) {
myself = new UserInfo();
}
const IMS::UserInfo& userInfo = resp->userInfo();
myself->load(userInfo);
}
那么我们这里可以提前看看netClient.getMyself函数
cpp
// 在这个函数内部, 完成具体的网络通信即可
void NetClient::getMyself(const QString &loginSessionId)
{
// 1. 构造出 HTTP 请求 body 部分
IMS::GetUserInfoReq req;
req.setRequestId(makeRequestId());
req.setSessionId(loginSessionId);
QByteArray body = req.serialize(&serializer);
LOG() << "[获取个人信息] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;
// 2. 构造出 HTTP 请求, 并发送出去.
QNetworkReply* httpResp = sendHttpRequest("/service/user/get_user_info", body);
// 3. 通过信号槽, 获取到当前的响应. finished 信号表示响应已经返回到客户端了.
connect(httpResp, &QNetworkReply::finished, this, [=]() {
// a) 先处理响应对象
bool ok = false;
QString reason;
auto resp = handleHttpResponse<IMS::GetUserInfoRsp>(httpResp, &ok, &reason);
// b) 判定响应是否正确
if (!ok) {
LOG() << "[获取个人信息] 出错! requestId=" << req.requestId() << "reason=" << reason;
return;
}
// c) 把响应的数据, 保存到 DataCenter 中
dataCenter->resetMyself(resp);
// d) 通知调用逻辑, 响应已经处理完了. 仍然通过信号槽, 通知.
emit dataCenter->getMyselfDone();
// e) 打印日志.
LOG() << "[获取个人信息] 处理响应 requestId=" << req.requestId();
});
}
在某个组件里面需要用到这些数据的时候(比如主界面)
我们的主界面左上角是需要显示一个头像的吧!!!

那么我们就把这个头像给实时更新。
cpp
// 提供一个具体的方法, 来获取到网络数据
connect(dataCenter, &DataCenter::getMyselfDone, this, [=]() {
// 从 DataCenter 中拿到响应结果的 myself, 把里面的头像取出来, 显示到界面上.
auto myself = dataCenter->getMyself();
userAvatar->setIcon(myself->avatar);//userAvatar就是这里需要更新数据的组件
});
dataCenter->getMyselfAsync();
我们仔细观察一下就会发现这里的过程:
在客户端内部,网络层(NetClient)使用的是 Qt 框架提供的信号与槽机制来实现异步通信。具体来说:
当"异步获取个人信息"函数被调用时,网络层会创建一个 HTTP 请求对象,并将该对象的 finished 信号连接到一个槽函数(我们这里是一个 Lambda 表达式)。这个 finished 信号是 Qt 网络模块自带的,当服务器的响应数据完全到达客户端后,系统会自动发出这个信号。
连接一旦建立,程序就不再阻塞等待。网络层发出请求后立即返回,界面可以继续响应用户操作。而 finished 信号被"挂起",等待网络事件循环触发。
当服务器的响应真正到达时,Qt 的事件循环会检测到该请求已完成,于是自动发射 finished 信号。与该信号连接的槽函数会被自动调用,无需任何手动轮询或检查。
在这个槽函数内部,会依次执行以下操作:
- 检查响应是否成功(网络错误?业务错误码?)。
- 如果成功,从响应中解析出服务器返回的用户信息。
- 调用数据中心对应的"重置/更新"函数,将信息写入内存。
- **发射一个自定义信号(例如 getMyselfDone()),**这个信号是 DataCenter 类中预先声明的,专门用于通知外界"个人信息已更新"。
界面层(例如个人资料窗口)在初始化时,已经将这个自定义信号DataCenter::getMyselfDone连接到自己的某个槽函数。因此,当自定义信号被发射时,界面的槽函数会被自动触发,它就会调用"同步获取个人信息"函数读取最新数据,并刷新显示。
整个思路是不是一下子就打开了。我们整个客户端都是采用这种设计思路来进行考量的。
我们可以再看一个例子
我们再以获取好友列表为例
cpp
// 获取好友列表
void getFriendListAsync();
QList<UserInfo> *getFriendList();
void resetFriendList(std::shared_ptr<IMS::GetFriendListRsp> resp);
我们看看它们的实现
cpp
void DataCenter::getFriendListAsync()
{
netClient.getFriendList(loginSessionId);
}
QList<UserInfo> *DataCenter::getFriendList()
{
return friendList;
}
void DataCenter::resetFriendList(std::shared_ptr<IMS::GetFriendListRsp> resp)
{
if (friendList == nullptr) {
friendList = new QList<UserInfo>();
}
friendList->clear();
QList<IMS::UserInfo>& friendListPB = resp->friendList();
for (auto& f : friendListPB) {
UserInfo userInfo;
userInfo.load(f);
friendList->push_back(userInfo);
}
}
接着我们去看看netClient.getFriendList函数
cpp
void NetClient::getFriendList(const QString& loginSessionId)
{
// 1. 通过 protobuf 构造 body
IMS::GetFriendListReq req;
req.setRequestId(makeRequestId());
req.setSessionId(loginSessionId);
QByteArray body = req.serialize(&serializer);
LOG() << "[获取好友列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;
// 2. 发送 HTTP 请求
QNetworkReply* httpResp = this->sendHttpRequest("/service/friend/get_friend_list", body);
// 3. 处理响应
connect(httpResp, &QNetworkReply::finished, this, [=]() {
// a) 解析响应
bool ok = false;
QString reason;
auto friendListResp = this->handleHttpResponse<IMS::GetFriendListRsp>(httpResp, &ok, &reason);
// b) 判定响应是否正确
if (!ok) {
LOG() << "[获取好友列表] 失败! requestId=" << req.requestId() << ", reason=" << reason;
return;
}
// c) 把结果保存在 DataCenter 中
dataCenter->resetFriendList(friendListResp);
// d) 发送信号, 通知界面, 当前这个操作完成了.
emit dataCenter->getFriendListDone();
// e) 打印日志.
LOG() << "[获取好友列表] 处理响应 requestId=" << req.requestId();
});
}
然后我们再看看
cpp
// 加载好友列表
void MainWidget::loadFriendList()
{
// 好友列表数据是在 DataCenter 中存储的
// 首先需要判定 DataCenter 中是否已经有数据了. 如果有数据, 直接加载本地的数据.
// 如果没有数据, 从服务器获取
DataCenter* dataCenter = DataCenter::getInstance();
if (dataCenter->getFriendList() != nullptr) {
// 从内存这个列表中加载数据
updateFriendList();
} else {
// 通过网络来加载数据
connect(dataCenter, &DataCenter::getFriendListDone, this, &MainWidget::updateFriendList, Qt::UniqueConnection);
dataCenter->getFriendListAsync();
}
}
void MainWidget::updateFriendList()
{
if (activeTab != FRIEND_LIST) {
// 当前的标签页不是好友列表, 就不渲染任何数据到界面上
return;
}
DataCenter* dataCenter = DataCenter::getInstance();
QList<UserInfo>* friendList = dataCenter->getFriendList();
// 清空一下之前界面上的数据.
sessionFriendArea->clear();
// 遍历好友列表, 添加到界面上
for (const auto& f : *friendList) {
sessionFriendArea->addItem(FriendItemType, f.userId, f.avatar, f.nickname, f.description);
}
}
可以看到,这个的思路和之前的是一模一样的。
只不过更新数据的方式有一点不一样而已。由于我们这里仅仅只是讲解一下我们的数据中心的部分,我们完全可以不要去理解它是怎么进行展示的,所以展示部分我们这里不多讲。
此外还有数不胜数的接口都是采用了这种设计,我觉得没有什么必要在这边进行讲解。核心思路和上面的都是一模一样的。
2.2.2.持久化管理
在客户端使用过程中,我们也会对某些数据进行持久化存储管理。
那么事实上,我们也就是使用下面这些函数来进行持久化存储管理。
cpp
// 初始化数据文件
void initDataFile();
// 存储数据到文件中
void saveDataFile();
// 从数据文件中加载数据到内存
void loadDataFile();
// 清空未读消息数目
void clearUnread(const QString &chatSessionId);
// 增加未读消息数目
void addUnread(const QString &chatSessionId);
// 获取未读消息数目
int getUnread(const QString &chatSessionId);
首先我们先创建一个.json文件来进行持久化存储
cpp
void DataCenter::initDataFile()
{
// 构造出文件的路径, 使用 appData 存储文件
QString basePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QString filePath = basePath + "/ChatClient.json";
LOG() << "filePath=" << filePath;
// 创建 QDir 对象用于操作目录
QDir dir;
// 如果基础目录不存在,则创建整个目录路径
if (!dir.exists(basePath)) {
dir.mkpath(basePath);
}
// 构造好文件路径之后, 把文件创建出来.
// 以只写、文本模式打开文件(会覆盖已有内容)
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
LOG() << "打开文件失败!" << file.errorString();
return;
}
// 打开成功, 写入初始内容:一个空 JSON 结构
QString data = "{\n\n}";
file.write(data.toUtf8());
// 关闭文件
file.close();
}
我们写了一个保存数据到文件里面
cpp
void DataCenter::saveDataFile()
{
// 拼接出完整的 JSON 文件路径
QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";
// 以只写、文本模式打开文件
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
LOG() << "文件打开失败! " << file.errorString();
return;
}
// 按照 json 格式来写入数据.
// 这个对象就可以当做 map 一样来使用.
QJsonObject jsonObj;
// 保存当前登录会话 id
jsonObj["loginSessionId"] = loginSessionId;
// 构造未读消息计数的 JSON 对象
QJsonObject jsonUnread;
// 遍历 unreadMessageCount 哈希表,将每个会话的未读数填入 jsonUnread
for (auto it = unreadMessageCount->begin(); it != unreadMessageCount->end(); ++it) {
// 注意 Qt 的迭代器使用细节和 STL 略有差别. 此处不是使用 first / second 的方式
jsonUnread[it.key()] = it.value();
}
// 将未读计数对象作为 unread 字段加入根对象
jsonObj["unread"] = jsonUnread;
// 把 json 写入文件了
QJsonDocument jsonDoc(jsonObj);
QString s = jsonDoc.toJson(); // 转换为格式化的 JSON 字符串
file.write(s.toUtf8()); // 写入文件
// 关闭文件
file.close();
}
我们这个只保存了2个数据
- **当前登录会话 ID(loginSessionId):**用于记录客户端与服务器之间的登录会话标识,以便程序重启后可以自动恢复会话(无需重新输入密码)。
- **每个会话的未读消息数量(unreadMessageCount):**这是一个哈希表,键为会话 ID,值为该会话中尚未阅读的消息条数。保存后,再次启动程序时可以恢复未读红点提示。
其他数据(如用户个人信息、好友列表、会话列表、消息历史、申请列表、成员列表、搜索结果等)不会被持久化到 JSON 文件中,它们仅在内存中缓存,程序关闭后会丢失,需要重新从服务器拉取。
有写入,肯定有加载,那么加载数据
cpp
// 加载文件, 是在 DataCenter 被实例化的时候, 调用执行的
void DataCenter::loadDataFile()
{
// 确保在加载之前, 先针对文件进行初始化操作.
QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";
// 判定文件是否存在, 不存在则初始化, 并创建出新的空白的 json 文件
QFileInfo fileInfo(filePath);
if (!fileInfo.exists()) {
initDataFile(); // 创建初始文件
}
// 以只读、文本模式打开文件
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
LOG() << "打开文件失败! " << file.errorString();
return;
}
// 读取到文件内容, 解析为 JSON 对象
QJsonDocument jsonDoc = QJsonDocument::fromJson(file.readAll());
if (jsonDoc.isNull()) {
LOG() << "解析 JSON 文件失败! JSON 文件格式有错误!";
file.close();
return;
}
// 获取根 JSON 对象
QJsonObject jsonObj = jsonDoc.object();
// 恢复登录会话 id
this->loginSessionId = jsonObj["loginSessionId"].toString();
LOG() << "loginSessionId=" << this->loginSessionId;
// 清空当前内存中的未读计数,准备从文件加载
this->unreadMessageCount->clear();
// 获取 unread 字段对应的 JSON 对象
QJsonObject jsonUnread = jsonObj["unread"].toObject();
// 遍历 jsonUnread,将每个键值对恢复为未读计数哈希表
for (auto it = jsonUnread.begin(); it != jsonUnread.end(); ++it) {
this->unreadMessageCount->insert(it.key(), it.value().toInt());
}
// 关闭文件
file.close();
}
作用:从本地 JSON 文件中加载之前保存的数据,恢复到内存中。
具体行为:
- 首先检查 ChatClient.json 文件是否存在,如果不存在则调用 initDataFile 创建一个空的文件。然后以只读方式打开文件,读取全部内容并解析为 JSON 对象。
- 从根对象中取出 loginSessionId 字段的值,赋值给内存中的 loginSessionId 变量。
- 再取出 unread 子对象,遍历其中的每一个键值对,将其恢复到内存中的未读计数哈希表 unreadMessageCount 中(先清空原有数据)。
- 最后关闭文件。
- 这个函数在 DataCenter 构造时被调用。
我们在哪里才会使用到这些持久化存储函数呢?
也就是下面这3个部分才会使用到。
- clearUnread(const QString &chatSessionId):内部调用了 saveDataFile(),用于在清零未读计数后立即保存到文件。
- addUnread(const QString &chatSessionId):内部调用了 saveDataFile(),用于在未读计数加一后立即保存到文件。
- resetLoginSessionId(const QString &loginSessionId):内部调用了 saveDataFile(),用于在登录会话 ID 改变后保存到文件。
cpp
void DataCenter::clearUnread(const QString &chatSessionId)
{
// 将该会话的未读消息数清零
(*unreadMessageCount)[chatSessionId] = 0;
// 手动保存一下结果到文件中,使未读数持久化
saveDataFile();
}
void DataCenter::addUnread(const QString &chatSessionId)
{
// 将该会话的未读消息数加一
++(*unreadMessageCount)[chatSessionId];
// 手动保存一下结果到文件,保证未读数变化后立即持久化
saveDataFile();
}
void DataCenter::resetLoginSessionId(const QString &loginSessionId)
{
this->loginSessionId = loginSessionId;
// 一旦会话 id 改变, 就需要保存到硬盘上.
saveDataFile();
}
这些就是会进行这个持久化存储的。