Day 24:模型-视图-控制器(Model-View-Controller, MVC)

C++/Qt 每日设计模式 · 架构模式阶段 · 2026-09-11

架构模式 Qt Model/View 分层解耦 可测试性

01

今日主题

一句话定义:MVC(Model-View-Controller)把应用程序切成三块互相分离的职责——Model 负责数据与业务规则,View 负责把数据画到屏幕上,Controller 负责接收用户输入并决定调用 Model 的哪个行为、让 View 如何更新。

它是 1979 年 Trygve Reenskaug 在 Xerox PARC 为 Smalltalk-80 提出的架构思想,也是现代几乎所有 GUI 框架(包括 Qt 的 Model/View)的地基。今天这篇不是再讲一个"类怎么写",而是讲一次架构级的职责切分:当你从"写页面"升级到"设计客户端架构"时,MVC 是你第一个必须想清楚的东西。

本篇定位:GoF 23 个经典模式已全部学完(Day 1–23)。从 Day 24 起进入架构模式阶段,路线为:MVC → MVVM → EventBus → Plugin → QUndo → StateMachine → Model-View 深入。MVC 是这条线里唯一"既是框架规范、又是设计思想"的一个,先把它的边界感建立起来。

02

为什么需要这个模式

先看反例。这是一个"能跑,但三个月后没人敢改"的 Qt 窗口类——数据、界面、业务逻辑、持久化全部挤在一个类里:

class TaskWindow : public QWidget {
    Q_OBJECT
public:
    TaskWindow(QWidget* parent = nullptr) : QWidget(parent) {
        auto* layout = new QVBoxLayout(this);
        list_   = new QListWidget(this);
        input_  = new QLineEdit(this);
        button_ = new QPushButton("添加", this);
        layout->addWidget(list_);
        layout->addWidget(input_);
        layout->addWidget(button_);

        // 事件处理、业务规则、数据存储、界面刷新全部混在这里
        connect(button_, &QPushButton::clicked, this, [this] {
            const QString text = input_->text().trimmed();
            if (text.isEmpty()) return;                 // 校验规则
            if (text.size() > 50) {                     // 业务规则
                QMessageBox::warning(this, "错误", "标题过长");
                return;
            }
            tasks_.push_back({text, false});            // 数据存储
            QSqlQuery q;                                // 又是持久化
            q.prepare("INSERT INTO tasks(title) VALUES(?)");
            q.addBindValue(text);
            q.exec();
            QListWidgetItem* item = new QListWidgetItem(text);  // 界面刷新
            item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
            item->setCheckState(Qt::Unchecked);
            list_->addItem(item);
            input_->clear();
            statusBar_->showMessage(QString("共 %1 条").arg(tasks_.size()));
        });
    }
private:
    QListWidget* list_;
    QLineEdit*   input_;
    QPushButton* button_;
    std::vector<Task> tasks_;
};

它的问题在哪?

MVC 就是对这一坨的正面回答:把"数据长什么样/怎么变"、"屏幕上长什么样"、"用户的动作翻译成什么操作"三件事,物理地拆到三个对象里去。

03

核心思想

通俗解释:把界面程序想象成一家餐厅。

关键点在于:三者之间只通过"稳定的接口"对话,而不是互相伸手进去改对方的内存。

数据流一句话概括:

用户输入 → Controller → 调用 Model 的行为 → Model 状态变化并发出通知 → View 收到通知重新渲染

注意:View 读取的是 Model 的数据,Controller 修改的是 Model 的状态。View 和 Controller 之间不互相持有业务数据,它们只在 Model 这一个"共同真理源"上汇合。

为什么"共同真理源"如此重要?因为一旦同一份数据同时存在两处(比如上面反例里的 tasks_ 和 QListWidget 里的条目),就必然出现"不同步"的 bug——删除时忘了删一边、排序后顺序错位、Undo 后对不上。MVC 用一句话消掉了整类 bug:数据只有一个权威副本,界面是它的投影。

04

