目录
- 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;//需要注意的是,这个列表中也必须包含创建者⾃⼰的⽤⼾IDrepeated 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. 构造请求 bodybite_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();// 构造响应 bodybite_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. 构造请求 bodybite_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) 把结果记录到 DataCenterdataCenter->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. 构造请求 bodybite_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) 把得到的结果, 记录到 DataCenterdataCenter->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();// 构造响应 bodybite_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. 构造请求 bodybite_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) 把响应结果写入到 DataCenterdataCenter->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();// 构造响应 bodybite_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. 构造请求 bodybite_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_OBJECTpublic: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 就可以起到 "刷新界面" , 本身就是触发 paintEventthis->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. 构造请求 bodybite_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();// 构造响应 bodybite_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. 构造请求 bodybite_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) 解析响应 bodybool 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();// 构造响应 bodybite_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. 构造请求 bodybite_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) 把响应结果记录到 DataCenterdataCenter->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. 构造请求 bodybite_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();// 构造响应 bodybite_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. 构造请求 bodybite_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();// 构造响应 bodybite_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_OBJECTpublic: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 不为空, 说明当前的这个数据就是已经现成. 直接就把 表示加载状态的变量设为 truethis->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. 构造请求 bodybite_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)
{// 解析请求 bodybite_im::SpeechRecognitionReq pbReq;pbReq.deserialize(&serializer, req.body());LOG() << "[REQ 语音转文字] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();// 构造响应 bodybite_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_OBJECTpublic: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){// 进行缩放, 缩放之后, 宽度就是固定的 300width = 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_OBJECTpublic: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_OBJECTpublic: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 DEPOLYoutput = 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。