微服务即时通讯系统的实现(客户端)----(4)
目录
- 1. 单聊消息会话详细信息界面逻辑
- 1.1 判定会话详情为单聊还是群聊
- 1.2 获取对方好友详情
- 1.3 删除好友
- 2. 选择好友界面逻辑
- 2.1 选择联系人
- 2.2 创建群聊会话
- 2.3 收到群聊会话创建通知
- 3. 实现群聊消息会话详细信息界面当中的获取群聊成员列表
- 4. 添加好友界面逻辑
- 4.1 搜索用户
- 4.2 发送好友申请
- 5. 历史消息界面逻辑 (1)
- 5.1 搜索历史消息 (1) - 按查询词搜索
- 5.2 搜索历史消息(2) - 按时间范围搜索
- 6. 用户名登录/注册界面
- 6.1 生成验证码
- 6.2 登录逻辑
- 6.3 注册逻辑
- 7. 手机号登录/注册界面
- 7.1 获取短信验证
- 7.2 登录逻辑
- 7.3 注册逻辑
- 8. 聊天界面逻辑 (2)
- 8.1 异步获取文件内容
- 8.2 图片消息的实现
- 8.3 文件消息的实现
- 8.4 语音消息的实现
- 8.5 语音识别文字
- 9. 历史消息界面逻辑 (2)
- 9.1 适配图片消息
- 9.2 适配文件消息
- 9.3 适配语音消息
- 10. 重定向日志到文件中
- 11. 发布程序
- 12. 客户端总结
1. 单聊消息会话详细信息界面逻辑
1.1 判定会话详情为单聊还是群聊
(1)在 MainWidget 的 extraButton 的槽函数中做⼀个条件判断即可,不需要和服务器通信:
/
/// 点击会话详情按钮, 弹出会话详情窗口
/
connect(extraBtn, &QPushButton::clicked, this, [=]()
{
// 判定当前会话是单聊还是群聊
// 获取到当前会话详细信息, 通过会话中的 userId 属性
ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionById(dataCenter->getCurrentChatSessionId());
if(chatSessionInfo == nullptr)
{
LOG() << "当前会话不存在, 无法弹出会话详情对话框";
return;
}
bool isSingleChat = chatSessionInfo->userId != "";
if(isSingleChat )
{
// 单聊, 弹出这个窗口
UserInfo* userInfo = dataCenter->findFriendById(chatSessionInfo->userId);
if(userInfo == nullptr)
{
LOG() << "单聊会话对应的用户不存在, 无法弹出会话详情窗口";
return;
}
SessionDetailWidget* sessiondetailwidget = new SessionDetailWidget(this, *userInfo);
sessiondetailwidget->exec();
}
else
{
GroupSessionDetailWidget* groupsessiondetailwidget = new GroupSessionDetailWidget(this);
groupsessiondetailwidget->exec();
}
});
1.2 获取对方好友详情
(1)通过 friendList 查询即可。不需要和服务器通信,在 SessionDetailWidget 构造函数中,添加数据加载逻辑:
#if LOAD_DATA_FROM_NETWORK
// 获取到当前的对⽅⽤⼾信息. 对⽅⼀定是咱们的好友.
DataCenter* dataCenter = DataCenter::getInstance();
UserInfo* userInfo = dataCenter->getFriendById(chatSessionInfo.userId);
if(userInfo != nullptr)
{
AvatarItem* currentUser = new AvatarItem(userInfo->avatar, userInfo->nickname);
layout->addWidget(currentUser, 0, 1);
}
#endif
1.3 删除好友
(1)和UserInfoWidget 中的删除好友是⼀样的逻辑:
- 在 SessionDetailWidget 构造函数中,绑定信号槽:
connect(deleteFriendBtn, &QPushButton::clicked, this, &SessionDetailWidget::clickDeleteFriendBtn);
- 实现 SessionDetailWidget::clickDeleteFriendBtn函数:
void SessionDetailWidget::clickDeleteFriendBtn()
{
// 1. 弹出一个对话框让用户确认是否真的要删除
auto result = QMessageBox::warning(this, "确认删除", "确认删除该好友?", QMessageBox::Ok | QMessageBox::Cancel);
if(result != QMessageBox::Ok)
{
LOG() << "用户取消了好友删除";
return;
}
// 2. 发送好友删除的请求
model::DataCenter* dataCenter = model::DataCenter::getInstance();
dataCenter->deleteFriendAsync(this->userInfo.userId);
// 3. 关闭当前窗口
this->close();
}
后续的 deleteFriendAsync 以及响应的处理已经在前⾯实现过了。此处直接复⽤即可。
2. 选择好友界面逻辑
2.1 选择联系人
(1)弹出选择联系人界面:
- 在 SessionDetailWidget 构造函数中,给 addBtn 注册槽函数:
addBtn->setClicked([=](){ChooseFriendDialog* dialog = newChooseFriendDialog(chatSessionInfo.userId);
// 弹出模态对话框
auto result = dialog->exec();
if(result == QDialog::Accepted)
{
// 关闭当前窗⼝
this->close();
}
delete dialog;
});
- 实现 AvatarItem::setClicked
void AvatarItem::setClicked(std::function<void ()> slotFunc)
{
connect(avatarBtn, &QPushButton::clicked, this, slotFunc);
}
(2)初始化待选择好友列表:
- 在 ChooseFriendDialog 构造函数中,新增加载数据逻辑:
void ChooseFriendDialog::initData()
{
// 遍历 好友列表, 把好友列表中的所有的元素, 添加到这个窗口界面上.
model::DataCenter* dataCenter = model::DataCenter::getInstance();
QList<model::UserInfo>* friendList = dataCenter->getFriendList();
if(friendList == nullptr)
{
LOG() << "加载数据时发现好友列表为空!";
return;
}
for(auto iter = friendList->begin(); iter != friendList->end(); ++iter)
{
if(iter->userId == userId)
{
this->addSelectedFriend(iter->userId, iter->avatar, iter->nickname);
this->addFriend(iter->userId, iter->avatar, iter->nickname, true);
}
else
{
this->addFriend(iter->userId, iter->avatar, iter->nickname, false);
}
}
}
(3)在待选择好友列表中勾选某个元素,添加到已选择列表。
2.2 创建群聊会话
(1)客户端发送请求:
- 点击 “完成” 按钮,发送创建会话请求,在 ChooseFriendDialog::initRight 中连接信号槽:
connect(okBtn, &QPushButton::clicked, this, &ChooseFriendDialog::clickOkBtn);
- 实现 ChooseFriendDialog::clickOkBtn函数:
void ChooseFriendDialog::clickOkBtn()
{
// 1. 根据选中的好友列表中的元素, 得到所有的要创建群聊会话的用户 id 列表
QList<QString> userIdList = generateMemberList();
if(userIdList.size() < 3)
{
Toast::showMessage("群聊中的成员不足三个, 无法创建群聊");
return;
}
// 2. 发送网络请求, 创建群聊
model::DataCenter* dataCenter = model::DataCenter::getInstance();
dataCenter->createGroupChatSessionAsync(userIdList);
// 3. 关闭当前窗口
this->close();
}
- 实现 ChooseFriendDialog::generateMemberList函数:
QList<QString> ChooseFriendDialog::generateMemberList()
{
QList<QString> result;
// 1. 把自己添加到结果中
model::DataCenter* dataCenter = model::DataCenter::getInstance();
if(dataCenter->getMyself() == nullptr)
{
LOG() << "个人信息尚未加载!";
return result;
}
result.push_back(dataCenter->getMyself()->userId);
// 2. 遍历选中的列表
QVBoxLayout* layout = dynamic_cast<QVBoxLayout*>(selectedContainer->layout());
for(int i = 0; i < layout->count(); i++)
{
auto* item = layout->itemAt(i);
if(item == nullptr || item->widget() == nullptr)
{
continue;
}
auto* chooseFriendItem = dynamic_cast<ChooseFriendItem*>(item->widget());
result.push_back(chooseFriendItem->getUserId());
}
return result;
}
- 实现 DataCenter::createChatSessionAsync函数:
void DataCenter::createGroupChatSessionAsync(const QList<QString>& userIdList)
{
netClient.createGroupChatSession(loginSessionId, userIdList);
}
- 实现 NetClient::createChatSession函数和接口定义:
//创建会话
message ChatSessionCreateReq {
string request_id = 1;
optional string session_id = 2;
optional string user_id = 3;
string chat_session_name = 4;
//需要注意的是,这个列表中也必须包含创建者⾃⼰的⽤⼾ID
repeated string member_id_list = 5;
}
message ChatSessionCreateRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
//这个字段属于后台之间的数据,给前端回复的时候不需要这个字段,会话信息通过通知进⾏发送
optional ChatSessionInfo chat_session_info = 4;
}
// 函数实现
void NetClient::createGroupChatSession(const QString& loginSessionId, const QList<QString>& userIdList)
{
// 1. 构造请求 body
bite_im::ChatSessionCreateReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setChatSessionName("新的群聊");
pbReq.setMemberIdList(userIdList);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[创建群聊会话] 发送请求 requestId=" << pbReq.requestId() << ", loginSessionId=" << loginSessionId
<< ", userIdList=" << userIdList;
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/friend/create_chat_session", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::ChatSessionCreateRsp>(resp, &ok, &reason);
// b) 判定结果是否正确
if(!ok)
{
LOG() << "[创建群聊会话] 响应失败! reason=" << reason;
return;
}
// c) 往 DataCenter 存储数据. 由于此处创建好的会话, 是 websocket 推送过来的.
// 在这里无需更新 DataCenter. 后续通过 websocket 的逻辑来更新即可.
// d) 通知调用者, 响应处理完毕了
emit dataCenter->createGroupChatSessionDone();
// e) 打印日志
LOG() << "[创建群聊会话] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 定义 DataCenter 信号
void createChatSessionDone();
- 在 MainWidget::initData 中处理上述信号:
connect(dataCenter, &DataCenter::createChatSessionDone, this, [=]() {
// 发送全局通知
Toast::showMessage("创建群聊会话请求已经发送!");
});
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/friend/create_chat_session", [=](const QHttpServerRequest& req)
{
return this->createChatSession(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::createChatSession(const QHttpServerRequest& req)
{
// 解析请求
bite_im::ChatSessionCreateReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 创建会话] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", userIdList=" << pbReq.memberIdList();
// 构造响应 body
bite_im::ChatSessionCreateRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
(4)客户端实现 “取消” 按钮:
connect(cancelBtn, &QPushButton::clicked, this, [=]()
{
// 关闭窗口
this->close();
});
2.3 收到群聊会话创建通知
当有用户创建会话时服务器会通过 websocket给所有会话成员的客户端发送会话创建通知。
(1)客户端处理推送:
- 实现 NetClient::handleWsSessionCreate函数:
void NetClient::handleWsSessionCreate(const model::ChatSessionInfo &chatSessionInfo)
{
// 把这个 ChatSessionInfo 添加到会话列表中即可
QList<model::ChatSessionInfo>* chatSessionList = dataCenter->getChatSessionList();
if(chatSessionList == nullptr)
{
LOG() << "客户端没有加载会话列表";
return;
}
// 新的元素添加到列表头部.
chatSessionList->push_front(chatSessionInfo);
// 发送一个信号, 通知界面更新
emit dataCenter->receiveSessionCreateDone();
}
- 定义 DataCenter 信号:
void receiveSessionCreateDone();
- 处理 receiveSessionCreateDone 信号。在 MainWidget::initSignalSlot中处理信号:
connect(dataCenter, &DataCenter::receiveSessionCreateDone, this, [=]()
{
this->updateChatSessionList();
// 通知用户, 入群
Toast::showMessage("您被拉入到新的群聊中!");
});
(2)服务器实现逻辑:
- 创建按钮 "发送创建会话通知"并定义槽函数:
void Widget::on_pushButton_5_clicked()
{
WebsocketServer* websocketServer = WebsocketServer::getInstance();
emit websocketServer->sendCreateChatSession();
}
- 定义 WebsocketServer 信号:
void sendCreateChatSession();
- 在 websocket 处理逻辑中, 处理上述信号:
connect(this, &WebsocketServer::sendCreateChatSession, this, [=]()
{
if(socket == nullptr || !socket->isValid())
{
LOG() << "socket 对象无效!";
return;
}
QByteArray avatar = loadFileToByteArray(":/resource/image/groupAvatar.png");
bite_im::NotifyMessage notifyMessage;
notifyMessage.setNotifyEventId("");
notifyMessage.setNotifyType(bite_im::NotifyTypeGadget::NotifyType::CHAT_SESSION_CREATE_NOTIFY);
bite_im::MessageInfo messageInfo = makeTextMessageInfo(0, "2100", avatar);
bite_im::ChatSessionInfo chatSessionInfo;
chatSessionInfo.setChatSessionId("2100");
chatSessionInfo.setSingleChatFriendId("");
chatSessionInfo.setChatSessionName("新的群聊");
chatSessionInfo.setPrevMessage(messageInfo);
chatSessionInfo.setAvatar(avatar);
bite_im::NotifyNewChatSession newChatSession;
newChatSession.setChatSessionInfo(chatSessionInfo);
notifyMessage.setNewChatSessionInfo(newChatSession);
// 序列化操作
QByteArray body = notifyMessage.serialize(&serializer);
// 通过 websocket 推送数据
socket->sendBinaryMessage(body);
LOG() << "通知创建会话!";
});
- 在 websocket 断开连接时, 断开信号槽连接:
disconnect(this, &WebsocketServer::sendCreateChatSession, this, nullptr);
3. 实现群聊消息会话详细信息界面当中的获取群聊成员列表
(1)客户端发送请求:
- 在 GroupSessionDetailWidget 构造函数中加载数据:
void GroupSessionDetailWidget::initData()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getMemberListDone, this, &GroupSessionDetailWidget::initMembers);
dataCenter->getMemberListAsync(dataCenter->getCurrentChatSessionId());
}
- 实现 DataCenter::getMemberListAsync函数:
void DataCenter::getMemberListAsync(const QString &chatSessionId)
{
netClient.getMemberList(loginSessionId, chatSessionId);
}
- 实现 NetClient::getMemberList函数和接口定义:
//获取会话成员列表
message GetChatSessionMemberReq {
string request_id = 1;
optional string session_id = 2;
optional string user_id = 3;
string chat_session_id = 4;
}
message GetChatSessionMemberRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
repeated UserInfo member_info_list = 4;
}
// 函数实现:
void NetClient::getMemberList(const QString& loginSessionId, const QString &chatSessionId)
{
// 1. 构造请求 body
bite_im::GetChatSessionMemberReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setChatSessionId(chatSessionId);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[获取会话成员列表] 发送请求 requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", chatSessionId=" << pbReq.chatSessionId();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/friend/get_chat_session_member", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::GetChatSessionMemberRsp>(resp, &ok, &reason);
// b) 判定响应结果是否正确
if(!ok)
{
LOG() << "[获取会话成员列表] 响应失败 reason=" << reason;
return;
}
// c) 把结果记录到 DataCenter
dataCenter->resetMemberList(chatSessionId, pbResp->memberInfoList());
// d) 发送信号
emit dataCenter->getMemberListDone(chatSessionId);
// e) 打印日志
LOG() << "[获取会话成员列表] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 实现 DataCenter::resetMemberList函数:
void DataCenter::resetMemberList(const QString& chatSessionId, const QList<bite_im::UserInfo>& memberList)
{
// 根据 chatSessionId, 这个 key, 得到对应的 value (QList)
QList<UserInfo>& currentMemberList = (*this->memberList)[chatSessionId];
currentMemberList.clear();
for(const auto& m : memberList)
{
model::UserInfo userInfo;
userInfo.load(m);
currentMemberList.push_back(userInfo);
}
}
- 定义 DataCenter 信号:
void getMemberListDone();
- 处理 getMemberListDone 信号。实现 GroupSessionDetailWidget::initMembers函数:
void GroupSessionDetailWidget::initMembers(const QString& chatSessionId)
{
// 根据刚才拿到的成员列表, 把成员列表渲染到界面上.
model::DataCenter* dataCenter = model::DataCenter::getInstance();
QList<UserInfo>* memberList = dataCenter->getMemberList(chatSessionId);
if(memberList == nullptr)
{
LOG() << "获取的成员列表为空! chatSessionId=" << chatSessionId;
return;
}
for(const auto& u : *memberList)
{
AvatarItem* avatarItem = new AvatarItem(u.avatar, u.nickname);
this->addMember(avatarItem);
}
// 群聊名称, 此处先设成固定名称.
groupNameLabel->setText("新的群聊");
}
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/friend/get_chat_session_member", [=](const QHttpServerRequest& req)
{
return this->getChatSessionMember(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getChatSessionMember(const QHttpServerRequest& req)
{
// 解析请求
bite_im::GetChatSessionMemberReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 获取会话成员列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", chatSessionId=" << pbReq.chatSessionId();
// 构造响应
bite_im::GetChatSessionMemberRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
// 循环的构造多个 userInfo, 添加到 memberInfoList 中
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
for(int i = 0; i < 10; ++i)
{
bite_im::UserInfo userInfo = makeUserInfo(i, avatar);
pbResp.memberInfoList().push_back(userInfo);
}
// 序列化
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
(4)扩展功能:
- 点击群成员头像查看详情。
- 新增群聊成员。
- 退出群聊。
- 修改群聊名称。
- 修改群聊名称。
4. 添加好友界面逻辑
4.1 搜索用户
(1)客户端发送请求:
- 在 AddFriendDialog 构造函数中连接信号槽:
connect(searchBtn, &QPushButton::clicked, this,
&AddFriendDialog::clickSearchBtn);
- 实现 clickSearchBtn函数:
void AddFriendDialog::clickSearchBtn()
{
const QString& text = searchEdit->text();
if(text == nullptr)
{
return;
}
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::searchUserDone, this, &AddFriendDialog::clickSearchBtnDone, Qt::UniqueConnection);
dataCenter->searchUserAsync(text);
}
- 实现 DataCenter::searchUserAsync函数:
void DataCenter::searchUserAsync(const QString &searchKey)
{
netClient.searchUser(loginSessionId, searchKey);
}
- 实现 NetClient::searchUser函数和接口定义:
//好友搜索
message FriendSearchReq {
string request_id = 1;
string search_key = 2;//就是名称模糊匹配关键字
optional string session_id = 3;
optional string user_id = 4;
}
message FriendSearchRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
repeated UserInfo user_info = 4;
}
// 函数实现
void NetClient::searchUser(const QString& loginSessionId, const QString& searchKey)
{
// 1. 构造请求 body
bite_im::FriendSearchReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setSearchKey(searchKey);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[搜索用户] 发送请求 requestId=" << pbReq.requestId() << ", loginSessionId=" << loginSessionId
<< ", searchKey=" << searchKey;
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/friend/search_friend", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::FriendSearchRsp>(resp, &ok, &reason);
// b) 判定响应成功
if(!ok)
{
LOG() << "[搜索用户] 响应失败 reason=" << reason;
return;
}
// c) 把得到的结果, 记录到 DataCenter
dataCenter->resetSearchUserResult(pbResp->userInfo());
// d) 发送信号, 通知调用者
emit dataCenter->searchUserDone();
// e) 打印日志
LOG() << "[搜索用户] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 实现 DataCenter::resetSearchUserResult 和 getSearchUserResult函数:
QList<UserInfo>* DataCenter::getSearchUserResult()
{
return searchUserResult;
}
void DataCenter::resetSearchUserResult(const QList<bite_im::UserInfo>& userList)
{
if(searchUserResult == nullptr)
{
searchUserResult = new QList<model::UserInfo>();
}
this->searchUserResult->clear();
for(auto& u : userList)
{
model::UserInfo userInfo;
userInfo.load(u);
searchUserResult->push_back(userInfo);
}
}
- 定义 DataCenter 的信号:
void searchUserDone();
- 处理 searchUserDone 信号。实现 AddFriendDialog::clickSearchBtnDone函数:
void AddFriendDialog::clickSearchBtnDone()
{
// 1. 拿到 DataCenter 中的搜索结果列表
model::DataCenter* dataCenter = model::DataCenter::getInstance();
QList<UserInfo>* searchResult = dataCenter->getSearchUserResult();
if(searchResult == nullptr)
{
return;
}
this->clear();
for(const auto& u : *searchResult)
{
this->addResult(u);
}
}
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/friend/search_friend", [=](const QHttpServerRequest& req)
{
return this->searchFriend(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::searchFriend(const QHttpServerRequest& req)
{
// 解析请求
bite_im::FriendSearchReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 搜索好友] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", searchKey=" << pbReq.searchKey();
// 构造响应 body
bite_im::FriendSearchRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
for(int i = 0; i < 30; ++i)
{
bite_im::UserInfo userInfo = makeUserInfo(i, avatar);
pbResp.userInfo().push_back(userInfo);
}
QByteArray body = pbResp.serialize(&serializer);
// 发送响应给客户端
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
4.2 发送好友申请
(1)客户端发送请求:
- “添加好友” 按钮在 FriendResultItem 的构造函数中添加信号槽:
connect(addBtn, &QPushButton::clicked, this, &FriendResultItem::clickAddBtn);
- 实现 FriendResultItem::clickAddBtn函数:
void FriendResultItem::clickAddBtn()
{
// 1. 发送好友申请
model::DataCenter* dataCenter = model::DataCenter::getInstance();
// 申请好友的逻辑, 都已经编写过了, 此处只需要在这里进行一个调用之前代码即可.
dataCenter->addFriendApplyAsync(this->userInfo.userId);
// 2. 设置按钮为禁用状态
addBtn->setEnabled(false);
addBtn->setText("已申请");
addBtn->setStyleSheet("QPushButton { border:none; color: rgb(255, 255, 255); background-color: rgb(200, 200, 200); border-radius: 10px;}");
}
- 实现 DataCenter::addFriendApplyAsync函数:前面已经实现过了。此处直接调用。
(2)客户端处理响应:前面已经实现过了。此处直接调用。
(3)服务器实现逻辑:前面已经实现过了。此处直接调用。
5. 历史消息界面逻辑 (1)
5.1 搜索历史消息 (1) - 按查询词搜索
(1)添加弹出对话框条件:当前会话id 不为 “” 才弹出。在MessageEditArea::initSignalSlot当中实现:
// 1. 关联 "显示历史消息窗口" 信号槽
connect(showHistoryBtn, &QPushButton::clicked, this, [=]()
{
if(dataCenter->getCurrentChatSessionId().isEmpty())
{
return;
}
HistoryMessageWidget* historyMessageWidget = new HistoryMessageWidget(this);
historyMessageWidget->exec();
});
(2)客户端发送请求:
- 在 HistoryMessageWidget 构造函数中连接信号槽:
connect(searchBtn, &QPushButton::clicked, this, &HistoryMessageWidget::clickSearchBtn);
- 实现 HistoryMessageWidget::clickSearchBtn函数:
void HistoryMessageWidget::clickSearchBtn()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::searchMessageDone, this, &HistoryMessageWidget::clickSearchBtnDone, Qt::UniqueConnection);
if(keyRadioBtn->isChecked())
{
// 按照关键词搜索
// 获取到输入框的关键词
const QString& searchKey = searchEdit->text();
if(searchKey.isEmpty())
{
return;
}
dataCenter->searchMessageAsync(searchKey);
}
else
{
// 按照时间搜索
// TODO
}
}
- 实现 DataCenter::searchMessageAsync函数:
void DataCenter::searchMessageAsync(const QString &searchKey)
{
netClient.searchMessage(loginSessionId, currentChatSessionId, searchKey);
}
- 实现 NetClient::searchMessage函数和接口定义:
message MsgSearchReq {
string request_id = 1;
optional string user_id = 2;
optional string session_id = 3;
string chat_session_id = 4;
string search_key = 5;
}
message MsgSearchRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
repeated MessageInfo msg_list = 4;
}
// 函数实现
void NetClient::searchMessage(const QString& loginSessionId, const QString& chatSessionId, const QString& searchKey)
{
// 1. 构造请求 body
bite_im::MsgSearchReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setChatSessionId(chatSessionId);
pbReq.setSearchKey(searchKey);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[按关键词搜索历史消息] 发送请求 requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", chatSessionId=" << pbReq.chatSessionId() << ", searchKey=" << searchKey;
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/message_storage/search_history", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::MsgSearchRsp>(resp, &ok, &reason);
// b) 判定响应是否正确
if(!ok)
{
LOG() << "[按关键词搜索历史消息] 响应失败! reason=" << reason;
return;
}
// c) 把响应结果写入到 DataCenter
dataCenter->resetSearchMessageResult(pbResp->msgList());
// d) 发送信号
emit dataCenter->searchMessageDone();
// e) 打印日志
LOG() << "[按关键词搜索历史消息] 响应完成 requestId=" << pbResp->requestId();
});
}
(3)客户端处理响应:
- 实现 DataCenter::resetSearchMessageResult函数:
void DataCenter::resetSearchMessageResult(const QList<bite_im::MessageInfo>& msgList)
{
if(searchMessageResult == nullptr)
{
searchMessageResult = new QList<model::Message>();
}
this->searchMessageResult->clear();
for(const auto& u : msgList)
{
model::Message message;
message.load(u);
searchMessageResult->push_back(message);
}
}
- 定义 DataCenter 信号:
void searchMessageDone();
- 处理 searchMessageDone 信号。实现 HistoryMessageWidget::clickSearchBtnDone函数:
void HistoryMessageWidget::clickSearchBtnDone()
{
// 1. 从 DataCenter 中拿到消息搜索的结果列表
model::DataCenter* dataCenter = model::DataCenter::getInstance();
QList<Message>* messageResult = dataCenter->getSearchMessageResult();
if(messageResult == nullptr)
{
return;
}
// 2. 把结果列表的数据, 显示到界面上
this->clear();
for(const Message& m : *messageResult)
{
this->addHistoryMessage(m);
}
}
(4)服务器实现逻辑:
- 注册路由
httpServer.route("/service/message_storage/search_history", [=](const QHttpServerRequest& req)
{
return this->searchHistory(req);
});
- 实现处理函数
QHttpServerResponse HttpServer::searchHistory(const QHttpServerRequest& req)
{
// 解析请求
bite_im::MsgSearchReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 搜索历史消息] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", chatSessionId=" << pbReq.chatSessionId() << ", searchKey=" << pbReq.searchKey();
// 构造响应 body
bite_im::MsgSearchRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
for (int i = 0; i < 10; ++i)
{
bite_im::MessageInfo message = makeTextMessageInfo(i, pbReq.chatSessionId(), avatar);
pbResp.msgList().push_back(message);
}
// 构造图片消息
bite_im::MessageInfo message = makeImageMessageInfo(10, pbReq.chatSessionId(), avatar);
pbResp.msgList().push_back(message);
// 构造文件消息
message = makeFileMessageInfo(11, pbReq.chatSessionId(), avatar);
pbResp.msgList().push_back(message);
// 构造语音消息
message = makeSpeechMessageInfo(12, pbReq.chatSessionId(), avatar);
pbResp.msgList().push_back(message);
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
5.2 搜索历史消息(2) - 按时间范围搜索
(1)客户端发送请求:
- 实现 HistoryMessageWidget::clickSearchBtn函数:
void HistoryMessageWidget::clickSearchBtn()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::searchMessageDone, this, &HistoryMessageWidget::clickSearchBtnDone, Qt::UniqueConnection);
if(keyRadioBtn->isChecked())
{
// 按照关键词搜索
// 获取到输入框的关键词
}
else
{
// 按照时间搜索
auto begTime = begTimeEdit->dateTime();
auto endTime = endTimeEdit->dateTime();
if(begTime >= endTime)
{
Toast::showMessage("时间错误! 开始时间大于结束时间!");
return;
}
dataCenter->searchMessageByTimeAsync(begTime, endTime);
}
}
- 实现 DataCenter::searchMessageByTimeAsync函数:
void DataCenter::searchMessageByTimeAsync(const QDateTime &begTime, const QDateTime &endTime)
{
netClient.searchMessageByTime(loginSessionId, currentChatSessionId, begTime, endTime);
}
- 实现 NetClient::searchMessageByTime函数和接口定义:
message GetHistoryMsgReq {
string request_id = 1;
string chat_session_id = 2;
int64 start_time = 3;
int64 over_time = 4;
optional string user_id = 5;
optional string session_id = 6;
}
message GetHistoryMsgRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
repeated MessageInfo msg_list = 4;
}
// 函数实现
void NetClient::searchMessageByTime(const QString &loginSessionId, const QString &chatSessionId, const QDateTime &begTime, const QDateTime &endTime)
{
// 1. 构造请求 body
bite_im::GetHistoryMsgReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setChatSessionId(chatSessionId);
pbReq.setStartTime(begTime.toSecsSinceEpoch());
pbReq.setOverTime(endTime.toSecsSinceEpoch());
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[按时间搜索历史消息] 发送请求 requestId=" << pbReq.requestId() << ", loginSessionId=" << loginSessionId
<< ", chatSessionId=" << chatSessionId << ", begTime=" << begTime << ", endTime=" << endTime;
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/message_storage/get_history", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::GetHistoryMsgRsp>(resp, &ok, &reason);
// b) 判定响应结果是否正确
if(!ok)
{
LOG() << "[按时间搜索历史消息] 响应失败! reason=" << reason;
return;
}
// c) 把响应结果记录到 DataCenter 中
dataCenter->resetSearchMessageResult(pbResp->msgList());
// d) 发送信号通知调用者
emit dataCenter->searchMessageDone();
// e) 打印日志
LOG() << "[按时间搜索历史消息] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:此处已经实现过了直接复用。
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/message_storage/get_history", [=](const QHttpServerRequest& req)
{
return this->getHistory(req);
});
- 实现处理函数
QHttpServerResponse HttpServer::getHistory(const QHttpServerRequest& req)
{
// 解析请求
bite_im::GetHistoryMsgReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 按时间搜索历史消息] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
<< ", chatSessionId=" << pbReq.chatSessionId() << ", begTime=" << pbReq.startTime() << ", endTime=" << pbReq.overTime();
// 构造响应
bite_im::GetHistoryMsgRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
for(int i = 0; i < 10; ++i)
{
bite_im::MessageInfo message = makeTextMessageInfo(i, pbReq.chatSessionId(), avatar);
pbResp.msgList().push_back(message);
}
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
6. 用户名登录/注册界面
6.1 生成验证码
(1)创建 VerifyCodeWidget 类:
class VerifyCodeWidget : public QWidget
{
Q_OBJECT
public:
explicit VerifyCodeWidget(QWidget *parent = nullptr);
// 通过这个函数, 生成随机的验证码字符串
QString generateVerifyCode();
// 重新生成验证码并显示到界面上
void refreshVerifyCode();
// 检验验证码是否匹配
bool checkVerifyCode(const QString& verifyCode);
void paintEvent(QPaintEvent* event) override;
// 用户点击的时候, 刷新验证码, 并重新显示.
void mousePressEvent(QMouseEvent* event) override;
private:
// 随机数生成器
QRandomGenerator randomGenerator;
// 保存验证码的值
QString verifyCode = "";
signals:
};
(2)生成验证码核心逻辑:
- 生成随机字符串。
- 按照随机的颜色和位置绘制。
- 引入噪点和噪线。
VerifyCodeWidget::VerifyCodeWidget(QWidget *parent)
: QWidget(parent)
{
verifyCode = generateVerifyCode();
}
QString VerifyCodeWidget::generateVerifyCode()
{
QString code;
for(int i = 0; i < 4; i++)
{
int init = 'A';
init += randomGenerator.generate() % 26;
code += static_cast<QChar>(init);
}
return code;
}
void VerifyCodeWidget::refreshVerifyCode()
{
verifyCode = generateVerifyCode();
// 通过 update 就可以起到 "刷新界面" , 本身就是触发 paintEvent
this->update();
}
bool VerifyCodeWidget::checkVerifyCode(const QString& verifyCode)
{
// 此处比较验证码的时候, 需要忽略大小写.
return this->verifyCode.compare(verifyCode, Qt::CaseInsensitive) == 0;
}
void VerifyCodeWidget::paintEvent(QPaintEvent *event)
{
(void) event;
const int width = 180;
const int height = 80;
QPainter painter(this);
QPen pen;
QFont font("楷体",25,QFont::Bold,true);
painter.setFont(font);
// 画点: 添加随机噪点
for(int i = 0; i < 100; i++)
{
pen = QPen(QColor(randomGenerator.generate() % 256, randomGenerator.generate() % 256, randomGenerator.generate() % 256));
painter.setPen(pen);
painter.drawPoint(randomGenerator.generate() % width, randomGenerator.generate() % height);
}
// 画线: 添加随机干扰线
for(int i = 0; i < 5; i++)
{
pen = QPen(QColor(randomGenerator.generate() % 256, randomGenerator.generate() % 256, randomGenerator.generate() % 256));
painter.setPen(pen);
painter.drawLine(randomGenerator.generate() % width, randomGenerator.generate() % height,
randomGenerator.generate() % width, randomGenerator.generate() % height);
}
// 绘制验证码
for(int i = 0; i < verifyCode.size(); i++)
{
pen = QPen(QColor(randomGenerator.generate() % 255, randomGenerator.generate() % 255, randomGenerator.generate() % 255));
painter.setPen(pen);
painter.drawText(5+20*i, randomGenerator.generate() % 10, 30, 30, Qt::AlignCenter, QString(verifyCode[i]));
}
}
void VerifyCodeWidget::mousePressEvent(QMouseEvent *event)
{
(void) event;
this->refreshVerifyCode();
}
6.2 登录逻辑
(1)客户端发送请求:
- 在 LoginWidget 构造函数中注册信号槽:
connect(submitBtn, &QPushButton::clicked, this, &LoginWidget::clickSubmitBtn);
- 实现 LoginWidget::clickSubmitBtn函数:
void LoginWidget::clickSubmitBtn()
{
// 1. 先从输入框拿到必要的内容
const QString& username = usernameEdit->text();
const QString& password = passwordEdit->text();
const QString& verifyCode = verifyCodeEdit->text();
if(username.isEmpty())
{
Toast::showMessage("用户名不能为空!");
return;
}
if(password.isEmpty())
{
Toast::showMessage("密码不能为空!");
return;
}
if(verifyCode.isEmpty())
{
Toast::showMessage("验证码不能为空!");
return;
}
// 2. 对比验证码是否正确
if(!verifyCodeWidget->checkVerifyCode(verifyCode))
{
Toast::showMessage("验证码不正确!");
return;
}
// 3. 真正去发送网络请求.
model::DataCenter* dataCenter = model::DataCenter::getInstance();
if(isLoginMode)
{
// 登录
connect(dataCenter, &model::DataCenter::userLoginDone, this, &LoginWidget::userLoginDone);
dataCenter->userLoginAsync(username, password);
}
else
{
// 注册
connect(dataCenter, &model::DataCenter::userRegisterDone, this, &LoginWidget::userRegisterDone);
dataCenter->userRegisterAsync(username, password);
}
}
- 实现 DataCenter::userLoginAsync函数:
void DataCenter::userLoginAsync(const QString &username, const QString &password)
{
netClient.userLogin(username, password);
}
- 实现 NetClient::userLogin函数和接口定义:
//⽤⼾名登录
message UserLoginReq {
string request_id = 1;
string nickname = 2;
string password = 3;
string verify_code_id = 4;
string verify_code = 5;
}
message UserLoginRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
string login_session_id = 4;
}
// 函数实现
void NetClient::userLogin(const QString& username, const QString& password)
{
// 1. 构造请求 body
bite_im::UserLoginReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setNickname(username);
pbReq.setPassword(password);
pbReq.setVerifyCodeId("");
pbReq.setVerifyCode("");
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[用户名登录] 发送请求 requestId=" << pbReq.requestId() << ", username=" << pbReq.nickname() << ", password=" << pbReq.password();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/user/username_login", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应内容
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::UserLoginRsp>(resp, &ok, &reason);
// b) 判定响应结果是否正确
if(!ok)
{
LOG() << "[用户名登录] 处理失败 reason=" << reason;
emit dataCenter->userLoginDone(false, reason);
return;
}
// c) 记录一下当前返回的数据
dataCenter->resetLoginSessionId(pbResp->loginSessionId());
// d) 发送信号, 通知调用者, 处理完毕了.
emit dataCenter->userLoginDone(true, "");
// e) 打印日志
LOG() << "[用户名登录] 处理响应 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 实现 DataCenter::resetLoginSessionId函数:
void DataCenter::resetLoginSessionId(const QString &loginSessionId)
{
this->loginSessionId = loginSessionId;
saveDataFile();
}
- 定义 DataCenter 信号:
// 用户名登录完成, 参数表⽰成功失败
void userLoginDone(bool ok, const QString reason);
- 处理 userLoginDone 信号和实现 LoginWidget::userLoginDone函数:
void LoginWidget::userLoginDone(bool ok, const QString& reason)
{
// 此处区分一下是否登录成功.
// 登录失败, 给用户反馈失败原因.
if(!ok)
{
Toast::showMessage("登录失败! " + reason);
return;
}
// 登录成功, 需要跳转到主界面.
MainWidget* mainWidget = MainWidget::getInstance();
mainWidget->show();
this->close();
}
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/user/username_login", [=](const QHttpServerRequest& req)
{
return this->usernameLogin(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::usernameLogin(const QHttpServerRequest& req)
{
// 解析请求
bite_im::UserLoginReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 用户名密码登录] requestId=" << pbReq.requestId() << ", username=" << pbReq.nickname()
<< ", password=" << pbReq.password();
// 构造响应 body
bite_im::UserLoginRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
pbResp.setLoginSessionId("testLoginSessionId");
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
6.3 注册逻辑
(1)客户端发送请求:
- 在LoginWidget::clickSubmitBtn当中调用注册逻辑后实现 DataCenter::userRegisterAsync函数:
void DataCenter::userRegisterAsync(const QString &username, const QString &password)
{
netClient.userRegister(username, password);
}
- 实现 NetClient::userRegister函数和接口定义:
//⽤⼾名注册
message UserRegisterReq {
string request_id = 1;
string nickname = 2;
string password = 3;
string verify_code_id = 4;
string verify_code = 5;
}
message UserRegisterRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
}
// 函数实现
void NetClient::userRegister(const QString& username, const QString& password)
{
// 1. 构造请求 body
bite_im::UserRegisterReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setNickname(username);
pbReq.setPassword(password);
pbReq.setVerifyCodeId("");
pbReq.setVerifyCode("");
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[用户名注册] 发送请求 requestId=" << pbReq.requestId() << ", username=" << pbReq.nickname() << ", password=" << pbReq.password();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/user/username_register", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应 body
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::UserRegisterRsp>(resp, &ok, &reason);
// b) 判定响应结果是否正确
if(!ok)
{
LOG() << "[用户名注册] 响应失败! reason=" << reason;
emit dataCenter->userRegisterDone(false, reason);
return;
}
// c) 把返回的数据保存到 DataCenter 中
// 对于注册来说, 不需要保存任何信息, 直接跳过这个环节.
// d) 通知调用者响应处理完成
emit dataCenter->userRegisterDone(true, "");
// e) 打印日志
LOG() << "[用户名注册] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 定义 DataCenter 信号:
// 用户名注册完成, 参数表⽰成功失败
void userRegisterDone(bool ok, const QString reason);
- 实现 LoginWidget::userRegisterDone函数:
void LoginWidget::userRegisterDone(bool ok, const QString& reason)
{
if(!ok)
{
Toast::showMessage("注册失败! " + reason);
return;
}
Toast::showMessage("注册成功! " + reason);
// 切换到登录界面
this->switchMode();
// 输入框清空一下.
// 主要是要清空用户名和密码, 验证码输入框的内容的.
// 但是此处, 只清空一下验证码. 用户名密码这里的情况大概率还是同样的内容.
verifyCodeEdit->clear();
// 更新验证码
verifyCodeWidget->refreshVerifyCode();
}
(3)实现服务器逻辑:
- 注册路由:
httpServer.route("/service/user/username_register", [=](const QHttpServerRequest& req)
{
return this->usernameRegister(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::usernameRegister(const QHttpServerRequest& req)
{
// 解析请求
bite_im::UserRegisterReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 用户名密码注册] requestId=" << pbReq.requestId() << ", username=" << pbReq.nickname()
<< ", password=" << pbReq.password();
// 构造响应 body
bite_im::UserRegisterRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QString body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
7. 手机号登录/注册界面
7.1 获取短信验证
(1)直接调用之前封装好的接口即可。客户端发送请求:
- 在 PhoneLoginWidget 构造函数中连接信号槽:
connect(sendVerifyCodeBtn, &QPushButton::clicked, this, &PhoneLoginWidget::sendVerifyCode);
- 实现 PhoneLoginWidget::sendVerifyCode函数:
void PhoneLoginWidget::sendVerifyCode()
{
// 1. 获取到手机号码
const QString phone = this->phoneEdit->text();
if(phone.isEmpty())
{
return;
}
this->currentPhone = phone;
// 2. 发送网络请求, 获取验证码
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getVerifyCodeDone, this, &PhoneLoginWidget::sendVerifyCodeDone, Qt::UniqueConnection);
dataCenter->getVerifyCodeAsync(phone);
// 3. 开启定时器, 开始倒计时
timer->start(1000);
}
- 实现 DataCenter::getVerifyCodeAsync函数:前面已经实现过。
(2)客户端处理响应:
- 实现 PhoneLoginWidget::sendVerifyCodeDone函数:
void PhoneLoginWidget::sendVerifyCodeDone()
{
// 给出提⽰即可
Toast::showMessage("验证码请求已发送!");
}
(3)服务器实现逻辑:前面已经实现过。
7.2 登录逻辑
(2)客户端发送请求:
- 在 PhoneLoginWidget 构造函数中连接信号槽:
connect(submitBtn, &QPushButton::clicked, this, &PhoneLoginWidget::clickSubmitBtn);
- 实现 PhoneLoginWidget::clickSubmitBtn 处理函数:
void PhoneLoginWidget::clickSubmitBtn()
{
const QString& phone = phoneEdit->text();
const QString& verifyCode = verifyCodeEdit->text();
if(phone.isEmpty())
{
Toast::showMessage("电话不应该为空");
return;
}
if(verifyCode.isEmpty())
{
Toast::showMessage("验证码不应该为空");
return;
}
// 2. 发送请求
model::DataCenter* dataCenter = model::DataCenter::getInstance();
if(isLoginMode)
{
// 登录
connect(dataCenter, &model::DataCenter::phoneLoginDone, this, &PhoneLoginWidget::phoneLoginDone, Qt::UniqueConnection);
dataCenter->phoneLoginAsync(phone, verifyCode);
}
else
{
// 注册
connect(dataCenter, &model::DataCenter::phoneRegisterDone, this, &PhoneLoginWidget::phoneRegisterDone, Qt::UniqueConnection);
dataCenter->phoneRegisterAsync(phone, verifyCode);
}
}
- 实现 DataCenter::phoneLoginAsync函数:
void DataCenter::phoneLoginAsync(const QString &phone, const QString &verifyCode)
{
netClient.phoneLogin(phone, verifyCode);
}
- 实现 NetClient::phoneLogin函数和接口定义:
//⼿机号登录
message PhoneLoginReq {
string request_id = 1;
string phone_number = 2;
string verify_code_id = 3;
string verify_code = 4;
}
message PhoneLoginRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
string login_session_id = 4;
}
// 函数实现
void NetClient::phoneLogin(const QString &phone, const QString &verifyCodeId, const QString &verifyCode)
{
// 1. 构造请求 body
bite_im::PhoneLoginReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setPhoneNumber(phone);
pbReq.setVerifyCodeId(verifyCodeId);
pbReq.setVerifyCode(verifyCode);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[手机号登录] 发送请求 requestId=" << pbReq.requestId() << ", phone=" << pbReq.phoneNumber()
<< ", verifyCodeId=" << pbReq.verifyCodeId() << ", verifyCode=" << pbReq.verifyCode();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/user/phone_login", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::PhoneLoginRsp>(resp, &ok, &reason);
// b) 判定响应是否成功
if(!ok)
{
LOG() << "[手机号登录] 响应出错! reason=" << reason;
emit dataCenter->phoneLoginDone(false, reason);
return;
}
// c) 把响应结果记录到 DataCenter
dataCenter->resetLoginSessionId(pbResp->loginSessionId());
// d) 发送信号
emit dataCenter->phoneLoginDone(true, "");
// e) 打印日志
LOG() << "[手机号登录] 响应完毕 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 实现 DataCenter::resetLoginSessionId函数:前面已经实现过了。
- 定义 DataCenter 信号:
// 电话登录完成, 参数表⽰成功失败
void phoneLoginDone(bool ok, const QString reason);
- 实现 PhoneLoginWidget::phoneLoginDone函数:
void PhoneLoginWidget::phoneLoginDone(bool ok, const QString& reason)
{
if(!ok)
{
Toast::showMessage("登录失败! " + reason);
return;
}
// 跳转到主窗口
MainWidget* mainWidget = MainWidget::getInstance();
mainWidget->show();
// 关闭自己
this->close();
}
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/user/phone_login", [=](const QHttpServerRequest& req)
{
return this->phoneLogin(req);
});
- 实现处理逻辑:
QHttpServerResponse HttpServer::phoneLogin(const QHttpServerRequest &req)
{
// 解析请求
bite_im::PhoneLoginReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 手机号登录] requestId=" << pbReq.requestId() << ", phone=" << pbReq.phoneNumber()
<< ", verifyCodeId=" << pbReq.verifyCodeId() << ", verifyCode=" << pbReq.verifyCode();
// 构造响应
bite_im::PhoneLoginRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
pbResp.setLoginSessionId("testLoginSessionId");
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
7.3 注册逻辑
(1)客户端发送请求:
- 实现 DataCenter::phoneRegisterAsync函数:
void DataCenter::phoneRegisterAsync(const QString& phone, const QString& verifyCode)
{
netClient.phoneRegister(phone, currentVerifyCodeId, verifyCode);
}
- 实现 NetClient::phoneRegister函数:
void NetClient::phoneRegister(const QString &phone, const QString &verifyCodeId, const QString &verifyCode)
{
// 1. 构造请求 body
bite_im::PhoneRegisterReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setPhoneNumber(phone);
pbReq.setVerifyCodeId(verifyCodeId);
pbReq.setVerifyCode(verifyCode);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[手机号注册] 发送请求 requestId=" << pbReq.requestId() << ", phone=" << pbReq.phoneNumber()
<< ", verifyCodeId=" << pbReq.verifyCodeId() << ", verifyCode=" << pbReq.verifyCode();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/user/phone_register", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::PhoneRegisterRsp>(resp, &ok, &reason);
// b) 判定响应是否成功
if(!ok)
{
LOG() << "[手机号注册] 响应失败! reason=" << reason;
emit dataCenter->phoneRegisterDone(false, reason);
return;
}
// c) 让 DataCenter 记录结果, 注册操作不需要记录
// d) 发送信号
emit dataCenter->phoneRegisterDone(true, "");
// e) 打印日志
LOG() << "[手机号注册] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 定义 DataCenter 信号
// 电话注册完成, 参数表⽰成功失败
void phoneRegisterDone(bool ok, const QString reason);
- 处理 phoneRegisterDone 信号。实现 PhoneLoginWidget::phoneRegisterDone函数:
void PhoneLoginWidget::phoneRegisterDone(bool ok, const QString& reason)
{
if(!ok)
{
Toast::showMessage("注册失败! " + reason);
return;
}
Toast::showMessage("注册成功!");
// 跳转到登录界面
switchMode();
// 清空一下输入框
verifyCodeEdit->clear();
// 处理一下倒计时的按钮
leftTime = 1;
}
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/user/phone_register", [=](const QHttpServerRequest& req)
{
return this->phoneRegister(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::phoneRegister(const QHttpServerRequest &req)
{
// 解析请求
bite_im::PhoneRegisterReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 手机号注册] requestId=" << pbReq.requestId() << ", phone=" << pbReq.phoneNumber()
<< ", verifyCodeId=" << pbReq.verifyCodeId() << ", verifyCode=" << pbReq.verifyCode();
// 构造响应 body
bite_im::PhoneRegisterRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
return resp;
}
8. 聊天界面逻辑 (2)
8.1 异步获取文件内容
(1)客户端发送请求:
- 如果 content 为空 (比如这个消息是服务器推送来的),还需要异步的从服务器获取到图片内容。实现 DataCenter::getSingleFileAsync函数:
void DataCenter::getSingleFileAsync(const QString &fileId)
{
netClient.getSingleFile(loginSessionId, fileId);
}
- 实现 NetClient::getSingleFile函数和接口定义:
message GetSingleFileReq {
string request_id = 1;
string file_id = 2;
optional string user_id = 3;
optional string session_id = 4;
}
message GetSingleFileRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
FileDownloadData file_data = 4;
}
message FileDownloadData {
string file_id = 1;
bytes file_content = 2;
}
// 函数实现
void NetClient::getSingleFile(const QString &loginSessionId, const QString &fileId)
{
// 1. 构造请求 body
bite_im::GetSingleFileReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setFileId(fileId);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[获取文件内容] 发送请求 requestId=" << pbReq.requestId() << ", fileId=" << fileId;
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/file/get_single_file", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::GetSingleFileRsp>(resp, &ok, &reason);
// b) 判定响应结果
if(!ok)
{
LOG() << "[获取文件内容] 响应失败 reason=" << reason;
return;
}
// c) 响应结果保存下来. 之前都是把结果保存到 DataCenter 的.
// 这里涉及到的文件可能会很多. 不使用 DataCenter 保存.
// 直接通过信号把文件数据, 投送到调用者的位置上.
// d) 发送信号
emit dataCenter->getSingleFileDone(fileId, pbResp->fileData().fileContent());
// e) 打印日志
LOG() << "[获取文件内容] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端响应:会在接下来的图片消息/文件消息/语音消息中分别实现。
(3)服务器实现逻辑:
- 注册路由:
httpServer.route("/service/file/get_single_file", [=](const QHttpServerRequest& req)
{
return this->getSingleFile(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::getSingleFile(const QHttpServerRequest& req)
{
// 解析请求
bite_im::GetSingleFileReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 获取单个文件] requestId=" << pbReq.requestId() << ", fileId=" << pbReq.fileId();
// 构造响应 body
bite_im::GetSingleFileRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
bite_im::FileDownloadData fileDownloadData;
fileDownloadData.setFileId(pbReq.fileId());
// 此处后续要能够支持三个情况, 图片文件, 普通文件, 语音文件.
// 直接使用 fileId 做区分
if(pbReq.fileId() == "testImage")
{
fileDownloadData.setFileContent(loadFileToByteArray(":/resource/image/logo.png"));
// fileDownloadData.setFileContent(loadFileToByteArray(":/resource/image/defaultAvatar.png"));
}
else if(pbReq.fileId() == "testFile")
{
fileDownloadData.setFileContent(loadFileToByteArray(":/resource/file/test.txt"));
}
else if(pbReq.fileId() == "testSpeech")
{
// 由于此处暂时还没有音频文件. 得后面写了 录音功能 才能生成.
fileDownloadData.setFileContent(loadFileToByteArray(":/resource/file/speech.pcm"));
}
else
{
pbResp.setSuccess(false);
pbResp.setErrmsg("fileId 不是预期的测试 fileId");
}
pbResp.setFileData(fileDownloadData);
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-Type", "application/x-protobuf");
return resp;
}
8.2 图片消息的实现
(1)客户端发送请求:
- 在 MessageEditArea::initSignalSlot 中连接信号槽:
// 4. 关联 "发送图片" 信号槽
connect(sendImageBtn, &QPushButton::clicked, this, &MessageEditArea::clickSendImageBtn);
- 实现 MessageEditArea::clickSendImageBtn函数:
void MessageEditArea::clickSendImageBtn()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
// 1. 判定当前是否有选中的会话
if(dataCenter->getCurrentChatSessionId().isEmpty())
{
// 没有选中会话
Toast::showMessage("您尚未选择任何会话, 不能发送图片!");
return;
}
// 2. 弹出文件对话框
QString filter = "Image Files (*.png *.jpg *.jpeg)";
QString imagePath = QFileDialog::getOpenFileName(this, "选择图片", QDir::homePath(), filter);
if(imagePath.isEmpty())
{
LOG() << "用户取消选择图片";
return;
}
// 3. 读取图片的内容
QByteArray imageContent = model::loadFileToByteArray(imagePath);
// 4. 发送请求
dataCenter->sendImageMessageAsync(dataCenter->getCurrentChatSessionId(), imageContent);
}
- 实现 DataCenter::sendImageMessageAsync函数:
void DataCenter::sendImageMessageAsync(const QString &chatSessionId, const QByteArray &content)
{
netClient.sendMessage(loginSessionId, chatSessionId, MessageType::IMAGE_TYPE, content);
}
(2)客户端处理响应:发送消息之后,服务器的相应最终会通过信号槽,调用到 addSelfMessage。此处重点是在 addSelfMessage 中MessageShowArea::addMessage 内部对图片消息的适配
- 实现 MessageShowArea 中的 MessageImageLabel类:
class MessageImageLabel : public QWidget
{
Q_OBJECT
public:
MessageImageLabel(const QString& fileId, const QByteArray& content, bool isLeft);
void updateUI(const QString& fileId, const QByteArray& content);
void paintEvent(QPaintEvent* event);
private:
QPushButton* imageBtn;
QString fileId; // 该图片在服务器对应的文件 id.
QByteArray content; // 图片的二进制数据
bool isLeft;
};
// 具体实现
MessageImageLabel::MessageImageLabel(const QString& fileId, const QByteArray& content, bool isLeft)
:fileId(fileId)
,content(content)
,isLeft(isLeft)
{
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
imageBtn = new QPushButton(this);
imageBtn->setStyleSheet("QPushButton { border: none; }");
if(content.isEmpty())
{
// 此处这个控件, 是针对 "从服务器拿到图片消息" 这种情况.
// 拿着 fileId, 去服务器获取图片内容
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &MessageImageLabel::updateUI);
dataCenter->getSingleFileAsync(fileId);
}
}
void MessageImageLabel::updateUI(const QString& fileId, const QByteArray& content)
{
if(this->fileId != fileId)
{
// 没对上 fileId, 当前响应的图片是其他的 图片消息 请求的.
return;
}
// 对上了, 真正显示图片内容
this->content = content;
// 进行绘制图片到界面上的操作.
this->update();
}
void MessageImageLabel::paintEvent(QPaintEvent* event)
{
(void)event;
// 1. 先拿到该元素的父元素, 看父元素的宽度是多少.
// 此处显示的图片宽度的上限 父元素宽度的 60% .
QObject* object = this->parent();
if(!object->isWidgetType())
{
// 这个逻辑理论上来说是不会存在的.
return;
}
QWidget* parent = dynamic_cast<QWidget*>(object);
int width = parent->width() * 0.6;
// 2. 加载二进制数据为图片对象
QImage image;
if(content.isEmpty())
{
// 此时图片的响应数据还没回来.
// 此处先拿一个 "固定默认图片" 顶替一下.
QByteArray tmpContent = model::loadFileToByteArray(":/resource/image/image.png");
image.loadFromData(tmpContent);
}
else
{
// 此处的 load 操作 QImage 能够自动识别当前图片是啥类型的 (png, jpg....)
image.loadFromData(content);
}
// 3. 针对图片进行缩放.
int height = 0;
if(image.width() > width)
{
// 发现图片更宽, 就需要把图片缩放一下, 使用 width 作为实际的宽度
// 等比例缩放.
height = ((double)image.height() / image.width()) * width;
}
else
{
// 图片本身不太宽, 不需要缩放.
width = image.width();
height = image.height();
}
// pixmap 只是一个中间变量. QImage 不能直接转成 QIcon, 需要 QPixmap 中转一下
QPixmap pixmap = QPixmap::fromImage(image);
// imageBtn->setFixedSize(width, height);
imageBtn->setIconSize(QSize(width, height));
imageBtn->setIcon(QIcon(pixmap));
// 4. 由于图片高度是计算算出来的. 该元素的父对象的高度, 能够容纳下当前的元素.
// 此处 + 50 是为了能够容纳下 上方的 "名字" 部分. 同时留下一点 冗余 空间.
parent->setFixedHeight(height + 50);
// 5. 确定按钮所在的位置.
// 左侧消息, 和右侧消息, 要显示的位置是不同的.
if(isLeft)
{
imageBtn->setGeometry(10, 0, width, height);
}
else
{
int leftPos = this->width() - width - 10;
imageBtn->setGeometry(leftPos, 0, width, height);
}
}
(3)服务器实现逻辑:
- 在界面上添加按钮"发送图片消息"并实现槽函数:
void Widget::on_pushButton_7_clicked()
{
WebsocketServer* websocketServer = WebsocketServer::getInstance();
emit websocketServer->sendImageResp();
}
- 定义 WebsocketServer 信号:
void sendImageResp();
- 在 websocket 逻辑中, 添加发送图片逻辑:
connect(this, &WebsocketServer::sendImageResp, this, [=]()
{
// 此处就可以捕获到 socket 对象, 从而可以通过 socket 对象给客户端返回数据.
if(socket == nullptr || !socket->isValid())
{
LOG() << "socket 对象无效!";
return;
}
// 构造响应数据
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
bite_im::MessageInfo messageInfo = makeImageMessageInfo(this->messageIndex++, "2000", avatar);
bite_im::NotifyNewMessage notifyNewMessage;
notifyNewMessage.setMessageInfo(messageInfo);
bite_im::NotifyMessage notifyMessage;
notifyMessage.setNotifyEventId("");
notifyMessage.setNotifyType(bite_im::NotifyTypeGadget::NotifyType::CHAT_MESSAGE_NOTIFY);
notifyMessage.setNewMessageInfo(notifyNewMessage);
// 序列化
QByteArray body = notifyMessage.serialize(&this->serializer);
// 发送消息给客户端
socket->sendBinaryMessage(body);
LOG() << "发送图片消息响应";
});
- 在断开 websocket 连接的逻辑中断开上述信号槽
disconnect(this, &WebsocketServer::sendImageResp, this, nullptr);
8.3 文件消息的实现
(1)客户端发送请求:
- 在 MessageEditArea::initSignalSlot 添加信号槽
// 处理点击发送⽂件
connect(sendFileBtn, &QPushButton::clicked, this, &MessageEditArea::clickSendFileBtn);
- 实现 MessageEditArea::clickSendFileBtn函数:
void MessageEditArea::clickSendFileBtn()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
// 1. 判定当前是否有选中的会话
if(dataCenter->getCurrentChatSessionId().isEmpty())
{
// 没有选中会话
Toast::showMessage("您尚未选择任何会话, 不能发送图片!");
return;
}
// 2. 弹出文件对话框
QString filter = "*";
QString path = QFileDialog::getOpenFileName(this, "选择文件", QDir::homePath(), filter);
if(path.isEmpty())
{
// 如果用户弹框之后, 没有真正选择文件, 而是取消了. 返回值就是 ""
LOG() << "用户取消选择文件";
return;
}
// 3. 读取文件内容
// 此处暂时不考虑大文件的情况
// 比如有的文件, 几百 MB, 或者几个 GB.
// 如果是针对大文件的话, 编写专门的网络通信接口, 实现 "分片传输" 效果.
QByteArray content = model::loadFileToByteArray(path);
// 4. 传输文件, 还需要获取到 文件名
QFileInfo fileInfo(path);
const QString& fileName = fileInfo.fileName();
// 5. 发送消息
dataCenter->sendFileMessageAsync(dataCenter->getCurrentChatSessionId(), fileName, content);
}
- 实现 DataCenter::sendFileMessageAsync函数:
void DataCenter::sendFileMessageAsync(const QString &chatSessionId, constQString &fileName, const QByteArray &content)
{
netClient.sendMessage(loginSessionId, chatSessionId, MessageType::FILE_TYPE, content, fileName);
}
(2)客户端处理响应:发送消息之后服务器的相应最终会通过信号槽,调用到 addSelfMessage。此处重点是在 addSelfMessage 中MessageShowArea::addMessage 内部对文件消息的适配。
- 在 MessageContentLabel 构造函数中添加逻辑,异步加载文件内容:
// 针对文件消息, 并且 content 为空的情况下, 通过网络来加载数据
if(messageType == model::TEXT_TYPE)
{
return;
}
if(this->content.isEmpty())
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &MessageContentLabel::updateUI);
dataCenter->getSingleFileAsync(this->fileId);
}
else
{
// content 不为空, 说明当前的这个数据就是已经现成. 直接就把 表示加载状态的变量设为 true
this->loadContentDone = true;
}
- 实现 MessageContentLabel::updateUI函数:
void MessageContentLabel::updateUI(const QString& fileId, const QByteArray& fileContent)
{
// 也和刚才图片消息的处理一样, 就需要判定收到的数据属于哪个 fileId 的.
if(fileId != this->fileId)
{
return;
}
this->content = fileContent;
this->loadContentDone = true;
// 对于文件消息来说, 要在界面上显示 "[文件] test.txt" 这样形式. 这个内容和文件 content 无关.
// 在从服务器拿到文件正文之前, 界面内容应该就是绘制好了. 此时拿到正文之后, 界面应该也不必做出任何实质性的调整.
// 所以下列的 this->update(), 没有也行.
this->update();
}
- 实现鼠标左键点击文件消息,触发文件另存为:
void MessageContentLabel::mousePressEvent(QMouseEvent* event)
{
// 实现鼠标点击之后, 触发文件另存为
if(event->button() == Qt::LeftButton)
{
if(this->messageType == model::MessageType::FILE_TYPE)
{
// 真正触发另存为
if(!this->loadContentDone)
{
Toast::showMessage("数据尚未加载成功, 请稍后重试");
return;
}
saveAsFile(this->content);
}
else if(this->messageType == model::MessageType::SPEECH_TYPE)
{
// 语言处理
}
}
}
void MessageContentLabel::saveAsFile(const QByteArray& content)
{
// 弹出对话框, 让用户选择路径
QString filePath = QFileDialog::getSaveFileName(this, "另存为", QDir::homePath(), "*");
if(filePath.isEmpty())
{
LOG() << "用户取消了文件另存为";
return;
}
model::writeByteArrayToFile(filePath, content);
}
(3)服务器实现逻辑:
- 在界面上添加按钮,"发送⽂件消息"并实现槽函数:
void Widget::on_pushButton_8_clicked()
{
WebsocketServer* websocketServer = WebsocketServer::getInstance();
emit websocketServer->sendFileResp();
}
- 定义 WebsocketServer 信号:
void sendFileResp();
- 在 websocket 逻辑中,添加发送文件逻辑:
connect(this, &WebsocketServer::sendFileResp, this, [=]()
{
// 此处就可以捕获到 socket 对象, 从而可以通过 socket 对象给客户端返回数据.
if(socket == nullptr || !socket->isValid())
{
LOG() << "socket 对象无效!";
return;
}
// 构造响应数据
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
bite_im::MessageInfo messageInfo = makeFileMessageInfo(this->messageIndex++, "2000", avatar);
bite_im::NotifyNewMessage notifyNewMessage;
notifyNewMessage.setMessageInfo(messageInfo);
bite_im::NotifyMessage notifyMessage;
notifyMessage.setNotifyEventId("");
notifyMessage.setNotifyType(bite_im::NotifyTypeGadget::NotifyType::CHAT_MESSAGE_NOTIFY);
notifyMessage.setNewMessageInfo(notifyNewMessage);
// 序列化
QByteArray body = notifyMessage.serialize(&this->serializer);
// 发送消息给客户端
socket->sendBinaryMessage(body);
LOG() << "发送文件消息响应";
});
// 构造⼀个⽂件消息对象
bite_im::MessageInfo makeFileMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{
bite_im::MessageInfo messageInfo;
messageInfo.setMessageId(QString::number(3000 + index));
messageInfo.setChatSessionId(chatSessionId);
messageInfo.setTimestamp(getTime());
messageInfo.setSender(makeUserInfo(index, avatar));
bite_im::FileMessageInfo fileMessageInfo;
fileMessageInfo.setFileId("testFile");
// 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.
// 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.
fileMessageInfo.setFileName("test.txt");
// 此处文件大小, 无法设置. 由于 fileSize 属性, 不是 optional , 此处先设置一个 0 进来
fileMessageInfo.setFileSize(0);
bite_im::MessageContent messageContent;
messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::FILE);
messageContent.setFileMessage(fileMessageInfo);
messageInfo.setMessage(messageContent);
return messageInfo;
}
- 在 websocket 断开连接时释放信号槽:
disconnect(this, &WebsocketServer::sendFileResp, this, nullptr);
8.4 语音消息的实现
(1)录制语音:Qt 录制语音提供两个方案:
- QMediaRecorder
- QAudioSource
其中 QMediaRecorder 方案是 Qt6 新增方案,目前使用体验感觉存在⼀些不好处理的 bug (比如设置采样率,声道等参数不生效)。因此我们使用 QAudioSource 实现。考虑到语音识别需求,需要使咱们录制的声音符合百度语音识别 SDK 的要求。
(2)创建 SoundRecorder 类以及实现。录制的语音文件是 pcm 格式的原始音频数据。还不能通过第三方播放器播放。只能通过下列代码来实现播放功能。所以以下是语音的录制和播放:
class SoundRecorder : public QObject
{
Q_OBJECT
public:
const QString RECORD_PATH = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/sound/tmpRecord.pcm";
const QString PLAY_PATH = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/sound/tmpPlay.pcm";
public:
static SoundRecorder* getInstance();
/
/// 录制语音语音
/
// 开始录制
void startRecord();
// 停止录制
void stopRecord();
private:
static SoundRecorder* instance;
explicit SoundRecorder(QObject *parent = nullptr);
QFile soundFile;
QAudioSource* audioSource;
/
/// 播放语音
/
public:
// 开始播放
void startPlay(const QByteArray& content);
// 停止播放
void stopPlay();
private:
QAudioSink *audioSink;
QMediaDevices *outputDevices;
QAudioDevice outputDevice;
QFile inputFile;
signals:
// 录制完毕后发送这个信号
void soundRecordDone(const QString& path);
// 播放完毕发送这个信号
void soundPlayDone();
};
// 具体实现
/
/// 单例模式
/
SoundRecorder* SoundRecorder::instance = nullptr;
SoundRecorder *SoundRecorder::getInstance()
{
if (instance == nullptr)
{
instance = new SoundRecorder();
}
return instance;
}
// 播放参考 https://www.cnblogs.com/tony-yang-flutter/p/16477212.html
// 录制参考 https://doc.qt.io/qt-6/qaudiosource.html
SoundRecorder::SoundRecorder(QObject *parent)
: QObject{parent}
{
// 1. 创建目录
QDir soundRootPath(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation));
soundRootPath.mkdir("sound");
// 2. 初始化录制模块
soundFile.setFileName(RECORD_PATH);
QAudioFormat inputFormat;
inputFormat.setSampleRate(16000);
inputFormat.setChannelCount(1);
inputFormat.setSampleFormat(QAudioFormat::Int16);
QAudioDevice info = QMediaDevices::defaultAudioInput();
if (!info.isFormatSupported(inputFormat))
{
LOG() << "录制设备, 格式不支持!";
return;
}
audioSource = new QAudioSource(inputFormat, this);
connect(audioSource, &QAudioSource::stateChanged, this, [=](QtAudio::State state)
{
if (state == QtAudio::StoppedState)
{
// 录制完毕
if (audioSource->error() != QAudio::NoError)
{
LOG() << audioSource->error();
}
}
});
// 3. 初始化播放模块
outputDevices = new QMediaDevices(this);
outputDevice = outputDevices->defaultAudioOutput();
QAudioFormat outputFormat;
outputFormat.setSampleRate(16000);
outputFormat.setChannelCount(1);
outputFormat.setSampleFormat(QAudioFormat::Int16);
if (!outputDevice.isFormatSupported(outputFormat))
{
LOG() << "播放设备, 格式不支持";
return;
}
audioSink = new QAudioSink(outputDevice, outputFormat);
connect(audioSink, &QAudioSink::stateChanged, this, [=](QtAudio::State state)
{
if (state == QtAudio::IdleState)
{
LOG() << "IdleState";
this->stopPlay();
emit this->soundPlayDone();
}
else if (state == QAudio::ActiveState)
{
LOG() << "ActiveState";
}
else if (state == QAudio::StoppedState)
{
LOG() << "StoppedState";
if (audioSink->error() != QtAudio::NoError)
{
LOG() << audioSink->error();
}
}
});
}
void SoundRecorder::startRecord()
{
soundFile.open( QIODevice::WriteOnly | QIODevice::Truncate );
audioSource->start(&soundFile);
}
void SoundRecorder::stopRecord()
{
audioSource->stop();
soundFile.close();
emit this->soundRecordDone(RECORD_PATH);
}
void SoundRecorder::startPlay(const QByteArray& content)
{
if (content.isEmpty())
{
Toast::showMessage("数据加载中, 请稍后播放");
return;
}
// 1. 把数据写入到临时文件
model::writeByteArrayToFile(PLAY_PATH, content);
// 2. 播放语音
inputFile.setFileName(PLAY_PATH);
inputFile.open(QIODevice::ReadOnly);
audioSink->start(&inputFile);
}
void SoundRecorder::stopPlay()
{
audioSink->stop();
inputFile.close();
}
(3)客户端发送请求:
- 在 MessageEditArea::initSignalSlot 注册信号槽。按下录音按钮开始录制,释放录音按钮则停止录制:
// 处理录制语⾳
SoundRecorder* soundRecorder = SoundRecorder::getInstance();
connect(sendSoundBtn, &QPushButton::pressed, this, &MessageEditArea::soundRecordPressed);
connect(sendSoundBtn, &QPushButton::released, this, &MessageEditArea::soundRecordReleased);
connect(soundRecorder, &SoundRecorder::soundRecordDone, this, &MessageEditArea::sendSpeech);
- 实现 MessageEditArea::soundRecordPressed函数:
void MessageEditArea::soundRecordPressed()
{
// 判定当前是否选中会话.
model::DataCenter* dataCenter = model::DataCenter::getInstance();
if(dataCenter->getCurrentChatSessionId().isEmpty())
{
LOG() << "未选中任何会话, 不能发送语音消息";
return;
}
// 切换语音按钮的图标
sendSpeechBtn->setIcon(QIcon(":/resource/image/sound_active.png"));
// 开始录音
SoundRecorder* soundRecorder = SoundRecorder::getInstance();
soundRecorder->startRecord();
tipLabel->show();
textEdit->hide();
}
- 实现 MessageEditArea::soundRecordReleased函数:
void MessageEditArea::soundRecordReleased()
{
// 判定当前是否选中会话.
model::DataCenter* dataCenter = model::DataCenter::getInstance();
if(dataCenter->getCurrentChatSessionId().isEmpty())
{
LOG() << "未选中任何会话, 不能发送语音消息";
return;
}
// 切换语音按钮的图标
sendSpeechBtn->setIcon(QIcon(":/resource/image/sound.png"));
// 停止录音
SoundRecorder* soundRecorder = SoundRecorder::getInstance();
soundRecorder->stopRecord();
tipLabel->hide();
textEdit->show();
}
在 stopRecord 中会触发 soundRecordDone 信号,进⼀步的触发MessageEditArea::sendSound函数。
- 实现 MessageEditArea::sendSpeech函数:
void MessageEditArea::sendSpeech(const QString &path)
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
// 1. 读取到语音文件的内容
QByteArray content = model::loadFileToByteArray(path);
if(content.isEmpty())
{
LOG() << "语音文件加载失败";
return;
}
dataCenter->sendSpeechMessageAsync(dataCenter->getCurrentChatSessionId(), content);
}
- 实现 DataCenter::sendSpeechMessageAsync函数:
void DataCenter::sendSpeechMessageAsync(const QString& chatSessionid, const QByteArray& content)
{
netClient.sendMessage(loginSessionId, chatSessionid, MessageType::SPEECH_TYPE, content, "");
}
(4)客户端处理响应:
- 发送消息之后服务器的相应最终会通过信号槽调用到 addSelfMessage。此处重点是在 addSelfMessage 中 MessageShowArea::addMessage 内部对语言消息的适配。在 MessageContentLabel 的 mousePressEvent 实现点击播放语音。此处要考虑到文本提示的切换。点击播放切换为 “播放中…”, 播放完毕切换回 “[语音]”
void MessageContentLabel::mousePressEvent(QMouseEvent* event)
{
// 实现鼠标点击之后, 触发文件另存为
if(event->button() == Qt::LeftButton)
{
if(this->messageType == model::MessageType::FILE_TYPE)
{
// 文件处理
}
else if(this->messageType == model::MessageType::SPEECH_TYPE)
{
if(!this->loadContentDone)
{
Toast::showMessage("数据尚未加载成功, 请稍后重试");
return;
}
SoundRecorder* soundRecorder = SoundRecorder::getInstance();
this->label->setText("播放中...");
connect(soundRecorder, &SoundRecorder::soundPlayDone, this, &MessageContentLabel::playDone, Qt::UniqueConnection);
soundRecorder->startPlay(this->content);
}
}
}
(4)服务器实现逻辑:
- 在界面上添加按钮"发送语音消息"并实现槽函数:
void Widget::on_pushButton_6_clicked()
{
WebsocketServer* websocketServer = WebsocketServer::getInstance();
emit websocketServer->sendSoundResp();
}
- 定义 WebsocketServer 信号:
void sendSoundResp();
- 在 websocket 逻辑中添加发送语言逻辑:
connect(this, &WebsocketServer::sendSpeechResp, this, [=]()
{
// 此处就可以捕获到 socket 对象, 从而可以通过 socket 对象给客户端返回数据.
if(socket == nullptr || !socket->isValid())
{
LOG() << "socket 对象无效!";
return;
}
// 构造响应数据
QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
bite_im::MessageInfo messageInfo = makeSpeechMessageInfo(this->messageIndex++, "2000", avatar);
bite_im::NotifyNewMessage notifyNewMessage;
notifyNewMessage.setMessageInfo(messageInfo);
bite_im::NotifyMessage notifyMessage;
notifyMessage.setNotifyEventId("");
notifyMessage.setNotifyType(bite_im::NotifyTypeGadget::NotifyType::CHAT_MESSAGE_NOTIFY);
notifyMessage.setNewMessageInfo(notifyNewMessage);
// 序列化
QByteArray body = notifyMessage.serialize(&this->serializer);
// 发送消息给客户端
socket->sendBinaryMessage(body);
LOG() << "发送语音消息响应";
});
// 制造语音数据
bite_im::MessageInfo makeSpeechMessageInfo(int index, const QString& chatSessionId, const QByteArray& avatar)
{
bite_im::MessageInfo messageInfo;
messageInfo.setMessageId(QString::number(3000 + index));
messageInfo.setChatSessionId(chatSessionId);
messageInfo.setTimestamp(getTime());
messageInfo.setSender(makeUserInfo(index, avatar));
bite_im::SpeechMessageInfo speechMessageInfo;
// 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.
// 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.
speechMessageInfo.setFileId("testSpeech");
bite_im::MessageContent messageContent;
messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::SPEECH);
messageContent.setSpeechMessage(speechMessageInfo);
messageInfo.setMessage(messageContent);
return messageInfo;
}
- 在 websocket 断开连接时释放信号槽
disconnect(this, &WebsocketServer::sendSoundResp, this, nullptr);
8.5 语音识别文字
(1)客户端发送请求:
- 给 MessageContentLabel 添加右键菜单。只针对语音消息才生效:
void MessageContentLabel::contextMenuEvent(QContextMenuEvent *event)
{
(void) event;
if (messageType != model::MessageType::SPEECH_TYPE)
{
LOG() << "非语音消息暂时不支持右键菜单";
return;
}
QMenu* menu = new QMenu(this);
QAction* action = menu->addAction("语音转文字");
menu->setStyleSheet("QMenu { color: rgb(0, 0, 0); }");
connect(action, &QAction::triggered, this, [=]()
{
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::speechConvertTextDone, this, &MessageContentLabel::speechConvertTextDone, Qt::UniqueConnection);
dataCenter->speechConvertTextAsync(this->fileId, this->content);
});
// 此处弹出 "模态对话框" 显示菜单/菜单项. exec 会在用户进一步操作之前, 阻塞.
menu->exec(event->globalPos());
delete menu;
}
- 实现 DataCenter::speechConvertTextAsync函数:
void DataCenter::speechConvertTextAsync(const QString& fileId, const QByteArray &content)
{
netClient.speechConvertText(loginSessionId, fileId, content);
}
- 实现 NetClient::speechConvertText函数和接口定义:
message SpeechRecognitionReq {
string request_id = 1;
bytes speech_content = 2;
optional string user_id = 3;
optional string session_id = 4;
}
message SpeechRecognitionRsp {
string request_id = 1;
bool success = 2;
string errmsg = 3;
string recognition_result = 4;
}
// 函数实现
void NetClient::speechConvertText(const QString &loginSessionId, const QString &fileId, const QByteArray &content)
{
// 1. 构造请求 body
bite_im::SpeechRecognitionReq pbReq;
pbReq.setRequestId(makeRequestId());
pbReq.setSessionId(loginSessionId);
pbReq.setSpeechContent(content);
QByteArray body = pbReq.serialize(&serializer);
LOG() << "[语音转文字] 发送请求 requestId=" << pbReq.requestId() << ", loginSessonId=" << pbReq.sessionId();
// 2. 发送 HTTP 请求
QNetworkReply* resp = this->sendHttpRequest("/service/speech/recognition", body);
// 3. 处理响应
connect(resp, &QNetworkReply::finished, this, [=]()
{
// a) 解析响应
bool ok = false;
QString reason;
auto pbResp = this->handleHttpResponse<bite_im::SpeechRecognitionRsp>(resp, &ok, &reason);
// b) 判定响应结果
if(!ok)
{
LOG() << "[语音转文字] 响应错误! reason=" << reason;
return;
}
// c) 把结果写入到 DataCenter 中. 此处不打算通过 DataCenter 表示这里的语音识别结果. 直接通过 信号 通知结果即可.
// d) 发送信号, 通知调用者
emit dataCenter->speechConvertTextDone(fileId, pbResp->recognitionResult());
// e) 打印日志
LOG() << "[语音转文字] 响应完成 requestId=" << pbResp->requestId();
});
}
(2)客户端处理响应:
- 定义 DataCenter 信号
// 语音识别完成
void speechConvertTextDone(const QString& fileId, bool ok, const QString reason, const QString text);
- 处理 speechConvertTextDone信号。实现 MessageContentLabel::speechConvertTextDone函数:
void MessageContentLabel::speechConvertTextDone(const QString &fileId, const QString &text)
{
if(this->fileId != fileId)
{
// 直接跳过, 此时的结果不是针对这一条语音消息的结果.
return;
}
// 修改界面内容
this->label->setText("[语音转文字] " + text);
this->update();
}
(3)服务器实现逻辑:
- 注册路由
httpServer.route("/service/speech/recognition", [=](const QHttpServerRequest& req)
{
return this->recognition(req);
});
- 实现处理函数:
QHttpServerResponse HttpServer::recognition(const QHttpServerRequest &req)
{
// 解析请求 body
bite_im::SpeechRecognitionReq pbReq;
pbReq.deserialize(&serializer, req.body());
LOG() << "[REQ 语音转文字] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();
// 构造响应 body
bite_im::SpeechRecognitionRsp pbResp;
pbResp.setRequestId(pbReq.requestId());
pbResp.setSuccess(true);
pbResp.setErrmsg("");
pbResp.setRecognitionResult("你好你好, 这是一段语音消息, 你好你好, 这是一段语音消息");
QByteArray body = pbResp.serialize(&serializer);
// 构造 HTTP 响应
QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
resp.setHeader("Content-type", "application/x-protobuf");
return resp;
}
9. 历史消息界面逻辑 (2)
9.1 适配图片消息
(1)定义 ImageButton 类:此处要针对拿到的图片适当缩放,使图片能正确显示:
class ImageButton : public QPushButton
{
Q_OBJECT
public:
ImageButton(const QString& fileId, const QByteArray& content);
void updateUI(const QString& fileId, const QByteArray& content);
private:
QString fileId;
};
// 具体实现
ImageButton::ImageButton(const QString& fileId, const QByteArray& content)
:fileId(fileId)
{
this->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
this->setStyleSheet("QPushButton { border: none; }");
if(!content.isEmpty())
{
// 直接显示到界面上
this->updateUI(fileId, content);
}
else
{
// 通过网络来获取
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &ImageButton::updateUI);
dataCenter->getSingleFileAsync(fileId);
}
}
void ImageButton::updateUI(const QString& fileId, const QByteArray& content)
{
if(this->fileId != fileId)
{
return;
}
// 如果图片尺寸太大, 需要进行缩放.
QImage image;
image.loadFromData(content);
int width = image.width();
int height = image.height();
if(image.width() >= 300)
{
// 进行缩放, 缩放之后, 宽度就是固定的 300
width = 300;
height = ((double)image.height() / image.width()) * width;
}
this->resize(width, height);
this->setIconSize(QSize(width, height));
QPixmap pixmap = QPixmap::fromImage(image);
this->setIcon(QIcon(pixmap));
}
(2)修改 HistoryMessageItem::makeHistoryMessageItem函数:
// 5. 创建消息体
QWidget* contentWidget = nullptr;
if (message.messageType == TEXT_TYPE)
{
// ......
}
else if (message.messageType == IMAGE_TYPE)
{
contentWidget = new ImageButton(message.fileId, message.content);
}
else if (message.messageType == FILE_TYPE)
{
// TODO
}
else if (message.messageType == SPEECH_TYPE)
{
// TODO
}
else
{
LOG() << "错误的 messageType = " << message.messageType;
}
9.2 适配文件消息
(1)创建 FileLabel 类:此处要实现点击另存为的功能。
class FileLabel : public QLabel
{
Q_OBJECT
public:
FileLabel(const QString &fileId, const QString &fileName);
void getContentDone(const QString& fileId, const QByteArray& fileContent);
// 通过这个函数, 来处理鼠标点击操作.
void mousePressEvent(QMouseEvent* event) override;
private:
QString fileId;
QByteArray content;
QString fileName;
bool loadDone = false;
};
// 具体实现
FileLabel::FileLabel(const QString &fileId, const QString &fileName)
:fileId(fileId)
,fileName(fileName)
{
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
this->setText("[文件] " + fileName);
this->setWordWrap(true);
// 自动调整尺寸让能够显示下文字内容
this->adjustSize();
this->setAlignment(Qt::AlignTop | Qt::AlignLeft);
// 需要从网络加载数据了
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &FileLabel::getContentDone);
dataCenter->getSingleFileAsync(this->fileId);
}
void FileLabel::getContentDone(const QString& fileId, const QByteArray& fileContent)
{
if(fileId != this->fileId)
{
return;
}
this->content = fileContent;
this->loadDone = true;
}
void FileLabel::mousePressEvent(QMouseEvent* event)
{
(void)event;
if(!this->loadDone)
{
// 说明数据还没准备好.
Toast::showMessage("文件内容加载中, 请稍后尝试!");
return;
}
// 弹出一个对话框, 让用户来选择当前要保存的位置
QString filePath = QFileDialog::getSaveFileName(this, "另存为", QDir::homePath(), "*");
if(filePath.isEmpty())
{
// 用户取消了保存
LOG() << "用户取消了保存";
return;
}
model::writeByteArrayToFile(filePath, content);
}
(2)修改 HistoryMessageItem::makeHistoryMessageItem函数:
// 5. 创建消息体
QWidget* contentWidget = nullptr;
if (message.messageType == TEXT_TYPE)
{
// ......
}
else if (message.messageType == IMAGE_TYPE)
{
// ......
}
else if (message.messageType == FILE_TYPE)
{
contentWidget = new FileLabel(message.fileId, message.content, message.fileName);
}
else if (message.messageType == SPEECH_TYPE)
{
// TODO
}
else
{
LOG() << "错误的 messageType = " << message.messageType;
}
9.3 适配语音消息
(1)创建 SoundLabel 类:
class SpeechLabel : public QLabel
{
Q_OBJECT
public:
SpeechLabel(const QString& fileId);
void getContentDone(const QString& fileId, const QByteArray& content);
// 通过这个函数处理鼠标点击
void mousePressEvent(QMouseEvent* event) override;
private:
QString fileId;
QByteArray content;
bool loadDone = false;
};
// 具体实现
SpeechLabel::SpeechLabel(const QString& fileId)
{
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
this->setText("[语音]");
this->setAlignment(Qt::AlignLeft | Qt::AlignTop);
// 这两个操作不太需要了. 此处只有 语音 两个字
this->setWordWrap(true);
this->adjustSize();
model::DataCenter* dataCenter = model::DataCenter::getInstance();
connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &SpeechLabel::getContentDone);
dataCenter->getSingleFileAsync(this->fileId);
}
void SpeechLabel::getContentDone(const QString& fileId, const QByteArray& content)
{
if(fileId != this->fileId)
{
return;
}
this->content = content;
this->loadDone = true;
}
void SpeechLabel::mousePressEvent(QMouseEvent* event)
{
(void)event;
if (!this->loadDone)
{
Toast::showMessage("文件内容加载中, 稍后重试");
return;
}
SoundRecorder* soundRecorder = SoundRecorder::getInstance();
soundRecorder->startPlay(this->content);
}
(2)修改 HistoryMessageItem::makeHistoryMessageItem函数:
// 5. 创建消息体
QWidget* contentWidget = nullptr;
if (message.messageType == TEXT_TYPE)
{
// ......
}
else if (message.messageType == IMAGE_TYPE)
{
// ......
}
else if (message.messageType == FILE_TYPE)
{
// ......
}
else if (message.messageType == SPEECH_TYPE)
{
contentWidget = new SoundLabel(message.fileId, message.content);
}
else
{
LOG() << "错误的 messageType = " << message.messageType;
}
10. 重定向日志到文件中
FILE* output = nullptr;
void msgHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg)
{
(void)type;
(void)context;
const QByteArray& log = msg.toUtf8();
fprintf(output, "%s\n", log.constData());
fflush(output); // 确保数据落入硬盘
}
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
#if DEPOLY
output = fopen("./log.txt", "a");
qInstallMessageHandler(msgHandler);
// ......
}
11. 发布程序
(1)按照 release 的方式构建程序:
(2)找到 exe (也就是构建的程序)所在的目录,并单独拷贝到一个单独的目录上:
(3)使用 windeployqt 获取到依赖的 dll:windeployqt 是 Qt SDK 自带的工具。注意 windeployqt 所在的路径, 根据个人机器的实际路径来设置:
(4)利用命令行进行windeployqt的操作。进入到exe拷贝的目录后按住Shift点击右键进行如下操作:
(5)进入命令行后:
12. 客户端总结
- 使用Qt的常用组件/布局管理器,实现界面的布局。
- 使用QSS针对界面做样式上的优化。
- 通过自定义控件,实现比较复杂的界面效果。
- 通过信号槽,实现了各种需要的人机交互。
- 通过protobuf实现了通信数据的序列化和反序列化。
- 基于HTTP/Websocket实现和服务器之间的异步通信。
- 还是用了多媒体组件,实现语音的录制和发送。
- 应用了单例模式和工厂模式,进行代码的组织. 观察者模式(Qt信号槽机制) 。
- 基于QPainter API实现了本地随机验证码的生成。
- 搭建了MockServer辅助客户端进行各个功能点的测试。
客户端整体代码链接:https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client。