UML / 角色关系

          ┌──────────────────────────────────────────────┐
          │                  用户输入                     │
          └───────────────────────┬──────────────────────┘
                                  │ 事件 / 动作
                                  ▼
   ┌────────────────────────┐          ┌──────────────────────────┐
   │      Controller        │          │          View            │
   │  输入翻译 + 调度        │          │  渲染 + 转发用户意图      │
   └───────────┬────────────┘          └────────────▲─────────────┘
               │  ① 调用 Model 的行为                │ ③ 通知变化后重新渲染
               │     (setValue / addTask ...)        │   (读取 data 拉取)
               ▼                                     │
   ┌───────────────────────────────────────────────────────────────┐
   │                          Model                                │
   │  数据 + 业务规则 + 状态通知(Observer / signal)                 │
   │  ✦ 不引用任何 View / Controller 的具体类型(只暴露抽象接口)      │
   └───────────────────────────────────────────────────────────────┘
               │  ② 状态变化 → 广播通知(不含任何界面细节)
               └──────────────────────────────►  View / Controller 订阅
角色核心职责允许知道什么绝对不能知道什么
Model 持有数据、实现业务规则(校验/计算/持久化)、状态变化时发出通知 自己的数据结构、业务不变量、通用的通知机制(信号/观察者列表) 任何按钮、窗口、颜色、字体;任何具体 View 类名
View 把 Model 或 ViewModel 的数据渲染成像素;把原始用户事件转成"语义意图" Model 的只读接口 / 通知信号;自己的控件 业务规则的实现细节(不该写 if (title.size() > 50))
Controller 把 View 的意图翻译成对 Model 的调用序列;决定"下一步给用户看什么" Model 的写接口、View 的回调接口 控件内部布局细节;数据的具体存储格式

依赖方向(这是最容易做错的一点):Controller 依赖 Model 和 View(它要调度两者);Model 不依赖任何人(它是被依赖方,只通过抽象的"通知"向外广播);View 依赖 Model 的只读接口。整条依赖链必须指向稳定的一端(Model),而不是指向易变的界面。很多"伪 MVC"失败的原因,就是 Model 里偷偷 #include <QMessageBox>——那一刻分层就崩了。

05

最小 C++ 示例(C++17 可编译)

下面是一个不依赖任何 GUI 框架的完整 MVC,用 std::function 做通知通道。它只有 80 行,但三层职责清晰可见。请重点体会:Model 里没有一行是给界面写代码。

// mvc_min.cpp  —  g++ -std=c++17 -Wall mvc_min.cpp -o mvc_min
#include <iostream>
#include <functional>
#include <string>
#include <vector>
#include <stdexcept>

// ================= Model:数据 + 业务规则 + 变更通知 =================
class CounterModel {
public:
    using Observer = std::function<void(int)>;   // 只传"值",不传任何界面类型

    void addObserver(Observer obs) { observers_.push_back(std::move(obs)); }

    // 业务动作:唯一合法的改状态入口
    void increment() { setValue(value_ + 1); }
    void decrement() { setValue(value_ - 1); }

    int value() const noexcept { return value_; }

private:
    void setValue(int v) {
        if (v == value_) return;          // 幂等:值没变就不通知,避免无谓刷新
        value_ = v;
        notify();
    }
    void notify() {
        for (const auto& obs : observers_) obs(value_);   // 广播,与界面无关
    }

    int value_{0};
    std::vector<Observer> observers_;
};

// ================= View:只负责渲染 + 暴露"用户意图"回调 =================
class ConsoleView {
public:
    // 视图对外暴露的语义意图(由 Controller 注入实现)
    std::function<void()> onIncrement;
    std::function<void()> onDecrement;

    void render(int value) const {                     // 纯渲染,无业务逻辑
        std::cout << "[View] 当前计数 = " << value << '\n';
    }

    // 模拟"用户点了按钮",把控件事件翻译成语义意图后交给外面
    void simulateClick(const std::string& btn) const {
        if (btn == "+" && onIncrement)      onIncrement();
        else if (btn == "-" && onDecrement) onDecrement();
    }
};

