简介这是一份基于QT框架复刻QQ经典界面的桌面应用源码面向具备C基础、希望深入掌握QT界面开发与即时通讯软件架构的学习者。项目围绕QT Widgets模块展开涉及QMainWindow主窗口、QListWidget好友列表、自定义部件绘制、QHBoxLayout等布局管理以及信号与槽机制实现点击好友弹出聊天窗口的交互逻辑同时涵盖QGraphicsScene与QGraphicsView图形场景、QNetworkAccessManager网络通信、QThread多线程处理、QState状态机等进阶知识点可作为课程设计或毕业设计的参考范例。资源包共89个文件约3.15MB包含15个cpp源文件、8个头文件、2个ui界面文件、1个pro工程文件及1个qrc资源文件另有36张png与3张jpg图片素材用于界面美化并附带编译生成的o、exe及debug、release目录方便直接运行体验。目前已有7815人学习下载适合对照源码理解QT项目从界面搭建到逻辑联调的完整流程。1. 从零手搓一个高仿QQ界面为什么Qt是最省心的那条路很多人第一次想复刻一个聊天软件界面脑子里蹦出来的第一个念头是「用 Electron 套个网页」或者「直接上 Flutter」。但如果你真的动手做过桌面端项目就会发现Electron 打包体积动辄上百兆Flutter 桌面端在 Windows 上的字体渲染和窗口阴影总差那么点意思。而 Qt 做出来的东西原生、轻量、跨平台编译出来一个 exe 也就十几兆启动速度肉眼可见地快。这就是为什么「QT实现高仿QQ」这个方向一直有人在做——它既是一个练手项目也是一块试金石能让你把布局、样式、事件、自定义控件这些桌面开发的核心能力全部串一遍。这篇文章面向的是有 C 基础、想通过一个完整项目把 Qt 用起来的开发者。我会从工程结构讲起一路拆到无边框窗口、自定义标题栏、消息气泡、侧边栏切换这些具体实现最后给出几个我踩过的坑和排查方法。你不需要事先懂 QSS也不需要用过 Qt Designer跟着走就能跑出一个能看、能点、能拖动的仿 QQ 主界面。2. 工程骨架与无边框窗口先把「壳」搭对2.1 为什么选 QWidget 而不是 QMLQt 提供了两套 UI 方案QWidget 和 QML。QML 做动画和触摸交互确实顺手但仿 QQ 这种界面控件层级深、自定义绘制多、窗口行为复杂QWidget 的 C 控制力反而更直接。QML 里想改一个按钮的 hover 效果你得写状态机QWidget 里直接重写 enterEvent 和 leaveEvent 就行。而且 QSS 的语法和 CSS 高度相似调样式的心智负担很低。我一般会这样组织工程目录FakeQQ/ ├── main.cpp ├── FakeQQ.pro ├── src/ │ ├── mainwindow.h/cpp │ ├── titlebar.h/cpp │ ├── sidebar.h/cpp │ ├── contactlist.h/cpp │ └── chatbubble.h/cpp ├── res/ │ ├── icons/ │ └── qss/ │ └── style.qss └── lib/.pro 文件里把 QT core gui widgets 写上资源用 .qrc 管理。这一步没什么玄学但目录分清楚后面改样式和换图标会省很多事。2.2 无边框窗口的三件套setWindowFlags、鼠标追踪、边缘拖拽QQ 主界面是没有系统标题栏的所以第一步就是去掉原生边框。核心代码就三行// mainwindow.cpp 构造函数中 setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint); setAttribute(Qt::WA_TranslucentBackground); setMouseTracking(true); // 必须开启否则边缘拖拽不触发第一行去掉系统边框第二行让窗口背景可以透明为了做圆角和阴影第三行开启鼠标追踪否则 mouseMoveEvent 只在按下鼠标时才触发。去掉边框之后窗口就不能拖动、不能缩放了得自己实现。拖动逻辑放在自定义标题栏里// titlebar.cpp void TitleBar::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { m_dragPos event-globalPos() - window()-frameGeometry().topLeft(); event-accept(); } } void TitleBar::mouseMoveEvent(QMouseEvent *event) { if (event-buttons() Qt::LeftButton) { window()-move(event-globalPos() - m_dragPos); event-accept(); } }这里有个细节用globalPos()而不是pos()因为pos()是相对于标题栏自身的窗口移动后坐标系会乱。m_dragPos记录的是鼠标按下时相对于窗口左上角的偏移移动时保持这个偏移不变窗口就跟手了。边缘缩放要复杂一些需要在MainWindow里判断鼠标是否落在窗口边缘 5 像素范围内然后设置对应的光标形状在mouseMoveEvent里根据光标形状调整窗口大小。常见做法是封装一个hitTest函数返回边缘区域枚举这里不展开但你要知道这是必须自己写的Qt 不会帮你做。2.3 圆角与阴影QSS 和 QGraphicsDropShadowEffect 的配合无边框窗口做圆角直接在 QSS 里写border-radius是不生效的因为窗口本身还是矩形。正确做法是窗口背景设为透明在窗口内部放一个QWidget作为容器容器设圆角和背景色再给容器加阴影。// mainwindow.cpp QWidget *centralWidget new QWidget(this); centralWidget-setObjectName(centralWidget); QVBoxLayout *layout new QVBoxLayout(this); layout-setContentsMargins(10, 10, 10, 10); // 给阴影留空间 layout-addWidget(centralWidget); setLayout(layout); QGraphicsDropShadowEffect *shadow new QGraphicsDropShadowEffect(this); shadow-setBlurRadius(15); shadow-setColor(QColor(0, 0, 0, 80)); shadow-setOffset(0, 2); centralWidget-setGraphicsEffect(shadow);QSS 里#centralWidget { background-color: #ffffff; border-radius: 8px; }注意setContentsMargins留出的 10 像素就是阴影的绘制区域不留的话阴影会被窗口边界裁掉看起来像没生效。这个坑我见过至少三个人踩过。3. 侧边栏与联系人列表用 QListWidget 还是自定义控件3.1 侧边栏的头像按钮和切换逻辑QQ 左侧那条窄窄的侧边栏上面是用户头像下面是几个功能图标消息、联系人、空间等。这个区域用QVBoxLayout垂直排列按钮就行关键是按钮的选中态切换。我一般用QButtonGroup管理互斥状态// sidebar.cpp m_btnGroup new QButtonGroup(this); m_btnGroup-setExclusive(true); QStringList icons {:/icons/msg.png, :/icons/contact.png, :/icons/space.png}; for (int i 0; i icons.size(); i) { QToolButton *btn new QToolButton(this); btn-setIcon(QIcon(icons[i])); btn-setIconSize(QSize(28, 28)); btn-setCheckable(true); btn-setObjectName(sidebarBtn); m_btnGroup-addButton(btn, i); m_layout-addWidget(btn); } m_btnGroup-button(0)-setChecked(true); connect(m_btnGroup, QOverloadint::of(QButtonGroup::buttonClicked), this, Sidebar::onButtonClicked);QSS 里给#sidebarBtn:checked设置高亮背景色就能实现选中态。QButtonGroup的setExclusive(true)保证同时只有一个按钮被选中省去手动取消其他按钮的麻烦。3.2 联系人列表的分组与自定义 Item联系人列表用QListWidget是最快的但默认的 item 样式太丑需要自定义 delegate 或者直接用setItemWidget。我倾向于后者因为仿 QQ 的列表项有头像、昵称、签名、在线状态点用 widget 拼更直观。// contactlist.cpp QListWidgetItem *item new QListWidgetItem(m_listWidget); item-setSizeHint(QSize(0, 60)); // 必须设置否则 widget 不显示 QWidget *itemWidget new QWidget; QHBoxLayout *hLayout new QHBoxLayout(itemWidget); hLayout-setContentsMargins(10, 5, 10, 5); QLabel *avatar new QLabel; avatar-setPixmap(QPixmap(:/icons/avatar.png).scaled(40, 40, Qt::KeepAspectRatio, Qt::SmoothTransformation)); avatar-setFixedSize(40, 40); QVBoxLayout *vLayout new QVBoxLayout; QLabel *name new QLabel(某开发者); QLabel *sign new QLabel(今天也要写代码); sign-setObjectName(signLabel); vLayout-addWidget(name); vLayout-addWidget(sign); hLayout-addWidget(avatar); hLayout-addLayout(vLayout); hLayout-addStretch(); m_listWidget-setItemWidget(item, itemWidget);这里的关键是setSizeHint不设置的话 item 高度默认是 0widget 会被压扁看不见。另外setItemWidget之后item 本身的选中高亮会被 widget 遮挡需要在 QSS 里给 item 设置background并在 widget 上设置透明背景或者干脆自己处理点击事件改 widget 样式。3.3 搜索框的实时过滤QQ 顶部有个搜索框输入时联系人列表实时过滤。用QLineEdit的textChanged信号连接一个过滤槽void ContactList::onSearchTextChanged(const QString text) { for (int i 0; i m_listWidget-count(); i) { QListWidgetItem *item m_listWidget-item(i); QWidget *w m_listWidget-itemWidget(item); QLabel *nameLabel w-findChildQLabel*(nameLabel); if (nameLabel) { bool visible nameLabel-text().contains(text, Qt::CaseInsensitive); item-setHidden(!visible); } } }findChild通过 objectName 查找子控件所以创建 QLabel 时记得setObjectName(nameLabel)。这个方案在联系人数量几百个以内没问题上千个建议用QSortFilterProxyModel配合QListView性能更好。4. 聊天窗口与消息气泡自定义绘制才是灵魂4.1 气泡的九宫格绘制与自适应高度QQ 的聊天气泡左边是对方消息右边是自己消息气泡有圆角和小三角。用 QSS 的border-image做九宫格拉伸是最简单的#bubbleLeft { border-image: url(:/icons/bubble_left.png) 20 20 20 20 stretch stretch; padding: 10px 15px; }但border-image有个问题它不会根据内容自动调整高度需要配合QLabel的setWordWrap(true)和布局的sizeConstraint。更可控的方式是重写paintEvent自己画void ChatBubble::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; QRectF rect this-rect().adjusted(0, 0, -8, 0); // 给三角留空间 path.addRoundedRect(rect, 8, 8); // 画小三角 QPolygonF triangle; if (m_isSelf) { triangle QPointF(rect.right(), 15) QPointF(rect.right() 8, 20) QPointF(rect.right(), 25); } else { triangle QPointF(rect.left(), 15) QPointF(rect.left() - 8, 20) QPointF(rect.left(), 25); } path.addPolygon(triangle); painter.setPen(Qt::NoPen); painter.setBrush(m_isSelf ? QColor(#0099ff) : QColor(#ffffff)); painter.drawPath(path); // 画文字 painter.setPen(m_isSelf ? Qt::white : Qt::black); painter.drawText(rect.adjusted(12, 8, -12, -8), Qt::TextWordWrap, m_text); }QPainterPath的addRoundedRect和addPolygon可以合并成一个路径这样填充时三角和圆角矩形是一体的不会有接缝。文字区域要减去 padding否则会贴边。4.2 消息列表的滚动与自动到底部聊天记录用QScrollArea包一个QVBoxLayout每来一条消息就addWidget一个气泡然后滚动到底部void ChatWindow::appendMessage(const QString text, bool isSelf) { ChatBubble *bubble new ChatBubble(text, isSelf, this); m_msgLayout-addWidget(bubble, 0, isSelf ? Qt::AlignRight : Qt::AlignLeft); // 延迟滚动等布局计算完成 QTimer::singleShot(0, this, [this]() { m_scrollArea-verticalScrollBar()-setValue( m_scrollArea-verticalScrollBar()-maximum()); }); }直接调setValue有时滚不到底因为新 widget 的尺寸还没计算完。用QTimer::singleShot(0, ...)把滚动操作推到事件循环的下一轮布局就更新完了。这个技巧在 Qt 里很常用记住就行。4.3 输入框的 Enter 发送与 ShiftEnter 换行QQ 的输入框按 Enter 发送ShiftEnter 换行。用QTextEdit子类化重写keyPressEventvoid ChatInput::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Return || event-key() Qt::Key_Enter) { if (event-modifiers() Qt::ShiftModifier) { QTextEdit::keyPressEvent(event); // 换行 } else { emit sendMessage(toPlainText()); clear(); } } else { QTextEdit::keyPressEvent(event); } }注意Qt::Key_Return和Qt::Key_Enter是两个不同的键小键盘上的回车是Key_Enter主键盘是Key_Return两个都要判断否则小键盘回车没反应。5. 避坑与排查那些让我熬夜的瞬间5.1 QSS 不生效先查选择器再查加载顺序现象写了 QSS 文件程序跑起来样式完全没变。原因通常有三个一是选择器写错了比如QPushButton#btn写成了QPushButton #btn多了空格含义变成后代选择器二是 QSS 文件没加载成功路径写错或者资源没编译进 qrc三是控件被setStyleSheet覆盖了父控件的样式会覆盖子控件。解决先用qApp-setStyleSheet(QPushButton { color: red; })测试全局样式是否生效排除加载问题。然后检查选择器用QApplication::styleSheet()打印当前样式表确认。最后注意setStyleSheet的继承规则子控件会继承父控件的样式但子控件自己的setStyleSheet优先级更高。5.2 无边框窗口拖动时闪烁现象拖动窗口时画面撕裂、闪烁。原因move()调用太频繁每次鼠标移动都触发重绘。解决在mouseMoveEvent里加一个判断只有位置变化超过 2 像素才move()QPoint newPos event-globalPos() - m_dragPos; if ((newPos - window()-pos()).manhattanLength() 2) { window()-move(newPos); }manhattanLength()是曼哈顿距离比欧几里得距离计算快用来做阈值判断足够。5.3 自定义控件在布局中不显示现象addWidget之后控件看不见或者大小不对。原因自定义控件没有重写sizeHint()布局不知道该给它多大空间。解决重写sizeHint()返回一个合理的默认尺寸QSize ChatBubble::sizeHint() const { return QSize(200, 60); }如果控件在QListWidgetItem里还要item-setSizeHint(widget-sizeHint())两者缺一不可。5.4 信号槽连接失败但编译通过现象按钮点了没反应编译没报错。原因Qt5 之后推荐用函数指针语法connect(btn, QPushButton::clicked, this, MainWindow::onClicked)但如果信号有重载比如QComboBox::currentIndexChanged有 int 和 QString 两个版本直接写函数指针会编译报错。反过来如果用旧的SIGNAL/SLOT宏语法拼写错误编译期不报错运行期才输出警告。解决优先用函数指针语法遇到重载用QOverloadint::of(QComboBox::currentIndexChanged)明确指定。如果必须用宏语法运行时打开QT_DEBUG_PLUGINS或者看控制台有没有QObject::connect: No such signal的警告。5.5 高 DPI 屏幕下界面模糊现象在 4K 屏幕上界面文字和图标发虚。原因Qt 默认不开启高 DPI 缩放。解决在main.cpp最前面加QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);第一行开启自动缩放第二行让 pixmap 也跟随缩放。注意这两行必须在QApplication构造之前调用否则无效。另外图标资源最好用 SVG这样缩放不会失真。6. 进阶技巧让仿 QQ 界面更「真」的几个细节6.1 用 QSS 变量和动态属性做主题切换QQ 有深色和浅色主题如果每个控件都写两套样式维护起来会疯。我一般用动态属性加 QSS 属性选择器// 切换主题时 qApp-setProperty(theme, dark); qApp-setStyleSheet(qApp-styleSheet()); // 触发重新应用QSS 里QWidget[themedark] { background-color: #2b2b2b; color: #ffffff; } QWidget[themelight] { background-color: #ffffff; color: #000000; }setProperty之后需要手动触发样式重算setStyleSheet重新设一遍是最简单的办法。这个方案的好处是主题色集中管理改一处全局生效。6.2 头像的圆形裁剪与在线状态角标QQ 头像都是圆形的用QPainterPath做裁剪QPixmap AvatarLabel::roundPixmap(const QPixmap src, int size) { QPixmap dest(size, size); dest.fill(Qt::transparent); QPainter painter(dest); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addEllipse(0, 0, size, size); painter.setClipPath(path); painter.drawPixmap(0, 0, size, size, src); return dest; }在线状态角标就是在头像右下角再画一个小圆绿色表示在线灰色表示离线。用QPainter在paintEvent里画就行注意角标要画在头像裁剪之后否则会被裁掉。6.3 窗口最小化动画与托盘图标QQ 点关闭是缩到托盘不是退出。用QSystemTrayIcon实现void MainWindow::closeEvent(QCloseEvent *event) { if (m_trayIcon-isVisible()) { hide(); event-ignore(); // 忽略关闭事件只隐藏窗口 } }托盘图标的activated信号里判断点击类型双击时showNormal()恢复窗口。这个逻辑不复杂但要注意event-ignore()必须调用否则窗口会被真正关闭。6.4 性能验证用 QElapsedTimer 测布局耗时界面卡不卡不能靠感觉。在appendMessage里加计时QElapsedTimer timer; timer.start(); // ... 添加气泡的代码 ... qDebug() append message cost: timer.elapsed() ms;如果单条消息超过 16ms滚动就会掉帧。优化方向减少setItemWidget的使用改用 delegate 绘制气泡的paintEvent里避免重复创建QPainterPath可以缓存起来。我自己的习惯是每做完一个界面模块先跑一遍计时超过 10ms 的地方就标记出来后面统一优化。仿 QQ 这种界面消息列表是性能大头其他部分基本不会成为瓶颈。希望帮到你。本文还有配套的精品资源点击获取