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

目录

  • 1. 将protobuf引入项目当中
  • 2. 前后端交互接口定义
    • 2.1 核心PB类
    • 2.2 HTTP接口定义
    • 2.3 websocket接口定义
  • 3. 核心数据结构和PB之间的转换
  • 4. 设计数据中心DataCenter类
  • 5. 网络通信
    • 5.1 定义NetClient类
    • 5.2 引入HTTP
    • 5.3 引入websocket
  • 6. 小结
  • 7. 搭建测试服务器
    • 7.1 创建项目
    • 7.2 服务器引入http
    • 7.3 服务器引入websocket
    • 7.4 服务器引protobuf
    • 7.5 编写工具函数和构造数据函数
    • 7.6 验证网络连通性
    • 7.7 网络通信注意事项
  • 8. 主界面逻辑的实现
    • 8.1 获取个人信息
    • 8.2 获取好友列表
    • 8.3 获取会话列表
    • 8.4 获取好友申请列表
    • 8.5 获取指定会话的近期消息
    • 8.6 点击某个好友项
  • 9. 小结

1. 将protobuf引入项目当中

(1)创建 proto 目录, 并把服务器提供的 proto 拷贝过来:

(2)proto文件链接:https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client/ChatClient/proto

2. 前后端交互接口定义

2.1 核心PB类

(1)用户信息:

//用户信息结构
message UserInfo {
    string user_id = 1;//用户ID
    string nickname = 2;//昵称
    string description = 3;//个人签名/描述
    string phone = 4; //绑定手机号
    bytes  avatar = 5;//头像照片,文件内容使用二进制
}

(2)会话信息:

//聊天会话信息
message ChatSessionInfo {
    optional string single_chat_friend_id = 1;//群聊会话不需要设置,单聊会话设置为对方ID
    string chat_session_id = 2; //会话ID
    string chat_session_name = 3;//会话名称git 
    optional MessageInfo prev_message = 4;//会话上一条消息,新建的会话没有最新消息
    optional bytes avatar = 5;//会话头像 --群聊会话不需要,直接由前端固定渲染,单聊就是对方的头像
}

(3)消息信息:

//消息类型
enum MessageType {
    STRING = 0;
    IMAGE = 1;
    FILE = 2;
    SPEECH = 3;
}
message StringMessageInfo {
    string content = 1;//文字聊天内容
}
message ImageMessageInfo {
    optional string file_id = 1;//图片文件id,客户端发送的时候不用设置,由transmit服务器进行设置后交给storage的时候设置
    optional bytes image_content = 2;//图片数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候需要原样转发
}
message FileMessageInfo {
    optional string file_id = 1;//文件id,客户端发送的时候不用设置
    int64 file_size = 2;//文件大小
    string file_name = 3;//文件名称
    optional bytes file_contents = 4;//文件数据,在ES中存储消息的时候只要id和元信息,不要文件数据, 服务端转发的时候也不需要填充
}
message SpeechMessageInfo {
    optional string file_id = 1;//语音文件id,客户端发送的时候不用设置
    optional bytes file_contents = 2;//文件数据,在ES中存储消息的时候只要id不要文件数据, 服务端转发的时候也不需要填充
}
message MessageContent {
    MessageType message_type = 1; //消息类型
    oneof msg_content {
        StringMessageInfo string_message = 2;//文字消息
        FileMessageInfo file_message = 3;//文件消息
        SpeechMessageInfo speech_message = 4;//语音消息
        ImageMessageInfo image_message = 5;//图片消息
    };
}
//消息结构
message MessageInfo {
    string message_id = 1;//消息ID
    string chat_session_id = 2;//消息所属聊天会话ID
    int64 timestamp = 3;//消息产生时间
    UserInfo sender = 4;//消息发送者信息
    MessageContent message = 5;
}

message Message {
    string request_id = 1;
    MessageInfo message = 2;
}

message FileDownloadData {
    string file_id = 1;
    bytes file_content = 2;
}

message FileUploadData {
    string file_name = 1;
    int64 file_size = 2;
    bytes file_content = 3;
}

2.2 HTTP接口定义

(1)请求响应基本格式:

//通信接口统一采用POST请求实现,正文采用protobuf协议进行组织
/*  
    HTTP HEADER:
    POST /service/xxxxx
    Content-Type: application/x-protobuf
    Content-Length: 123

    xxxxxx

    -------------------------------------------------------

    HTTP/1.1 200 OK 
    Content-Type: application/x-protobuf
    Content-Length: 123

    xxxxxxxxxx
*/

(2)约定路径:每个接口都提供对应的请求响应的 proto 对象:

//在客户端与网关服务器的通信中,使用HTTP协议进行通信
//  通信时采用POST请求作为请求方法
//  通信时,正文采用protobuf作为正文协议格式,具体内容字段以前边各个文件中定义的字段格式为准
/*  以下是HTTP请求的功能与接口路径对应关系:
    SERVICE HTTP PATH:
    {
        获取随机验证码                  /service/user/get_random_verify_code
        获取短信验证码                  /service/user/get_phone_verify_code
        用户名密码注册                  /service/user/username_register
        用户名密码登录                  /service/user/username_login
        手机号码注册                    /service/user/phone_register
        手机号码登录                    /service/user/phone_login
        获取个人信息                    /service/user/get_user_info
        修改头像                        /service/user/set_avatar
        修改昵称                        /service/user/set_nickname
        修改签名                        /service/user/set_description
        修改绑定手机                    /service/user/set_phone

        获取好友列表                    /service/friend/get_friend_list
        获取好友信息                    /service/friend/get_friend_info
        发送好友申请                    /service/friend/add_friend_apply
        好友申请处理                    /service/friend/add_friend_process
        删除好友                        /service/friend/remove_friend
        搜索用户                        /service/friend/search_friend
        获取指定用户的消息会话列表       /service/friend/get_chat_session_list
        创建消息会话                    /service/friend/create_chat_session
        获取消息会话成员列表             /service/friend/get_chat_session_member
        获取待处理好友申请事件列表       /service/friend/get_pending_friend_events

        获取历史消息/离线消息列表        /service/message_storage/get_history
        获取最近N条消息列表             /service/message_storage/get_recent
        搜索历史消息                    /service/message_storage/search_history
        
        发送消息                        /service/message_transmit/new_message

        获取单个文件数据                /service/file/get_single_file
        获取多个文件数据                /service/file/get_multi_file
        发送单个文件                    /service/file/put_single_file
        发送多个文件                    /service/file/put_multi_file

        语音转文字                     /service/speech/recognition
    }
    
*/

2.3 websocket接口定义

(1)身份认证:

/*
    消息推送使用websocket长连接进行
    websocket长连接转换请求:ws://host:ip/ws
    长连建立以后,需要客户端给服务器发送一个身份验证信息
*/
message ClientAuthenticationReq {
    string request_id = 1;
    string session_id = 2;
}
message ClientAuthenticationRsp {
    string request_id = 1;
    bool success = 2;
    string errmsg = 3;
}

(2)消息推送。当前存在五种消息推送:

  • 申请好友通知。
  • 好友申请处理通知 (同意/拒绝)。
  • 创建消息会话通知。
  • 收到消息通知。
  • 删除好友通知。
enum NotifyType {
    FRIEND_ADD_APPLY_NOTIFY = 0;
    FRIEND_ADD_PROCESS_NOTIFY = 1;
    CHAT_SESSION_CREATE_NOTIFY = 2;
    CHAT_MESSAGE_NOTIFY = 3;
    FRIEND_REMOVE_NOTIFY = 4;
}

message NotifyFriendAddApply {
    UserInfo user_info = 1;  //申请人信息
}
message NotifyFriendAddProcess {
    bool agree = 1;
    UserInfo user_info = 2;  //处理人信息
}
message NotifyFriendRemove {
    string user_id = 1; //删除自己的用户ID
}
message NotifyNewChatSession {
    ChatSessionInfo chat_session_info = 1; //新建会话信息
}
message NotifyNewMessage {
    MessageInfo message_info = 1; //新消息
}


message NotifyMessage {
    optional string notify_event_id = 1;//通知事件操作id(有则填无则忽略)
    NotifyType notify_type = 2;//通知事件类型
    oneof notify_remarks {      //事件备注信息
        NotifyFriendAddApply friend_add_apply = 3;
        NotifyFriendAddProcess friend_process_result = 4;
        NotifyFriendRemove friend_remove = 7;
        NotifyNewChatSession new_chat_session_info = 5;//会话信息
        NotifyNewMessage new_message_info = 6;//消息信息
    }
}

3. 核心数据结构和PB之间的转换

(1)以下是protobuf数据和QString的数据转化函数:(类里面的成员变量没有写出来):

//
/// 用户信息
//
class UserInfo
{
public:
    // 该类的成员变量没有写出来。。。

    // 从 protobuffer 的 UserInfo 对象, 转成当前代码的 UserInfo 对象
    void load(const bite_im::UserInfo& userInfo)
    {
        this->userId = userInfo.userId();
        this->nickname = userInfo.nickname();
        this->description = userInfo.description();
        this->phone = userInfo.phone();
        if(userInfo.avatar().isEmpty())
        {
            // 使用默认头像即可
            this->avatar = QIcon(":/resource/image/defaultAvatar.png");
        }
        else
        {
            this->avatar = makeIcon(userInfo.avatar());
        }
    }
};

//
/// 消息信息
//
enum MessageType
{
    TEXT_TYPE,		// 文本消息
    IMAGE_TYPE, 	// 图片消息
    FILE_TYPE, 		// 文件消息
    SPEECH_TYPE 	// 语音消息
};

class Message
{
public:
    // 该类的成员变量没有写出来。。。

    // 此处 extraInfo 目前只是在消息类型为文件消息时, 作为 "文件名" 补充.
    static Message makeMessage(MessageType messageType, const QString& chatSessionId,
                               const UserInfo& sender, const QByteArray& content,
                               const QString& extraInfo)
    {
        if(messageType == TEXT_TYPE)
        {
            return makeTextMessage(chatSessionId, sender, content);
        }
        else if(messageType == IMAGE_TYPE)
        {
            return makeImageMessage(chatSessionId, sender, content);
        }
        else if(messageType == FILE_TYPE)
        {
            return makeFileMessage(chatSessionId, sender, content, extraInfo);
        }
        else if(messageType == SPEECH_TYPE)
        {
            return makeSpeechMessage(chatSessionId, sender, content);
        }
        else
        {
            // 触发了未知的消息类型
            return Message();
        }
    }

