Bootstrap

微服务即时通讯系统的实现(客户端)----(4)

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. 客户端总结

  1. 使用Qt的常用组件/布局管理器,实现界面的布局。
  2. 使用QSS针对界面做样式上的优化。
  3. 通过自定义控件,实现比较复杂的界面效果。
  4. 通过信号槽,实现了各种需要的人机交互。
  5. 通过protobuf实现了通信数据的序列化和反序列化。
  6. 基于HTTP/Websocket实现和服务器之间的异步通信。
  7. 还是用了多媒体组件,实现语音的录制和发送。
  8. 应用了单例模式和工厂模式,进行代码的组织. 观察者模式(Qt信号槽机制) 。
  9. 基于QPainter API实现了本地随机验证码的生成。
  10. 搭建了MockServer辅助客户端进行各个功能点的测试。

客户端整体代码链接https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client。

;