74、前端项目源码剖析---------云视频服务项目

前端项目源码剖析

需求分析

创建会议

点击创建会议按钮,申请会议室,同时自动加入

服务器提前开辟好一定的房间,当申请者创建会议时,服务器返回房间号。

加入会议

其他人得知房间号,申请加入,服务器收到后会判断房间号是否合法,如果合法,则加入,并且广播给房间内所有其他用户。

用户登录

用户通过连接服务器端口和地址,登录。

服务器通过TCP维持长连接。

退出会议

当参与人退出时,直接退出,并且广播给同房间内的其他用户。

当房主退出,房间解散。

视频传输

由主要发起人开启摄像头后,激活。

将视频同步给其他人。

实现细节: 发起人调用视频抽取逻辑,按帧抽取图片,将图片定时发送给同房间的所有人即可。

33ms抽取一次,将图片同步给其他人即可。

视频传输抽取频率和发送频率不同步怎么办?

思路:引入队列逻辑,将抽取的数据放入队列中,然后由发送线程从队列中取数据进行发送。

采取生产者,消费者模式。

视频抽取要用到QAbstractVideoSurface

文本传输

在同房间内的人,发送信息时,需要广播给其他人。

文本传输比视频传输更轻量级,尽量不要将二者放入一个队列中。

有一个文本队列,专门存放待发送的文本消息。

音频传输

音频传输和视频传输类似,音频需要从录音设备录制,保存为二进制字节流发送给服务器,服务器再转发给其他人。

音频传输要用到QAudioInput

日志打印

当产生必要信息需要打印时,采用异步方式。

独立线程记录日志信息,配合消息队列。

服务器高并发

  1. 服务器要采用多路复用技术
  2. 尽可能采用线程分布式通信(主从通信模式)
  3. 服务器要采用消息队列解耦合
  4. 发送时因为存在未发送完的情况,所以要将未发送完的数据放入队列中
  5. 接收时,也可能接收不全,所以要采用接收队列。
  6. 主要采用异步发送,配合非阻塞socket,实现高并发。

效果案例展示

源码剖析

  1. 从顺序结构入手,找到main函数
  2. 跟踪所有流程,了解大概
  3. 对某一个模块深入理解,再扩充其他模块。

走入工作岗位中,领导分配的任务不是写框架,是基于某一个功能做定制化修改,或者添加功能。

主函数

设置编码

QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF8"));

采用utf-8编码,好处

  1. utf-8变长编码,节省空间
  2. 语言支持范围广
  3. 传输时节省带宽。
cpp 复制代码
int main(int argc, char* argv[])
{
    // 1. 设置程序整体的本地字符编码为 UTF-8,
    //    确保后续使用 QString、QTextStream 等 Qt 类别时,
    //    能正确处理及显示多国语系文字
    QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF8"));

    // 2. 创建 QApplication 对象,这是所有 Qt GUI 应用程序的核心
    //    - argc, argv:将命令行参数传入,方便后续解析自定义参数
    QApplication app(argc, argv);

    // 3. 初始化屏幕相关设置
    //    Screen::init() 可放置高 DPI 支持、多屏幕配置或分辨率调整等逻辑
    Screen::init();

    // 4. 创建主窗口/控件对象
    Widget w;
    // 5. 显示窗口,只有调用 show() 才会让窗口出现在屏幕上
    w.show();

    // 6. 启动 Qt 事件循环
    //    - 作用:持续监听并分发鼠标、键盘、重绘等事件
    //    - exec() 返回值:程序结束时的状态码,可供外部脚本或系统检查
    return app.exec();
}

主函数

  1. 设置编码
  2. 初始化屏幕
  3. 创建窗口对象
  4. 开启事件循环

Screen类

  1. 获取屏幕的矩形区域
  2. 将来根据矩形区域放置我们的窗口

Widget

前置声明
cpp 复制代码
class QCameraImageCapture;
class MyVideoSurface;
class SendImg;
class QListWidgetItem;
class Widget : public QWidget{
  //...  
};

如果"A.h" include "B.h"

"B.h" include "A.h" 这就重复包含了

解决方案,A.h中不包含B.h, 在A.h只前置声明B, 并且在A.cpp中包含B.h

cpp 复制代码
class B;
class A{

};

QAbstractVideoSurface

这个类就是视频处理的中间类,相当于一个过滤器

将视频中的每一帧都做抽取,以及format格式判断等。

需要重写present函数,这个函数每一帧都会调用

cpp 复制代码
bool MyVideoSurface::present(const QVideoFrame &frame)
{
    if(!frame.isValid())
    {
        stop();
        return false;
    }
    //frame是否已经在内存中
    if(frame.isMapped())
    {
        emit frameAvailable(frame);
    }
    else
    {
        //映射到内存中
        QVideoFrame f(frame);
        f.map(QAbstractVideoBuffer::ReadOnly);

        emit frameAvailable(f);
    }

    return true;
}