    void load(const bite_im::MessageInfo& messageInfo)
    {
        this->messageId = messageInfo.messageId();
        this->chatSessionId = messageInfo.chatSessionId();
        this->time = formatTime(messageInfo.timestamp());
        this->sender.load(messageInfo.sender());

        // 设置消息类型
        auto type = messageInfo.message().messageType();
        if(type == bite_im::MessageTypeGadget::MessageType::STRING)
        {
            this->messageType = TEXT_TYPE;
            this->content = messageInfo.message().stringMessage().content().toUtf8();
        }
        else if(type == bite_im::MessageTypeGadget::MessageType::IMAGE)
        {
            this->messageType = IMAGE_TYPE;
            if(messageInfo.message().imageMessage().hasImageContent())
            {
                this->content = messageInfo.message().imageMessage().imageContent();
            }

            if(messageInfo.message().imageMessage().hasFileId())
            {
                this->fileId = messageInfo.message().imageMessage().fileId();
            }
        }
        else if(type == bite_im::MessageTypeGadget::MessageType::FILE)
        {
            this->messageType = FILE_TYPE;
            if(messageInfo.message().fileMessage().hasFileContents())
            {
                this->content = messageInfo.message().fileMessage().fileContents();
            }

            if(messageInfo.message().fileMessage().hasFileId())
            {
                this->fileId = messageInfo.message().fileMessage().fileId();
            }

            this->fileName = messageInfo.message().fileMessage().fileName();
        }
        else if(type == bite_im::MessageTypeGadget::MessageType::SPEECH)
        {
            this->messageType = SPEECH_TYPE;
            if(messageInfo.message().speechMessage().hasFileContents())
            {
                this->content = messageInfo.message().speechMessage().fileContents();
            }

            if(messageInfo.message().speechMessage().hasFileId())
            {
                this->fileId = messageInfo.message().speechMessage().fileId();
            }
        }
        else
        {
            // 错误的类型, 啥都不做了, 只是打印一个日志
            LOG() << "非法的消息类型! type=" << type;
        }
    }


private:
    // 通过这个方法生成唯一的 messageId
    static QString makeId()
    {
        return "M" + QUuid::createUuid().toString().sliced(25, 12);
    }

    static Message makeTextMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = TEXT_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // 对于文本消息来说, 这俩属性不使用, 设为 ""
        message.fileId = "";
        message.fileName = "";

        return message;
    }

    static Message makeImageMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = IMAGE_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候再进一步设置
        message.fileId = "";
        // fileName 不使用, 直接设为 ""
        message.fileName = "";
        return message;
    }

    static Message makeFileMessage(const QString& chatSessionId, const UserInfo& sender,
                                   const QByteArray& content, const QString& fileName)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = FILE_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候进一步设置
        message.fileId = "";
        message.fileName = fileName;

        return message;
    }

    static Message makeSpeechMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = SPEECH_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候进一步设置
        message.fileId = "";
        // fileName 不使用, 直接设为 ""
        message.fileName = "";

        return message;
    }
};

//
/// 会话信息
//
class ChatSessionInfo
{
public:
   	// 该类的成员变量没有写出来。。。

    void load(const bite_im::ChatSessionInfo& chatSessionInfo)
    {
        this->chatSessionId = chatSessionInfo.chatSessionId();
        this->chatSessionName = chatSessionInfo.chatSessionName();
        if(chatSessionInfo.hasSingleChatFriendId())
        {
            this->userId = chatSessionInfo.singleChatFriendId();
        }

        if(chatSessionInfo.hasPrevMessage())
        {
            lastMessage.load(chatSessionInfo.prevMessage());
        }

        if(chatSessionInfo.hasAvatar() && !chatSessionInfo.avatar().isEmpty())
        {
            // 已经有头像了, 直接设置这个头像
            this->avatar = makeIcon(chatSessionInfo.avatar());
        }
        else
        {
            // 如果没有头像, 则根据当前会话是单聊还是群聊, 使用不同的默认头像.
            if(userId != "")
            {
                // 单聊
                this->avatar = QIcon(":/resource/image/defaultAvatar.png");
            }
            else
            {
                // 群聊
                this->avatar = QIcon(":/resource/image/groupAvatar.png");
            }
        }
    }
};

4. 设计数据中心DataCenter类

(1)在model文件夹当中创建datacenter.h的头文件,并且在该头文件当中创建DataCenter类来管理所有客户端需要的数据。这是一个单例类:

class DataCenter : public QObject
{
    Q_OBJECT
public:
    static DataCenter* getInstance();

    ~DataCenter();

private:
    DataCenter();
    static DataCenter* instance;

    // 列出 DataCenter 中要组织管理的所有的数据

    // 当前客户端登录到服务器对应的登录会话 id
    QString loginSessionId = "";

    // 当前的用户信息
    model::UserInfo* myself = nullptr;

    // 好友列表
    QList<model::UserInfo>* friendList = nullptr;

    // 会话列表
    QList<model::ChatSessionInfo>* chatSessionList = nullptr;
    // 记录当前选中的会话是哪个~~
    QString currentChatSessionId = "";
    // 记录每个会话中, 都有哪些成员(主要针对群聊). key 为 chatSessionId, value 为成员列表
    QHash<QString, QList<model::UserInfo>>* memberList = nullptr;

    // 待处理的好友申请列表
    QList<model::UserInfo>* applyList = nullptr;

    // 每个会话的最近消息列表, key 为 chatSessionId, value 为消息列表
    QHash<QString, QList<model::Message>>* recentMessages = nullptr;

    // 存储每个会话, 未读消息的个数. key 为 chatSessionId, value 为未读消息的个数.
    QHash<QString, int>* unreadMessageCount = nullptr;

    // 用户的好友搜索结果.
    QList<model::UserInfo>* searchUserResult = nullptr;

    // 历史消息搜索结果.
    QList<model::Message>* searchMessageResult = nullptr;

    // 短信验证码的验证 id
    QString currentVerifyCodeId = "";

    // 让 DataCenter 持有 NetClient 实例.
    network::NetClient netClient;

public:
    // 初始化数据文件
    void initDataFile();
    // 存储数据到文件中
    void saveDataFile();
    // 从数据文件中加载数据到内存
    void loadDataFile();

signals:
};

(2)具体实现:

DataCenter* DataCenter::instance = nullptr;

DataCenter* DataCenter::getInstance()
{
    if(instance == nullptr)
    {
        instance = new DataCenter();
    }

    return instance;
}

DataCenter::DataCenter()
    :netClient(this)
{
    // 此处只是把这几个 hash 类型的属性 new 出实例. 其他的 QList 类型的属性, 都暂时不实例化.
    // 主要是为了使用 nullptr 表示 "非法状态"
    // 对于 hash 来说, 不关心整个 QHash 是否是 nullptr, 而是关心, 某个 key 对应的 value 是否存在~~
    // 通过 key 是否存在, 也能表示该值是否有效.
    recentMessages = new QHash<QString, QList<Message>>();
    memberList = new QHash<QString, QList<UserInfo>>();
    unreadMessageCount = new QHash<QString, int>();
}

DataCenter::~DataCenter()
{
    // 释放所有的成员
    // 此处不必判定 nullptr, 直接 delete 即可!
    // C++ 标准中明确规定, 针对 nullptr 进行 delete, 是合法行为, 不会有任何副作用.
    delete myself;
    delete friendList;
    delete chatSessionList;
    delete memberList;
    delete applyList;
    delete recentMessages;
    delete unreadMessageCount;
    delete searchUserResult;
    delete searchMessageResult;
}

NetClient 的实现后续完成。

(3)数据持久化:使用文件存储 sessionId 和 未读消息信息:

void DataCenter::initDataFile()
{
    // 构造出文件的路径, 使用 appData 存储文件
    QString basePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    QString filePath = basePath + "/ChatClient.json";
    LOG() << "filePath=" << filePath;

    QDir dir;
    if(!dir.exists(basePath))
    {
        dir.mkpath(basePath);
    }

    // 构造好文件路径之后, 把文件创建出来.
    // 写方式打开, 并且写入初始内容
    QFile file(filePath);
    if(!file.open(QIODevice::WriteOnly | QIODevice::Text))
    {
        LOG() << "打开文件失败!" << file.errorString();
        return;
    }

    // 打开成功, 写入初始内容.
    QString data = "{\n\n}";
    file.write(data.toUtf8());
    file.close();
}

void DataCenter::saveDataFile()
{
    QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";

    QFile file(filePath);
    if(!file.open(QIODevice::WriteOnly | QIODevice::Text))
    {
        LOG() << "打开文件失败!" << file.errorString();
        return;
    }

    // 按照 json 格式来写入数据.
    // 这个对象就可以当做 map 一样来使用.
    QJsonObject jsonObj;
    jsonObj["loginSessionId"] = loginSessionId;

    QJsonObject jsonUnread;
    for(auto it = unreadMessageCount->begin(); it != unreadMessageCount->end(); ++it)
    {
        // 注意 Qt 的迭代器使用细节和 STL 略有差别. 此处不是使用 first / second 的方式
        jsonUnread[it.key()] = it.value();
    }

    jsonObj["unread"] = jsonUnread;

    // 把 json 写入文件了
    QJsonDocument jsonDoc(jsonObj);
    QString s = jsonDoc.toJson();
    file.write(s.toUtf8());

    // 关闭文件
    file.close();
}