// ================= Controller:把 View 的意图接到 Model 的行为上 =================
class CounterController {
public:
    CounterController(CounterModel& model, ConsoleView& view)
        : model_(model), view_(view) {
        // ① Model -> View:状态变了就重新渲染
        model_.addObserver([this](int v) { view_.render(v); });
        // ② View -> Model:用户意图转发成模型行为
        view_.onIncrement = [this] { model_.increment(); };
        view_.onDecrement = [this] { model_.decrement(); };
    }

private:
    CounterModel& model_;   // 用引用表达"不拥有、只管调度"
    ConsoleView&  view_;
};

int main() {
    CounterModel     model;
    ConsoleView      view;
    CounterController controller(model, view);   // 接线(Wiring)

    view.render(model.value());       // 首次渲染初值
    view.simulateClick("+");          // 用户点击 "+"
    view.simulateClick("+");          // 再次点击 "+"
    view.simulateClick("-");          // 点击 "-"
    return 0;
}

程序实际输出:

[View] 当前计数 = 0
[View] 当前计数 = 1
[View] 当前计数 = 2
[View] 当前计数 = 1

这段代码做对了什么?①CounterModel 可以单独写单元测试,断言 increment() 两次后 value()==2,完全不需要界面;②换一个 QtView 或 WebView,Model 一行不改;③setValue 里的幂等判断把"值没变不刷新"这条性能规则收在 Model 内,任何 View 都自动受益,不会漏;④所有改状态的路径都必须经过 setValue,通知永不遗漏——这是 MVC 能"自动同步"的根本原因。

06

Qt 实战示例(Qt6 + C++17)

Qt 官方文档《Model/View Programming》里明确写道:Qt 的 Model/View 架构是经典 MVC 的一个变体——Qt 把 View 与 Controller 合并了(View 自己处理输入),并引入 Delegate(委托)来接管"如何绘制单个单元格 + 如何为单元格提供编辑器"。所以你在 Qt 里写 MVC,实际是写"Model + View(含输入) + Controller(调度)"。

下面用一个任务清单演示:TaskModel 是纯数据,TaskController 是调度层,QListView/QLineEdit 是视图。

文件 1:taskmodel.h(Model——注意它完全没有引用任何 View)

#pragma once
#include <QAbstractListModel>
#include <QString>
#include <QByteArray>
#include <vector>

// Model:只负责数据与业务规则,不认识任何 View
class TaskModel : public QAbstractListModel {
    Q_OBJECT
public:
    enum Roles { TitleRole = Qt::UserRole + 1, DoneRole };

    explicit TaskModel(QObject* parent = nullptr);

    // ---- QAbstractItemModel 必填的三个纯虚/关键接口 ----
    int rowCount(const QModelIndex& parent = QModelIndex()) const override;
    QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
    QHash<int, QByteArray> roleNames() const override;

    // ---- 业务动作(供 Controller 调用)----
    void addTask(const QString& title);
    void toggleDone(int row);
    void removeTask(int row);
    int  count() const { return static_cast<int>(tasks_.size()); }

private:
    struct Task { QString title; bool done; };
    std::vector<Task> tasks_;
};

文件 2:taskmodel.cpp(注意每个改动都用 begin/end 配对包裹,这是 Qt 通知 View 刷新的协议)

#include "taskmodel.h"

TaskModel::TaskModel(QObject* parent) : QAbstractListModel(parent) {
    tasks_ = { {"学习 MVC 分层", false}, {"写 Qt Model/View 示例", true} };
}

int TaskModel::rowCount(const QModelIndex& parent) const {
    if (parent.isValid()) return 0;            // 列表模型:只有顶层有行
    return static_cast<int>(tasks_.size());
}

QVariant TaskModel::data(const QModelIndex& index, int role) const {
    if (!index.isValid() || index.row() >= rowCount()) return {};
    const Task& t = tasks_[index.row()];
    switch (role) {
    case Qt::DisplayRole:
    case TitleRole:  return t.title;
    case Qt::CheckStateRole:
    case DoneRole:   return t.done ? Qt::Checked : Qt::Unchecked;
    default:         return {};
    }
}