判断frame是否在内存中,如果在则直接将帧数据发送给处理线程

否则,构造成内存数据,发送。

三者之间的关系

整体注释

cpp 复制代码
#ifndef WIDGET_H
#define WIDGET_H

#include "AudioInput.h"
#include <QWidget>
#include <QVideoFrame>
#include <QTcpSocket>
#include "mytcpsocket.h"
#include <QCamera>
#include "sendtext.h"
#include "recvsolve.h"
#include "partner.h"
#include "netheader.h"
#include <QMap>
#include "AudioOutput.h"
#include "chatmessage.h"
#include <QStringListModel>

QT_BEGIN_NAMESPACE
namespace Ui { class Widget; }
QT_END_NAMESPACE

class QCameraImageCapture;
class MyVideoSurface;
class SendImg;
class QListWidgetItem;
class Widget : public QWidget
{
    Q_OBJECT
private:
    //窗口位置
    static QRect pos;
    quint32 mainip; //主屏幕显示的IP图像
    QCamera *_camera; //摄像头
    //安帧抽取摄像头视频中的图片, QCameraImageCapture这是Qt提供的辅助类,配合camera使用
    QCameraImageCapture *_imagecapture;
    bool  _createmeet; //是否创建会议
    bool _joinmeet; // 加入会议
    bool _openCamera; //是否开启摄像头
    //视频表面,过滤器,过滤符合格式的图像帧
    MyVideoSurface *_myvideosurface;
    //视频帧
    QVideoFrame mainshow;
    //缓存视频帧图片,放入自己的队列中,由独立线程消费,再投递到发送队列
    SendImg *_sendImg;
    //图像处理线程
    QThread *_imgThread;

    //处理接收的数据,这个类独立运行在单独线程中,消费对端发送数据
    RecvSolve *_recvThread;
    //管理发送文本,将发送的文本放入自己的队列中,交给线程独立发送。
    SendText * _sendText;
    //独立文本发送线程
    QThread *_textThread;
    //socket 套接字,发送和接收数据
    MyTcpSocket * _mytcpSocket;
    void paintEvent(QPaintEvent *event);

    QMap<quint32, Partner *> partner; //用于记录房间用户
    Partner* addPartner(quint32);
    void removePartner(quint32);
    void clearPartner(); //退出会议,或者会议结束
    void closeImg(quint32); //根据IP重置图像

    //不会直接显示,是通过槽函数调用触发
    void dealMessage(ChatMessage *messageW, QListWidgetItem *item, QString text, QString time, QString ip ,ChatMessage::User_Type type); //用户发送文本
    void dealMessageTime(QString curMsgTime); //处理时间

    //音频录入设备
    AudioInput* _ainput;
    //独立线程管理录音,消费队列并且发送
    QThread* _ainputThread;
    //当我们收到对方的音频时,需要播放。播放设备
    AudioOutput* _aoutput;

    QStringList iplist;

public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

private slots:
    //创建会议的槽函数
    void on_createmeetBtn_clicked();
    //退出会议槽函数
    void on_exitmeetBtn_clicked();
    //打开摄像头的槽函数
    void on_openVedio_clicked();
    //打开音频设备槽函数
    void on_openAudio_clicked();
    //点击连接服务器的槽函数
    void on_connServer_clicked();
    //摄像头触发错误,回调此函数
    void cameraError(QCamera::Error);
    //音频设备触发错误,回调此函数
    void audioError(QString);
//    void mytcperror(QAbstractSocket::SocketError);
    //网络线程将收到的消息放入队列,recv线程消费这个队列。解析数据包,tlv解析,发送信号触发此槽函数
    void datasolve(MESG *);
    //点击别人label会触发槽函数显示特效
    void recvip(quint32);
    //就是摄像头按帧捕获,然后发送信号,触发此槽函数
    void cameraImageCapture(QVideoFrame frame);
    //加入会议
    void on_joinmeetBtn_clicked();
    //水平条滚动
    void on_horizontalSlider_valueChanged(int value);
    //当有人说话时,显示label
    void speaks(QString);
    //当有人发送文本消息时,触发这个槽函数
    void on_sendmsg_clicked();
    //当网络线程发送完数据后,触发信号,触发这个槽函数
    void textSend();

signals:
    //推送图片给网络,发送信号
    void pushImg(QImage);
    //推送文本给网络,发送信号
    void PushText(MSG_TYPE, QString = "");
    //通过线程发送停止声音信号,触发其他线程槽函数
    void stopAudio();
    //通过线程发送开始信号,触发声音线程槽函数
    void startAudio();
    //修改声音大小的信号
    void volumnChange(int);
private:
    Ui::Widget *ui;
};
#endif // WIDGET_H

