Day 4

2D 图形绘制 / 多线程 / 动画框架与状态机

C++/Qt 方向实训 · 第 4 / 5 天 · 讲:练 ≈ 1:2.7

  • 模块 A(3 课时):2D 绘图 / 多线程
  • 模块 B(3 课时):动画框架 / 状态机 → 交互式小游戏案例

模块 A · 讲授 35min

2D 绘图 / 多线程

QPainter:由 Qt 驱动的重绘模型

核心概念

  • Qt 的绘制是"框架驱动"而非"你想画就画":所有绘制只能发生在 paintEvent 里,由 Qt 在需要重绘时(窗口刚显示、被遮挡后重新露出、你主动请求)自动调用——这和直接式 绘图(想画就立刻画到屏幕)是不同的模型。
  • 控件想主动触发重绘要调用 update()(异步,排队到下次事件循环处理)而不是 直接调用 paintEvent()QPainter 只是"在哪个设备上画"的画笔, 画布可以是控件本身,也可以是 QImage/QPixmap 等离屏设备。

常见的坑

  • paintEvent 之外的地方直接 new QPainter(this) 画东西: 要么无效要么未定义行为,绘制必须在 paintEvent 回调期间进行。
  • 数据变了却忘记调用 update():界面不会自动感知"要重画了",看起来像是 "画错了",其实是"根本没重画"。

绘图:QPainter


void paintEvent(QPaintEvent*) override {
    QPainter painter(this);
    painter.drawLine(QLine(10, 10, 200, 10));
    painter.drawRect(QRect(10, 30, 100, 50));
    painter.drawEllipse(QRect(150, 30, 100, 80));
}
    

所有绘制都发生在 paintEvent 里,由 Qt 在需要重绘时自动调用。 出处:p03/ch10/qpainter_basic.cpp;绘图设备 (QImage/QPixmap/QPicture)见 paint_device_qimage.cpppaint_device_pixmaps.cpp

QThread:线程亲和性与 worker-object

核心概念

  • "线程亲和性"(thread affinity):每个 QObject 都归属于创建它/被 moveToThread 移动到的那个线程,它的槽函数默认在所属线程执行——GUI 控件必须 始终归属主线程,这是 Qt 线程模型的核心约束。
  • 官方推荐的是 worker-object 模式(把耗时逻辑放进一个普通 QObject, moveToThread 到工作线程),而不是继承 QThread 重写 run()——前者能继续用信号槽通信,后者容易把线程亲和性搞混。
  • 跨线程的信号槽连接会自动变成 Queued(回顾 Day2),结果通过事件循环 安全地"带回"接收者所在线程,不需要手动加锁。

常见的坑

  • 在工作线程里直接操作 GUI 控件(比如直接 label->setText()):Qt 的 GUI 模块不是线程安全的,这是未定义行为/崩溃的常见来源——正确做法永远是发信号,排队回主线程处理。
  • moveToThread 要在 connectthread.start() 之前完成;对象一旦有耗时任务在跑,中途再挪线程会有竞态。

QThread 多线程(教学补充)


class Worker : public QObject {
    Q_OBJECT
public slots:
    void process() {
        qint64 sum = 0;
        for (int i = 1; i <= 1000000; ++i) sum += i;
        emit resultReady(sum);   // 排队连接自动把结果带回主线程
    }
signals:
    void resultReady(qint64 result);
};
worker.moveToThread(&workerThread);   // 官方推荐写法:worker-object + moveToThread
connect(&workerThread, &QThread::started, &worker, &Worker::process);
    

出处:docs/teaching/examples/qthread_worker/qt_thread_worker.cpp配套 README)—— 仓库 p03/ 的 wiki 抓取内容没有多线程,这是本轮补写的教学补充示例,已验证跑通。

模块 A · 练习 95min

动手:绘图 + 多线程

任务卡 1 · 画一个简单图形界面

目标
仿 qpainter_basic.cpp,在 paintEvent 里画一个组合图形 (如棋盘格/仪表盘刻度)
验收标准
编译通过,离屏或 X 环境下能看到图形正确绘制(可截图验证)
基础知识 · 参考
p03/ch10/qpainter_basic.cpp

任务卡 2 · 感受多线程不卡界面

目标
qt_thread_worker.cpp 里的耗时计算换成一个更长的循环或 QThread::msleep,观察主线程/事件循环没有被卡住
验收标准
能说清楚"如果把这段计算直接放主线程会发生什么"(界面无响应),并解释 moveToThread 起的作用
基础知识 · 参考
qt_thread_worker.cpp · README

模块 B · 讲授 35min

动画框架 / 状态机