QHash<int, QByteArray> TaskModel::roleNames() const {
    return { {TitleRole, "title"}, {DoneRole, "done"} };   // 方便 QML / Delegate 使用
}

void TaskModel::addTask(const QString& title) {
    const QString t = title.trimmed();
    if (t.isEmpty() || t.size() > 50) return;   // 业务校验收在 Model,界面无从绕过
    const int row = rowCount();
    beginInsertRows(QModelIndex(), row, row);   // ① 通知 View "我要插入了"
    tasks_.push_back({t, false});
    endInsertRows();                            // ② View 自动请求新行数据并刷新
}

void TaskModel::toggleDone(int row) {
    if (row < 0 || row >= rowCount()) return;
    tasks_[row].done = !tasks_[row].done;
    const QModelIndex idx = index(row, 0);
    emit dataChanged(idx, idx, {DoneRole, Qt::CheckStateRole});  // 只刷新这一行
}

void TaskModel::removeTask(int row) {
    if (row < 0 || row >= rowCount()) return;
    beginRemoveRows(QModelIndex(), row, row);
    tasks_.erase(tasks_.begin() + row);
    endRemoveRows();
}

文件 3:taskcontroller.h / taskcontroller.cpp(Controller——唯一同时认识 Model 和 View 的地方)

// ---------- taskcontroller.h ----------
#pragma once
#include <QObject>
#include <QModelIndex>

class TaskModel;
class QLineEdit;
class QListView;

// Controller:把视图事件翻译成 Model 行为;同时负责"跨层协调"
class TaskController : public QObject {
    Q_OBJECT
public:
    TaskController(TaskModel* model, QLineEdit* input, QListView* view,
                   QObject* parent = nullptr);

public slots:
    void submitNewTask();               // 用户在输入框按回车
    void handleDoubleClicked(const QModelIndex& index);  // 双击切换完成
    void handleDeleteRequested();       // 删除当前选中项

private:
    TaskModel* model_;                  // 借用指针,不拥有
    QLineEdit* input_;
    QListView* view_;
};
// ---------- taskcontroller.cpp ----------
#include "taskcontroller.h"
#include "taskmodel.h"
#include <QLineEdit>
#include <QListView>
#include <QItemSelectionModel>

TaskController::TaskController(TaskModel* model, QLineEdit* input,
                               QListView* view, QObject* parent)
    : QObject(parent), model_(model), input_(input), view_(view) {
    // View 事件 -> Controller 槽(这就是 MVC 里 "View 把意图交给 Controller")
    connect(input_, &QLineEdit::returnPressed, this, &TaskController::submitNewTask);
    connect(view_,  &QListView::doubleClicked, this, &TaskController::handleDoubleClicked);
}

void TaskController::submitNewTask() {
    const QString text = input_->text();
    if (text.trimmed().isEmpty()) return;
    model_->addTask(text);        // 只和 Model 打交道
    input_->clear();             // 视图清理属于"交互反馈",可由 Controller 决定
}

void TaskController::handleDoubleClicked(const QModelIndex& index) {
    if (index.isValid()) model_->toggleDone(index.row());
}

void TaskController::handleDeleteRequested() {
    const QModelIndex cur = view_->currentIndex();
    if (cur.isValid()) model_->removeTask(cur.row());
}

文件 4:main.cpp(组装与接线,main 是唯一知道"三层怎么拼起来"的地方)

#include <QApplication>
#include <QWidget>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QListView>
#include <QLineEdit>
#include <QPushButton>
#include "taskmodel.h"
#include "taskcontroller.h"