具体实现,留给后面详细讲解 todo...

日志类

cpp 复制代码
class LogQueue : public QThread
{
private:
    //重写run函数,实现日志记录线程的功能
    void run();
    //互斥量,保证访问队列安全
    QMutex m_lock;
    //判断是否正在运行
    bool m_isCanRun;
    //日志队列,模板类
    QUEUE_DATA<Log> log_queue;
    //日志和文件关联
    FILE *logfile;
public:
    explicit LogQueue(QObject *parent = nullptr);
    //可以立即停止
    void stopImmediately();
    //将消息放入日志队列中
    void pushLog(Log*);
};

异步日志类,提供队列,外部将要写入的数据先投递给队列,然后由独立的线程消费。

日志类继承于QThread, 所以只需重写run即可。

为了详细了解实现细节,剖析QUEUE_DATA这个模板类

cpp 复制代码
struct MESG //消息结构体
{
    MSG_TYPE msg_type;
    uchar* data;
    long len;
    quint32 ip;
};
Q_DECLARE_METATYPE(MESG *);

//-------------------------------

//todo... 要改为两个条件变量
template<class T>
struct QUEUE_DATA //消息队列
{
private:
    //互斥锁,用来锁定队列
    QMutex send_queueLock;
    //条件变量
    QWaitCondition send_queueCond;
    //队列
    QQueue<T*> send_queue;
public:
    //生产者将要处理的数据放入队列中
    void push_msg(T* msg)
    {
        send_queueLock.lock();
        while(send_queue.size() > QUEUE_MAXSIZE)
        {
            send_queueCond.wait(&send_queueLock);
        }
        send_queue.push_back(msg);
        send_queueLock.unlock();
        send_queueCond.wakeOne();
    }

    //消费者从队列中消费数据
    T* pop_msg()
    {
        send_queueLock.lock();
        while(send_queue.size() == 0)
        {
            //条件变量和生产者共用一个,容易造成死等。
            bool f = send_queueCond.wait(&send_queueLock, WAITSECONDS * 1000);
            if(f == false)
            {
                send_queueLock.unlock();
                return NULL;
            }
        }
        T* send = send_queue.front();
        send_queue.pop_front();
        send_queueLock.unlock();
        send_queueCond.wakeOne();
        return send;
    }

    void clear()
    {
        send_queueLock.lock();
        send_queue.clear();
        send_queueLock.unlock();
    }
};

缺陷

需要两个条件变量,分别控制队列满和空的情况

cpp 复制代码
void LogQueue::pushLog(Log* log)
{
    log_queue.push_msg(log);
}

多线程情况下,要加锁

分析日志类run函数
cpp 复制代码
while ((ret = fwrite( (char*)log->ptr + haswrite, 1 ,hastowrite - haswrite, logfile)) < hastowrite)
        {
            if (ret < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) )
            {
                ret = 0;
            }
            else
            {
                qDebug() << "write logfile error";
                break;
            }
            haswrite += ret;
            hastowrite -= ret;
        }

fwrite

第一个参数你要从那个地址开始写,

第二个参数是写的单元大小

第三个参数是你每次写多少个单元

第四个参数表示向哪个文件中写

返回值ret>0表示写成功,返回实际写入的字节数

ret < 0 判断, errno 如果不是EAGAIN或者EWOULDBLOCK说明是严重错误。

如果是EAGAIN或者EWOULDBLOCK说明缓冲区已满,留给下次去写。

具体实现

元对象系统

对于信号连接,我们采用connect方式,其实是默认链接。

  1. 发送者和接收在一个线程时,直接函数回调方式,不存在入队。
  2. 发送者和接收者在不同的线程,需要将发送者的信号入队,也要将参数一起打包入队。如果参数是自定义类型,需要注册为元对象才能继续连接。

当发送信号的线程和接收信号的槽函数所在的线程不是同一个线程

比如我们的信号

void mysig(Student s);

槽函数

void myslot(Student s);

将无法触发槽函数。

解决方案

cpp 复制代码
struct Student{
    QString name;
    int age;
};

//声明为元对象类型
Q_DECLARE_METATYPE(Student);  

//注册为元对象类型
qRegisterMetaType<Student>("Student");

构造函数

