# Day 4 · 2D绘图/多线程/动画框架与状态机

# 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.cpp、 paint_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` 要在 `connect` 和 `thread.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 选做，量力而行）。

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