int main(int argc, char* argv[]) {
    QApplication app(argc, argv);

    TaskModel model;                                  // ---- Model ----
    QWidget window;
    window.setWindowTitle("Day 24 · Qt MVC 演示");
    auto* root   = new QVBoxLayout(&window);
    auto* view   = new QListView(&window);            // ---- View(显示)----
    auto* input  = new QLineEdit(&window);            // ---- View(输入)----
    auto* delBtn = new QPushButton("删除选中", &window);
    auto* row    = new QHBoxLayout;
    row->addWidget(input);
    row->addWidget(delBtn);
    input->setPlaceholderText("输入任务后回车…");
    root->addWidget(view);
    root->addLayout(row);

    view->setModel(&model);                           // View 绑定 Model(Qt 的关键一步)

    TaskController controller(&model, input, view, &window);  // ---- Controller ----
    QObject::connect(delBtn, &QPushButton::clicked,
                     &controller, &TaskController::handleDeleteRequested);

    window.resize(360, 460);
    window.show();
    return app.exec();
}

文件 5:CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(Day24QtMvc LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)          # 自动为含 Q_OBJECT 的类生成 moc 代码

find_package(Qt6 REQUIRED COMPONENTS Widgets)

add_executable(Day24QtMvc
    main.cpp
    taskmodel.cpp
    taskcontroller.cpp
)
target_link_libraries(Day24QtMvc PRIVATE Qt6::Widgets)

构建与运行:cmake -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64 && cmake --build build && ./build/Day24QtMvc。你会看到:输入框回车后列表自动多一行;双击某行其复选框自动切换;删除按钮移除选中行。注意全程没有任何一行代码去 list->addItem() 或手动重建列表——View 是 Model 的自动投影,这就是 Qt Model/View 替 MVC 省掉的最大的活。

07

代码执行流程

以"用户在输入框敲字后按回车,列表出现新任务"这条主线,逐帧拆解:

  1. ① 接线阶段(main):view->setModel(&model) 让 View 订阅 Model 的数据通知;TaskController 构造函数里把 QLineEdit::returnPressed 连到 submitNewTask()。此时三方互不认识对方内部,只持有接口级别的引用。
  2. ② View 捕获输入:QLineEdit 收到回车键,发出 returnPressed 信号。View 不做任何业务判断——它既不知道 50 字上限,也不知道数据存在哪,它只负责"报告一个语义意图:用户提交了"。
  3. ③ Controller 调度:TaskController::submitNewTask() 被调用。它做三件事:读取输入框文本、判断空串直接返回、调用 model_->addTask(text)、然后 input_->clear()。请注意:Controller 是唯一"跨层"的角色,它既碰 View 又碰 Model。
  4. ④ Model 改状态 + 广播:addTask() 先做业务校验(trim、长度 ≤ 50),然后 beginInsertRows() 宣布"我将在第 row 行插入",修改 tasks_,再 endInsertRows() 广播。此刻 Model 完全不知道有一个 QListView 存在。
  5. ⑤ View 自动重绘:endInsertRows() 内部触发 rowsInserted 信号,QListView 收到后回调 rowCount() 与 data() 拉取新行内容并绘制,必要时重新计算滚动条。整个过程零手工刷新代码。

一句话记住这条链:输入从 View 出发 → 经 Controller 翻译 → 落到 Model 改状态 → Model 广播 → View 拉数据重绘。数据是"推"通知、"拉"内容;View 永远不写数据,Model 永远不画界面,Controller 永远不存业务数据。

反方向(双击切换完成状态)同理:QListView::doubleClicked → TaskController::handleDoubleClicked(index) → model->toggleDone(index.row()) → emit dataChanged(idx, idx, {DoneRole}) → 只有那一行重绘。注意 toggleDone 里"翻转"这个业务动作由 Model 完成,Controller 只负责把"双击"翻译成"对第 N 行做翻转",它不亲自改 bool。

08

为什么这样设计

把 MVC 拆开看,它其实同时回答了六个工程问题,每一个都对应一条设计原则:

一句话概括设计意图:MVC 用"三个角色 + 一条通知通道"把 GUI 程序里变化频率不同的东西分开——业务规则相对稳定,界面天天改,交互流程偶尔改。把稳定和易变隔开,改易变的东西时才不会震动稳定的东西。

09

不使用会怎样

不用 MVC(或任何分层),代码不会立刻崩,而是以三种可预期的形态腐烂:

更隐蔽的第四种腐烂:"伪 MVC"。有人把类拆成了三个文件,但 Model 里 #include <QMessageBox> 弹窗、View 里写 if (text.length() > 50) 校验、Controller 里缓存了一份 QVector<Task> 副本。文件分开了,职责没分开——这比不拆更危险,因为它让人误以为架构已经理清。

10

何时使用

✅ 适合的场景:

❌ 不适合的场景:

⚠️ 过度设计提醒:MVC 最大的滥用是"为了分层而分层"——把每个 setter 都包一层 Controller 方法、把只有一个实现的接口也抽成抽象类、为 3 个字段的数据建一个"服务层 + 仓储层 + DTO"。判断标准很简单:问自己"如果把这三层合成一层,我会失去什么具体能力?"如果答案是"代码看起来不够专业"而不是"测试写不了/第二个视图加不了",那就别拆。真正的信号是痛:当你第二次因为"改了业务规则得改 4 个界面文件"而烦躁时,才是该上 MVC 的时候。

11

与其他模式的区别

对比对象核心差异怎么选
MVC vs MVP vs MVVM 三者都是"数据-界面-调度"三件套,差别在中间层与 View 的耦合方式:MVC 中 Controller 通常还持有 View 的具体引用(View 与 Controller 双向);MVP 的 Presenter 面向一个抽象的 IView 接口,View 极薄、几乎无逻辑,测试性最好;MVVM 的 ViewModel 通过数据绑定(Qt 里是 Q_PROPERTY + NOTIFY 信号 + QML binding)与 View 自动同步,中间层甚至不知道 View 存在。 Qt Widgets 传统项目 → MVC/MVP;Qt Quick/QML 项目 → MVVM(因为 QML 的绑定机制就是为 MVVM 设计的)。
MVC vs Observer 容易混淆,因为 MVC 内部的"Model 变化通知 View"就是用 Observer 实现的。但层级不同:Observer 是微观的一对多通知机制(一个 subject 通知多个 observer);MVC 是宏观的架构分层——它包含了 Observer,还额外规定了"谁改数据、谁画界面、谁翻译输入"的职责边界。可以说:MVC = 角色划分 + Observer 通道 + 单向数据流约定。 Qt 里 signals/slots 就是 Observer 的实现;写 MVC 时你不必手写观察者列表,用 signal 即可。
MVC vs Mediator Mediator(Day 16)聚焦"多个对象之间的通信爆炸",让它们只跟中介者说话,把 N×N 通信降到 N×1;MVC 聚焦"职责分层"。在复杂界面里两者常叠加:Controller/ViewModel 同时充当 Mediator 的角色,协调多个子 View 之间的联动。 界面里出现"改 A 要联动 B、C、D"时,在 MVC 之上再引入 Mediator 思维。
MVC vs Command 不冲突,是搭档。Controller 收到"删除"意图后,理想做法不是直接 removeTask(),而是构造一个 RemoveTaskCommand 交给执行器——这样 Undo/Redo 免费得到(Day 的 QUndo 阶段会展开)。Controller 负责"翻译意图",Command 负责"把意图变成可撤销的动作对象"。 有 Undo/Redo、宏操作、操作日志需求时,把 Controller 里的写操作升级为 Command。

一句话区分:Observer 管"通知",Command 管"动作对象化",Mediator 管"通信收口",MVC 管"职责分层"。三个 GoF 模式是零件,MVC 是用这些零件搭起来的建筑结构。

12

Qt 源码中的体现

MVC 在 Qt 里不是"可以用",而是框架的骨架。抓住这几个类,你就抓住了 Qt 架构的半壁江山:

可以验证的事实:在 Qt 里使用 QListView::setModel(&model) 之后,你永远不需要手动调用"刷新列表"的 API——Qt 没有这样的公开接口。刷新完全由 Model 的 begin/end 通知驱动。这正是 MVC "View 是 Model 的自动投影"这一论断在框架层面的硬性落地。

13

面试常见问题