cpp 复制代码
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{


    // 初始化日志队列并启动日志线程
    logqueue = new LogQueue();
    logqueue->start();

    qDebug() << "main: " <<QThread::currentThread();
    // 注册自定义消息类型,以便在信号槽中传递
    qRegisterMetaType<MSG_TYPE>();

    WRITE_LOG("-------------------------Application Start---------------------------");
    WRITE_LOG("main UI thread id: 0x%p", QThread::currentThreadId());
    //ui界面
    _createmeet = false;
    _openCamera = false;
    _joinmeet = false;
    // 计算并保存主窗口的初始位置和大小(基于屏幕尺寸的 0.1 到 0.9 区域)
    Widget::pos = QRect(0.1 * Screen::width, 0.1 * Screen::height, 0.8 * Screen::width, 0.8 * Screen::height);

    // 创建并设置 UI 界面
    ui->setupUi(this);

    // 设置按钮文本(支持 UTF-8 编码)
    ui->openAudio->setText(QString(OPENAUDIO).toUtf8());
    ui->openVedio->setText(QString(OPENVIDEO).toUtf8());

    // 设置主窗口几何和大小限制
    this->setGeometry(Widget::pos);
    this->setMinimumSize(QSize(Widget::pos.width() * 0.7, Widget::pos.height() * 0.7));
    this->setMaximumSize(QSize(Widget::pos.width(), Widget::pos.height()));

    // 初始时禁用一些操作按钮,等待连接成功后再启用
    ui->exitmeetBtn->setDisabled(true);
    ui->joinmeetBtn->setDisabled(true);
    ui->createmeetBtn->setDisabled(true);
    ui->openAudio->setDisabled(true);
    ui->openVedio->setDisabled(true);
    ui->sendmsg->setDisabled(true);
     // 用于显示主屏幕用户图像的 IP(稍后赋值)
    mainip = 0;

    //------------------- 局部线程:视频帧发送 ----------------------------
    // 创建发送视频帧的对象和线程
    _sendImg = new SendImg();
    _imgThread = new QThread();
     // 将发送对象移到新线程中
    _sendImg->moveToThread(_imgThread);
    //新起线程接受视频帧
    _sendImg->start();

    //处理每一帧数据

    //------------------- 局部线程:TCP 数据处理 ----------------------------
    // 创建底层 TCP 连接和处理对象
    _mytcpSocket = new MyTcpSocket();
    // 文本发送完成后触发 UI 更新
    connect(_mytcpSocket, SIGNAL(sendTextOver()), this, SLOT(textSend()));

    //------------------- 局部线程:文本发送 ----------------------------
    // 创建文本发送对象和线程
    _sendText = new SendText();
    _textThread = new QThread();
    _sendText->moveToThread(_textThread);
    _textThread->start(); // 启动线程
    _sendText->start();  // 开始发送

    // 将 UI 的 PushText 信号连接到文本发送对象
    connect(this, SIGNAL(PushText(MSG_TYPE,QString)), _sendText, SLOT(push_Text(MSG_TYPE,QString)));
    //-----------------------------------------------------------

    // 创建摄像头对象(属于主 UI 线程)
    _camera = new QCamera(this);
    // 监听摄像头错误
    connect(_camera, SIGNAL(error(QCamera::Error)), this, SLOT(cameraError(QCamera::Error)));


     // 自定义视频输出表面(接收每帧数据)
    _myvideosurface = new MyVideoSurface(this);

    // 当有新帧可用时,通过信号传递到 cameraImageCapture 槽
    connect(_myvideosurface, SIGNAL(frameAvailable(QVideoFrame)), this, SLOT(cameraImageCapture(QVideoFrame)));

    // 将捕获的 QImage 推送到发送线程
    connect(this, SIGNAL(pushImg(QImage)), _sendImg, SLOT(ImageCapture(QImage)));


    //监听_imgThread退出信号
    connect(_imgThread, SIGNAL(finished()), _sendImg, SLOT(clearImgQueue()));


    //------------------启动接收数据线程-------------------------
    _recvThread = new RecvSolve();
    // 使用阻塞队列连接
    connect(_recvThread, SIGNAL(datarecv(MESG*)), this,
            SLOT(datasolve(MESG*)), Qt::BlockingQueuedConnection);
     // 启动接收线程
    _recvThread->start();

    //将自定义视频表面作为摄像头预览窗口
    _camera->setViewfinder(_myvideosurface);
    _camera->setCaptureMode(QCamera::CaptureStillImage);

    //音频输入输出
    _ainput = new AudioInput();
    _ainputThread = new QThread();
    _ainput->moveToThread(_ainputThread);


    _aoutput = new AudioOutput();
    _ainputThread->start(); //获取音频,发送
    _aoutput->start(); //播放

     // 连接音频开始/停止信号到采集槽
    connect(this, SIGNAL(startAudio()), _ainput, SLOT(startCollect()));
    connect(this, SIGNAL(stopAudio()), _ainput, SLOT(stopCollect()));
    // 连接音频错误信号
    connect(_ainput, SIGNAL(audioinputerror(QString)), this, SLOT(audioError(QString)));
    connect(_aoutput, SIGNAL(audiooutputerror(QString)), this, SLOT(audioError(QString)));
    connect(_aoutput, SIGNAL(speaker(QString)), this, SLOT(speaks(QString)));
    

    QFont te_font = this->font();
    te_font.setFamily("MicrosoftYaHei");
    // 设置 listWidget 使用微软雅黑字体、字号 12
    te_font.setPointSize(12);

    ui->listWidget->setFont(te_font);

    ui->tabWidget->setCurrentIndex(1);
    ui->tabWidget->setCurrentIndex(0);
}