void DataCenter::loadDataFile()
{
    // 确保在加载之前, 先针对文件进行初始化操作.
    QString filePath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ChatClient.json";

    // 判定文件是否存在, 不存在则初始化, 并创建出新的空白的 json 文件
    QFileInfo fileInfo(filePath);
    if(!fileInfo.exists())
    {
        initDataFile();
    }

    QFile file(filePath);
    if(!file.open(QIODevice::ReadOnly | QIODevice::Text))
    {
        LOG() << "打开文件失败!" << file.errorString();
        return;
    }

    // 读取到文件内容, 解析为 JSON 对象
    QJsonDocument jsonDoc = QJsonDocument::fromJson(file.readAll());
    if(jsonDoc.isNull())
    {
        LOG() << "解析 JSON 文件失败! JSON 文件格式有错误!";
        file.close();
        return;
    }

    QJsonObject jsonObj = jsonDoc.object();
    this->loginSessionId = jsonObj["loginSessionId"].toString();

    this->unreadMessageCount->clear();
    QJsonObject jsonUnread = jsonObj["unread"].toObject();
    for(auto it = jsonUnread.begin(); it != jsonUnread.end(); ++it)
    {
        this->unreadMessageCount->insert(it.key(), it.value().toInt());
    }

    file.close();
}

void DataCenter::clearUnread(const QString& chatSessionId)
{
    (*unreadMessageCount)[chatSessionId] = 0;

    // 手动保存一下结果到文件中.
    saveDataFile();
}

未读消息的实现放到后面完成。

5. 网络通信

5.1 定义NetClient类

(1)创建network文件夹,在创建netclient.h头文件,在此头文件创建 NetClient 类来管理所有的和服务器通信的内容。NetClient 内部又分成 httpClient 和 websocketClient 两个部分。DataCenter 中会持有 NetClient 的指针。

class NetClient : public QObject
{
    Q_OBJECT

private:
    // 定义重要常量. ip 都暂时使用本地的环回 ip. 端口号约定成 8000 和 8001
    const QString HTTP_URL = "http://127.0.0.1:8000";
    const QString WEBSOCKET_URL = "ws://127.0.0.1:8001/ws";

public:
    NetClient(model::DataCenter* dataCenter);

	// 生成请求 id
    static QString makeRequestId();

    // 封装发送请求的逻辑
    QNetworkReply* sendHttpRequest(const QString& apiPath, const QByteArray& body);

private:
    model::DataCenter* dataCenter;

    QNetworkAccessManager httpClient;   // http 客户端

    QWebSocket websocketClient;         // websocket 客户端

    QProtobufSerializer serializer;     // 序列化器

signals:
};

5.2 引入HTTP

(1)进行网络测试:

void NetClient::ping()
{
    QNetworkRequest httpReq;
    httpReq.setUrl(QUrl(HTTP_URL + "/ping"));

    QNetworkReply* httpResp = httpClient.get(httpReq);
    connect(httpResp, &QNetworkReply::finished, this, [=]()
    {
        // 这里面, 说明响应已经回来了.
        if(httpResp->error() != QNetworkReply::NoError)
        {
            // 请求失败!
            LOG() << "HTTP 请求失败! " << httpResp->errorString();
            httpResp->deleteLater();
            return;
        }

        // 获取到响应的 body
        QByteArray body = httpResp->readAll();
        LOG() << "响应内容: " << body;
        httpResp->deleteLater();
    });
}

(2)封装构造 HTTP 请求和处理响应以及请求id:

QString NetClient::makeRequestId()
{
    // 基本要求, 确保每个请求的 id 都是不重复(唯一的)
    // 通过 UUID 来实现上述效果.
    return "R" + QUuid::createUuid().toString().sliced(25, 12);
}

// 通过这个函数, 把发送 HTTP 请求操作封装一下.
// apiPath 应该要以 / 开头
QNetworkReply* NetClient::sendHttpRequest(const QString &apiPath, const QByteArray &body)
{
    QNetworkRequest httpReq;
    httpReq.setUrl(QUrl(HTTP_URL + apiPath));
    httpReq.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-protobuf");

    QNetworkReply* httpResp = httpClient.post(httpReq, body);
    return httpResp;
}

// 封装处理响应的逻辑(包括判定 HTTP 正确性, 反序列化, 判定业务上的正确性)
// 由于不同的 api, 返回的 pb 对象结构, 不同, 为了让一个函数能处理多种不同类型, 需要使用 模板.
// 通过输出型参数, 表示这次操作是成功还是失败, 以及失败的原因.
template <typename T>
std::shared_ptr<T> handleHttpResponse(QNetworkReply* httpResp, bool* ok, QString* reason)
{
    // 1. 判定 HTTP 层面上, 是否出错
    if(httpResp->error() != QNetworkReply::NoError)
    {
        *ok = false;
        *reason = httpResp->errorString();
        httpResp->deleteLater();
        return std::shared_ptr<T>();
    }

    // 2. 获取到响应的 body
    QByteArray respBody = httpResp->readAll();

    // 3. 针对 body 反序列化
    std::shared_ptr<T> respObj = std::make_shared<T>();
    respObj->deserialize(&serializer, respBody);

    // 4. 判定业务上的结果是否正确
    if(!respObj->success())
    {
        *ok = false;
        *reason = respObj->errmsg();
       	httpResp->deleteLater();
        return std::shared_ptr<T>();
    }

    // 5. 释放 httpResp 对象
    httpResp->deleteLater();
    *ok = true;
    return respObj;
}

5.3 引入websocket

(1)Websocket 在主窗口加载后,才和服务器建立连接,并且在建立连接后给服务器发送⼀个 认证请求之后, 才能收到后续数据。初始化 websocket:

void NetClient::initWebsocket()
{
    // 1. 准备好所有需要的信号槽
    connect(&websocketClient, &QWebSocket::connected, this, [=]()
    {
        LOG() << "websocket 连接成功!";
        // 不要忘记! 在 websocket 连接成功之后, 发送身份认证消息!
        sendAuth();
    });

    connect(&websocketClient, &QWebSocket::disconnected, this, [=]()
    {
        LOG() << "websocket 连接断开!";
    });

    connect(&websocketClient, &QWebSocket::errorOccurred, this, [=](QAbstractSocket::SocketError error)
    {
        LOG() << "websocket 连接出错!" << error;
    });

    connect(&websocketClient, &QWebSocket::textMessageReceived, this, [=](const QString& message)
    {
        LOG() << "websocket 收到文本消息!" << message;
    });

    connect(&websocketClient, &QWebSocket::binaryMessageReceived, this, [=](const QByteArray& byteArray)
    {
        LOG() << "websocket 收到二进制消息!" << byteArray.length();
        bite_im::NotifyMessage notifyMessage;
        notifyMessage.deserialize(&serializer, byteArray);
        handleWsResponse(notifyMessage);
    });

    // 2. 和服务器真正建立连接
    websocketClient.open(WEBSOCKET_URL);
}

(2)初始化身份信息:

void NetClient::sendAuth()
{
    bite_im::ClientAuthenticationReq req;
    req.setRequestId(makeRequestId());
    req.setSessionId(dataCenter->getLoginSessionId());
    QByteArray body = req.serialize(&serializer);
    websocketClient.sendBinaryMessage(body);
    LOG() << "[WS身份认证] requestId=" << req.requestId() << ", loginSessionId=" << req.sessionId();
}

(3)搭建 websocket 消息推送的逻辑:

void NetClient::handleWsResponse(const bite_im::NotifyMessage& notifyMessage)
{
    if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::CHAT_MESSAGE_NOTIFY)
    {
        // 收到消息
        // 1. 把 pb 中的 MessageInfo 转成客户端自己的 Message
        model::Message message;
        message.load(notifyMessage.newMessageInfo().messageInfo());
        // 2. 针对自己的 message 做进一步的处理
        handleWsMessage(message);
    }
    else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::CHAT_SESSION_CREATE_NOTIFY)
    {
        // 创建新的会话通知
        model::ChatSessionInfo chatSessionInfo;
        chatSessionInfo.load(notifyMessage.newChatSessionInfo().chatSessionInfo());
        handleWsSessionCreate(chatSessionInfo);
    }
    else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_ADD_APPLY_NOTIFY)
    {
        // 添加好友申请通知
        model::UserInfo userInfo;
        userInfo.load(notifyMessage.friendAddApply().userInfo());
        handleWsAddFriendApply(userInfo);
    }
    else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_ADD_PROCESS_NOTIFY)
    {
        // 添加好友申请的处理结果通知
        model::UserInfo userInfo;
        userInfo.load(notifyMessage.friendProcessResult().userInfo());
        bool agree = notifyMessage.friendProcessResult().agree();
        handleWsAddFriendProcess(userInfo, agree);
    }
    else if(notifyMessage.notifyType() == bite_im::NotifyTypeGadget::NotifyType::FRIEND_REMOVE_NOTIFY)
    {
        // 删除好友通知
        const QString& userId = notifyMessage.friendRemove().userId();
        handleWsRemoveFriend(userId);
    }
}

(4)针对上述每种消息的处理实现,后续再进⼀步完成。

6. 小结

(1)三个层次关系:


NetClient从网络拿到数据,只交给DataCenter通过网络收到的数据,DataCenter负责发送信号给 MainWidget,从而异步通知界面更新。

7. 搭建测试服务器

7.1 创建项目

(1)基于 CMake 创建 Qt 项目。虽然使用控制台项目也可以(创建成 Qt Core Application), 但是使用图形界面更合适⼀些。尤其是后面构造⼀些测试数据,图形界面更方便进行操作。比如在界面上提供不同的按钮,按下不同按钮就可以给客户端推送不同的数据:

cmake_minimum_required(VERSION 3.16)

find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets HttpServer WebSockets Protobuf)

file(GLOB PB_FILES "../ChatClient/proto/*.proto")

qt_add_protobuf(ChatServerMock PROTO_FILES ${PB_FILES})

target_link_libraries(ChatServerMock PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt6::HttpServer Qt6::WebSockets Qt6::Protobuf)

7.2 服务器引入http

(1)创建HttpServer类来实现此功能:

class HttpServer : public QObject
{
    Q_OBJECT

public:
    static HttpServer* getInstance();

    // 通过这个函数, 针对 HTTP Server 进行初始化 (绑定端口, 配置路由....)
    bool init();

private:
    static HttpServer* instance;
    HttpServer() {}