QPropertyAnimation:动画建立在属性系统上

核心概念

  • Qt 的属性系统(Q_PROPERTY,同样靠 Day2 讲过的 moc 生成)给每个属性配了 统一的 get/set 接口;动画框架的本质就是"在一段时间内,用缓动曲线不断调用某个属性的 setter",所以任何声明成 Q_PROPERTY 的属性(不只是内置的 geometry/opacity) 都能被动画驱动。
  • QEasingCurve 决定的是"数值随时间变化的曲线形状"(线性/回弹/加减速……), 动画本身只是"定时器 + 插值 + 调 setter"的组合,理解这点能解释为什么几乎所有属性都能做动画。

常见的坑

  • 要动画化的属性必须是真正的 Q_PROPERTY(有 setter),随手传一个字符串 属性名如果对应类没有声明该属性,动画不会报错,只是什么也不会发生。
  • 动画作用的对象在动画播放完成前被销毁会导致悬空访问; QAbstractAnimation::DeleteWhenStopped 能让动画结束后自动释放自身,但不管 目标对象的生命周期,目标对象仍需自己保证不提前销毁。

QPropertyAnimation 动画(教学补充)


auto* anim = new QPropertyAnimation(button, "geometry", &window);
anim->setDuration(800);
anim->setStartValue(QRect(10, 10, 100, 30));
anim->setEndValue(QRect(180, 140, 120, 40));
anim->setEasingCurve(QEasingCurve::OutBounce);
anim->start(QAbstractAnimation::DeleteWhenStopped);
    

出处:docs/teaching/examples/property_animation/qt_property_animation.cpp配套 README

QStateMachine:状态机建立在信号槽上

核心概念

  • 状态机解决的是"同一个控件在不同状态下行为/外观不同"的问题——与其用一堆 bool 标志位加 if-else 判断当前处于什么状态,不如让"状态"本身成为一等对象 (QState),每个状态自己声明"进入这个状态要怎样"(assignProperty, 又用到了 Qt 属性系统)。
  • 状态之间的切换(addTransition)本质是"信号触发的转移"——转移条件就是 一个信号,这让状态机和信号槽自然衔接:任何能发信号的事件都能驱动状态切换。

常见的坑

  • 忘记 machine.setInitialState(...) 或忘记调用 machine.start(): 状态机对象存在但从未真正运行,所有 assignProperty/addTransition 都不会生效。
  • 某个状态没有覆盖所有可能触发的信号/条件,会导致状态机"卡住"——设计状态机前先把所有 状态和转移条件画成图,比直接写代码更不容易漏。

QStateMachine 状态机(教学补充)


auto* redState = new QState(&machine);
auto* greenState = new QState(&machine);
redState->assignProperty(&button, "text", QStringLiteral("红灯:禁止通行"));
greenState->assignProperty(&button, "text", QStringLiteral("绿灯:允许通行"));
redState->addTransition(&button, &QPushButton::clicked, greenState);
greenState->addTransition(&button, &QPushButton::clicked, redState);
machine.setInitialState(redState);
machine.start();
    

出处:docs/teaching/examples/state_machine/qt_state_machine.cpp配套 README)—— QStateMachine/QState 属于 QtCore,不需要额外模块。以上两个 (动画、状态机)都是仓库 p03/ 原本没有的教学补充示例,已离屏验证跑通。

模块 B · 练习 95min(交互式小游戏案例,练习任务,选做,非预建方案)

动手:动画 + 状态机小游戏

任务卡 1 · 动画 + 状态机组合

目标
把动画和状态机组合到同一个窗口——例如按钮在"红灯"状态时禁止移动,"绿灯"状态时 点击才触发动画
验收标准
状态切换正确(红灯不触发动画,绿灯触发动画);动画播放完整不报错
基础知识 · 参考
property_animation/ · state_machine/

任务卡 2(选做)· 简易反应力小游戏

目标
结合任务卡 1 + 鼠标事件(Day3 mouse_event.cpp)+ 多线程计时 (Day4 qt_thread_worker.cpp),做一个"状态变化后限时点击"的最小交互游戏
验收标准
游戏至少有"开始→进行中→结束"三个可辨识状态,计时或计分逻辑不崩溃
基础知识 · 参考
上述三个教学补充示例 + mouse_event.cpp

当日产出验证

现场演示

QT_QPA_PLATFORM=offscreen tools/run_qt.sh <target>
# 有 X 环境:去掉 QT_QPA_PLATFORM=offscreen 直接看窗口

分组演示动画+状态机组合小工具(任务卡 2 选做,量力而行)。

返回目录 · ← 上一天 · 下一天 →