意思:构造函数里做了大量初始化,把 UI、网络、音视频设备都准备好,并且用多线程保证数据处理不卡 UI。

退出会议

原逻辑退出会议时并没有发送EXIT_MEETING

强制断开连接,服务器根据断开的连接所属用户进行踢人操作。

这么做不好,建议大家优化,增加退出会议的协议。

cpp 复制代码
void Widget::on_exitmeetBtn_clicked()
{
    if(_camera->status() == QCamera::ActiveStatus)
    {
        _camera->stop();
    }

    ui->createmeetBtn->setDisabled(true);
    ui->exitmeetBtn->setDisabled(true);
    _createmeet = false;
    _joinmeet = false;
    //-----------------------------------------
    //清空partner
    clearPartner();
    // 关闭套接字

    //关闭socket
    _mytcpSocket->disconnectFromHost();
    _mytcpSocket->wait();

    ui->outlog->setText(tr("已退出会议"));

    ui->connServer->setDisabled(false);
    ui->groupBox_2->setTitle(QString("主屏幕"));
//    ui->groupBox->setTitle(QString("副屏幕"));
    //清除聊天记录
    while(ui->listWidget->count() > 0)
    {
        QListWidgetItem *item = ui->listWidget->takeItem(0);
        ChatMessage *chat = (ChatMessage *) ui->listWidget->itemWidget(item);
        delete item;
        delete chat;
    }
    iplist.clear();
    ui->plainTextEdit->setCompleter(iplist);


    WRITE_LOG("exit meeting");

    QMessageBox::warning(this, "Information", "退出会议" , QMessageBox::Yes, QMessageBox::Yes);

    //-----------------------------------------
}

音频打开逻辑

当用户连接到服务器后,需要将音频打开按钮激活

聊天框

widget就是我们的chatmessage类

聊天框实现逻辑

我来帮你拆解一下这段 Qt C++ 代码的作用和逻辑,它主要是在 聊天气泡(ChatMessage)里计算文本、头像、背景框、三角形等 UI 元素的位置和大小。


总体作用

这两个函数:

  • **fontRect(QString str)**接收一段聊天内容 str,计算并保存各种 UI 元素(头像、气泡框、三角形箭头、文本区域等)的矩形坐标,并返回整体所需的尺寸 QSize
  • **getRealString(QString src)**根据字体宽度等限制,把过长的文字分行(自动换行),并计算实际显示文本的 宽度高度

详细流程

1. fontRect() --- 主布局计算

m_msg = str;

保存传入的消息内容。

(1) 设置一些固定参数(像素单位)

cpp 复制代码
int minHei = 30;      // 最小高度
int iconWH = 40;      // 头像宽高
int iconSpaceW = 20;  // 头像左右留白
int iconRectW = 5;    // 头像矩形边距
int iconTMPH = 10;    // 头像上下偏移
int sanJiaoW = 6;     // 气泡三角箭头宽度
int kuangTMP = 20;    // 气泡额外宽度边距
int textSpaceRect = 12; // 文本左右内边距

(2) 计算气泡宽度

cpp 复制代码
m_kuangWidth = this->width() - kuangTMP - 2*(iconWH+iconSpaceW+iconRectW);
m_textWidth  = m_kuangWidth - 2*textSpaceRect;
m_spaceWid   = this->width() - m_textWidth;
  • m_kuangWidth:气泡框的最大宽度(整体宽度减去左右头像、间隔、边距)。
  • m_textWidth:气泡内实际文本的最大宽度(再减去内边距)。
  • m_spaceWid:文本区域外的剩余空白宽度。

(3) 设置头像位置

cpp 复制代码
m_iconLeftRect  = QRect(iconSpaceW, iconTMPH + 10, iconWH, iconWH);
m_iconRightRect = QRect(this->width() - iconSpaceW - iconWH, iconTMPH + 10, iconWH, iconWH);
  • 左侧头像和右侧头像的矩形位置。

(4) 获取文本真实显示尺寸

QSize size = getRealString(m_msg);

调用 getRealString() 得到文本宽高(考虑自动换行)。

(5) 高度修正