Q1:MVC 里 Model 到底能不能发通知?如果能,那它不就"知道"View 的存在了吗?

A:Model 发通知,但不认识 View。关键在于通知机制的抽象性:Model 只调用一个泛化的"广播"接口(Qt 里是 emit dataChanged(...),通用 C++ 里是遍历 std::function 列表),它传出去的是数据坐标/值,不是界面对象。谁订阅、订阅了几个、是界面还是日志还是缓存,Model 一无所知。这就是 DIP——Model 依赖抽象的通知概念,不依赖具体的观察者类型。如果 Model 里出现 QListView* 或弹出 QMessageBox,那才是真正的违规。

Q2:Qt 的 Model/View 和经典 MVC 到底差在哪?为什么 Qt 要把 Controller 去掉?

A:三点差异:①View 与 Controller 合并——QListView 自己处理鼠标键盘并调用 edit(),省掉了大部分"转发型 Controller",因为对列表/表格这类标准控件,转发逻辑几乎千篇一律,交给框架更省事;②引入 Delegate——把"单元格怎么画、怎么编辑"从 View 抽出,实现了绘制与布局的分离;③Model 接口是"坐标 + 角色"式(data(QModelIndex, role))而非"对象集合"式,这让同一个 Model 能被 List、Table、Tree 三种 View 复用(role 决定取哪一列/哪个属性)。但 MVC 的核心思想没变:数据与显示分离、单一数据源、通知驱动刷新。

Q3:为什么 QTableWidget 用起来更简单,但大项目禁用?

A:QTableWidget 是 Item-based(基于条目):每个单元格都是一个 QTableWidgetItem 对象,数据即界面。10 万行 × 10 列就是 100 万个对象,构造和内存直接爆掉,而且数据被锁死在界面容器里,无法给第二个视图复用、无法在无 GUI 环境测试。它是"简易场合的便利 API",不是架构方案。QTableView + QAbstractItemModel 是 Model/View:Model 只按 View 请求的 data(index, role) 惰性供数,界面只创建可视区域那几十个单元格,"数据"与"界面"彻底分离。判断标准:数据需要持久、复用、过滤、多视图共享、或规模大于千行级,就必须用 Model/View。

Q4:Controller 在 Qt Widgets 项目里到底该怎么落地?很多人写着写着就没了。

A:这是 Qt 项目的真实痛点,因为 Qt 允许你在任何地方 connect,于是代码容易退化成"窗口类里到处连线"。三种可落地的做法:①独立 Controller 对象(如本文示例),持有 Model 指针与 View 指针,所有 connect 集中在它的构造函数里,窗口类只做布局;②Presenter 模式(MVP),把 IView 抽象成接口,Controller 只依赖接口,便于用假 View 测试;③接受 Qt 的合并风格,但强制纪律:窗口类里只允许出现"视图事件 → 调用 Model 或发出高层信号",绝不出现业务计算、绝不直接改数据结构、绝不写 SQL。选哪种取决于团队规模,但底线是"业务规则与界面控件不得直接互相依赖"。

Q5:多个 View 共享一个 Model 时,如何保证不崩?

A:三条纪律。①一切修改必须走 Model 的公开方法,且用 begin/end 成对包裹:直接在别处改内部容器而不发通知,View 的行数缓存就会与真实数据错位,下一次绘制越界读取——这是 Qt Model/View 崩溃的头号原因。②索引可能失效:插入/删除后,之前保存的 QModelIndex 可能不再指向原来的数据项(Qt 用的是"持久索引"需显式 persistentIndexList() 处理),因此不要在成员变量里长期缓存行号。③修改期间禁止嵌套修改:不要在 beginInsertRows() 与 endInsertRows() 之间再触发另一处插入,否则通知时序混乱。遵守这三条,多视图共享 Model 是安全且高效的。

14

今日练习

需求(15–30 分钟):把本文第 ⑤ 节的 CounterModel 改造成一个"温度控制器"的最小 MVC,并在不改动 Model 与 Controller 一行代码的前提下,新增第二个 View。

