本周只学习两个核心问题。第一,Qt 控件由谁管理,什么时候销毁。第二,GUI 为什么能够持续响应,以及长时间代码为什么会让窗口卡住。本修订版已经结合你的实际学习结果:十个知识关和完整实验均已完成,重点不再是从头重复,而是巩固曾经混淆的术语、复述完整因果链,并把排错经验转化为下一次可独立使用的方法。
第二周十关路线
|--------|------------|-------------------------------|----------|
| 关卡 | 知识点 | 通过标准 | 当前状态 |
| 1 | 变量 指针 对象 | 先分清名字、地址和真实对象 | 重点巩固 |
| 2 | QObject 身份 | 确认哪些类型具有父子关系 | 已掌握 |
| 3 | 父对象与所有权 | 解释 new QLineEdit this 的含义 | 已掌握 |
| 4 | 顶层窗口生命周期 | 说明 MainWindow 为什么不需要手动 delete | 重点巩固 |
| 5 | 成员指针与局部指针 | 区分作用域结束和对象销毁 | 重点巩固 |
| 6 | 布局对象的管理 | 说明无 parent 的布局后来由谁管理 | 已掌握 |
| 7 | 事件循环 | 说明 app.exec 持续处理什么 | 已掌握 |
| 8 | 点击事件执行链 | 说明槽函数在哪个线程执行 | 已掌握 |
| 9 | return 与阻塞 | 区分结束一次调用和占住 GUI 线程 | 已掌握 |
| 10 | 阻塞实验 | 预测 观察 解释并恢复正常代码 | 已验证 |
第一部分 对象生命周期
第 1 关 变量 指针和对象
类比理解 对象像真实房间,指针变量像写着房间地址的纸条。纸条被丢掉,不代表房间立刻消失;房间被拆除后,旧地址也不能再使用。
专业表达 nameEdit 是一个 QLineEdit 指针成员,它保存地址。new QLineEdit(this) 在堆上创建真正的 QLineEdit 对象。指针变量和它指向的对象不是同一个东西。
cpp
QLineEdit *nameEdit; // 声明一个指针成员
nameEdit = new QLineEdit(this); // 创建对象并保存地址
重点理解任务 分别指出 nameEdit、nameEdit 中保存的值和 new 创建的对象是什么。
闯关题 1 如果一个局部指针变量离开作用域,为什么不能直接断定它指向的 QObject 已经销毁?
第 2 关 QObject 身份
类比理解 Qt 的父子管理规则只适用于加入这套管理系统的对象。普通 C++ 对象不会因为旁边有一个 Qt 窗口,就自动成为它的孩子。
专业表达 QObject 提供父子对象树。QWidget、QLineEdit、QPushButton、QLabel 和 QTableWidget 最终都继承 QObject,因此可以使用 parent 参数和自动销毁机制。父子关系不是所有 C++ 类都天然拥有的。
QObject
`-- QWidget
|-- QLineEdit
|-- QPushButton
|-- QLabel
`-- QTableWidget
重点理解任务 判断一个对象能否使用 QObject 父子管理前,先确认它是否继承 QObject。
闯关题 2 为什么可以把 MainWindow 传给 QLineEdit 构造函数作为 parent?普通 int 变量是否也会进入 QObject 对象树?说明原因。
第 3 关 父对象与所有权
类比理解 给控件指定 MainWindow 为家长,相当于把控件登记到窗口名下。窗口销毁时会沿着登记关系处理孩子。
专业表达 new QLineEdit(this) 把当前 MainWindow 设置为输入框的 QObject 父对象。父对象析构时会自动销毁仍属于它的子对象,因此这些控件通常不需要在 MainWindow 析构函数中逐个 delete。
cpp
nameEdit(new QLineEdit(this)),
ipEdit(new QLineEdit(this)),
addButton(new QPushButton("Add Device", this)),
deviceTable(new QTableWidget(0, 4, this)),
statusLabel(new QLabel("Ready.", this))
重点理解任务 画出 MainWindow 到五个控件的父子箭头,并说明箭头代表对象所有权,不是 C++ 类继承。
闯关题 3 为什么这些控件通常不需要手动 delete?这里的父子关系与 MainWindow 继承 QWidget 有什么区别?
第一次短复盘
不看前文,用三个句子分别解释指针、QObject 子对象和类继承。能够把三者分开,本阶段才算通过。
第 4 关 顶层窗口生命周期
类比理解 MainWindow 是整棵对象树的根。它先在 main 函数中建立,应用运行期间一直存在;事件循环结束后,main 继续向下执行,窗口对象离开作用域并销毁。
专业表达 MainWindow window 是 main 函数中的栈对象。app.exec 返回后,main 继续执行并到达作用域末尾,window 按 C++ 自动存储期规则开始析构。在 MainWindow 及其基类的析构过程中,QObject 父子机制清理仍属于窗口的子对象。
cpp
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
MainWindow window;
window.show();
return app.exec();
}
重点理解任务 把顺序说完整:创建 window,显示窗口,运行事件循环,关闭窗口,app.exec 返回,window 离开作用域,子对象被销毁。
闯关题 4 MainWindow 没有使用 new,为什么不需要 delete?它的子控件通常在什么时候被销毁?
第 5 关 成员指针与局部指针
类比理解 成员指针像贴在房屋档案里的长期地址,局部指针像施工人员手里的临时便签。便签的保存时间不同,但它们都可能指向同一个仍然存在的对象。
专业表达 成员变量的生命周期与所属 MainWindow 对象一致,局部变量在函数返回时结束。指针变量结束只会移除地址记录,不会自动 delete 它指向的堆对象。QObject 对象是否销毁,要看 delete、父对象析构或其他明确的生命周期管理。
cpp
void example(MainWindow *window)
{
QLineEdit *temporary = new QLineEdit(window);
// 函数结束时 temporary 消失。
// 输入框仍是 window 的子对象。
}
重点理解任务 解释 temporary 消失后发生了什么,并指出虽然对象仍存在,但已经没有这个局部指针可供后续代码访问。
你的修正 你曾把"无 parent 且局部指针消失"的情况判断为对象也销毁。正确结果是堆对象仍存在,但地址丢失,无法再正常访问和释放,形成内存泄漏。
闯关题 5 函数结束后 temporary 和 QLineEdit 对象分别怎样变化?如果构造时没有 parent,又没有保存指针,会产生什么问题?
第 6 关 布局对象的管理
类比理解 formLayout 创建时还没有负责人,随后被加入 mainLayout,相当于被主布局接管。mainLayout 又安装在 MainWindow 上,最终随窗口一起清理。
专业表达 QLayout 继承 QObject。formLayout 创建时没有 parent,但把它加入 mainLayout 后,子布局会由主布局管理。new QVBoxLayout(this) 把主布局安装到 MainWindow,并建立相应的所有权关系。
cpp
QFormLayout *formLayout = new QFormLayout;
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->addLayout(formLayout);
重点理解任务 不要只找 new 后面有没有 this,还要观察对象是否在后续操作中被 Qt 组件接管。
闯关题 6 formLayout 创建时没有 parent,为什么最终仍不会泄漏?如果只 new QFormLayout 后既不指定 parent,也不加入其他布局,会怎样?
第二次短复盘
画一棵简化对象树,至少包含 MainWindow、nameEdit、addButton、mainLayout 和 formLayout。再说明 MainWindow 自己为什么由 C++ 自动销毁。
第二部分 事件循环
第 7 关 事件循环
类比理解 GUI 主线程像只有一名值班员的前台。前台从队列中取出一次点击、键盘输入或重绘任务,处理完后再取下一项。
专业表达 app.exec 启动 Qt 应用事件循环。事件循环持续等待并分发鼠标、键盘、窗口、定时器和重绘事件,直到应用退出。它不是反复执行 main 函数,而是在 GUI 线程中调度事件。
cpp
MainWindow window;
window.show();
return app.exec(); // 启动事件循环,直到应用退出才返回
重点理解任务 说明 show 只让窗口具备显示条件,真正让窗口持续响应的是 app.exec 运行的事件循环。
闯关题 7 如果只调用 window.show,却不调用 app.exec,为什么窗口可能一闪而过或无法正常交互?
第 8 关 点击事件执行链
类比理解 值班员收到点击任务后,让按钮发出通知,再按已经连接好的线路调用 addDevice。接待工作没有完成前,这名值班员不能处理下一项任务。
专业表达 本项目的按钮和 MainWindow 位于同一 GUI 线程。事件循环分发鼠标事件后,QPushButton 发出 clicked 信号;默认连接会在当前线程直接调用 addDevice。槽函数返回后,事件循环才能继续处理后续事件。
鼠标点击事件
-> Qt 事件循环分发事件
-> QPushButton 发出 clicked
-> MainWindow::addDevice 执行
-> addDevice 返回
-> 事件循环处理下一项事件
重点理解任务 把 clicked 看成通知,把 addDevice 看成处理通知的函数,同时记住本项目中它们都在 GUI 线程执行。
闯关题 8 点击按钮后,为什么 addDevice 执行期间其他界面事件通常要等待?connect 本身是否创建了后台线程?
第 9 关 return 与阻塞
类比理解 return 相当于办完或拒绝一项业务后回到前台继续叫号。长循环则是前台一直忙着同一项工作,后面的点击和重绘只能排队。
专业表达 addDevice 中的 return 只结束本次槽函数调用,控制权随后回到事件循环。长时间循环不会让事件循环消失,但会持续占用 GUI 线程,使队列中的输入和重绘事件无法及时处理。
cpp
if (name.isEmpty() || ip.isEmpty())
{
QMessageBox::warning(this, "Missing Information",
"Please enter a device name and IP address.");
return; // 结束本次 addDevice,不退出应用
}
重点理解任务 必须说出卡顿的完整因果链:槽函数未返回,GUI 线程被占用,事件循环不能继续分发,点击和重绘事件积压,窗口表现为卡住。
闯关题 9 为什么 return 不会关闭程序?为什么 while 循环即使没有报错,也可能让窗口暂时无响应?
第三次短复盘
从 app.exec 开始完整讲一次 Add Device 的执行链。然后把正常 addDevice 与一个执行 3 秒的槽函数进行对比。
第 10 关 三秒阻塞实验
实验目的 主动制造一次可恢复的 GUI 卡顿,用观察结果验证事件循环和 GUI 线程的关系。这段阻塞代码只用于学习,实验后必须删除。
预测 点击 Block GUI 3s 后,窗口约 3 秒不能及时拖动、重绘或响应其他点击。状态文字 Starting blocking task 可能来不及显示,因为重绘也需要事件循环。循环结束后,最终状态文字出现,积压事件继续处理。
cpp
void MainWindow::blockGui()
{
statusLabel->setText("Starting blocking task...");
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 3000)
{
// 故意占用 GUI 线程,仅用于本周实验。
}
statusLabel->setText("Blocking task finished.");
}
重点理解任务 实验前写出预测,实验中观察窗口,实验后用 GUI 线程和事件循环解释现象。最后删除 Block GUI 3s 按钮、槽函数和 QElapsedTimer 相关代码。
你的修正 你的实际观察是 3 秒内窗口无法拖动,结束后显示 Blocking task finished.。这与预测一致,说明卡顿发生在 blockGui 槽函数持续占用 GUI 线程期间。
闯关题 10 为什么第一条状态文字可能看不到,而最后一条能看到?删除 while 循环后,界面为什么恢复正常?
第三部分 完整代码练习
只有十个知识关和三次短复盘通过后,才开始完整代码。第一遍对照输入,第二遍关闭文档重新加入 Block GUI 3s 功能,第三遍完成实验后删除阻塞功能并重新编译。
lan device monitor pro
cpp
QT += widgets
CONFIG += c++11
TEMPLATE = app
TARGET = lan_device_monitor
SOURCES += main.cpp \
mainwindow.cpp
HEADERS += mainwindow.h
main cpp
cpp
#include <QApplication>
#include "mainwindow.h"
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
// window 是 main 函数中的栈对象。
// app.exec 返回后,window 离开作用域并自动析构。
MainWindow window;
window.show();
// 启动 GUI 线程的事件循环。
return app.exec();
}
mainwindow h
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QWidget>
class QLabel;
class QLineEdit;
class QPushButton;
class QTableWidget;
class MainWindow : public QWidget
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
private slots:
void addDevice();
// 第二周实验槽函数,实验结束后删除。
void blockGui();
private:
QLineEdit *nameEdit;
QLineEdit *ipEdit;
QPushButton *addButton;
// 第二周实验按钮,实验结束后删除。
QPushButton *blockButton;
QTableWidget *deviceTable;
QLabel *statusLabel;
};
#endif
mainwindow cpp
cpp
#include "mainwindow.h"
#include <QAbstractItemView>
#include <QElapsedTimer> // 第二周新增,用于稳定计时 3 秒。
#include <QFormLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QString>
#include <QStringList>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
MainWindow::MainWindow(QWidget *parent)
: QWidget(parent),
// 五个控件把 this 设为父对象。
nameEdit(new QLineEdit(this)),
ipEdit(new QLineEdit(this)),
addButton(new QPushButton("Add Device", this)),
// 第二周实验按钮,实验结束后删除。
blockButton(new QPushButton("Block GUI 3s", this)),
deviceTable(new QTableWidget(0, 4, this)),
statusLabel(new QLabel("Ready.", this))
{
setWindowTitle("LAN Device Monitor");
resize(640, 400);
nameEdit->setPlaceholderText("Ubuntu PC");
ipEdit->setPlaceholderText("192.168.10.10");
QStringList headers;
headers << "Name"
<< "IP Address"
<< "Status"
<< "Last Check";
deviceTable->setHorizontalHeaderLabels(headers);
deviceTable->horizontalHeader()->setSectionResizeMode(
QHeaderView::Stretch);
deviceTable->setSelectionBehavior(
QAbstractItemView::SelectRows);
deviceTable->setEditTriggers(
QAbstractItemView::NoEditTriggers);
// formLayout 创建时没有 parent。
QFormLayout *formLayout = new QFormLayout;
formLayout->addRow("Device Name:", nameEdit);
formLayout->addRow("IP Address:", ipEdit);
// mainLayout 安装到 MainWindow。
QVBoxLayout *mainLayout = new QVBoxLayout(this);
// addLayout 让主布局管理 formLayout。
mainLayout->addLayout(formLayout);
mainLayout->addWidget(addButton);
mainLayout->addWidget(blockButton);
mainLayout->addWidget(deviceTable);
mainLayout->addWidget(statusLabel);
connect(addButton, &QPushButton::clicked,
this, &MainWindow::addDevice);
// 同一 GUI 线程中的按钮点击会执行 blockGui。
connect(blockButton, &QPushButton::clicked,
this, &MainWindow::blockGui);
}
void MainWindow::addDevice()
{
const QString name = nameEdit->text().trimmed();
const QString ip = ipEdit->text().trimmed();
if (name.isEmpty() || ip.isEmpty())
{
QMessageBox::warning(
this,
"Missing Information",
"Please enter a device name and IP address.");
return;
}
const int row = deviceTable->rowCount();
deviceTable->insertRow(row);
deviceTable->setItem(
row, 0, new QTableWidgetItem(name));
deviceTable->setItem(
row, 1, new QTableWidgetItem(ip));
deviceTable->setItem(
row, 2, new QTableWidgetItem("Unknown"));
deviceTable->setItem(
row, 3, new QTableWidgetItem("Never"));
statusLabel->setText(
QString("Added %1.").arg(name));
nameEdit->clear();
ipEdit->clear();
nameEdit->setFocus();
}
void MainWindow::blockGui()
{
// 修改了标签内容,但重绘事件还需要事件循环处理。
statusLabel->setText("Starting blocking task...");
QElapsedTimer timer;
timer.start();
// 故意占用 GUI 线程 3 秒,仅用于学习阻塞现象。
while (timer.elapsed() < 3000)
{
}
// 槽函数返回后,事件循环才能继续处理重绘和点击。
statusLabel->setText("Blocking task finished.");
}
编译和运行
cpp
qmake lan_device_monitor.pro
make -j4
./lan_device_monitor
阻塞实验步骤
- 运行程序,先确认 Add Device 功能仍然正常。
- 点击 Block GUI 3s,立刻尝试拖动窗口、连续点击按钮或遮挡后再显示窗口。
- 记录 3 秒内和 3 秒后的变化,特别观察状态文字与重绘。
- 用槽函数未返回、GUI 线程被占用和事件积压解释结果。
- 删除 blockButton、blockGui、QElapsedTimer 及相关连接,重新执行 qmake、make 和运行测试。
恢复代码防错清单
你在恢复第一周版本时遇到过 undefined reference to MainWindow::blockGui()。这不是语法错误,而是链接阶段发现槽函数仍被声明或引用,却找不到对应定义。由于 MainWindow 使用 Q_OBJECT,moc 生成的元对象代码也会引用 private slots 中声明的 blockGui,因此只删除 cpp 中的函数实现还不够。
1. 在 mainwindow.h 中删除 void blockGui(); 和 QPushButton *blockButton;。
2. 在 mainwindow.cpp 中删除 QElapsedTimer 头文件、blockButton 初始化、布局添加、connect 和 blockGui 函数定义。
3. 执行 grep -nE "blockGui|blockButton|QElapsedTimer" mainwindow.h mainwindow.cpp,确认实验标识已经全部清理。
4. 重新执行 qmake lan_device_monitor.pro 和 make -j4,再验证 Add Device 正常。
重点理解任务 看到 undefined reference 时,先检查声明、定义和 connect 是否一致;涉及 Q_OBJECT 或 slots 的头文件修改后,再重新运行 qmake。
第四部分 综合自测
答题要求 每题说明对象或事件、执行顺序、直接原因和最终结果。只说 Qt 自动管理或循环太慢,不能通过。
1. 画出 MainWindow、nameEdit、addButton、mainLayout 和 formLayout 的所有权关系,并说明每个对象最终由谁销毁。
2. 比较成员指针、局部指针、栈对象和 new 创建的对象,分别说明它们何时结束。
3. 从 window.show 到用户关闭窗口,说明 app.exec、事件循环、MainWindow 析构和子对象销毁的顺序。
4. 解释 connect 为什么不会创建后台线程,以及 addDevice 为什么通常在 GUI 线程执行。
5. 解释 return 为什么只结束一次槽函数调用,而 while 循环为什么会阻止后续界面事件及时处理。
6. 预测 blockGui 中第一条状态文字、窗口拖动和最后一条状态文字的表现,并给出完整因果链。
7. 说明为什么实验结束后必须删除阻塞代码,并指出将来解决耗时任务问题的大方向。
8. 关闭完整代码,只看功能要求重新加入阻塞按钮,完成实验后再恢复第一周的正常版本。
9. 解释为什么只删除 mainwindow.cpp 中的 blockGui 定义,却在 mainwindow.h 中保留槽声明,可能产生 undefined reference。
第二周验收标准
|---------|---------------------------------------------------------|
| 检查项 | 通过标准 |
| 对象树 | 能画出父子关系,并区分所有权与类继承。 |
| 生命周期 | 能解释 MainWindow、成员指针、局部指针和子控件何时结束。 |
| 事件循环 | 能从 app.exec 讲到事件分发、槽函数返回和重绘。 |
| 阻塞原因 | 能说出 GUI 线程被占用导致事件无法及时分发。 |
| 代码实验 | 能独立加入实验、观察现象、恢复代码并重新编译。 |
| 构建排错 | 能根据 undefined reference 检查声明、定义、connect 和 moc 相关代码的一致性。 |
| 表达 | 能够使用 QObject 父子关系、生命周期、GUI 线程和事件循环等术语。 |
第五部分 闯关题参考答案
使用方法 先合上答案独立说明原因,再对照专业表达。你已经完成过全部关卡,复习时不需要逐字背诵。
第 1 题 局部指针只是保存地址的变量。它离开作用域时地址记录消失,但指向的堆对象不会因此自动 delete。QObject 是否销毁还取决于父对象、显式 delete 或其他生命周期管理。
第 2 题 QLineEdit 继承 QWidget,QWidget 又继承 QObject;MainWindow 也是 QWidget,因此可以作为 QLineEdit 的 QObject 父对象。int 不是 QObject,不进入对象树。
第 3 题 MainWindow 是五个控件的父对象,父对象析构时自动销毁子对象。对象父子关系管理实例的生命周期;类继承描述类型能力,两者不是同一个关系。
第 4 题 MainWindow window 是栈对象,离开 main 的作用域时由 C++ 自动析构,不需要 delete。MainWindow 析构过程中,它仍拥有的 QObject 子对象被自动销毁。
第 5 题 函数结束时 temporary 局部指针消失,QLineEdit 仍由 window 管理。若对象没有 parent,指针又丢失,就无法正常释放和访问,形成内存泄漏。
第 6 题 addLayout 让 mainLayout 接管 formLayout,mainLayout 又属于 MainWindow,所以最终统一清理。若布局既无 parent 又未被其他布局接管,也没有手动 delete,就会泄漏。
第 7 题 show 只提出显示窗口的要求。没有 app.exec 运行事件循环,main 很快结束,window 随即析构,而且点击和重绘事件也无法持续分发。
第 8 题 按钮和 MainWindow 在同一 GUI 线程。clicked 触发 addDevice 后,槽函数要先执行完,事件循环才能处理下一项事件。connect 只建立通知关系,不会自动创建线程。
第 9 题 return 结束当前 addDevice 调用,然后控制权回到事件循环。while 循环持续占用 GUI 线程,点击和重绘事件只能排队,所以窗口暂时无响应。
第 10 题 第一条 setText 改变了标签内容,但重绘事件尚未得到处理,紧接着的循环又占住 GUI 线程,因此它可能看不到。循环结束后设置最终文字,槽函数返回,事件循环恢复并完成重绘。删除循环后,槽函数迅速返回,界面恢复正常。
第二周最终复述模板
MainWindow 是 main 函数中的栈对象,app.exec 返回后,main 到达作用域末尾,window 按 C++ 局部对象规则开始析构。输入框、按钮、表格和标签最终继承 QObject,并把 MainWindow 设置为父对象,因此会在父对象的析构过程中被清理。指针变量的作用域与对象生命周期必须分开判断。app.exec 启动 GUI 线程的事件循环,事件循环分发点击和重绘等事件。本项目的按钮槽函数在 GUI 线程执行,只有槽函数返回后才能继续处理后续事件。长循环持续占用 GUI 线程,因此事件无法及时分发,窗口表现为卡住。恢复实验代码时,还必须保持头文件声明、cpp 定义和 connect 一致。
完成标准 合上文档后能够独立复述这段内容,画出对象树,解释阻塞实验,并从实验版恢复正常项目;遇到 undefined reference 时能先检查声明和定义。
三分钟复习顺序
- 第一分钟:画出 MainWindow、控件和两层布局的所有权关系。
- 第二分钟:从鼠标事件讲到 clicked、槽函数、返回事件循环和重绘。
- 第三分钟:解释无 parent 的内存泄漏,以及 blockGui 声明与定义不一致为什么会产生链接错误。