int hei = size.height() < minHei ? minHei : size.height();

  • 保证最小高度不低于 minHei

(6) 气泡三角箭头位置

cpp 复制代码
m_sanjiaoLeftRect  = QRect(...);
m_sanjiaoRightRect = QRect(...);
  • 根据文本高度和行高,计算三角形箭头的矩形位置。

(7) 气泡框矩形

cpp 复制代码
if(size.width() < (m_textWidth+m_spaceWid)) {
    // 文本没有占满最大宽度 → 用文本宽度
} else {
    // 文本很长 → 用最大宽度
}
  • 分情况计算左、右气泡框的矩形。

(8) 文本矩形

cpp 复制代码
m_textLeftRect.setRect(...);
m_textRightRect.setRect(...);
  • 在气泡框内再加内边距,得到文本矩形位置。

(9) IP 矩形

cpp 复制代码
m_ipLeftRect.setRect(...);
m_ipRightRect.setRect(...);
  • 可能是用于显示 发送者 IP 或时间标签 的位置。

(10) 返回最终整体大小

return QSize(size.width(), hei + 15);

  • 宽度用文本宽度,高度加一些额外边距。

2. getRealString() --- 计算文本实际大小

cpp 复制代码
QFontMetricsF fm(this->font());
m_lineHeight = fm.lineSpacing();
  • 获取当前字体的度量工具 QFontMetricsF,记录行高。
cpp 复制代码
int nCount = src.count("\n");
int nMaxWidth = 0;
  • nCount:已有的换行数。
  • nMaxWidth:记录文本最长一行的宽度。

(1) 如果没有手动换行

cpp 复制代码
if(nCount == 0) {
    nMaxWidth = fm.width(src);
    if(nMaxWidth > m_textWidth) {
        // 超过最大宽度 → 手动切割成多行
    }
}
  • 超宽时,按空格宽度估算一行能放多少字符,然后循环 mid() 截取。

(2) 如果已有换行

cpp 复制代码
else {
    for(int i = 0; i < (nCount + 1); i++) {
        QString value = src.split("\n").at(i);
        ...
    }
}
  • 对每一行都检查是否超宽,若超宽则同样进行切割并增加 nCount(多了几行)。

(3) 返回实际大小

return QSize(nMaxWidth + m_spaceWid, (nCount + 1) * m_lineHeight + 2*m_lineHeight);

  • 宽度 = 最长行宽 + 空白区域宽度
  • 高度 = 行数 × 行高 + 上下额外行高

总结

这段代码主要是:

  1. 根据 控件宽度、头像大小、内边距等计算气泡布局。
  2. 根据 字体度量 进行自动换行,得到真实的文本显示尺寸。
  3. 为头像、气泡、箭头、文本、IP 区域分别计算 QRect
  4. 返回整个消息所需的 QSize,供外部布局使用。

如果你需要的话,我可以帮你画一个 示意图,把头像、气泡、三角箭头、文本区域的相对位置标出来,这样就更容易看懂布局逻辑。

cpp 复制代码
左侧消息(别人发)                         右侧消息(自己发)
──────────────────────────────────────────────────────────────
┌─────────── this->width() ───────────────────────────────────┐

   [头像]                     气泡矩形 + 文本区域                       [头像]
   ┌─────┐    ┌──────────────────────────────┐    ┌─────┐
   │     │    │  ← textSpaceRect →            │    │     │
   │     │    │  ┌────────────────────────┐   │    │     │
   │     │    │  │                        │   │    │     │
   │icon │    │  │   文本内容 (多行)       │   │    │icon │
   │WH   │    │  │                        │   │    │WH   │
   │     │    │  └────────────────────────┘   │    │     │
   └─────┘    │   ↑ iconTMPH                 │    └─────┘
              │   ↑↑↑ 文字上内边距            │
              │                              │
              │   ↓                          │
              └───▲───────────────▲──────────┘
                  │               │
          三角箭头矩形       气泡右边缘
          m_sanjiaoLeftRect   (m_kuangLeftRect.x + width)

IP 区域(可选):
左:m_ipLeftRect  在气泡上方/下方显示发送者IP
右:m_ipRightRect

高度关系:
hei = max(文本高度, minHei)
总高度 = hei + 15 (额外留白)

宽度计算:
m_textWidth = m_kuangWidth - 2*textSpaceRect
m_kuangWidth = this->width() - kuangTMP - 2*(iconWH+iconSpaceW+iconRectW)

行数计算规则

好,这段 getRealString 方法的主要作用是:根据给定的字符串 src****计算它在当前控件字体下实际占用的像素宽度和高度 (返回一个 QSize),并且在超过指定最大宽度 m_textWidth 时自动进行换行计算。

我帮你拆分一下逻辑流程(用注释思路解释):