    QHttpServer httpServer;
    QProtobufSerializer serializer;

signals:
};

(2)具体实现:

HttpServer* HttpServer::instance = nullptr;

HttpServer* HttpServer::getInstance()
{
    if(instance == nullptr)
    {
        instance = new HttpServer();
    }

    return instance;
}

bool HttpServer::init()
{
	// 返回的值是 int, 表示成功绑定的端口号的数值.
    int ret = httpServer.listen(QHostAddress::Any, 8000);
    
	// 配置路由
    httpServer.route("/ping", [](const QHttpServerRequest& req)
    {
        (void) req;
        qDebug() << "[http] 收到 ping 请求";
        return "pong";
    });
    
	return ret == 8000;
}

7.3 服务器引入websocket

(1)创建WebsocketServer类来实现此功能:

class WebsocketServer : public QObject
{
    Q_OBJECT

private:
    static WebsocketServer* instance;
    WebsocketServer() : websocketServer("websocket server", QWebSocketServer::NonSecureMode) {}

    QWebSocketServer websocketServer;
    QProtobufSerializer serializer;

public:
    static WebsocketServer* getInstance();

    bool init();

    int messageIndex = 0;


signals:
};

(2)具体实现:

WebsocketServer* WebsocketServer::instance = nullptr;

WebsocketServer *WebsocketServer::getInstance()
{
    if (instance == nullptr)
    {
        instance = new WebsocketServer();
    }

    return instance;
}

// 针对 websocket 服务器进行初始化操作
bool WebsocketServer::init()
{
    // 1. 连接信号槽
    connect(&websocketServer, &QWebSocketServer::newConnection, this, [=]()
    {
        // 连接建立成功之后.
        qDebug() << "[websocket] 连接建立成功!";

        // 获取到用来通信的 socket 对象. nextPendingConnection 类似于 原生 socket 中的 accept
        QWebSocket* socket = websocketServer.nextPendingConnection();

        // 针对这个 socket 对象, 进行剩余信号的处理
        connect(socket, &QWebSocket::disconnected, this, [=]()
        {
            qDebug() << "[websocket] 连接断开!";
            
        });

        connect(socket, &QWebSocket::errorOccurred, this, [=](QAbstractSocket::SocketError error)
        {
            qDebug() << "[websocket] 连接出错! " << error;
        });

        connect(socket, &QWebSocket::textMessageReceived, this, [=](const QString& message)
        {
            qDebug() << "[websocket] 收到文本数据! message=" << message;
        });

        connect(socket, &QWebSocket::binaryMessageReceived, this, [=](const QByteArray& byteArray)
        {
            qDebug() << "[websocket] 收到二进制数据! " << byteArray.length();
        });
        
    });


    // 2. 绑定端口, 启动服务
    bool ok = websocketServer.listen(QHostAddress::Any, 8001);
    return ok;
}

7.4 服务器引protobuf

(1)cmake增加内容文件:

find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets HttpServer WebSockets Protobuf)
file(GLOB PB_FILES "../ChatClient/proto/*.proto")

直接从ChatClient项目中引入proto文件。

(2)如果出现下列报错:

  • 则给 target_link_libraries 引入 PRIVATE。从
target_link_libraries(ChatServerMock Qt${QT_VERSION_MAJOR}::Core Qt6::Network Qt6::WebSockets)
  • 修改为:
target_link_libraries(ChatServerMock PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt6::Network Qt6::WebSockets)

7.5 编写工具函数和构造数据函数

(1)工具函数:

// 读写文件操作.
// 从指定文件中, 读取所有的二进制内容. 得到一个 QByteArray
static inline QByteArray loadFileToByteArray(const QString& path) {
    QFile file(path);
    bool ok = file.open(QFile::ReadOnly);
    if (!ok) {
        LOG() << "文件打开失败!";
        return QByteArray();
    }
    QByteArray content = file.readAll();
    file.close();
    return content;
}

// 把 QByteArray 中的内容, 写入到某个指定文件里
static inline void writeByteArrayToFile(const QString& path, const QByteArray& content) {
    QFile file(path);
    bool ok = file.open(QFile::WriteOnly);
    if (!ok) {
        LOG() << "文件打开失败!";
        return;
    }
    file.write(content);
    file.flush();
    file.close();
}

(2)构造数据函数:

// 生成默认的 UserInfo 对象
bite_im::UserInfo makeUserInfo(int index, const QByteArray& avatar)
{
    bite_im::UserInfo userInfo;
    userInfo.setUserId(QString::number(1000 + index));
    userInfo.setNickname("张三" + QString::number(index));
    userInfo.setDescription("个性签名" + QString::number(index));
    userInfo.setPhone("18612345678");
    userInfo.setAvatar(avatar);
    return userInfo;
}

bite_im::MessageInfo makeTextMessageInfo(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::StringMessageInfo stringMessageInfo;
    stringMessageInfo.setContent("这是一条消息内容" + QString::number(index));

    bite_im::MessageContent messageContent;
    messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::STRING);
    messageContent.setStringMessage(stringMessageInfo);
    messageInfo.setMessage(messageContent);
    return messageInfo;
}

bite_im::MessageInfo makeImageMessageInfo(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::ImageMessageInfo imageMessageInfo;
    imageMessageInfo.setFileId("testImage");
    // 真实服务器推送的消息数据里, 本身也就不带图片的正文. 只是 fileId.
    // 需要通过 fileId, 二次发起请求, 通过 getSingleFile 接口来获取到内容.
    // imageMessageInfo.setImageContent();

    bite_im::MessageContent messageContent;
    messageContent.setMessageType(bite_im::MessageTypeGadget::MessageType::IMAGE);
    messageContent.setImageMessage(imageMessageInfo);
    messageInfo.setMessage(messageContent);
    return messageInfo;
}

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;
}

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;
}

7.6 验证网络连通性

(1)修改客户端的 main.cpp , 添加网络测试代码:

// 测试⽹络联通
#if TEST_NETWORK
 	network::NetClient netClient(nullptr);
 	netClient.ping();
#endif

运行客户端, 连接测试服务器,并验证是否 HTTP / Websocket网络能连通。

7.7 网络通信注意事项

  1. 不能使用两个 Qt Creator 分别启动服务器和客户端。后启动的程序 qDebug 会失效。提示:“无法获取调试输出”。
  2. websocket 客户端代码要编写完整,再连接服务器。否则会直接崩溃,而没有任何具体提示。
  3. ⼀定要确保 websocket 的 connected 信号触发之后,才能 sendTextMessage。否则不会有任何提示,但是消息发送不成功。Qt 这⼀套信号槽,用起来和 Node.js 非常相似的。时刻注意 “异步” 的问题。
  4. 每次更新完 PB,⼀定要记得服务器和客户端都需要重新编译运行!!否则程序会出现不可预期的错误。

8. 主界面逻辑的实现

8.1 获取个人信息

(1)客户端发送请求:

  • 在MainWidget::initSignalSlot函数当中添加获取信息的信号除力getMyselfDone槽函数:
connect(dataCenter, &DataCenter::getMyselfDone, this, [=]() 
{
	// 从 DataCenter 中拿到响应结果的 myself, 把里面的头像取出来, 显示到界面上.
 	const auto* myself = dataCenter->getMyself();
 	this->userAvatar->setIcon(myself->avatar);
});

dataCenter->getMyselfAsync();
  • 编写 DataCenter::getMyselfAsync函数:
void DataCenter::getMyselfAsync()
{
 	netClient.getMyself(loginSessionId);
}
  • 编写NetClient::getMyself函数以及接口定义:
//个⼈信息获取-这个只⽤于获取当前登录⽤⼾的信息
// 客⼾端传递的时候只需要填充session_id即可
//其他个⼈/好友信息的获取在好友操作中完成
message GetUserInfoReq {
 	string request_id = 1;
 	optional string user_id = 2;
 	optional string session_id = 3;
}

message GetUserInfoRsp {
 	string request_id = 1;
 	bool success = 2;
 	string errmsg = 3; 
	UserInfo user_info = 4;
}

// 具体实现:
void NetClient::getMyself(const QString& loginSessionId)
{
    // 1. 构造出 HTTP 请求 body 部分
    bite_im::GetUserInfoReq req;
    req.setRequestId(makeRequestId());
    req.setSessionId(loginSessionId);
    QByteArray body = req.serialize(&serializer);
    LOG() << "[获取个人信息] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;

    // 2. 构造出 HTTP 请求, 并发送出去.
    QNetworkReply* httpResp = sendHttpRequest("/service/user/get_user_info", body);

    // 3. 通过信号槽, 获取到当前的响应. finished 信号表示响应已经返回到客户端了.
    connect(httpResp, &QNetworkReply::finished, this, [=]()
    {
        // a) 先处理响应对象
        bool ok = false;
        QString reason;
        auto resp = handleHttpResponse<bite_im::GetUserInfoRsp>(httpResp, &ok, &reason);

        // b) 判定响应是否正确
        if (!ok)
        {
            LOG() << "[获取个人信息] 出错! requestId=" << req.requestId() << "reason=" << reason;
            return;
        }

        // c) 把结果保存在 DataCenter 中
        dataCenter->resetMyself(resp);

        // d) 通知调用逻辑, 响应已经处理完了. 仍然通过信号槽, 通知.
        emit dataCenter->getMyselfDone();

        // e) 打印日志.
        LOG() << "[获取个人信息] 处理响应 requestId=" << req.requestId();
    });
}

(2)客户端处理响应:

  • 实现 DataCenter::resetMyself函数:
void DataCenter::resetMyself(std::shared_ptr<bite_im::GetUserInfoRsp> resp)
{
    if(myself == nullptr)
    {
        myself = new UserInfo();
    }

    const bite_im::UserInfo userInfo = resp->userInfo();
    myself->load(userInfo);
}
  • 定义DataCenter信号:
signals:
 	// 获取个⼈信息完成
 	void getMyselfDone();

(3)服务器处理请求:

  • 编写 HttpServer::init 注册路由:
httpServer.route("/service/user/get_user_info", [=](const QHttpServerRequest& req) 
{
 	return this->getUserInfo(req);
});
  • 实现处理函数:
QHttpServerResponse HttpServer::getUserInfo(const QHttpServerRequest& req)
{
    // 解析请求, 把 req 的 body 取出来, 并且通过 pb 进行反序列化
    bite_im::GetUserInfoReq pbReq;
    pbReq.deserialize(&serializer, req.body());
    LOG() << "[REQ 获取用户信息] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();

    // 构造响应数据
    bite_im::GetUserInfoRsp pbResp;
    pbResp.setRequestId(pbReq.requestId());
    pbResp.setSuccess(true);
    pbResp.setErrmsg("");

    bite_im::UserInfo userInfo;
    userInfo.setUserId("1029");    // 调整自己的用户 id, 和返回的消息列表的内容匹配上
    userInfo.setNickname("张三");
    userInfo.setDescription("这是个性签名");
    userInfo.setPhone("18612345678");
    userInfo.setAvatar(loadFileToByteArray(":/resource/image/groupAvatar.png"));
    pbResp.setUserInfo(userInfo);

    QByteArray body = pbResp.serialize(&serializer);

    // 构造 HTTP 响应数据
    QHttpServerResponse httpResp(body, QHttpServerResponse::StatusCode::Ok);
    httpResp.setHeader("Content-Type", "application/x-protobuf");
    return httpResp;
}

(4)整体流程小结:

8.2 获取好友列表

(1)客户端发送请求:

  • 在MainWidget::initSignalSlot添加槽函数:

/// 获取好友列表

loadFriendList();
  • 具体实现loadFriendList函数:
// 加载好友列表
void MainWidget::loadFriendList()
{
    // 好友列表数据是在 DataCenter 中存储的
    // 首先需要判定 DataCenter 中是否已经有数据了. 如果有数据, 直接加载本地的数据.
    // 如果没有数据, 从服务器获取
    DataCenter* dataCenter = DataCenter::getInstance();
    if(dataCenter->getFriendList() != nullptr)
    {
        // 从内存这个列表中加载数据
        updateFriendList();
    }
    else
    {
        // 通过网络来加载数据
        connect(dataCenter, &DataCenter::getFriendListDone, this, &MainWidget::updateFriendList, Qt::UniqueConnection);
        dataCenter->getFriendListAsync();
    }
}
  • 注意:

    • loadFriendList 不仅仅会在初始化时调用,也会在后续切换标签页时调用。
    • 多次 connect 虽然不会报错,但是会导致槽函数被⼀个信号触发多次。
    • 可以在 connect 的时候使用 Qt::UniqueConnection 参数(第五个参数),避免触发多次的情况。
  • 实现 DataCenter 中的 getFriendList和getFriendListAsync函数:

QList<UserInfo>* DataCenter::getFriendList()
{
 	return friendList;
}
void DataCenter::getFriendListAsync()
{
 	netClient.getFriendList(loginSessionId);
}
  • 实现 NetClient::getFriendList函数:
// 接⼝定义
//--------------------------------------
//好友列表获取
message GetFriendListReq {
 	string request_id = 1;
 	optional string user_id = 2;
 	optional string session_id = 3;
}

message GetFriendListRsp {
 	string request_id = 1;
 	bool success = 2;
 	string errmsg = 3; 
 	repeated UserInfo friend_list = 4;
}

// 代码实现
void NetClient::getFriendList(const QString& loginSessionId)
{
    // 1. 通过 protobuf 构造 body
    bite_im::GetFriendListReq req;
    req.setRequestId(makeRequestId());
    req.setSessionId(loginSessionId);
    QByteArray body = req.serialize(&serializer);
    LOG() << "[获取好友列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;

    // 2. 发送 HTTP 请求
    QNetworkReply* httpResp = this->sendHttpRequest("/service/friend/get_friend_list", body);


    // 3. 处理响应
    connect(httpResp, &QNetworkReply::finished, this, [=]()
    {
        // a) 先处理响应对象
        bool ok = false;
        QString reason;
        auto friendListResp = this->handleHttpResponse<bite_im::GetFriendListRsp>(httpResp, &ok, &reason);

        // b) 判定响应是否正确
        if(!ok)
        {
            LOG() << "[获取好友列表] 失败! requestId=" << req.requestId() << ", reason=" << reason;
            return;
        }

        // c) 把结果保存在 DataCenter 中
        dataCenter->resetFriendList(friendListResp);

        // d) 发送信号, 通知界面, 当前这个操作完成了.
        emit dataCenter->getFriendListDone();

        // e) 打印日志.
        LOG() << "[获取好友列表] 处理响应 requestId=" << req.requestId();
    });
}

(2)客户端处理响应:

  • 编写 DataCenter::resetFriendList函数:
void DataCenter::resetFriendList(std::shared_ptr<bite_im::GetFriendListRsp> resp)
{
    if(friendList == nullptr)
    {
        friendList = new QList<UserInfo>();
    }

    friendList->clear();
    QList<bite_im::UserInfo>& friendListPB = resp->friendList();
    for(auto& f : friendListPB)
    {
        UserInfo userinfo;
        userinfo.load(f);
        friendList->push_back(userinfo);
    }
}
  • 定义 DataCenter 信号:
void getFriendListDone();
  • 实现 MainWidget::updateFriendList函数:
void MainWidget::updateFriendList()
{
    if(activeTab != FRIEND_LIST)
    {
        // 当前的标签页不是好友列表, 就不渲染任何数据到界面上
        return;
    }

    DataCenter* dataCenter = DataCenter::getInstance();
    QList<UserInfo>* friendList = dataCenter->getFriendList();

    // 清空一下之前界面上的数据.
    sessionFriendArea->clear();

    // 遍历好友列表, 添加到界面上
    for (const auto& f : *friendList)
    {
        sessionFriendArea->addItem(FriendItemType, f.userId, f.avatar, f.nickname, f.description);
    }
}

(3)服务器处理请求:

  • 编写 HttpServer::init 注册路由:
httpServer.route("/service/friend/get_friend_list", [=](constQHttpServerRequest& req) 
{
 	return this->getFriendList(req);
});
  • 实现处理函数:
QHttpServerResponse HttpServer::getFriendList(const QHttpServerRequest& req)
{
    // 解析请求, 把 req 的 body 拿出来.
    bite_im::GetFriendListReq pbReq;
    pbReq.deserialize(&serializer, req.body());
    LOG() << "[REQ 获取好友列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();

    // 构造响应
    bite_im::GetFriendListRsp pbRsp;
    pbRsp.setRequestId(pbReq.requestId());
    pbRsp.setSuccess(true);
    pbRsp.setErrmsg("");

    // 从文件读取数据操作, 其实是比较耗时的. (读取硬盘)
    // 耗时操作如果放在循环内部, 就会使整个的响应处理时间, 更长.
    QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
    for(int i = 0; i < 20; i++)
    {
        bite_im::UserInfo userInfo = makeUserInfo(i, avatar);
        pbRsp.friendList().push_back(userInfo);
    }

    // 进行序列化
    QByteArray body = pbRsp.serialize(&serializer);

    // 构造成 HTTP 响应对象
    QHttpServerResponse httpResp(body, QHttpServerResponse::StatusCode::Ok);
    httpResp.setHeader("Content-Type", "application/x-protobuf");
    return httpResp;
}

(4)整体流程小结:

8.3 获取会话列表

(1)客户端发送请求:

  • 编写 MainWidget::init槽函数:

/// 获取会话列表

loadSessionList();
  • 具体实现loadSessionList()函数:
// 加载会话列表
void MainWidget::loadSessionList()
{
    // 先判定会话列表数据是否在本地 (DataCenter) 中存在. 如果本地存在, 直接构造界面内容.
    // 如果本地不存在, 则从服务器获取数据.
    DataCenter* dataCenter = DataCenter::getInstance();
    if(dataCenter->getFriendList() != nullptr)
    {
        // 从内存这个列表中加载数据
        updateChatSessionList();
    }
    else
    {
        // 从网络加载数据
        connect(dataCenter, &DataCenter::getChatSessionListDone, this, &MainWidget::updateChatSessionList, Qt::UniqueConnection);
        dataCenter->getChatSessionListAsync();
    }
}
  • 编写 DataCenter:
QList<ChatSessionInfo>* DataCenter::getChatSessionList()
{
 	return chatSessionList;
}
void DataCenter::getChatSessionListAsync()
{
 	netClient.getChatSessionList(loginSessionId);
}
  • 编写 NetClient以及接口定义:
//--------------------------------------
//会话列表获取
message GetChatSessionListReq {
 	string request_id = 1;
 	optional string session_id = 2;
 	optional string user_id = 3;
}

message GetChatSessionListRsp {
 	string request_id = 1;
 	bool success = 2;
 	string errmsg = 3; 
 	repeated ChatSessionInfo chat_session_info_list = 4;
}

// 函数实现
void NetClient::getChatSessionList(const QString& loginSessionId)
{
    // 1. 通过 protobuf 构造 body
    bite_im::GetChatSessionListReq req;
    req.setRequestId(makeRequestId());
    req.setSessionId(loginSessionId);
    QByteArray body = req.serialize(&serializer);
    LOG() << "[获取会话列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;

    // 2. 发送 HTTP 请求
    QNetworkReply* resp = this->sendHttpRequest("/service/friend/get_chat_session_list", body);

    // 3. 针对响应进行处理
    connect(resp, &QNetworkReply::finished, this, [=]()
    {
        // a) 解析响应
        bool ok = false;
        QString reason;
        auto pbResp = this->handleHttpResponse<bite_im::GetChatSessionListRsp>(resp, &ok, &reason);

        // b) 判定响应是否正确
        if (!ok)
        {
            LOG() << "[获取会话列表] 失败! reason=" << reason;
            return;
        }

        // c) 把得到的数据, 写入到 DataCenter 里
        dataCenter->resetChatSessionList(pbResp);

        // d) 通知调用者, 此处响应处理完毕
        emit dataCenter->getChatSessionListDone();

        // e) 打印日志
        LOG() << "[获取会话列表] 处理响应完毕! requestId=" << pbResp->requestId();
    });
}