具体要求:

  1. 把 CounterModel 改名为 ThermostatModel,内部保存 int temperature(单位 0.1℃,初值 220 即 22.0℃)。
  2. 业务规则(必须写在 Model 内):温度允许范围 10.0℃ – 30.0℃(即 100–300),超范围时 raiseUp()/lowerDown() 直接静默拒绝且不发出通知;另外提供 setTemperature(int) 用于直接设定。
  3. 写 ConsoleView:渲染成 [视图A] 当前温度: 22.0 ℃ 的格式(注意整数 220 要除以 10 显示一位小数)。
  4. 写 ThermostatController:接线 raiseUp/lowerDown,并提供 simulate(int delta) 模拟用户按加/减键(delta 为 +1 或 -1)。
  5. 关键一步:再写一个完全不同的 BarView(第二个 View),把温度渲染成一串方块,例如 22.0℃ 输出 [视图B] ████████████████████░░░░░░░░░░ (220/300),方块总数固定 30 个。它同样只通过 addObserver 订阅同一个 Model。
  6. 在 main() 里同时监控:让它连续按 15 次"+"、再按 5 次"-",观察两个视图是否始终一致,并且超过 30.0℃ 后是否自动停止增长。

提示 1:第二个 View 之所以能"零改动"加入,是因为 Model 的通知接口是 std::function<void(int)> 这种不暴露观察者身份的泛化形式——只要遵守这个约定,Model 就无需知道有几种视图。这正是 MVC + Observer 组合的威力,也是检验你有没有真解耦的试金石。

提示 2:"静默拒绝不发通知"是本节的重点陷阱。如果越界时你仍然调用了 notify(),两个视图虽然显示正确,但你会丢失"值没变就不刷新"这一性能不变量——尝试故意违反一次,用打印计数观察刷新次数从 20 次变成 21 次,你就真正理解了第 ⑦ 节里"幂等通知"的意义。

加分项(选做):给 ThermostatModel 加一个 std::vector<int> history() 记录每次成功变更的历史,再写第三个 View 打印变化曲线(用字符 ASCII 画)。思考:这个功能为什么必须放在 Model 而不是任何一个 View 里?

15

今日总结

一句话记忆:MVC 就是"数据归 Model、画面归 View、翻译归 Controller"——所有状态只有一份权威副本(Model),界面只是它的投影,输入必须先翻译再执行。

代码特征信号(看到这些,基本可以断定这里有 MVC 的影子):

  1. 一个不包含任何 #include <QWidget> / 界面类型的数据类,能独立编译进控制台程序并被单测;
  2. Model 内部有 beginInsertRows/endInsertRows、emit dataChanged 或自建的 notify() 广播,且广播出去的内容只有"数据坐标/值";
  3. View 里出现的是 render(data) / data(index, role) 这类只读调用,找不到 if (业务规则);
  4. 所有 connect(...) 或回调接线集中在 Controller / main 的构造函数里,像一张清晰的电路图;
  5. 新增一个视图(或一个导出功能)时,Model 与 Controller 的 diff 是 0 行——这是 OCP 达标的硬指标。

今日避坑清单:① Model 里禁止弹窗、禁止持有控件指针;② 禁止在别处绕过 Model 直接改数据;③ 不要为了分层而分层,先感到"痛"再拆;④ QTableWidget 是便利 API 不是架构,规模一大必须换 QTableView + QAbstractItemModel。

明日预告 · Day 25:MVVM 与数据绑定(Model-View-ViewModel)。MVC 里 Controller 还得拿着 View 的引用去"命令"界面刷新;MVVM 更彻底——ViewModel 只暴露可绑定的属性(Q_PROPERTY ... NOTIFY),界面通过 bindable 自动同步,ViewModel 甚至不知道 View 长什么样。我们将用 Qt6 的 QBindable/Property Binding 写一个"输入框 ↔ Model 双向自动同步"的例子,并对比 MVC/MVP/MVVM 三者的测试成本差异。届时你会看到:从 MVC 到 MVVM,本质是把"手动刷新"这条线也交给框架。