1. 初始化字体测量工具

cpp 复制代码
QFontMetricsF fm(this->font());
m_lineHeight = fm.lineSpacing();
  • QFontMetricsF 用来测量字符串在当前字体下的像素尺寸。
  • lineSpacing() 获取一行文字的高度,保存到 m_lineHeight

2. 统计换行数

cpp 复制代码
int nCount = src.count("\n");
int nMaxWidth = 0;
  • nCount:字符串里已有的 \n 换行符个数。
  • nMaxWidth:当前字符串中最长一行的宽度(像素)。

3. 如果字符串中没有手动换行

cpp 复制代码
if (nCount == 0) {
    nMaxWidth = fm.width(src); // 测量整行宽度

    if (nMaxWidth > m_textWidth) { // 超过最大宽度则需要换行
        nMaxWidth = m_textWidth;
        
        // 一行能放多少个空格宽度的字符
        int size = m_textWidth / fm.width(" ");
        
        // 需要多少行(粗略计算)
        int num = fm.width(value) / m_textWidth;
        nCount += num;
        
        // 手动切割成多行
        QString temp = "";
        for (int i = 0; i < num; i++) {
            temp += value.mid(i * size, (i + 1) * size) + "\n";
        }
        src.replace(value, temp);
    }
}
  • 思路
    1. 测量整行宽度,如果比 m_textWidth 大,就用空格宽度估算一行可容纳的字符数。
    2. 按这个数量切分字符串,插入 \n
    3. 更新行数统计。

4. 如果字符串本来就有多行

cpp 复制代码
else {
    for (int i = 0; i < (nCount + 1); i++) {
        QString value = src.split("\n").at(i);
        nMaxWidth = fm.width(value) > nMaxWidth ? fm.width(value) : nMaxWidth;

        if (fm.width(value) > m_textWidth) {
            nMaxWidth = m_textWidth;
            int size = m_textWidth / fm.width(" ");
            
            // 这里计算 num 的方式有点奇怪,带上了 (i+num)*fm.width(" ")
            int num = ((i + num) * fm.width(" ") + fm.width(value)) / m_textWidth;
            nCount += num;
            
            QString temp = "";
            for (int i = 0; i < num; i++) {
                temp += value.mid(i * size, (i + 1) * size) + "\n";
            }
            src.replace(value, temp);
        }
    }
}
  • 对每一行进行单独处理。
  • 如果某行超过 m_textWidth,同样用空格宽度估算字符数,插入换行。
  • nMaxWidth 更新为最宽的一行宽度。

5. 返回计算结果

cpp 复制代码
return QSize(nMaxWidth + m_spaceWid,
             (nCount + 1) * m_lineHeight + 2 * m_lineHeight);
  • 宽度:最长一行宽度 + m_spaceWid(额外边距)。
  • 高度:行数 × 行高 + 上下额外的两倍行高。

总结计算逻辑

  1. 测量原始字符串 → 看它的实际像素宽度和行高。
  2. 判断是否超出最大宽度 → 如果超出,按字符宽度估算需要多少行并插入 \n
  3. 统计行数和最大行宽
  4. 返回实际像素尺寸(带边距)。

不过我注意到:

  • fm.width() 在 Qt5 中已经被弃用,应该用 fm.horizontalAdvance()
  • 多行部分 (i + num) 的计算可能会产生错误行数(逻辑上不太严谨)。
  • 这种用"空格宽度"估算字符数的方法,对于宽度不等的字体(比如中文、英文字母混排)会有误差。

如果你需要的话,我可以帮你优化成一个更精准的版本 ,用 fm.horizontalAdvance() 精确测量逐字宽度来断行,这样换行会更准确。 你是想我帮你改成精准版本吗?

PaintEvent逻辑

1 初始化

cpp 复制代码
QPainter painter(this);
    painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);//消锯齿
    painter.setPen(Qt::NoPen);
    painter.setBrush(QBrush(Qt::gray));
  1. 绑定this指针构建喷绘器
  2. 设置一个无边框的画笔

2. 绘制用户消息( User_She**)**

  • 用户头像:使用 drawPixmap 来绘制左侧头像。
  • 消息框
    • 使用 drawRoundedRect 绘制一个圆角矩形作为消息框,背景为灰色,边框为白色。
    • 绘制了一个外框边,颜色为浅灰色,用来增强消息框的层次感。
  • 三角形
    • 绘制一个向右的三角形,用于指示消息框是从用户头像发出的。
  • IP 地址
    • 绘制用户的 IP 地址(显示在消息框内),字体大小为 10,居中对齐。
  • 消息内容
    • 设置文本颜色为深灰色,并使用 QTextOption::WrapAtWordBoundaryOrAnywhere 来自动换行。

