前端项目源码剖析
需求分析
创建会议
点击创建会议按钮,申请会议室,同时自动加入
服务器提前开辟好一定的房间,当申请者创建会议时,服务器返回房间号。

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

用户登录
用户通过连接服务器端口和地址,登录。
服务器通过TCP维持长连接。
退出会议
当参与人退出时,直接退出,并且广播给同房间内的其他用户。
当房主退出,房间解散。

视频传输
由主要发起人开启摄像头后,激活。
将视频同步给其他人。
实现细节: 发起人调用视频抽取逻辑,按帧抽取图片,将图片定时发送给同房间的所有人即可。
33ms抽取一次,将图片同步给其他人即可。
视频传输抽取频率和发送频率不同步怎么办?
思路:引入队列逻辑,将抽取的数据放入队列中,然后由发送线程从队列中取数据进行发送。
采取生产者,消费者模式。
视频抽取要用到QAbstractVideoSurface类
文本传输
在同房间内的人,发送信息时,需要广播给其他人。
文本传输比视频传输更轻量级,尽量不要将二者放入一个队列中。
有一个文本队列,专门存放待发送的文本消息。
音频传输
音频传输和视频传输类似,音频需要从录音设备录制,保存为二进制字节流发送给服务器,服务器再转发给其他人。
音频传输要用到QAudioInput类
日志打印
当产生必要信息需要打印时,采用异步方式。
独立线程记录日志信息,配合消息队列。
服务器高并发
- 服务器要采用多路复用技术
- 尽可能采用线程分布式通信(主从通信模式)
- 服务器要采用消息队列解耦合
- 发送时因为存在未发送完的情况,所以要将未发送完的数据放入队列中
- 接收时,也可能接收不全,所以要采用接收队列。
- 主要采用异步发送,配合非阻塞socket,实现高并发。
效果案例展示



源码剖析
- 从顺序结构入手,找到main函数
- 跟踪所有流程,了解大概
- 对某一个模块深入理解,再扩充其他模块。
走入工作岗位中,领导分配的任务不是写框架,是基于某一个功能做定制化修改,或者添加功能。
主函数
设置编码
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF8"));
采用utf-8编码,好处
- utf-8变长编码,节省空间
- 语言支持范围广
- 传输时节省带宽。
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();
}
主函数
- 设置编码
- 初始化屏幕
- 创建窗口对象
- 开启事件循环
Screen类
- 获取屏幕的矩形区域
- 将来根据矩形区域放置我们的窗口
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方式,其实是默认链接。
- 发送者和接收在一个线程时,直接函数回调方式,不存在入队。
- 发送者和接收者在不同的线程,需要将发送者的信号入队,也要将参数一起打包入队。如果参数是自定义类型,需要注册为元对象才能继续连接。
当发送信号的线程和接收信号的槽函数所在的线程不是同一个线程
比如我们的信号
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);
- 宽度 = 最长行宽 + 空白区域宽度
- 高度 = 行数 × 行高 + 上下额外行高
总结
这段代码主要是:
- 根据 控件宽度、头像大小、内边距等计算气泡布局。
- 根据 字体度量 进行自动换行,得到真实的文本显示尺寸。
- 为头像、气泡、箭头、文本、IP 区域分别计算
QRect。 - 返回整个消息所需的
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);
}
}
- 思路:
-
- 测量整行宽度,如果比
m_textWidth大,就用空格宽度估算一行可容纳的字符数。 - 按这个数量切分字符串,插入
\n。 - 更新行数统计。
- 测量整行宽度,如果比
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(额外边距)。 - 高度:行数 × 行高 + 上下额外的两倍行高。
总结计算逻辑
- 测量原始字符串 → 看它的实际像素宽度和行高。
- 判断是否超出最大宽度 → 如果超出,按字符宽度估算需要多少行并插入
\n。 - 统计行数和最大行宽。
- 返回实际像素尺寸(带边距)。
不过我注意到:
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));
- 绑定this指针构建喷绘器
- 设置一个无边框的画笔
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. 图形细节和样式
QColor和QBrush被广泛用于设置颜色,QPen用来设置绘制路径的颜色。QTextOption用于配置文本绘制的对齐和换行策略。- 各种
drawRoundedRect和drawPolygon操作来绘制消息框、头像、三角形等图形元素。
总结
这个 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();
- 首先socketcp是非阻塞的,所以每次调用write并不是直接发送给对方,而是直接放到发送缓冲区,立即返回。
- 这种方式阻塞等待所有数据刷入内核,效率不高。
改进要点
新增发送队列,将要发送的数据写入队列,交给队列统一发送。
队列:
- 队列保证线程安全。
- 队列保证发送顺序。


消费发送队列
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));
}
}
- 从queue_send消费数据,将数据取出发送
- 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;
}
- 采用信号和槽方式异步回调获取读取字节数,而不是同步循环读取
- 为保证接受的数据不丢失,希望通过队列存储接受的数据。
读取数据TLV解析
因为服务器给客户端传输的格式多了四个字节(客户端ip),所以获取传输body长度需要从第7个字节开始
