教学示例补 5 个:3 个坑点复现 + QTableWidget + Day5 学员管理器参考实现
- pitfall_stack_widget / pitfall_lambda_capture / pitfall_double_connect: 默认跑正确写法(离屏验证退出码 0),--crash / --dangle 开关课堂现场触发 真实崩溃/悬空访问 - table_widget:QTableWidget 单元格事件最小示例(新安排 Day4「表格事件」) - student_manager:Day5 综合案例参考实现,离屏自测覆盖 JSON round-trip 与 QThread 后台导入闭环 - 均接入 examples/CMakeLists.txt 的 add_teaching_example,手工维护区不影响生成器
This commit is contained in:
@@ -19,10 +19,24 @@ add_teaching_example(qt_thread_worker qthread_worker)
|
||||
add_teaching_example(qt_property_animation property_animation)
|
||||
add_teaching_example(qt_state_machine state_machine)
|
||||
|
||||
# 坑点最小复现示例(OUTLINE_4DAY.md §9.2):默认跑「正确写法」离屏可验证退出码 0,
|
||||
# 错误写法一律用命令行开关现场触发(--crash / --dangle),不影响自动化验证。
|
||||
add_teaching_example(qt_pitfall_stack_widget pitfall_stack_widget)
|
||||
add_teaching_example(qt_pitfall_lambda_capture pitfall_lambda_capture)
|
||||
add_teaching_example(qt_pitfall_double_connect pitfall_double_connect)
|
||||
add_teaching_example(qt_table_widget_basics table_widget)
|
||||
# Day5 综合案例参考实现(OUTLINE_4DAY.md §3 / §9.3)
|
||||
add_teaching_example(qt_student_manager student_manager)
|
||||
|
||||
add_custom_target(all_teaching_examples DEPENDS
|
||||
qt_qss_styling
|
||||
qt_model_view_basics
|
||||
qt_json_parse_write
|
||||
qt_thread_worker
|
||||
qt_property_animation
|
||||
qt_state_machine)
|
||||
qt_state_machine
|
||||
qt_pitfall_stack_widget
|
||||
qt_pitfall_lambda_capture
|
||||
qt_pitfall_double_connect
|
||||
qt_table_widget_basics
|
||||
qt_student_manager)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 坑点复现:重复 connect 导致槽触发多次 —— 教学补充示例
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午 ★坑卡
|
||||
> 「重复 connect 导致槽触发多次」。手工维护,非生成产物。
|
||||
|
||||
## 概念
|
||||
|
||||
`connect` 每调用一次就**新增一条**连接——Qt 不会去重。同一对信号槽连了两次,
|
||||
信号一发槽就被调两次。典型事故现场:`connect` 写在会被多次执行的路径上
|
||||
(每次打开对话框/每次刷新界面都跑一遍的「初始化」函数)。
|
||||
|
||||
现象非常有迷惑性:「点一次按钮弹两次框」——学员的第一反应是查按钮、查事件,
|
||||
而根源在连接次数。
|
||||
|
||||
## 两种修复
|
||||
|
||||
1. **把 connect 收拢到只执行一次的地方**(构造函数)——首选,从结构上杜绝;
|
||||
2. `Qt::UniqueConnection`——同一对(发送者,信号,接收者,槽)只允许一条连接。
|
||||
**注意它只对成员函数指针有效**:lambda 没有可比较的身份,UniqueConnection
|
||||
对 lambda 连接不起作用(这是它常被误用的点)。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_pitfall_double_connect # 确定性输出:2 次 → 1 次,退出码 0
|
||||
```
|
||||
|
||||
## 练习方向(供任务卡引用)
|
||||
|
||||
- 把 `Popup::showOnce` 换成 lambda 再加 `Qt::UniqueConnection`,验证「lambda
|
||||
防不了重」;
|
||||
- 在 `showOnce` 里打印 `sender()`,观察两次调用来自同一发送者的两条连接。
|
||||
|
||||
## 参考
|
||||
|
||||
- 本仓库对照:[`p03/ch04/custom_signal_slot.cpp`](../../../../p03/ch04/custom_signal_slot.cpp)
|
||||
- Qt 官方文档:`QObject::connect`(`Qt::ConnectionType` 一节,选读·需联网)
|
||||
@@ -0,0 +1,69 @@
|
||||
// ============================================================================
|
||||
// qt_pitfall_double_connect —— 坑点最小复现:重复 connect 导致槽触发多次
|
||||
// 对应教案:OUTLINE_4DAY.md Day2 下午 ★坑卡「重复 connect 导致槽触发多次」
|
||||
// 配套文档:docs/teaching/examples/pitfall_double_connect/README.md
|
||||
//
|
||||
// 现象还原:「点一次按钮弹两次框」——根源不在按钮,在 connect 被执行了两次。
|
||||
// 同时演示 Qt::UniqueConnection 防重(只对成员函数指针有效,lambda 用不了)。
|
||||
// 确定性输出,无 UB,离屏/终端直接跑,退出码 0。
|
||||
// ============================================================================
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QObject>
|
||||
|
||||
class Button : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
signals:
|
||||
void clicked();
|
||||
};
|
||||
|
||||
class Popup : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
int shownCount = 0;
|
||||
public slots:
|
||||
void showOnce() {
|
||||
++shownCount;
|
||||
qInfo() << " 弹框!(第" << shownCount << "次)";
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
Button btn;
|
||||
Popup popup;
|
||||
|
||||
// 典型事故现场:connect 写在会被反复执行的函数里(比如每次打开某个界面
|
||||
// 都调一次 setup()),每调一次就多一条连接——信号一发,槽被调 N 次。
|
||||
auto setup = [&]() { QObject::connect(&btn, &Button::clicked, &popup, &Popup::showOnce); };
|
||||
setup();
|
||||
setup(); // 第二次“初始化”——现实里往往藏在另一条调用路径上
|
||||
|
||||
qInfo() << "重复 connect 两次后,点一次按钮:";
|
||||
emit btn.clicked();
|
||||
qInfo() << "槽被调用了" << popup.shownCount << "次(预期演示值:2)";
|
||||
|
||||
// 修复一:连接前先断开旧连接
|
||||
QObject::disconnect(&btn, &Button::clicked, &popup, &Popup::showOnce);
|
||||
QObject::disconnect(&btn, &Button::clicked, &popup, &Popup::showOnce);
|
||||
|
||||
// 修复二:Qt::UniqueConnection——同一对(发送者,信号,接收者,槽)只允许一条连接。
|
||||
// 注意它只认成员函数指针;lambda 没有身份,UniqueConnection 对 lambda 无效。
|
||||
popup.shownCount = 0;
|
||||
QObject::connect(&btn, &Button::clicked, &popup, &Popup::showOnce, Qt::UniqueConnection);
|
||||
QObject::connect(&btn, &Button::clicked, &popup, &Popup::showOnce, Qt::UniqueConnection);
|
||||
|
||||
qInfo() << "改用 Qt::UniqueConnection 连两次后,点一次按钮:";
|
||||
emit btn.clicked();
|
||||
qInfo() << "槽被调用了" << popup.shownCount << "次(预期演示值:1)";
|
||||
|
||||
qInfo() << "结论:connect 不要写在会被多次执行的路径上;防御手段是"
|
||||
"UniqueConnection(仅限成员函数槽)或把 connect 收拢到构造函数里只做一次。";
|
||||
return 0;
|
||||
}
|
||||
|
||||
#include "qt_pitfall_double_connect.moc"
|
||||
@@ -0,0 +1,40 @@
|
||||
# 坑点复现:lambda 槽的生命周期与 connect 第五参数 —— 教学补充示例
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午 ★坑卡
|
||||
> 「lambda 槽的生命周期与第五参数」。手工维护,非生成产物。
|
||||
|
||||
## 概念
|
||||
|
||||
`connect` 的第五参数(context object)决定这条连接**跟谁的生命周期挂钩**:
|
||||
|
||||
- `connect(sender, signal, lambda)`——连接只跟 sender 挂钩,**接收方对象销毁后
|
||||
lambda 仍会被调用**,捕获的指针就是悬空指针;
|
||||
- `connect(sender, signal, receiver, lambda)`——receiver 析构时 Qt 自动断开
|
||||
连接,lambda 不可能再访问已销毁的对象。
|
||||
|
||||
规则:**lambda 里捕获了某个 QObject,就把它作为第五参数传进去**。
|
||||
|
||||
## 运行结果解读
|
||||
|
||||
默认运行打印两次 emit 的对比:delete receiver 之后,只有「无 context」的连接
|
||||
还在触发。`--dangle` 让这条 lambda 真的去访问已销毁对象——你可能看到程序崩溃,
|
||||
也可能看到它**打印出看似正常的旧值**(刚释放的内存还没被复用)。后者更危险:
|
||||
UB 在开发机上「看起来正常」,到了演示/交付现场才炸。
|
||||
|
||||
## 验证与演示方式
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_pitfall_lambda_capture # 确定性对比输出,退出码 0
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_pitfall_lambda_capture --dangle # 课堂演示:真实悬空访问(UB)
|
||||
```
|
||||
|
||||
## 练习方向(供任务卡引用)
|
||||
|
||||
- 把连接 A 的 lambda 改成按引用捕获一个局部 `QString`,在函数返回后触发信号,
|
||||
分析这是哪一种悬空(对象悬空 vs 捕获变量悬空);
|
||||
- 用 `QObject::destroyed` 信号打印 receiver 的销毁时机,确认连接 B 断开发生在何时。
|
||||
|
||||
## 参考
|
||||
|
||||
- 本仓库对照:[`p03/ch04/lambda_signal_slot.cpp`](../../../../p03/ch04/lambda_signal_slot.cpp)(lambda 槽基本写法)
|
||||
- Qt 官方文档:*Differences between String-Based and Functor-Based Connections*(选读·需联网)
|
||||
@@ -0,0 +1,70 @@
|
||||
// ============================================================================
|
||||
// qt_pitfall_lambda_capture —— 坑点最小复现:lambda 槽的生命周期与第五参数
|
||||
// 对应教案:OUTLINE_4DAY.md Day2 下午 ★坑卡「lambda 槽的生命周期与第五参数」
|
||||
// 配套文档:docs/teaching/examples/pitfall_lambda_capture/README.md
|
||||
//
|
||||
// 默认运行:对比两条连接——不带 context 的 lambda 在接收者销毁后**仍会被调用**,
|
||||
// 带 context(connect 第五参数)的连接随接收者销毁自动断开。退出码 0。
|
||||
// 加 --dangle:不带 context 的 lambda 里真的去访问已销毁的接收者(悬空指针,
|
||||
// UB,课堂演示专用——可能打印垃圾值、也可能直接崩,两种结果都是教学素材)。
|
||||
// ============================================================================
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QObject>
|
||||
#include <cstring>
|
||||
|
||||
class Sender : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QObject::QObject;
|
||||
signals:
|
||||
void ping();
|
||||
};
|
||||
|
||||
class Receiver : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Receiver(QObject* parent = nullptr)
|
||||
: QObject(parent), m_name(QStringLiteral("接收者甲")) {}
|
||||
QString name() const { return m_name; }
|
||||
private:
|
||||
QString m_name;
|
||||
};
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
const bool dangle = (argc > 1 && std::strcmp(argv[1], "--dangle") == 0);
|
||||
|
||||
Sender sender;
|
||||
auto* receiver = new Receiver;
|
||||
|
||||
// 连接 A(错误示范):lambda 捕获了 receiver 指针,但没有传第五参数。
|
||||
// 这条连接的生存期只跟 sender 挂钩——receiver 死了它也不会断开。
|
||||
QObject::connect(&sender, &Sender::ping, [receiver, dangle]() {
|
||||
qInfo() << "[无 context] lambda 被调用";
|
||||
if (dangle) {
|
||||
// 悬空访问:第二次触发时 receiver 已被 delete —— UB
|
||||
qInfo() << " 访问 receiver->name() =" << receiver->name();
|
||||
}
|
||||
});
|
||||
|
||||
// 连接 B(正确写法):第五参数传 receiver 作为 context object,
|
||||
// receiver 析构时 Qt 自动断开这条连接,lambda 不可能再被调用。
|
||||
QObject::connect(&sender, &Sender::ping, receiver, [receiver]() {
|
||||
qInfo() << "[带 context] lambda 被调用,receiver->name() =" << receiver->name();
|
||||
});
|
||||
|
||||
qInfo() << "---- 第一次 emit(receiver 活着,两条连接都触发)----";
|
||||
emit sender.ping();
|
||||
|
||||
delete receiver;
|
||||
qInfo() << "---- delete receiver 之后,第二次 emit ----";
|
||||
emit sender.ping(); // 只有连接 A 还会触发——这就是悬空风险所在
|
||||
|
||||
qInfo() << "结论:lambda 槽捕获了对象指针,就必须把该对象作为 connect 的"
|
||||
"第五参数(context),让连接的生命周期跟着对象走。";
|
||||
return 0;
|
||||
}
|
||||
|
||||
#include "qt_pitfall_lambda_capture.moc"
|
||||
@@ -0,0 +1,45 @@
|
||||
# 坑点复现:栈对象与父子所有权打架(双重释放)—— 教学补充示例
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 上午 ★坑卡
|
||||
> 「栈对象与父子所有权打架」。手工维护,非 `tools/gen_part3.py` 生成产物。
|
||||
|
||||
## 概念
|
||||
|
||||
Qt 的父子树是一套**所有权**机制:parent 析构时会 `delete` 它的所有子对象。
|
||||
这隐含一个硬性前提——**交给父子树管的对象必须建在堆上**。一个对象只能有一个
|
||||
所有者:给了 parent,就不能再由栈作用域、智能指针或手动 `delete` 管理。
|
||||
|
||||
## 错在哪(`--crash` 演示的代码)
|
||||
|
||||
```cpp
|
||||
QPushButton btn("我建在栈上"); // 先声明 → 后析构
|
||||
QWidget window; // 后声明 → 先析构
|
||||
btn.setParent(&window);
|
||||
// 离开作用域:window 先析构 → 父子树 delete &btn(栈地址!)→
|
||||
// glibc 报 "free(): invalid pointer" 后 abort
|
||||
```
|
||||
|
||||
注意声明顺序反过来(parent 先声明)时这段代码**恰好不崩**——`btn` 先析构时会把
|
||||
自己从 parent 的子对象列表里摘除。「有时不崩」正是这类所有权错误难查的原因:
|
||||
不要依赖析构顺序的运气,规则只有一条——**给了 parent 就必须在堆上**。
|
||||
|
||||
## 验证与演示方式
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_pitfall_stack_widget # 正确写法,退出码 0
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_pitfall_stack_widget --crash # 课堂演示:现场崩给学员看
|
||||
```
|
||||
|
||||
`--crash` 必然非正常退出(core dump),**不要**放进自动化验证。
|
||||
|
||||
## 练习方向(供任务卡引用)
|
||||
|
||||
- 把错误版本的两行声明顺序对调,观察「恰好不崩」,再解释为什么它仍然是错的;
|
||||
- 同一误区的第三次出现:非模态对话框建在栈上一闪而过,见
|
||||
[`p03/ch06/modeless_dialog_stack.cpp`](../../../../p03/ch06/modeless_dialog_stack.cpp)
|
||||
与堆版/`WA_DeleteOnClose` 版的三方对照。
|
||||
|
||||
## 参考
|
||||
|
||||
- 本仓库对照:[`p03/ch03/button_creation.cpp`](../../../../p03/ch03/button_creation.cpp)(正确的 parent 用法)
|
||||
- 前置概念:Day1 ★坑卡「所有权心智模型:谁负责 delete」
|
||||
@@ -0,0 +1,62 @@
|
||||
// ============================================================================
|
||||
// qt_pitfall_stack_widget —— 坑点最小复现:栈对象与父子所有权打架(双重释放)
|
||||
// 对应教案:OUTLINE_4DAY.md Day2 上午 ★坑卡「栈对象与父子所有权打架」
|
||||
// 配套文档:docs/teaching/examples/pitfall_stack_widget/README.md
|
||||
//
|
||||
// 默认运行:正确写法(子控件建在堆上、交给 parent 管),离屏验证退出码 0。
|
||||
// 加 --crash:错误写法(带 parent 的控件建在栈上),亲眼看双重释放崩溃——
|
||||
// 课堂演示专用,退出码必然非 0,不要用于自动化验证。
|
||||
// ============================================================================
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
#include <cstring>
|
||||
|
||||
// 错误写法:child 声明在 parent 之前 → 作用域结束时 parent(后声明)先析构,
|
||||
// 父子树机制会 delete 它的所有子对象——包括这个根本不在堆上的 btn(UB,典型
|
||||
// 表现是 glibc 报 "free(): invalid pointer" 后 abort);随后 btn 自己的栈析构
|
||||
// 又会跑一遍。一个对象只能有一个所有者:交给了 Qt 父子树,就不能再由栈管理。
|
||||
static void wrongVersion() {
|
||||
QPushButton btn(QStringLiteral("我建在栈上")); // 先声明 → 后析构
|
||||
QWidget window; // 后声明 → 先析构
|
||||
btn.setParent(&window);
|
||||
// 离开作用域:window 析构 → delete &btn(栈地址!)→ 崩溃
|
||||
}
|
||||
|
||||
// 正确写法:子控件一律 new 在堆上、把 parent 传进去(或加进布局,布局会代为
|
||||
// 设置 parent),此后它的生命周期完全由父子树负责,不写任何 delete。
|
||||
static QWidget* correctVersion() {
|
||||
auto* window = new QWidget;
|
||||
auto* layout = new QVBoxLayout(window);
|
||||
layout->addWidget(new QLabel(QStringLiteral("子控件都在堆上,由父子树负责释放"), window));
|
||||
layout->addWidget(new QPushButton(QStringLiteral("确定"), window));
|
||||
return window; // window 析构时自动 delete 上面所有子对象
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
|
||||
if (argc > 1 && std::strcmp(argv[1], "--crash") == 0) {
|
||||
qInfo() << "演示错误写法:栈上控件交给父子树管……";
|
||||
wrongVersion(); // 走不到下一行就崩
|
||||
qInfo() << "(如果你看到这行,说明该平台的 UB 恰好没炸——它仍然是错的)";
|
||||
return 0;
|
||||
}
|
||||
|
||||
QWidget* window = correctVersion();
|
||||
window->setWindowTitle(QStringLiteral("正确写法:堆 + parent"));
|
||||
window->resize(320, 120);
|
||||
window->show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
int rc = app.exec();
|
||||
delete window; // 顶层窗口没有 parent,是唯一需要我们自己负责的对象
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# 学员信息管理器 —— Day5 综合案例参考实现
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) §3「Day5 综合项目案例讲解」。
|
||||
> 教师侧参考答案:把 Day1–4 作业线(计算器→主窗口化→JSON 落盘)收束成一个完整
|
||||
> 小软件。手工维护,非生成产物。
|
||||
|
||||
## 覆盖的知识点(讲解时逐条回指)
|
||||
|
||||
| 环节 | 知识点 | 首次出现 |
|
||||
|---|---|---|
|
||||
| 主窗口骨架 | QMainWindow 菜单/工具栏/状态栏/`setCentralWidget` | Day3 上午 |
|
||||
| 列表展示 | QTableWidget + item 所有权 | Day4 上午 |
|
||||
| 添加记录 | 模态 QDialog `exec()` + QFormLayout | Day3 上午/下午 |
|
||||
| 通信 | 信号槽(成员函数槽 + 带 context 的 lambda 槽) | Day2 |
|
||||
| 数据层 | Student ↔ QJson 互逆序列化,round-trip 校验 | Day4 下午 |
|
||||
| 路径 | `applicationDirPath()` 拼数据文件路径(不用相对路径) | Day4 ★坑卡 |
|
||||
| 健壮性 | `open()` 返回值、`QJsonParseError` 都检查 | Day4 坑卡 |
|
||||
| **提升** | QThread worker-object 模式后台导入,界面不冻结 | Day5 新授 |
|
||||
|
||||
QThread 三件套在 `startImport()` 里全部有注释标注:worker 不给 parent、
|
||||
`moveToThread` 在 `start()` 之前、跨线程信号→主线程槽自动 Queued(`addRow`
|
||||
安全地在 GUI 线程执行)。
|
||||
|
||||
## 验证方式(自测模式)
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_student_manager
|
||||
# 自测流程:添加 2 条 → 保存 JSON → 清空 → 读回校验 →
|
||||
# 后台线程导入 3 条 → 总数校验 5 条;全过退出码 0,任一步失败退出码 1
|
||||
tools/run_qt.sh qt_student_manager # 有 X 环境:完整交互(菜单/工具栏/对话框/后台导入)
|
||||
```
|
||||
|
||||
有显示环境时数据保存在可执行文件旁的 `students.json`(状态栏显示完整路径)。
|
||||
|
||||
## 课堂讲解建议(对应 OUTLINE_4DAY.md §3 时段表)
|
||||
|
||||
1. 上午先讲界面骨架与数据层(自顶向下:先跑成品,再拆 MainWindow → 数据层);
|
||||
2. 下午「后台导入」是提升重点:先把 `msleep` 挪进主线程按钮槽里演示界面冻结,
|
||||
再回到 worker 版本对比——「卡与不卡」亲眼见一次胜过讲十分钟;
|
||||
3. 每讲完一段,回指上表对应的 Day1–4 知识点,让学员意识到「项目 = 已学内容的组装」。
|
||||
|
||||
## 练习方向(供 Day6–10 项目热身)
|
||||
|
||||
- 加「删除选中行」(`currentRow` + `removeRow`);
|
||||
- 保存前若有未保存修改,关窗时弹确认框(`closeEvent` + `QMessageBox::question`);
|
||||
- 把成绩列改为按数值排序(提示:`QTableWidgetItem::setData(Qt::EditRole, double)`)。
|
||||
|
||||
## 参考
|
||||
|
||||
- 前身作业:[`../json_io/`](../json_io/README.md)(Day4 数据落盘)、
|
||||
[`../table_widget/`](../table_widget/README.md)(Day4 表格)、
|
||||
[`../qthread_worker/`](../qthread_worker/README.md)(worker 模式最小版)
|
||||
@@ -0,0 +1,284 @@
|
||||
// ============================================================================
|
||||
// qt_student_manager —— Day5 综合案例参考实现:学员信息管理器
|
||||
// 对应教案:OUTLINE_4DAY.md §3(Day5 综合项目案例讲解)——把 Day1–4 作业线
|
||||
// 收束成一个完整小软件:QMainWindow 骨架 + QTableWidget 列表 + 增改对话框 +
|
||||
// JSON 落盘读回 + QThread 后台导入不卡界面。
|
||||
// 配套文档:docs/teaching/examples/student_manager/README.md
|
||||
//
|
||||
// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_student_manager
|
||||
// 自测流程:添加 2 条记录 → 保存 JSON → 清空 → 读回校验 → 后台导入 3 条 →
|
||||
// 总数校验,全部通过退出码 0,任一步失败退出码 1。
|
||||
// ============================================================================
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFile>
|
||||
#include <QFormLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QLineEdit>
|
||||
#include <QMainWindow>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
#include <QTableWidget>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QToolBar>
|
||||
|
||||
// ---------------------------------------------------------------- 数据层 ----
|
||||
|
||||
struct Student {
|
||||
QString name;
|
||||
double score = 0.0;
|
||||
QString tag;
|
||||
};
|
||||
|
||||
// 序列化/反序列化是一对互逆操作(Day4):正确性标准 = 写出去再读回来数据一致。
|
||||
static QJsonObject toJson(const Student& s) {
|
||||
return QJsonObject{{"name", s.name}, {"score", s.score}, {"tag", s.tag}};
|
||||
}
|
||||
|
||||
static Student fromJson(const QJsonObject& obj) {
|
||||
// 类型不匹配时 toString()/toDouble() 不报错只给默认值(Day4 坑卡)——
|
||||
// 教学示例里字段固定,生产代码应先 contains()+isDouble() 逐字段校验。
|
||||
return Student{obj["name"].toString(), obj["score"].toDouble(), obj["tag"].toString()};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 添加记录对话框 ----
|
||||
|
||||
class AddStudentDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AddStudentDialog(QWidget* parent = nullptr) : QDialog(parent) {
|
||||
setWindowTitle(QStringLiteral("添加学员"));
|
||||
m_name = new QLineEdit(this);
|
||||
m_score = new QLineEdit(this);
|
||||
m_tag = new QLineEdit(this);
|
||||
|
||||
auto* buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto* form = new QFormLayout(this);
|
||||
form->addRow(QStringLiteral("姓名"), m_name);
|
||||
form->addRow(QStringLiteral("成绩"), m_score);
|
||||
form->addRow(QStringLiteral("标签"), m_tag);
|
||||
form->addRow(buttons);
|
||||
}
|
||||
|
||||
Student student() const {
|
||||
return Student{m_name->text(), m_score->text().toDouble(), m_tag->text()};
|
||||
}
|
||||
|
||||
private:
|
||||
QLineEdit* m_name;
|
||||
QLineEdit* m_score;
|
||||
QLineEdit* m_tag;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------ 后台导入 worker ----
|
||||
|
||||
// worker-object 模式(而非继承 QThread):耗时活儿放进 worker,把 worker
|
||||
// moveToThread 到工作线程;worker 与界面之间只通过信号槽通信——跨线程连接
|
||||
// 自动变 Queued,槽在接收者所属线程执行,天然不碰 GUI 线程以外的控件。
|
||||
class ImportWorker : public QObject {
|
||||
Q_OBJECT
|
||||
public slots:
|
||||
void doImport(int rows) {
|
||||
for (int i = 1; i <= rows; ++i) {
|
||||
QThread::msleep(20); // 模拟耗时 IO;放主线程就是界面冻结(Day4 埋点)
|
||||
emit rowReady(QStringLiteral("导入学员%1").arg(i), 60.0 + i, QStringLiteral("导入"));
|
||||
}
|
||||
emit finished();
|
||||
}
|
||||
signals:
|
||||
void rowReady(const QString& name, double score, const QString& tag);
|
||||
void finished();
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------- 主窗口 ----
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr) : QMainWindow(parent) {
|
||||
setWindowTitle(QStringLiteral("学员信息管理器"));
|
||||
|
||||
// QMainWindow 不是大号 QWidget:中心部件必须显式安放(Day3 坑卡)
|
||||
m_table = new QTableWidget(0, 3, this);
|
||||
m_table->setHorizontalHeaderLabels(
|
||||
{QStringLiteral("姓名"), QStringLiteral("成绩"), QStringLiteral("标签")});
|
||||
m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
setCentralWidget(m_table);
|
||||
|
||||
auto* actAdd = new QAction(QStringLiteral("添加记录"), this);
|
||||
auto* actSave = new QAction(QStringLiteral("保存"), this);
|
||||
auto* actLoad = new QAction(QStringLiteral("读取"), this);
|
||||
m_actImport = new QAction(QStringLiteral("后台导入"), this);
|
||||
auto* actAbout = new QAction(QStringLiteral("关于"), this);
|
||||
|
||||
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
|
||||
fileMenu->addAction(actSave);
|
||||
fileMenu->addAction(actLoad);
|
||||
QMenu* recordMenu = menuBar()->addMenu(QStringLiteral("记录(&R)"));
|
||||
recordMenu->addAction(actAdd);
|
||||
recordMenu->addAction(m_actImport);
|
||||
menuBar()->addMenu(QStringLiteral("帮助(&H)"))->addAction(actAbout);
|
||||
|
||||
QToolBar* bar = addToolBar(QStringLiteral("main"));
|
||||
bar->addAction(actAdd);
|
||||
bar->addAction(actSave);
|
||||
bar->addAction(actLoad);
|
||||
bar->addAction(m_actImport);
|
||||
|
||||
// QAction 显示出来 ≠ 生效:忘 connect 就是「菜单点了没反应」(Day3 坑卡)
|
||||
connect(actAdd, &QAction::triggered, this, &MainWindow::onAdd);
|
||||
connect(actSave, &QAction::triggered, this, [this] { saveTo(m_dataPath); });
|
||||
connect(actLoad, &QAction::triggered, this, [this] { loadFrom(m_dataPath); });
|
||||
connect(m_actImport, &QAction::triggered, this, &MainWindow::onImport);
|
||||
connect(actAbout, &QAction::triggered, this, [this] {
|
||||
QMessageBox::about(this, QStringLiteral("关于"),
|
||||
QStringLiteral("Day5 综合案例参考实现:Day1–4 全部知识点的收束。"));
|
||||
});
|
||||
|
||||
// 相对路径随工作目录漂移(Day4 坑卡)——一律基于 applicationDirPath 拼路径
|
||||
m_dataPath = QCoreApplication::applicationDirPath() + "/students.json";
|
||||
statusBar()->showMessage(QStringLiteral("数据文件:") + m_dataPath);
|
||||
}
|
||||
|
||||
int rowCount() const { return m_table->rowCount(); }
|
||||
void clearAll() { m_table->setRowCount(0); }
|
||||
|
||||
void addRow(const Student& s) {
|
||||
const int row = m_table->rowCount();
|
||||
m_table->insertRow(row);
|
||||
m_table->setItem(row, 0, new QTableWidgetItem(s.name));
|
||||
m_table->setItem(row, 1, new QTableWidgetItem(QString::number(s.score)));
|
||||
m_table->setItem(row, 2, new QTableWidgetItem(s.tag));
|
||||
}
|
||||
|
||||
bool saveTo(const QString& path) {
|
||||
QJsonArray arr;
|
||||
for (int row = 0; row < m_table->rowCount(); ++row) {
|
||||
arr.append(toJson(Student{m_table->item(row, 0)->text(),
|
||||
m_table->item(row, 1)->text().toDouble(),
|
||||
m_table->item(row, 2)->text()}));
|
||||
}
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly)) { // open() 必须检查(Day4 坑卡)
|
||||
statusBar()->showMessage(QStringLiteral("保存失败:打不开 ") + path);
|
||||
return false;
|
||||
}
|
||||
file.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
|
||||
statusBar()->showMessage(QStringLiteral("已保存 %1 条记录").arg(arr.size()));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool loadFrom(const QString& path) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
statusBar()->showMessage(QStringLiteral("读取失败:打不开 ") + path);
|
||||
return false;
|
||||
}
|
||||
QJsonParseError err; // 不检查 ParseError = 坏文件静默变空表(Day4 坑卡)
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isArray()) {
|
||||
statusBar()->showMessage(QStringLiteral("JSON 解析失败:") + err.errorString());
|
||||
return false;
|
||||
}
|
||||
clearAll();
|
||||
const QJsonArray arr = doc.array();
|
||||
for (const QJsonValue& v : arr) addRow(fromJson(v.toObject()));
|
||||
statusBar()->showMessage(QStringLiteral("已读取 %1 条记录").arg(arr.size()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void startImport(int rows) {
|
||||
m_actImport->setEnabled(false); // 防重复启动(也防重复 connect,Day2 坑卡)
|
||||
auto* thread = new QThread(this);
|
||||
auto* worker = new ImportWorker; // 不能给 parent:有 parent 的对象禁止 moveToThread
|
||||
worker->moveToThread(thread); // 必须在 start() 之前(Day5 讲授点)
|
||||
|
||||
connect(thread, &QThread::started, worker, [worker, rows] { worker->doImport(rows); });
|
||||
// 跨线程信号 → 主线程槽:自动 Queued,addRow 在 GUI 线程安全执行
|
||||
connect(worker, &ImportWorker::rowReady, this,
|
||||
[this](const QString& name, double score, const QString& tag) {
|
||||
addRow(Student{name, score, tag});
|
||||
});
|
||||
connect(worker, &ImportWorker::finished, this, [this] {
|
||||
m_actImport->setEnabled(true);
|
||||
statusBar()->showMessage(QStringLiteral("后台导入完成"));
|
||||
emit importFinished();
|
||||
});
|
||||
connect(worker, &ImportWorker::finished, thread, &QThread::quit);
|
||||
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
|
||||
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
|
||||
thread->start();
|
||||
}
|
||||
|
||||
signals:
|
||||
void importFinished();
|
||||
|
||||
private slots:
|
||||
void onAdd() {
|
||||
AddStudentDialog dlg(this); // 模态:exec() 阻塞等结果(Day3)
|
||||
if (dlg.exec() == QDialog::Accepted) addRow(dlg.student());
|
||||
}
|
||||
void onImport() { startImport(10); }
|
||||
|
||||
private:
|
||||
QTableWidget* m_table = nullptr;
|
||||
QAction* m_actImport = nullptr;
|
||||
QString m_dataPath;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ 自测入口 ----
|
||||
|
||||
// 离屏自测:走一遍「添加→保存→清空→读回→后台导入」完整闭环并校验。
|
||||
static void runSelfTest(MainWindow* win) {
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/students_selftest.json";
|
||||
win->addRow({QStringLiteral("张三"), 91.5, QStringLiteral("组长")});
|
||||
win->addRow({QStringLiteral("李四"), 88.0, QStringLiteral("美工")});
|
||||
if (!win->saveTo(path)) { QCoreApplication::exit(1); return; }
|
||||
win->clearAll();
|
||||
if (!win->loadFrom(path) || win->rowCount() != 2) {
|
||||
qCritical() << "自测失败:JSON 读回记录数 =" << win->rowCount() << "预期 2";
|
||||
QCoreApplication::exit(1);
|
||||
return;
|
||||
}
|
||||
qInfo() << "自测 1/2 通过:JSON 写入→清空→读回,2 条记录完整";
|
||||
|
||||
QObject::connect(win, &MainWindow::importFinished, win, [win, path] {
|
||||
QFile::remove(path);
|
||||
if (win->rowCount() != 5) {
|
||||
qCritical() << "自测失败:导入后总数 =" << win->rowCount() << "预期 5";
|
||||
QCoreApplication::exit(1);
|
||||
return;
|
||||
}
|
||||
qInfo() << "自测 2/2 通过:后台线程导入 3 条,总计 5 条,界面未阻塞";
|
||||
QCoreApplication::exit(0);
|
||||
});
|
||||
win->startImport(3);
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
MainWindow win;
|
||||
win.resize(560, 360);
|
||||
win.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &win, [&win] { runSelfTest(&win); });
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "qt_student_manager.moc"
|
||||
@@ -0,0 +1,44 @@
|
||||
# QTableWidget 表格与单元格事件 —— 教学补充示例
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day4 上午「表格」环节
|
||||
> (新版 10 天安排原文「绘图事件及表格事件」)。手工维护,非生成产物。
|
||||
|
||||
## 概念
|
||||
|
||||
Qt 展示表格数据有两条路线:
|
||||
|
||||
- **item 类控件(本示例)**:`QTableWidget`——数据直接塞进控件
|
||||
(`QTableWidgetItem`),上手快,中小项目够用;
|
||||
- **Model/View 架构(延伸钩子)**:数据(model)与展示(view)彻底分离,
|
||||
同一模型可喂多个视图,见 [`../model_view/`](../model_view/README.md)。
|
||||
|
||||
「表格事件」以**信号**形式暴露:`cellClicked` / `cellDoubleClicked` /
|
||||
`itemChanged`(编辑单元格后触发)……常规交互不需要重写 `event` 函数。
|
||||
|
||||
## 代码走读
|
||||
|
||||
见 [`qt_table_widget_basics.cpp`](qt_table_widget_basics.cpp):
|
||||
|
||||
1. 构造 3×3 表格,`setHorizontalHeaderLabels` 设列头,列宽 `Stretch` 自适应;
|
||||
2. 每个单元格一个 `QTableWidgetItem`,`new` 出来交给表格接管所有权
|
||||
(又是父子所有权模型——第几次出现了?);
|
||||
3. `cellClicked` 连成员函数槽、`itemChanged` 连 lambda(注意第五参数传了
|
||||
`this`,呼应 Day2 坑卡),点击/编辑结果显示在下方 `QLabel`。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_table_widget_basics # 离屏跑通,200ms 自动退出
|
||||
tools/run_qt.sh qt_table_widget_basics # 有 X 环境:点击/编辑单元格看状态栏变化
|
||||
```
|
||||
|
||||
## 练习方向(供任务卡引用)
|
||||
|
||||
- 加一个「添加行」按钮:`insertRow` + 三个新 item;
|
||||
- 双击某行弹 `QMessageBox` 显示整行内容(`cellDoubleClicked`);
|
||||
- 进阶:改用 `../model_view/` 的做法重做本例,体会两条路线的差异。
|
||||
|
||||
## 参考
|
||||
|
||||
- 本仓库对照:[`../model_view/qt_model_view_basics.cpp`](../model_view/qt_model_view_basics.cpp)
|
||||
- 综合运用:[`../student_manager/`](../student_manager/README.md)(Day5 案例的核心控件就是它)
|
||||
@@ -0,0 +1,80 @@
|
||||
// ============================================================================
|
||||
// qt_table_widget_basics —— 教学补充示例:QTableWidget 表格与单元格事件
|
||||
// 对应教案:OUTLINE_4DAY.md Day4 上午「表格」环节(新安排原文「表格事件」)
|
||||
// 配套文档:docs/teaching/examples/table_widget/README.md
|
||||
// 离屏验证:QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_table_widget_basics
|
||||
//
|
||||
// QTableWidget 是「item 类」控件:数据直接塞进控件里(QTableWidgetItem),
|
||||
// 上手快、够中小项目用;数据/展示彻底分离的完整 Model/View 架构见
|
||||
// ../model_view/qt_model_view_basics.cpp(延伸钩子)。
|
||||
// ============================================================================
|
||||
|
||||
#include <QApplication>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QTableWidget>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
class TableDemo : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TableDemo(QWidget* parent = nullptr) : QWidget(parent) {
|
||||
m_table = new QTableWidget(3, 3, this); // 行列数在构造时给定
|
||||
m_table->setHorizontalHeaderLabels(
|
||||
{QStringLiteral("姓名"), QStringLiteral("成绩"), QStringLiteral("标签")});
|
||||
m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||
|
||||
const char* names[] = {"张三", "李四", "王五"};
|
||||
const char* scores[] = {"91.5", "88", "76"};
|
||||
const char* tags[] = {"组长", "美工", "测试"};
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
// 每个单元格一个 QTableWidgetItem,new 出来交给表格接管所有权
|
||||
m_table->setItem(row, 0, new QTableWidgetItem(QString::fromUtf8(names[row])));
|
||||
m_table->setItem(row, 1, new QTableWidgetItem(QString::fromUtf8(scores[row])));
|
||||
m_table->setItem(row, 2, new QTableWidgetItem(QString::fromUtf8(tags[row])));
|
||||
}
|
||||
|
||||
m_status = new QLabel(QStringLiteral("点击任意单元格试试"), this);
|
||||
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
layout->addWidget(m_table);
|
||||
layout->addWidget(m_status);
|
||||
|
||||
// 「表格事件」以信号形式暴露:cellClicked / cellDoubleClicked /
|
||||
// itemChanged(编辑单元格后触发)…… 不需要重写 event 函数。
|
||||
connect(m_table, &QTableWidget::cellClicked, this, &TableDemo::onCellClicked);
|
||||
connect(m_table, &QTableWidget::itemChanged, this, [this](QTableWidgetItem* item) {
|
||||
m_status->setText(QStringLiteral("(%1,%2) 被改为 %3")
|
||||
.arg(item->row()).arg(item->column()).arg(item->text()));
|
||||
});
|
||||
}
|
||||
|
||||
private slots:
|
||||
void onCellClicked(int row, int column) {
|
||||
QTableWidgetItem* item = m_table->item(row, column);
|
||||
m_status->setText(QStringLiteral("点击了 (%1,%2):%3")
|
||||
.arg(row).arg(column)
|
||||
.arg(item ? item->text() : QStringLiteral("<空>")));
|
||||
}
|
||||
|
||||
private:
|
||||
QTableWidget* m_table = nullptr;
|
||||
QLabel* m_status = nullptr;
|
||||
};
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
TableDemo demo;
|
||||
demo.setWindowTitle(QStringLiteral("QTableWidget 基础"));
|
||||
demo.resize(420, 240);
|
||||
demo.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "qt_table_widget_basics.moc"
|
||||
Reference in New Issue
Block a user