3. 绘制自己发送的消息( User_Me**)**

  • 与上面类似,但是这部分针对的是自己发送的消息:
    • 头像绘制在右侧。
    • 消息框的背景颜色为蓝色 ((75,164,242)),并且有右侧的三角形指示。
    • IP 地址为黑色,消息文本为白色,显示在右侧消息框内。
    • 其他绘制方式与用户消息部分类似。

4. 绘制时间信息( User_Time**)**

  • 这种类型的消息通常是显示在聊天框中间,用于显示时间戳。
  • 字体 :使用微软雅黑,字号为 10,颜色为浅灰色 (#999999)。
  • 时间文本 :居中显示当前时间,使用 QTextOption::WrapAtWordBoundaryOrAnywhere 保证文本换行。

5. 图形细节和样式

  • QColorQBrush 被广泛用于设置颜色,QPen 用来设置绘制路径的颜色。
  • QTextOption 用于配置文本绘制的对齐和换行策略。
  • 各种 drawRoundedRectdrawPolygon 操作来绘制消息框、头像、三角形等图形元素。

总结

这个 paintEvent 函数主要用于根据不同类型的用户消息(普通用户、自己发送的消息、时间戳)来绘制聊天界面的视觉效果。它精细地处理了消息框的外观、头像、三角箭头、IP 地址、消息内容等,确保不同类型的消息能够清晰、直观地显示出来。

MySocket类解析

继承于QThread, 本身是跑在独立线程中

主要职责

启动线程,从send_que(发送队列)中取出数据发送给服务器。

将收到的数据写入recv_que(接收队列中),之后会有recv_thread独立处理。

协议格式

需改进的发送点

cpp 复制代码
//----------------write to server-------------------------
    qint64 hastowrite = bytestowrite;
    qint64 ret = 0, haswrite = 0;
    while ((ret = _socktcp->write((char*)sendbuf + haswrite, hastowrite - haswrite)) < hastowrite)
    {
        if (ret == -1 && _socktcp->error() == QAbstractSocket::TemporaryError)
        {
            ret = 0;
        }
        else if (ret == -1)
        {
            qDebug() << "network error";
            break;
        }
        haswrite += ret;
        hastowrite -= ret;
    }

    //暂时阻塞等待所有数据刷入内核
    _socktcp->waitForBytesWritten();
  1. 首先socketcp是非阻塞的,所以每次调用write并不是直接发送给对方,而是直接放到发送缓冲区,立即返回。
  2. 这种方式阻塞等待所有数据刷入内核,效率不高。

改进要点

新增发送队列,将要发送的数据写入队列,交给队列统一发送。

队列:

  1. 队列保证线程安全。
  2. 队列保证发送顺序。

消费发送队列

cpp 复制代码
void MyTcpSocket::run()
{
    //qDebug() << "send data" << QThread::currentThreadId();
    m_isCanRun = true; //标记可以运行
    /*
    *$_MSGType_IPV4_MSGSize_data_# //
    * 1 2 4 4 MSGSize 1
    *底层写数据线程
    */
    for(;;)
    {
        {
            QMutexLocker locker(&m_lock);
            if(m_isCanRun == false) return; //在每次循环判断是否可以运行,如果不行就退出循环
        }
        
        //构造消息体
        MESG * send = queue_send.pop_msg();
        if(send == NULL) continue;
        QMetaObject::invokeMethod(this, "sendData", Q_ARG(MESG*, send));
    }
}
  1. 从queue_send消费数据,将数据取出发送
  2. invokeMethod表示不是直接调用,而是跨线程调用,通知this调用sendData, 参数为MESG*, send

读取逻辑优化

cpp 复制代码
//这么写不合适,可以作为优化点
qint64 MyTcpSocket::readn(char * buf, quint64 maxsize, int n)
{
    //要读取多少字节
    quint64 hastoread = n;
    //已经读取多少字节
    quint64 hasread = 0;
    do
    {
        //socket非阻塞,不要循环读。
        qint64 ret  = _socktcp->read(buf + hasread, hastoread);
        if(ret < 0)
        {
            //缺陷,未判断EWOULDBLOCK以及EAGAIN等问题
            return -1;
        }
        if(ret == 0)
        {
            return hasread;
        }
        //累计已经读取的字节数
        hasread += ret;
        //减少将来要读取的字节数
        hastoread -= ret;
    }while(hastoread > 0 && hasread < maxsize);
    return hasread;
}
  1. 采用信号和槽方式异步回调获取读取字节数,而不是同步循环读取
  2. 为保证接受的数据不丢失,希望通过队列存储接受的数据。

读取数据TLV解析

因为服务器给客户端传输的格式多了四个字节(客户端ip),所以获取传输body长度需要从第7个字节开始