(2)客户端处理响应:

  • 实现DataCenter::resetChatSessionList函数:
void DataCenter::resetChatSessionList(std::shared_ptr<bite_im::GetChatSessionListRsp> resp)
{
    if(chatSessionList == nullptr)
    {
        chatSessionList = new QList<ChatSessionInfo>();
    }

    chatSessionList->clear();
    auto& chatSessionListPB = resp->chatSessionInfoList();
    for (auto& c : chatSessionListPB)
    {
        ChatSessionInfo chatSessionInfo;
        chatSessionInfo.load(c);
        chatSessionList->push_back(chatSessionInfo);
    }
}
  • 定义 DataCenter 信号:
// 获取会话列表完成
void getChatSessionListDone();
  • 实现 MainWidget::updateChatSessionList函数:
void MainWidget::updateChatSessionList()
{
    if(activeTab != SESSION_LIST)
    {
        // 当前的标签页不是好友列表, 就不渲染任何数据到界面上
        return;
    }

    DataCenter* dataCenter = DataCenter::getInstance();
    QList<ChatSessionInfo>* chatSessionList = dataCenter->getChatSessionList();

    sessionFriendArea->clear();
    // 遍历好友列表, 添加到界面上
    for (const auto& c : *chatSessionList)
    {
        if(c.lastMessage.messageType == TEXT_TYPE)
        {
            sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, c.lastMessage.content);
        }
        else if(c.lastMessage.messageType == IMAGE_TYPE)
        {
            sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[图片]");
        }
        else if(c.lastMessage.messageType == FILE_TYPE)
        {
            sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[文件]");
        }
        else if(c.lastMessage.messageType == SPEECH_TYPE)
        {
            sessionFriendArea->addItem(SessionItemType, c.chatSessionId, c.avatar, c.chatSessionName, "[语音]");
        }
        else
        {
            LOG() << "错误的消息类型! messageType=" << c.lastMessage.messageType;
        }
    }
}

(3)服务器处理请求:

  • 编写 HttpServer::init 注册路由
httpServer.route("/service/friend/get_chat_session_list", [=](constQHttpServerRequest& req) 
{
	return this->getChatSessionList(req);
});
  • 实现处理函数:
QHttpServerResponse HttpServer::getChatSessionList(const QHttpServerRequest& req)
{
    // 解析请求
    bite_im::GetChatSessionListReq pbReq;
    pbReq.deserialize(&serializer, req.body());
    LOG() << "[REQ 获取会话列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();

    // 构造响应
    bite_im::GetChatSessionListRsp pbRsp;
    pbRsp.setRequestId(pbReq.requestId());
    pbRsp.setSuccess(true);
    pbRsp.setErrmsg("");

    QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");

    // 构造若干个单聊会话
    for (int i = 0; i < 30; ++i)
    {
        bite_im::ChatSessionInfo chatSessionInfo;
        chatSessionInfo.setChatSessionId(QString::number(2000 + i));
        chatSessionInfo.setChatSessionName("会话" + QString::number(i));
        chatSessionInfo.setSingleChatFriendId(QString::number(1000 + i));
        chatSessionInfo.setAvatar(avatar);
        bite_im::MessageInfo messageInfo = makeTextMessageInfo(i, chatSessionInfo.chatSessionId(), avatar);

        chatSessionInfo.setPrevMessage(messageInfo);

        pbRsp.chatSessionInfoList().push_back(chatSessionInfo);
    }

    // 序列化响应
    QByteArray body = pbRsp.serialize(&serializer);
    // 构造 HTTP 响应
    QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
    resp.setHeader("Content-Type", "application/x-protobuf");
    return resp;
}

(4)整体流程小结:

8.4 获取好友申请列表

(1)客户端发送请求:

  • 添加MainWidget::initSignalSlot槽函数:
loadApplyList();
  • 具体实现loadApplyList()函数:
// 加载好友申请列表
void MainWidget::loadApplyList()
{
    // 好友申请列表在 DataCenter 中存储的
    // 首先判定 DataCenter 本地是否已经有数据了. 如果有, 直接加载到界面上.
    // 如果没有则需要从服务器获取
    DataCenter* dataCenter = DataCenter::getInstance();
    if(dataCenter->getApplyList() != nullptr)
    {
        // 本地有数据, 直接加载
        updateApplyList();
    }
    else
    {
        // 本地没有数据, 通过网络加载
        connect(dataCenter, &DataCenter::getApplyListDone, this, &MainWidget::updateApplyList, Qt::UniqueConnection);
        dataCenter->getApplyListAsync();
    }
}
  • 实现 getApplyList 和 getApplyListAsync函数:
QList<UserInfo> *DataCenter::getApplyList()
{
 	return applyList;
}

void DataCenter::getApplyListAsync()
{
 	netClient.getApplyList(loginSessionId);
}
  • 实现 NetClient::getApplyList和接口定义:
//获取待处理的,申请⾃⼰好友的信息列表
message GetPendingFriendEventListReq {
 	string request_id = 1;
 	optional string session_id = 2;
 	optional string user_id = 3;
}

message FriendEvent {
 	string event_id = 1;
 	UserInfo sender = 3;
}

message GetPendingFriendEventListRsp {
 	string request_id = 1;
 	bool success = 2;
 	string errmsg = 3; 
 	repeated FriendEvent event = 4;
}

// 函数实现
void NetClient::getApplyList(const QString& loginSessionId)
{
    // 1. 通过 protobuf 构造 body
    bite_im::GetPendingFriendEventListReq req;
    req.setRequestId(makeRequestId());
    req.setSessionId(loginSessionId);
    QByteArray body = req.serialize(&serializer);
    LOG() << "[获取好友申请列表] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId;

    QNetworkReply* resp = sendHttpRequest("/service/friend/get_pending_friend_events", body);

    // 3. 处理响应
    connect(resp, &QNetworkReply::finished, this, [=]()
    {
        // a) 解析响应
        bool ok = false;
        QString reason;
        auto pbResp = this->handleHttpResponse<bite_im::GetPendingFriendEventListRsp>(resp, &ok, &reason);

        // b) 判定结果是否出错
        if(!ok)
        {
            LOG() << "[获取好友申请列表] 失败! reason=" << reason;
            return;
        }

        // c) 拿到的数据, 写入到 DataCenter 中
        dataCenter->resetApplyList(pbResp);

        // d) 通知界面, 处理完毕
        emit dataCenter->getApplyListDone();

        // e) 打印日志
        LOG() << "[获取好友申请列表] 处理响应完成! requestId=" << req.requestId();
    });
}

(2)客户端处理响应:

  • 实现 DataCenter::resetApplyList函数:
void DataCenter::resetApplyList(std::shared_ptr<bite_im::GetPendingFriendEventListRsp> resp)
{
    if(applyList == nullptr)
    {
        applyList = new QList<UserInfo>();
    }

    applyList->clear();
    auto& eventList = resp->event();
    for (auto& event : eventList)
    {
        UserInfo userInfo;
        userInfo.load(event.sender());
        applyList->push_back(userInfo);
    }
}
  • 定义 DataCenter 信号:
void getApplyListDone();
  • 实现 MainWidget::updateApplyList函数:
void MainWidget::updateFriendList()
{
    if(activeTab != FRIEND_LIST)
    {
        // 当前的标签页不是好友列表, 就不渲染任何数据到界面上
        return;
    }

    DataCenter* dataCenter = DataCenter::getInstance();
    QList<UserInfo>* friendList = dataCenter->getFriendList();

    // 清空一下之前界面上的数据.
    sessionFriendArea->clear();

    // 遍历好友列表, 添加到界面上
    for (const auto& f : *friendList)
    {
        sessionFriendArea->addItem(FriendItemType, f.userId, f.avatar, f.nickname, f.description);
    }
}

(3)服务器逻辑实现:

  • 注册路由:
httpServer.route("/service/friend/get_pending_friend_events", [=](constQHttpServerRequest& req) 
{
 	return this->getApplyList(req);
});
  • 实现处理函数:
QHttpServerResponse HttpServer::getApplyList(const QHttpServerRequest& req)
{
    // 解析请求
    bite_im::GetPendingFriendEventListReq pbReq;
    pbReq.deserialize(&serializer, req.body());
    LOG() << "[REQ 获取好友申请列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId();

    // 构造响应
    bite_im::GetPendingFriendEventListRsp pbResp;
    pbResp.setRequestId(pbReq.requestId());
    pbResp.setSuccess(true);
    pbResp.setErrmsg("");

    // 循环构造出 event 对象, 构造出整个结果数组
    QByteArray avatar = loadFileToByteArray(":/resource/image/defaultAvatar.png");
    for (int i = 0; i < 5; ++i)
    {
        bite_im::FriendEvent friendEvent;
        friendEvent.setEventId("");	// 此处不再使用这个 eventId, 直接设为 ""
        friendEvent.setSender(makeUserInfo(i, avatar));

        pbResp.event().push_back(friendEvent);
    }

    // 序列化成字节数组
    QByteArray body = pbResp.serialize(&serializer);

    // 构造 HTTP 响应对象
    QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
    resp.setHeader("Content-Type", "application/x-protobuf");
    return resp;
}

(4)整体流程小结:

8.5 获取指定会话的近期消息

(1)点击会话列表中的列表项,获取该会话的最后 N 个历史消息,并展示到界面上。客户端发送请求:

  • 编写 SessionItem::active函数:
  • 此处的 active 在 select 中已经通过多态的方式调用到了。只要用户点击,就能触发这个逻辑:
void SessionItem::active()
{
    // 点击之后, 要加载会话的历史消息列表
    LOG() << "点击 SessionItem 触发的逻辑! chatSessionId=" << chatSessionId;

    // 加载会话历史消息, 即会涉及到当前内存的数据操作, 又会涉及到网络通信, 还涉及到界面的变更.
    MainWidget* mainWidget = MainWidget::getInstance();
    mainWidget->loadRecentMessage(chatSessionId);

    // TODO 后续在这⾥添加针对未读消息的处理.
}
  • 编写 MainWidget::loadRecentMessages函数:
void MainWidget::loadRecentMessage(const QString& chatSessionId)
{
    // 也是先判定, 本地内存中是否已经有对应的消息列表数据.
    // 有的话直接显示到界面上. 没有的话从网络获取.
    DataCenter* dataCenter = DataCenter::getInstance();

    if(dataCenter->getRecentMessageList(chatSessionId) != nullptr)
    {
        // 拿着本地数据更新界面
        updateRecentMessage(chatSessionId);
    }
    else
    {
        // 本地没有数据, 从网络加载
        connect(dataCenter, &DataCenter::getRecentMessageListDone, this, &MainWidget::updateRecentMessage, Qt::UniqueConnection);
        dataCenter->getRecentMessageListAsync(chatSessionId, true);
    }
}
  • 编写 DataCenter当中的对应函数:
void DataCenter::getRecentMessageListAsync(const QString& chatSessionId, bool updateUI)
{
    netClient.getRecentMessageList(loginSessionId, chatSessionId, updateUI);
}

QList<Message>* DataCenter::getRecentMessageList(const QString& chatSessionId)
{
    if(!recentMessages->contains(chatSessionId))
    {
        return nullptr;
    }

    return &(*recentMessages)[chatSessionId];
}
  • 编写 NetClient和接口定义:
message GetRecentMsgReq {
 	string request_id = 1;
 	string chat_session_id = 2;
 	int64 msg_count = 3;
 	optional int64 cur_time = 4;//⽤于扩展获取指定时间前的n条消息
 	optional string user_id = 5;
 	optional string session_id = 6;
}
message GetRecentMsgRsp {
 	string request_id = 1;
 	bool success = 2;
 	string errmsg = 3; 
 	repeated MessageInfo msg_list = 4;
}

// 函数实现
void NetClient::getRecentMessageList(const QString& loginSessionId, const QString& chatSessionId, bool updateUI)
{
    // 1. 通过 protobuf 构造请求 body
    bite_im::GetRecentMsgReq req;
    req.setRequestId(makeRequestId());
    req.setChatSessionId(chatSessionId);
    req.setMsgCount(50);	// 此处固定获取最近 50 条记录
    req.setSessionId(loginSessionId);
    QByteArray body = req.serialize(&serializer);
    LOG() << "[获取最近消息] 发送请求 requestId=" << req.requestId() << ", loginSessionId=" << loginSessionId << ", chatSessionId=" << chatSessionId;

    // 2. 发送 http 请求
    QNetworkReply* resp = this->sendHttpRequest("/service/message_storage/get_recent", body);

    // 3. 处理响应
    connect(resp, &QNetworkReply::finished, this, [=]()
    {
        // a) 解析响应, 反序列化
        bool ok = false;
        QString reason;
        auto pbResp = this->handleHttpResponse<bite_im::GetRecentMsgRsp>(resp, &ok, &reason);

        // b) 判定响应是否出错
        if(!ok)
        {
            LOG() << "[获取最近消息] 失败! reason=" << reason;
            return;
        }

        // c) 把拿到的数据, 设置到 DataCenter 中
        dataCenter->resetRecentMessageList(chatSessionId, pbResp);

        // d) 发送信号, 告知界面进行更新
        if (updateUI)
        {
            emit dataCenter->getRecentMessageListDone(chatSessionId);
        }
        else
        {
            emit dataCenter->getRecentMessageListDoneNoUI(chatSessionId);
        }

    });
}

(2)客户端处理响应:

  • 实现 DataCenter::resetRecentMsgList函数:
void DataCenter::resetRecentMessageList(const QString& chatSessionId, std::shared_ptr<bite_im::GetRecentMsgRsp> resp)
{
    // 拿到 chatSessionId 对应的消息列表, 并清空
    // 注意此处务必是引用类型, 才是修改哈希表内部的内容.
    QList<Message>& messageList = (*recentMessages)[chatSessionId];
    messageList.clear();

    for(auto& m : resp->msgList())
    {
        Message message;
        message.load(m);

        messageList.push_back(message);
    }
}
  • 定义 DataCenter 信号:
// 获取近期消息完成
void getRecentMsgListDone(const QString& chatSessionId); // 更新UI
void getRecentMsgListDoneNoUI(const QString& chatSessionId); // 不更新 UI
  • 实现 MainWidget::updateRecentMessages函数:
void MainWidget::updateRecentMessage(const QString& chatSessionId)
{
    // 1. 拿到该会话的最近消息列表
    DataCenter* dataCenter = DataCenter::getInstance();
    auto* recentMessageList = dataCenter->getRecentMessageList(chatSessionId);

    // 2. 清空原有界面上显示的消息列表
    messageShowArea->clear();

    // 3. 根据当前拿到的消息列表, 显示到界面上
    //    此处把数据显示到界面上, 可以使用头插, 也可以使用尾插.
    //    这里打算使用头插的方式来进行实现.
    //    主要因为消息列表来说, 用户首先看到的, 应该是 "最近" 的消息, 也就是 "末尾" 的消息.
    for(int i = recentMessageList->size() - 1; i >= 0; --i)
    {
        const Message& message = recentMessageList->at(i);
        bool isLeft = message.sender.userId != dataCenter->getMyself()->userId;
        messageShowArea->addFrontMessage(isLeft, message);
    }

    // 4. 设置会话标题
    ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionById(chatSessionId);
    if(chatSessionInfo != nullptr)
    {
        // 把会话名称显示到界面上.
        sessionTitleLabel->setText(chatSessionInfo->chatSessionName);
    }

    // 5. 保存当前选中的会话是哪个.
    dataCenter->setCurrentChatSessionId(chatSessionId);

    // 6. 自动把滚动条, 滚动到末尾
    messageShowArea->scrollToEnd();
}
  • 实现 DataCenter::findChatSessionById函数方便找到对应的会话id:
ChatSessionInfo* DataCenter::findChatSessionById(const QString& chatSessionId)
{
    if(chatSessionList == nullptr)
    {
        return nullptr;
    }

    for(auto& info : *chatSessionList)
    {
        if (info.chatSessionId == chatSessionId)
        {
            return &info;
        }
    }

    return nullptr;
}
  • 实现 DataCenter::setCurrentChatSessionId 和DataCenter::getCurrentChatSessionId方便设置会话id和获取会话id:
void DataCenter::setCurrentChatSessionId(const QString &chatSessionId)
{
    this->currentChatSessionId = chatSessionId;
}

const QString& DataCenter::getCurrentChatSessionId()
{
    return this->currentChatSessionId;
}

(3)服务器处理请求:

  • 注册路由:
httpServer.route("/service/message_storage/get_recent", [=](constQHttpServerRequest& req) 
{
 	return this->getRecent(req);
});
  • 实现处理函数:
QHttpServerResponse HttpServer::getRecent(const QHttpServerRequest& req)
{
    // 解析请求
    bite_im::GetRecentMsgReq pbReq;
    pbReq.deserialize(&serializer, req.body());
    LOG() << "[REQ 获取最近消息列表] requestId=" << pbReq.requestId() << ", loginSessionId=" << pbReq.sessionId()
          << ", chatSessionId=" << pbReq.chatSessionId();

    // 构造响应
    bite_im::GetRecentMsgRsp 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::MessageInfo messageInfo = makeTextMessageInfo(i, "2000", avatar);
        pbResp.msgList().push_back(messageInfo);
    }

    // 序列化
    QByteArray body = pbResp.serialize(&serializer);

    // 构造 HTTP 响应对象
    QHttpServerResponse resp(body, QHttpServerResponse::StatusCode::Ok);
    resp.setHeader("Content-Type", "application/x-protobuf");
    return resp;
}

(4)整体流程小结:

8.6 点击某个好友项

(1)切换到会话列表:

  • 编写 FriendItem::active:
  • active 已经在 select 方法中通过多态的方式调用到了:
void FriendItem::active()
{
 	LOG() << "FriendItem active. userId=" << userId;
 	// 切换到当前会话. 如果没有就创建会话
 	MainWidget* mainWidget = MainWidget::getInstance();
 	mainWidget->switchToSession(userId);
}

(2)该会话置顶并被选中:

  • 实现 MainWidget::switchSession函数:
void MainWidget::switchSession(const QString& userId)
{
    // 1. 在会话列表中, 先找到对应的会话元素
    DataCenter* dataCenter = DataCenter::getInstance();
    ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionByUserId(userId);

    if(chatSessionInfo == nullptr)
    {
        // 正常来说, 每个好友, 都会有一个对应的会话(哪怕从来没说过话).
        // 添加好友的时候, 就创建出来的会话.
        LOG() << "[严重错误] 当前选中的好友, 对应的会话不存在!";
        return;
    }

    // 2. 把选中的会话置顶, 把这个会话信息放到整个会话列表的第一个位置.
    //    后续在界面显示的时候, 就是按照列表的顺序, 从前往后显示的.
    dataCenter->topChatSessionInfo(*chatSessionInfo);

    // 3. 切换到会话列表标签页
    switchTabToSession();

    // 4. 加载这个会话对应的历史消息. 刚刚做了一个 "置顶操作" , 被选中的好友对应的会话, 在会话列表的最前头, 也就是 0 号下标.
    sessionFriendArea->clickItem(0);
}

switchTabToSession已经在前⾯实现过了。

  • 实现 DataCenter::findChatSessionByUserId函数方便找到用户id:
ChatSessionInfo* DataCenter::findChatSessionByUserId(const QString& userId)
{
    if(chatSessionList == nullptr)
    {
        return nullptr;
    }

    for(auto& info : *chatSessionList)
    {
        if (info.userId == userId)
        {
            return &info;
        }
    }

    return nullptr;
}
  • 实现 DataCenter::topChatSessionInfo函数将选中好友置顶:
void DataCenter::topChatSessionInfo(const ChatSessionInfo &chatSessionInfo)
{
    if(chatSessionList == nullptr)
    {
        return;
    }

    // 1. 把这个元素从列表中找到
    auto iter = chatSessionList->begin();
    for(; iter != chatSessionList->end(); ++iter)
    {
        if(iter->chatSessionId == chatSessionInfo.chatSessionId)
        {
            break;
        }
    }

    if(iter == chatSessionList->end())
    {
        // 上面的循环没有找到匹配的元素, 直接返回. 正常来说, 不会走这个逻辑的.
        return;
    }

    // 2. 把这个元素备份一下, 然后删除
    ChatSessionInfo backup = chatSessionInfo;
    chatSessionList->erase(iter);

    // 3. 把备份的元素, 插入到头部
    chatSessionList->push_front(backup);
}
  • 实现 SessionFriendArea::clickItem函数:
void SessionFriendArea::clickItem(int index)
{
    if(index < 0 || index >= container->layout()->count())
    {
        LOG() << "点击元素的下标超出范围! index=" << index;
        return;
    }

    QLayoutItem* layoutItem = container->layout()->itemAt(index);
    if(layoutItem == nullptr || layoutItem->widget() == nullptr)
    {
        LOG() << "指定的元素不存在! index=" << index;
        return;
    }

    SessionFriendItem* item = dynamic_cast<SessionFriendItem*>(layoutItem->widget());
    item->select();
}

(3)加载该会话的最近消息并显示:

  • 在上述 clickItem 中会调⽤ item->select() , 进⼀步调⽤到 active ⽅法, 从⽽触发加载最近消息的逻辑.

(4)整体流程小结:

(5)注意:

  • 每个会话中的用户列表,应该是按需加载的,不应该是程序启动全都加载进来!!
  • 创建会话操作放到同意好友申请时。换而言之每个用户都⼀定存在⼀个和他对应的会话。

9. 小结

(1)在进行前后端交互接口的实现的时候代码格式基本上都是一样的,只需要将其中一个流程搞清楚即可。如下图就是基本的流程图了:

(2)剩下的需要实现的前后端交互接口见博客:https://blog.csdn.net/m0_65558082/article/details/143817211?spm=1001.2014.3001.5502。

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/917703.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【功耗现象】com.gorgeous.lite后台Camera 使用2小时平均电流200mA耗电量400mAh现象

现象 轻颜相机(com.gorgeous.lite)后台Camera 使用2小时平均电流200mA(BugReport提供的电流参考数据),耗电量400mAh 即耗电占比(200mA*2h)/(12.83h*52.68mA )400mAh/623mAh62% CameraOct 10 202321:03:08 - 23:03:372h16m15s859ms to 4h16m44s984msactive duration: 2h 0m 29…

蓝桥杯每日真题 - 第15天

题目&#xff1a;&#xff08;钟表&#xff09; 题目描述&#xff08;13届 C&C B组B题&#xff09; 解题思路&#xff1a; 理解钟表指针的运动&#xff1a; 秒针每分钟转一圈&#xff0c;即每秒转6度。 分针每小时转一圈&#xff0c;即每分钟转6度。 时针每12小时转一圈…

ctfshow-web入门-SSRF(web351-web360)

目录 1、web351 2、web352 3、web353 4、web354 5、web355 6、web356 7、web357 8、web358 9、web359 10、web360 1、web351 看到 curl_exec 函数&#xff0c;很典型的 SSRF 尝试使用 file 协议读文件&#xff1a; urlfile:///etc/passwd 成功读取到 /etc/passwd 同…

快速搭建Android开发环境:Docker部署docker-android并实现远程连接

目录 前言 1. 虚拟化环境检查 2. Android 模拟器部署 3. Ubuntu安装Cpolar 4. 配置公网地址 5. 远程访问 小结 6. 固定Cpolar公网地址 7. 固定地址访问 作者简介&#xff1a; 懒大王敲代码&#xff0c;计算机专业应届生 今天给大家聊聊快速搭建Android开发环境&#x…

2024-11-16-机器学习方法:无监督学习(1) 聚类(上)

文章目录 机器学习方法&#xff1a;无监督学习&#xff08;1&#xff09; 聚类&#xff08;上&#xff09;1. 聚类的基本概念1.1 聚类的概念1.2 聚类的功能1.3 聚类的算法 2. 相似度或距离2.1 闵可夫斯基距离2.2 相关系数2.3 夹角余弦 3 类或簇3.1 类的特征 4 类与类之间的距离…

Vue-组件三大组成组件通信

一、学习目标 1.组件的三大组成部分&#xff08;结构/样式/逻辑&#xff09; scoped解决样式冲突/data是一个函数 2.组件通信 组件通信语法 父传子 子传父 非父子通信&#xff08;扩展&#xff09; 3.综合案例&#xff1a;小黑记事本&#xff08;组件版&#xff09; 拆…

Scratch 014生日贺卡(上)

知识回顾&#xff1a; 1、“面向鼠标指针”积木块 2、“重复执行直到”积木块 本次分享制作生日贺卡引入广播模块 案列效果&#xff1a; 生日贺卡上案例效果-CSDN直播 步骤拆解&#xff1a; 1、添加背景和角色 2、编辑贺卡造型添加名字 3、流程图的组成和画法 4、…

MySQL中将一个字符串字段按层级树状展开

水善利万物而不争&#xff0c;处众人之所恶&#xff0c;故几于道&#x1f4a6; 文章目录 需求1.分析2.实现3.思路刨析表结构和数据 需求 数据库中有个字段如下 如何将其转换为如下形式&#xff1a; 1.分析 1.他的层级个数是不确定的&#xff0c;也就是说有的有2层有的有5…

hive搭建

1.准备环境 三台节点主机已安装hadoopmysql数据库 2.环境 2.1修改三台节点上hadoop的core-site.xml <!-- 配置 HDFS 允许代理任何主机和组 --> <property><name>hadoop.proxyuser.hadoop.hosts</name><value>*</value> </property&…

创建vue+electron项目流程

一个vue3和electron最基本的环境搭建步骤如下&#xff1a;// 安装 vite vue3 vite-plugin-vue-setup-extend less normalize.css mitt pinia vue-router npm create vuelatest npm i vite-plugin-vue-setup-extend -D npm i less -D npm i normalize.css -S &#xff0…

Pyhon基础数据结构(列表)【蓝桥杯】

a [1,2,3,4,5] a.reverse() print("a ",a) a.reverse() print("a ",a)# 列表 列表&#xff08;list&#xff09;有由一系列按照特定顺序排序的元素组成 列表是有顺序的&#xff0c;访问任何元素需要通过“下标访问” 所谓“下标”就是指元素在列表从左…

帽子矩阵--记录

帽子矩阵&#xff08;Hat Matrix&#xff09;并不是由某一位具体的科学家单独发明的&#xff0c;而是逐渐在统计学和线性代数的发展过程中形成的。帽子矩阵的概念最早出现在20世纪初的统计学文献中&#xff0c;尤其是在回归分析的研究中得到了广泛应用。然而&#xff0c;具体是…

一.安装版本为19c的Oracle数据库管理系统(Oracle系列)

1.数据库版本信息&#xff1a; 版本信息&#xff1a; 或者直接由命令查出来&#xff1a; 2.操作系统的版本信息 3.安装包下载与上传 可以去oracle官网下载也可以从其他人的百度网盘链接中下载&#xff1a; 使用xftp工具或者其他的工具&#xff08;mobaxterm&#xff09;上传到l…

计算机视觉 ---图像模糊

1、图像模糊的作用&#xff1a; 减少噪声&#xff1a; 在图像获取过程中&#xff0c;例如通过相机拍摄或者传感器采集&#xff0c;可能会受到各种因素的干扰&#xff0c;从而引入噪声。这些噪声在图像上表现为一些孤立的、不符合图像主体内容的像素变化&#xff0c;如椒盐噪声&…

关于强化学习的一份介绍

在这篇文章中&#xff0c;我将介绍与强化学习有关的一些东西&#xff0c;具体包括相关概念、k-摇臂机、强化学习的种类等。 一、基本概念 所谓强化学习就是去学习&#xff1a;做什么才能使得数值化的收益信号最大化。学习者不会被告知应该采取什么动作&#xff0c;而是必须通…

嵌入式硬件杂谈(二)-芯片输入接入0.1uf电容的本质(退耦电容)

引言&#xff1a;对于嵌入式硬件这个庞大的知识体系而言&#xff0c;太多离散的知识点很容易疏漏&#xff0c;因此对于这些容易忘记甚至不明白的知识点做成一个梳理&#xff0c;供大家参考以及学习&#xff0c;本文主要针对芯片输入接入0.1uf电容的本质的知识点的进行学习。 目…

近几年新笔记本重装系统方法及一些注意事项

新笔记本怎么重装系统&#xff1f; 近几年的新笔记本默认开启了raid on模式或vmd选项&#xff0c;安装过程中会遇到问题&#xff0c;新笔记本电脑重装自带的系统建议采用u盘方式安装&#xff0c;默认新笔记本有bitlocker加密机制&#xff0c;如果采用一键重装系统或硬盘方式安装…

GPIO相关的寄存器(重要)

目录 一、GPIO相关寄存器概述 二、整体介绍 三、详细介绍 1、端口配置低寄存器&#xff08;GPIOx_CRL&#xff09;&#xff08;xA...E&#xff09; 2、端口配置高寄存器&#xff08;GPIOx_CRH&#xff09;&#xff08;xA...E&#xff09; 3、端口输入数据寄存器&#xff…

华为Mate 70临近上市:代理IP与抢购攻略

随着科技的飞速发展&#xff0c;智能手机已经成为我们日常生活中不可或缺的一部分。而在众多智能手机品牌中&#xff0c;华为一直以其卓越的技术和创新力引领着行业的发展。近日&#xff0c;华为Mate 70系列手机的发布会正式定档在11月26日&#xff0c;这一消息引发了众多科技爱…

NVR录像机汇聚管理EasyNVR多品牌NVR管理工具视频汇聚技术在智慧安防监控中的应用与优势

随着信息技术的快速发展和数字化时代的到来&#xff0c;安防监控领域也在不断进行技术创新和突破。NVR管理平台EasyNVR作为视频汇聚技术的领先者&#xff0c;凭借其强大的视频处理、汇聚与融合能力&#xff0c;展现出了在安防监控领域巨大的应用潜力和价值。本文将详细介绍Easy…