架构模式 数据绑定 Qt/QML 可测试性 Q_PROPERTY
一句话定义:MVVM(Model-View-ViewModel)在 MVC 的基础上删掉了 Controller,换成 ViewModel——它把「界面上要显示什么」抽象成一组可绑定属性(状态)和一组命令(行为);View 不再手动去读数据、写界面,而是声明「我的文字绑定到 vm.totalText,我的可用性绑定到 vm.canSubmit」,剩下的同步工作交给绑定引擎自动完成。
MVVM 由微软的 John Gossman 在 2005 年为 WPF / Silverlight 提出(同时代微软还有 Martin Fowler 的 PM 模式做铺垫)。它和 MVC 的真正差别不是「多了一层」,而是通信方式变成了双向自动同步:MVC 里 Controller 要亲手 view.setTotal(...),MVVM 里你只是写下 text: vm.totalText,之后 Model 一变,界面自己就变了。
本篇定位:Day 24 我们学了 MVC——它解决了「职责切分」,但把「同步」这件事留给了人。今天这篇要补上最后一块拼图:把同步也交给框架。我们会先用 60 行纯 C++17(不含 Qt)手写一个最小绑定引擎,让你看清 MVVM 的内核其实只是「可观察属性 + 自动订阅」;再用 Qt6 的 Q_PROPERTY + NOTIFY、QML 声明式绑定、QProperty / QBindable 写出真正能跑的代码;最后对比 MVC / MVP / MVVM 三者的测试成本,给你一套「什么时候该上 MVVM」的判断标准。
如果你是从 Web 转过来的架构师,可以先记下一个类比:MVVM ≈ React/Vue 的「响应式状态 → 视图自动渲染」模型搬到桌面端。ViewModel 就是你的 store / setup() 里的 ref/reactive,View 就是模板,绑定引擎就是 Vue 的依赖收集 + React 的重渲染。区别在于:前端靠虚拟 DOM / Proxy 收集依赖,Qt 靠 元对象系统(NOTIFY 信号)收集依赖。
先看一段你一定写过的代码——一个「订单录入」界面,需求是:改数量或单价时,下方合计要跟着变;合计为 0 时禁用提交按钮。
// 反例:所有职责塞进一个 QWidget 子类(约 60 行,且每行都有问题)
class OrderForm : public QWidget {
Q_OBJECT
public:
OrderForm(QWidget* parent = nullptr) : QWidget(parent) {
m_qty = new QSpinBox(this);
m_price = new QDoubleSpinBox(this);
m_total = new QLabel(this);
m_submit = new QPushButton("提交", this);
m_qty->setValue(2);
m_price->setValue(12.5);
// 问题 1:手动同步 —— 每加一个输入控件,就要再加一条 connect
connect(m_qty, QOverload<int>::of(&QSpinBox::valueChanged), this, &OrderForm::refresh);
connect(m_price, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this, &OrderForm::refresh);
refresh();
}
private slots:
void refresh() {
// 问题 2:业务规则(运费/折扣/格式化)直接写在 UI 类里
const int q = m_qty->value();
const double p = m_price->value();
const double shipping = (q >= 5) ? 0.0 : 8.0;
const double rate = (q >= 10) ? 0.9 : 1.0;
const double total = q * p * rate + shipping;
// 问题 3:UI 类直接认识 QString 格式化细节、直接认识按钮
m_total->setText(QString("¥%1").arg(total, 0, 'f', 2));
m_submit->setEnabled(q > 0 && p > 0);
// 问题 4:一旦要加"库存不足禁用提交",这里继续 if-else 膨胀
}
void onSubmitClicked() { /* 问题 5:下单动作也在这里,还顺手弹了 QMessageBox */ }
private:
QSpinBox* m_qty; QDoubleSpinBox* m_price; QLabel* m_total; QPushButton* m_submit;
};
这段代码能跑,但它把四件事焊死在了一起:控件布局(View)、状态与业务规则(Qty/Price/运费/折扣)、展示态转换(格式化成「¥33.00」)、同步方式(connect + 手动 refresh)。逐条看它违背了什么:
refresh():加一个 m_coupon、加一条 connect、在函数里加分支。m_qty->value() 这种具体控件。想要在无 GUI 环境(CI、批处理、命令行工具)复用这份计算逻辑?做不到。QApplication + 造窗口 + 模拟点击(QTest::mouseClick),测试脆弱且慢。症结不在 connect,而在「谁认识谁」。MVC 已经切开了 Model 和 View,但 Controller 仍要同时握住两边并手工指挥刷新。MVVM 的答案很锐利:让界面自己去订阅状态——View 只在初始化时声明一次「我依赖哪些属性」,此后所有同步都是自动的,再也不用写第二遍 connect,也再也不会漏写。
通俗解释:MVVM 把一次界面开发拆成三道问题——「数据与规则是什么」(Model)、「界面上有哪些可以显示/编辑的东西,它们怎么算出来」(ViewModel)、「这些东西画成什么样」(View)。关键在于第三问只允许声明,不允许运算:View 里不写计算逻辑,只写「这里显示 vm.totalText」。
生活类比——餐厅:把程序当成一家餐厅。
三个技术要点决定了 MVVM 能不能立住:
Q_PROPERTY(... NOTIFY xxxChanged);没有 NOTIFY 的属性是绑不上的。text: vm.totalText 是绑定;Widgets 里的 label->setText(...) 是命令。这是 MVVM 与 MVC 最本质的分水岭。最后记住 MVVM 的一条铁律:依赖箭头是单向的。Model 不知道 ViewModel,ViewModel 不知道 View,View 知道 ViewModel(通过绑定)。任何一条反向引用(比如在 ViewModel 里 #include "MainWindow.h")都会立刻让这个架构退化成「更难维护的 MVC」。
MVVM 的角色非常少,只有四个(其中「绑定引擎」是框架提供的隐形角色):
┌──────────────────────────────────────────────┐
│ View │
│ (QML 文件 / 带绑定的 QWidget 组合) │
│ 职责:只声明"显示什么、绑定到谁" │
│ 不做计算、不持有业务数据 │
└───────────────┬──────────────────────────────┘
声明绑定 │ ▲ 属性变化自动回灌
(读属性) │ │ (NOTIFY 信号)
▼ │
┌──────────────────────────────────────────────┐
│ ViewModel │
│ 职责:暴露可绑定属性(状态) │
│ 暴露命令(行为) │
│ 做展示态转换(格式化/置灰/排序) │
│ ★ 必须不知道 View 的存在(不 include 任何 UI)│
└───────────────┬──────────────────────────────┘
调用/订阅 │ ▲ 数据与规则变化
▼ │
┌──────────────────────────────────────────────┐
│ Model │
│ 职责:领域数据 + 业务规则(与 UI 完全无关) │
│ 可被 ViewModel 复用、可脱离 GUI 运行 │
└──────────────────────────────────────────────┘
┌─── 绑定引擎(框架层,不是你的代码)──────────────┐
│ QML 引擎的依赖追踪 / QProperty 系统 / QBindable │
│ 作用:自动订阅 NOTIFY 与属性读取,按需重算表达式 │
└──────────────────────────────────────────────────┘
| 角色 | 职责 | 不该做的事 | Qt 中的典型形态 |
|---|---|---|---|
| Model | 领域数据、业务规则、持久化、网络请求结果 | 不该知道 QString 显示格式,不该 include QtWidgets / QtQuick | 纯 C++/QObject 服务类、QAbstractItemModel、Repository |
| ViewModel | 把 Model 投影成界面需要的状态;派生计算;暴露命令;做格式化 | 不该 include 任何具体 View 头文件;不该出现 QWidget* / QLabel* |
QObject 子类 + Q_PROPERTY(..., NOTIFY) + Q_INVOKABLE |
| View | 布局、样式、声明绑定、把用户输入写回 ViewModel | 不该写业务规则,不该缓存业务状态,不该自己去 connect 做同步 | QML .qml 文件 / Widgets + 绑定表达式 |
| 绑定引擎 | 收集依赖、在 NOTIFY 时重算、批量调度刷新 | (框架实现,你只需理解它的语义) | QML 引擎、QProperty / QBindable / QObjectBindableProperty |
注意一个容易被忽视的角色职责:命令(Command)。ViewModel 不仅要暴露「数据」,还要暴露「动作」,例如 Q_INVOKABLE void submit() 或 void submit() 槽函数。View 侧的按钮只写 onClicked: vm.submit(),于是「点击后做什么」完全离开界面代码,可以被单元测试直接调用——这就是 Day 14 命令模式在架构层的延续:把「请求」变成一等对象/一等接口。
为了看清 MVVM 的内核,我们先不用 Qt:手写一个极小的「可观察属性 + 订阅」机制,然后搭起 Model → ViewModel → View 三层,跑起来验证「改 Model,View 自动更新」。本节代码完整可编译(C++17)。
// mvvm_min.cpp —— 用不到 100 行理解 MVVM 的本质
// 编译:g++ -std=c++17 -O2 -Wall mvvm_min.cpp -o mvvm_min && ./mvvm_min
#include <cmath>
#include <functional>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
// ---------------------------------------------------------------------------
// 基础设施:Observable<T> = 「值 + 变更通知」
// 等价于 Qt 里带 NOTIFY 信号的 Q_PROPERTY(订阅者相当于 QML 的绑定表达式)
// ---------------------------------------------------------------------------
template <typename T>
class Observable {
public:
explicit Observable(T init) : m_value(std::move(init)) {}
const T& value() const noexcept { return m_value; }
// 唯一写入口:值真的变了才通知,避免无意义刷新
void setValue(const T& v) {
if (m_value == v) return; // 去重,是绑定的性能前提
m_value = v;
notify();
}
// 订阅返回一个 id,实践中可配合 unsubscribe(id) 做生命周期管理(RAII 思路)
int subscribe(std::function<void()> fn) {
m_subs.push_back(std::move(fn));
return static_cast<int>(m_subs.size()) - 1;
}
private:
void notify() const { for (const auto& f : m_subs) f(); }
T m_value;
std::vector<std::function<void()>> m_subs;
};
// ---------------------------------------------------------------------------
// Model:领域数据 + 业务规则。完全不认识 UI,可以在无界面环境下运行
// ---------------------------------------------------------------------------
class OrderModel {
public:
Observable<int> quantity{2}; // 数量
Observable<double> unitPrice{12.5}; // 单价
// 业务规则(属于 Model,不属于界面)
double shippingFee() const { return quantity.value() >= 5 ? 0.0 : 8.0; }
double discountRate() const { return quantity.value() >= 10 ? 0.9 : 1.0; }
bool isOrderable() const { return quantity.value() > 0 && unitPrice.value() > 0; }
};
// ---------------------------------------------------------------------------
// ViewModel:把 Model 投影成"界面要的东西",并做展示态转换(格式化)
// ★ 它不知道 View 是谁:没有控制台输出以外的任何界面依赖
// ---------------------------------------------------------------------------
class OrderViewModel {
public:
explicit OrderViewModel(OrderModel& model) : m_model(model) {
// 绑定的本质:订阅 Model 的变化 → 重算 → 通知 View
m_model.quantity.subscribe([this] { recalc(); });
m_model.unitPrice.subscribe([this] { recalc(); });
recalc(); // 首次求值(相当于 QML 首次绑定)
}
const Observable<std::string>& totalText() const noexcept { return m_totalText; }
const Observable<bool>& canSubmit() const noexcept { return m_canSubmit; }
// 命令:界面上的"提交"按钮只调用它,动作本身可被单元测试直接调用
void submit() const {
if (!m_canSubmit.value()) {
std::cout << " [ViewModel] 提交被拒绝:订单不合法\n";
return;
}
std::cout << " [ViewModel] 提交订单,合计 " << m_totalText.value() << "\n";
}
private:
void recalc() {
const int q = m_model.quantity.value();
const double p = m_model.unitPrice.value();
const double raw = q * p * m_model.discountRate() + m_model.shippingFee();
std::ostringstream oss;
oss << "¥" << std::fixed << std::setprecision(2) << raw; // 展示态转换
m_totalText.setValue(oss.str());
m_canSubmit.setValue(m_model.isOrderable());
}
OrderModel& m_model; // 持有引用:Model 生命周期长于 ViewModel
Observable<std::string> m_totalText{"¥0.00"};
Observable<bool> m_canSubmit{false};
};
// ---------------------------------------------------------------------------
// View:只做"渲染"和"把变化订阅进来",不含任何业务计算
// ---------------------------------------------------------------------------
class ConsoleOrderView {
public:
explicit ConsoleOrderView(OrderViewModel& vm) {
// 声明绑定:以后 vm 属性一变,下面的 lambda 自动被调用(相当于 QML 的重求值)
vm.totalText().subscribe([this, &vm] { render(vm); });
vm.canSubmit().subscribe([this, &vm] { render(vm); });
render(vm); // 首帧
}
public:
void render(const OrderViewModel& vm) const {
std::cout << " [View] 合计 = " << vm.totalText().value()
<< " | 提交按钮 " << (vm.canSubmit().value() ? "可用" : "禁用") << "\n";
}
};
int main() {
OrderModel model; // Model
OrderViewModel vm(model); // ViewModel(依赖 Model)
ConsoleOrderView view(vm); // View(依赖 ViewModel)
std::cout << "--- 初始状态(数量 2)---\n";
view.render(vm);
std::cout << "--- 数量改为 5:运费从 8 元变 0 元 ---\n";
model.quantity.setValue(5); // ★ 只改 Model,界面自动更新
std::cout << "--- 数量改为 10:触发 9 折 ---\n";
model.quantity.setValue(10);
std::cout << "--- 单价改为 9.9 ---\n";
model.unitPrice.setValue(9.9);
std::cout << "--- 数量改为 0:按钮应自动禁用 ---\n";
model.quantity.setValue(0);
std::cout << "--- 点击提交 ---\n";
vm.submit();
return 0;
}
程序输出:
[View] 合计 = ¥33.00 | 提交按钮 可用
--- 初始状态(数量 2)---
[View] 合计 = ¥33.00 | 提交按钮 可用
--- 数量改为 5:运费从 8 元变 0 元 ---
[View] 合计 = ¥62.50 | 提交按钮 可用
--- 数量改为 10:触发 9 折 ---
[View] 合计 = ¥112.50 | 提交按钮 可用
--- 单价改为 9.9 ---
[View] 合计 = ¥89.10 | 提交按钮 可用
--- 数量改为 0:按钮应自动禁用 ---
[View] 合计 = ¥8.00 | 提交按钮 禁用
--- 点击提交 ---
[ViewModel] 提交被拒绝:订单不合法
注意最后一步的细节:数量改成 0 后合计变成 ¥8.00(首重运费仍然计入了原始公式),但「提交」被业务规则拦住了——这正说明规则在 Model、格式在 ViewModel、显示在 View,三者各自独立演进。你可以在 main() 里删掉整个 ConsoleOrderView,程序照样能算对——这就是「ViewModel 可脱离界面测试」的含义。
这段代码里,MVVM 的三个技术点全都在了:① Observable<T> 提供可观察状态(≈ NOTIFY);② subscribe 提供声明式订阅(≈ QML 绑定表达式);③ subscribe 返回 id / 用 std::function 持有回调,所以订阅者的生命周期必须长于被订阅者——这就是 Qt 里「绑定会在对象销毁时自动断开」为什么如此重要的原因(QML 绑定与信号连接都基于 QObject 的父子/连接追踪,对象析构即自动断开,无需你手工清理)。
Qt 的 MVVM 主战场是 QML:QML 本身就是声明式语言,绑定是语言级能力,而 Qt 的元对象系统天然提供 NOTIFY 信号——两者合起来正好就是绑定引擎需要的一切。下面这个示例做「订单小面板」,注意三个关键设计:ViewModel 用 QML_ELEMENT 注册到 QML 类型系统、每个可绑定属性都有 NOTIFY、展示态字符串在 C++ 侧算好(totalText),QML 只做拼接显示。
// orderviewmodel.h —— ViewModel:只认识 Model 与 Qt 元对象,不认识任何界面
#pragma once
#include <QObject>
#include <QString>
#include <QtQml/qqmlregistration.h> // 提供 QML_ELEMENT 宏
class OrderModel; // 领域模型(前向声明,cpp 里才 include)
class OrderViewModel : public QObject {
Q_OBJECT
QML_ELEMENT // Qt6:自动注册为 QML 可实例化类型
// ★ 关键:必须带 NOTIFY,否则 QML 无法建立绑定(属性变化不会触发重求值)
Q_PROPERTY(int quantity READ quantity WRITE setQuantity NOTIFY quantityChanged)
Q_PROPERTY(double unitPrice READ unitPrice WRITE setUnitPrice NOTIFY unitPriceChanged)
Q_PROPERTY(QString totalText READ totalText NOTIFY totalTextChanged)
Q_PROPERTY(bool canSubmit READ canSubmit NOTIFY canSubmitChanged)
public:
explicit OrderViewModel(QObject* parent = nullptr);
int quantity() const noexcept { return m_quantity; }
double unitPrice() const noexcept { return m_unitPrice; }
QString totalText() const { return m_totalText; }
bool canSubmit() const noexcept { return m_canSubmit; }
void setQuantity(int v);
void setUnitPrice(double v);
// 命令:View 的按钮只调用它;逻辑留在 ViewModel,可被 QSignalSpy 直接测试
Q_INVOKABLE void submit();
signals:
void quantityChanged();
void unitPriceChanged();
void totalTextChanged();
void canSubmitChanged();
void orderSubmitted(const QString& summary); // 需要向外部广播结果时用信号
private:
void recalc(); // 派生状态统一在这里重算
int m_quantity = 2;
double m_unitPrice = 12.5;
QString m_totalText = QStringLiteral("¥0.00");
bool m_canSubmit = true;
};
#include "orderviewmodel.h"
#include <QString>
#include "ordermodel.h" // Model:纯业务规则
OrderViewModel::OrderViewModel(QObject* parent)
: QObject(parent) {
recalc();
}
void OrderViewModel::setQuantity(int v) {
if (m_quantity == v) return; // ★ 去重写:绑定系统靠它避免无限循环
m_quantity = v;
emit quantityChanged();
recalc(); // 输入变了 → 派生状态跟着变
}
void OrderViewModel::setUnitPrice(double v) {
if (qFuzzyCompare(m_unitPrice, v)) return;
m_unitPrice = v;
emit unitPriceChanged();
recalc();
}
void OrderViewModel::recalc() {
const double raw = OrderModel::totalPrice(m_quantity, m_unitPrice);
// 展示态转换:把数字变成"¥112.50",把规则变成"按钮是否可用"
const QString text = QStringLiteral("¥%1").arg(raw, 0, 'f', 2);
if (text != m_totalText) { // 只在字符串真的变了才发信号(减少无谓刷新)
m_totalText = text;
emit totalTextChanged();
}
const bool ok = OrderModel::isOrderable(m_quantity, m_unitPrice);
if (ok != m_canSubmit) {
m_canSubmit = ok;
emit canSubmitChanged();
}
}
void OrderViewModel::submit() {
if (!m_canSubmit) return;
emit orderSubmitted(QStringLiteral("下单成功:数量 %1,合计 %2")
.arg(m_quantity).arg(m_totalText));
}
#pragma once
// Model 层:只做业务规则,连 QString 都能不用就最好不用
// 因为它完全是 static 纯函数,所以测试时不需要 QApplication、不需要窗口
struct OrderModel {
static double shippingFee(int quantity) noexcept { return quantity >= 5 ? 0.0 : 8.0; }
static double discountRate(int quantity) noexcept { return quantity >= 10 ? 0.9 : 1.0; }
static double totalPrice(int quantity, double unitPrice) noexcept {
return static_cast<double>(quantity) * unitPrice * discountRate(quantity)
+ shippingFee(quantity);
}
static bool isOrderable(int quantity, double unitPrice) noexcept {
return quantity > 0 && unitPrice > 0.0;
}
};
// Main.qml —— View:写"绑定到谁",不写"怎么算"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: root
width: 380; height: 260
visible: true
title: "MVVM 订单面板"
// ViewModel 作为 View 的数据源被实例化(真实项目里通常由 C++ 注入)
OrderViewModel { id: vm }
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 10
Label { text: "数量" }
// ★ 绑定:vm.quantity 一变,value 自动重算(不是手动 setValue)
SpinBox {
id: qtyBox
from: 0; to: 99
value: vm.quantity
// 双向:界面改动 → 写回 ViewModel(显式写回,避免与绑定互相覆盖)
onValueModified: vm.quantity = value
}
Label { text: "单价" }
TextField {
id: priceField
text: Number(vm.unitPrice).toFixed(2)
onEditingFinished: vm.unitPrice = Number(text) // 校验后写回
}
Label {
text: "合计:" + vm.totalText // ★ 派生状态直接绑定
font.bold: true
}
Button {
text: "提交订单"
enabled: vm.canSubmit // ★ 可用性也是绑定
onClicked: vm.submit() // 命令
}
Connections {
target: vm
function onOrderSubmitted(summary) { console.log("[QML]", summary) }
}
Item { Layout.fillHeight: true }
}
}
// main.cpp
#include <QGuiApplication>
#include <QQmlApplicationEngine>
int main(int argc, char* argv[]) {
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
// Qt 6.2+:按模块 URI + 类型名加载(配合下面的 qt_add_qml_module)
engine.loadFromModule("MvvmDemo", "Main");
if (engine.rootObjects().isEmpty())
return -1; // 加载失败:QML 语法/注册错误会打印在 stderr
return app.exec();
}
# CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(mvvm_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # Q_PROPERTY / Q_OBJECT 需要 moc
find_package(Qt6 6.5 REQUIRED COMPONENTS Quick)
qt_standard_project_setup()
qt_add_executable(mvvm_demo main.cpp)
qt_add_qml_module(mvvm_demo
URI MvvmDemo
VERSION 1.0
SOURCES
orderviewmodel.cpp
orderviewmodel.h
ordermodel.h
QML_FILES
Main.qml
)
target_link_libraries(mvvm_demo PRIVATE Qt6::Quick)
构建与运行:
cmake -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.5.0/gcc_64
cmake --build build -j
./build/mvvm_demo # 修改数量后,"合计"与按钮可用性自动变化
QML 双向绑定的坑(务必注意):如果你写成 SpinBox { value: vm.quantity; onValueChanged: vm.quantity = value },就会形成绑定循环(QML 会打印 Binding loop detected)。正确做法是:用绑定负责「显示方向」,用信号处理器负责「写回方向」,且写回前先做相等判断。示例里 value: vm.quantity(显示)+ onValueModified: vm.quantity = value(写回),以及 C++ 侧 setter 里的 if (m_quantity == v) return; 去重,这两道防线缺一不可。
以示例程序为例,从启动到用户改一次数量,完整经过四步:
① 装载与装配(Composition Root)
main() 创建 QQmlApplicationEngine → loadFromModule("MvvmDemo","Main") → QML 引擎解析 Main.qml → 遇到 OrderViewModel { id: vm },通过 QML_ELEMENT 注册的信息反射构造出 C++ 的 OrderViewModel(构造函数里已调用 recalc(),所以 totalText 一开始就是「¥33.00」而不是空串)。此时完整对象图是:View 持有 vm,vm 持有 Model 规则。
② 首次求值建立依赖(Initial Binding Pass)
QML 引擎逐条求值绑定表达式:读 vm.quantity、vm.unitPrice、vm.totalText、vm.canSubmit。关键在于「读」这个动作本身会被记录——表达式开始求值时引擎打开依赖收集,凡是求值过程中访问过的、带 NOTIFY 的属性(或 BINDABLE 属性的 QBindable::value())都被登记为这个表达式的依赖。求值结束,依赖表建立完成。这就是「不写 connect,却能自动同步」的全部秘密。
③ 用户操作 → 写回 → 通知
用户在 SpinBox 上点加减 → onValueModified 执行 vm.quantity = value(这是 C++ 属性写操作,QML 通过元对象调用 setQuantity())→ setter 判断值确实变了 → 赋值 → emit quantityChanged() → recalc() 重算 m_totalText / m_canSubmit,各自有变化才 emit。
④ 传播与重绘
引擎收到 totalTextChanged / canSubmitChanged → 查依赖表定位受影响表达式(「合计:」Label 的 text、Button 的 enabled)→ 标记为脏并批量调度重求值(Qt 会把刷新合并到下一次事件循环迭代,避免一次操作触发 N 次重复重算)→ 重求值 → 更新 Label / Button 属性 → 场景图标记更新 → 下一次渲染刷新到屏幕。整条链路里,没有任何一行代码是「手工同步」。
用一张时序图记住这条链路:
用户操作 ──▶ View(SpinBox) ──写属性──▶ ViewModel.setQuantity()
│ 去重判断
├── emit quantityChanged() ──┐
└── recalc() │
├─ emit totalTextChanged()│
└─ emit canSubmitChanged()│
│ │
绑定引擎:查依赖表 ◀──┘◀────────────────┘
│
标记脏 → 批量调度(事件循环)
│
View 重求值:Label.text / Button.enabled
│
场景图更新 → 重绘
MVVM 的价值不在「少写代码」,而在把变化关进笼子。逐条拆解:
OrderModel::totalPrice(...) 这种业务语义接口,而不是具体控件或数据库。Q_INVOKABLE void applyCoupon(const QString& code),View 侧接一个按钮即可。| 原则 | 在 MVVM 中的体现 |
|---|---|
| SRP | View 只管画、ViewModel 只管状态投影、Model 只管规则。一个类只有一个变化原因:改皮肤不动 vm,改规则不动 QML。 |
| OCP | 绑定表达式是「数据驱动的扩展点」:新增显示/联动靠加属性加绑定,而不是改 refresh() 里的 if-else。 |
| LSP | ViewModel 面向「可观察状态」抽象;换成另一个 ViewModel 实现(如离线模式/演示模式)对 View 透明。 |
| ISP | 一个界面只依赖它需要的属性。Big FormViewModel 可以拆成多个小 ViewModel(Header/List/Footer),每个 View 只依赖自己那一小块。 |
| DIP | ViewModel 依赖 Model 的抽象接口(Repository/Service),不依赖具体实现,因此可以注入 Fake 实现做测试。 |
经典 MVC 常被写成「让窗口类继承一个 Controller 基类」,于是「窗口」与「控制器」的继承体系纠缠;MVVM 的做法是组合:窗口持有一个 ViewModel 对象。OrderViewModel 可以是纯 QObject,不需要继承任何 UI 基类。好处是它可以放进任何容器(QML 类型、Widgets 成员、测试夹具),也可以在运行时替换整个实例(切换租户/主题/模式时整块状态一起换)。
这是架构落地时最有说服力的一条。用 QTest 测这段业务:
// tst_orderviewmodel.cpp —— 不需要窗口、不需要点击,直接测 ViewModel
#include <QSignalSpy>
#include <QtTest>
#include "orderviewmodel.h"
class TestOrderViewModel : public QObject {
Q_OBJECT
private slots:
void discountKicksInAtTen() {
OrderViewModel vm;
QSignalSpy textSpy(&vm, &OrderViewModel::totalTextChanged);
vm.setUnitPrice(10.0);
vm.setQuantity(10); // 触发 9 折
QCOMPARE(vm.totalText(), QStringLiteral("¥90.00")); // 10*10*0.9 + 0
QVERIFY(textSpy.count() >= 1);
}
void submitIsBlockedWhenInvalid() {
OrderViewModel vm;
QSignalSpy submittedSpy(&vm, &OrderViewModel::orderSubmitted);
vm.setQuantity(0);
QVERIFY(!vm.canSubmit());
vm.submit();
QCOMPARE(submittedSpy.count(), 0); // 未发出信号 = 未下单
}
};
QTEST_MAIN(TestOrderViewModel)
#include "tst_orderviewmodel.moc"
对应的 MVC 版本要测同一件事,得先建 QApplication、造 OrderForm、QTest::mouseClick 加减按钮、再去读 QLabel 的文本——测试从「毫秒级纯逻辑断言」变成「依赖窗口系统的集成测试」。同一个断言,MVVM 的失败信息告诉你「规则错了」,MVC 的失败信息可能是「控件没找到」。
不用 MVVM(或任何分层)的界面代码,结局通常是下面五种病症按顺序发作:
connect 地狱(N×M 同步矩阵):界面上 N 个可编辑项、M 个派生显示项,最坏情况下要维护 N×M 条同步关系。加一个输入框,就要重新审一遍「它影响哪些显示项」,漏一条就是线上 bug。这类 bug 的典型表现是「改了数量,合计更新了,但提交按钮还亮着」——不是逻辑错,是忘了连那一条线。MVVM 把 N×M 降成 N 条绑定 + 1 处派生计算。
if-else 膨胀:同一个界面上「A 视图模式显示 X、B 模式显示 Y、管理员还要多显示 Z」,很快演变成 refresh() 里 200 行的条件分支,且每个分支都要小心不要漏改某个控件。真实项目里这个函数经常成为全项目最长的函数之一。
类膨胀与复制粘贴:同一个订单逻辑出现在「创建订单页」「编辑订单页」「快速下单弹窗」三处。第一版复制粘贴很爽,第三版就开始漂移——某一个页面漏了「满 10 件 9 折」的修复,客诉来了才发现。MVVM 里三处界面共享同一个 ViewModel(或同一个 Model 规则),逻辑只有一份。
UI 变更引发回归:只想把按钮从底部挪到顶部,却因为 refresh() 里夹杂着对文本格式和控件指针的操作,改动牵动业务代码,必须回归测试整条链路。分层正确的代码里,改布局不碰任何逻辑。
无法自动化测试,只能靠人点:CI 里跑不了 GUI 交互,最终所有验证退化成人肉点击清单。团队规模一上来,这直接决定发布节奏。反过来,一个干净 ViewModel 的单元测试可以覆盖 80% 以上的状态组合。
一句话总结危害:MVVM 解决的根本问题是「状态与界面之间的同步关系数量爆炸」。只要你的界面有一个以上派生显示项,你就在付这份维护成本——区别只是早付(写绑定声明)还是晚付(修线上 bug)。
Connections 手工同步,得不偿失。过度设计提醒:不要为了「架构」而把每个属性都包一层 ViewModel。判断标准很具体——只要你能列出「属性 A 变化时需要同步刷新 B、C、D」,就有引入绑定的理由;如果每个控件都是独立的孤立输入(没有派生关系),那它只是一个 Value Object,套 ViewModel 是空转。另外,别做「ViewModel 套 ViewModel 套 ViewModel」——超过两层的投影通常意味着职责没划清(该拆成多个并列的小 ViewModel,而不是继续往深里包)。
MVVM 常和 MVC、MVP 混着说,也会被误认为就是观察者模式。三者界线如下:
| 维度 | MVC | MVP | MVVM |
|---|---|---|---|
| 中间层 | Controller | Presenter | ViewModel |
| View 与中间层的关系 | Controller 同时持有 View 和 Model,主动调用 view.setX() |
View 持有 Presenter 并实现 View 接口,Presenter 反向调用 view->showX()(双向但显式) |
View 声明绑定到 ViewModel 属性,中间层完全不知道 View(单向依赖) |
| 同步方式 | 手工命令式刷新 | 手工显式调用接口 | 框架自动(依赖追踪 + 通知) |
| 可测试性 | 中(Controller 可测,但常与 View 交互) | 高(View 被接口抽象,Presenter 可测) | 最高(ViewModel 连 View 的存在都不知道) |
| Qt 中的典型对应 | Qt Model/View 中的 delegate + 窗口类;传统 Widgets 应用 | Widgets 里手工抽出的 Presenter 类 | QML + C++ ViewModel(Q_PROPERTY NOTIFY) |
| 主要代价 | 同步代码易漏、难测 | 接口数量膨胀(每个视图动作一个接口方法) | 绑定是「隐式控制流」,调试需要理解依赖收集;过度使用会有魔法感 |
MVVM 不是观察者模式的替代品,而是观察者模式的架构级应用:NOTIFY 信号是观察者机制的载体,绑定表达式是「订阅者」,依赖收集是「自动注册订阅」。可以说 MVVM = 观察者 + 求值引擎 + 展示态投影规则。理解了 Day 18,你就已经握住了 MVVM 的引擎——今天补的是「谁来订阅、谁来做派生计算」的架构约定。
ViewModel 暴露的 Q_INVOKABLE submit() 把「用户意图」变成可调用的一等接口,View 侧不再内联业务动作。这与命令模式把「请求」封装成对象的动机一致:可测试、可复用、可撤销(QUndoStack 与 ViewModel 结合是桌面客户端的常见做法)。
ViewModel 内部常把「格式化策略」「校验策略」抽成可替换组件:同一份数据,实体店模式显示含税价、电商模式显示包邮价。MVVM 负责结构,策略负责可换的算法,两者互补不冲突。
QML 的整个属性系统就是一套 MVVM 绑定引擎(源码位于 qtdeclarative/src/qml/qml/):绑定表达式求值时收集依赖,属性 NOTIFY 发出时把依赖它的表达式标记为脏、批量调度重算,并检测绑定循环(Binding loop detected 警告)。这正是 QQmlBinding / 依赖追踪要做的事,也是为什么「Q_PROPERTY 必须带 NOTIFY」是硬性要求——绑定引擎要靠通知来触发重算。
Qt 6.0 起为 C++ 侧引入可绑定属性:QProperty<T>(普通绑定属性)、QObjectBindableProperty(QObject 上的可绑定属性,配合 Q_PROPERTY(... BINDABLE bindableX) 使用)、QObjectComputedProperty(只读计算属性)。三者让「无需 QML,纯 C++ 也能做声明式绑定」:
// 纯 C++ 的绑定:输入框文本变化 → 派生字符串自动重算(Qt 6.5+)
QLabel* preview = ...;
QLineEdit* edit = ...;
// ★ 为既有 QObject 属性构造 QBindable(该属性必须有 NOTIFY 信号)
// QLineEdit::text 自带 textChanged 信号,因此可以直接参与依赖追踪
QBindable<QString> editText(edit, "text");
QProperty<QString> previewText; // 我们的派生属性
previewText.setBinding([editText]() { // 声明依赖,而不是 connect
return QStringLiteral("预览:%1").arg(editText.value().toUpper());
});
// 把派生属性接到控件上(此处为演示用 lambda,真实项目里可用可绑定属性/信号)
preview->setText(previewText.value()); // 首帧
QObject::connect(edit, &QLineEdit::textChanged, preview,
[preview, &previewText]() { preview->setText(previewText.value()); });
注意 QBindable(QObject*, const char*) 这个构造是 Qt 6.5 引入的:它让「早已存在的、带 NOTIFY 的普通属性」也能被拉进绑定体系(官方文档明确要求该属性必须有 notify 信号,且必须以 QBindable::value() 读取才能建立依赖追踪)。它是把存量 Widgets 代码渐进改造为 MVVM 的关键工具。
Qt Model/View 里,QAbstractItemModel 就是标准化的 Model(数据 + 角色),QTableView/QListView 是 View,delegate 负责绘制单元——整体是「委托版 MVC」:View 通过 dataChanged 信号自动刷新,与 MVVM 的「通知驱动刷新」同源。把 QAbstractItemModel 作为 ViewModel 的 Q_PROPERTY 暴露给 QML,是桌面/移动端列表界面的标准做法(列表数据是 Model,列表的标题/空态/是否可加载属于 ViewModel)。
Qt Widgets 自带的 QDataWidgetMapper 能把 model 的某一行的列映射到一组控件的属性上(addMapping(lineEdit, 0)),并支持 SubmitPolicy 决定何时写回。它是「手写 MVVM 时代的过渡产物」:能省掉一部分同步代码,但需要手工设置映射、手工 toNext()/submit(),没有表达式级的依赖追踪。
很多老教程用 engine.rootContext()->setContextProperty("vm", &vm) 把 ViewModel 塞进 QML。它的问题在于:绕过了类型系统(拼错属性名只在运行时报警告、IDE 无法补全重构)、生成的属性不具备静态类型信息、性能与可维护性都较差。Qt6 的推荐做法是 QML_ELEMENT 注册类型,或把 ViewModel 做成 QML_SINGLETON(配合 qt_add_qml_module),让 QML 侧 import 后直接实例化、类型可检查。示例代码用的正是这种现代写法。
Q1:ViewModel 和 Model 的边界到底怎么划?我总是分不清某个字段该放哪边。
答:用三个判据依次问:① 「脱离界面后这个词还有意义吗?」「数量 10」「单价 12.5」「运费 0」都有意义 → Model;「¥112.50」只为显示服务 → ViewModel。② 「它是原始状态,还是为界面算出来的值?」原始状态 → Model;派生值(合计、是否可提交、排序键、图标名)→ ViewModel。③ 「换个 View(QML 换 Widgets、换命令行)还要不要它?」要 → Model;不要(如「按钮置灰」)→ ViewModel。
一句话记法:Model 回答「业务是什么」,ViewModel 回答「界面上应该呈现成什么样」。注意边界也有例外:如果格式化规则本身就是业务(如财务金额必须按会计准则舍入),那它属于 Model,ViewModel 只负责搬运。
Q2:为什么 Qt 里 Q_PROPERTY 必须带 NOTIFY 才能被绑定?
答:因为绑定引擎的工作机制是「读时登记依赖,变时通知重算」。首次求值表达式时,引擎记录该表达式读过哪些属性;只有当某个被依赖的属性发出变更通知时,引擎才把表达式标记为脏并重算。没有 NOTIFY,属性值即使改了也不会发出任何信号,引擎永远不会知道要重算——绑定只生效一次,之后界面就"冻住"了。这也是 QBindable 文档明确要求「属性必须有 notify 信号」的原因。此外,通知要精确:setter 应先做相等判断(如 if (m_qty == v) return;),否则会引发无意义重算,甚至在双向绑定中形成循环。
Q3:双向绑定为什么会死循环?工程上怎么防?
答:因为两个方向互相写入——A 变→写 B,B 变→写 A,若两边都用「值变化就写回去」的写法,就会无限来回(QML 会报 Binding loop detected)。三道防线:① 写前判等:setter/写回处先比较值,相等就返回;② 方向分离:显示方向用绑定(value: vm.quantity),写回方向用显式处理器(onValueModified: vm.quantity = value),不要用 onValueChanged 做写回;③ 必要时加保护位:在批量更新期间置 m_updating = true 跳过写回,或用 QML 的 Binding { ... } 显式控制生命周期。真正的最佳实践是减少双向绑定:让 ViewModel 只做单向数据源,输入通过命令(vm.setQuantity(v))进入。
Q4:ViewModel 需要弹一个对话框/提示 Toast,能不能持有 View 的指针?
答:不应该。一旦 ViewModel 持有 QWidget* 或 #include 了任何界面头文件,它就与具体 UI 绑死:无法在命令行/QML/单元测试中复用,还会带来悬空指针风险(View 先被销毁而 ViewModel 还在)。正确做法两条:① 用信号广播意图——ViewModel 只 emit orderSubmitted(summary) 或 emit needConfirm(message),由 View 或上层协调者决定怎么展示(弹框、Toast、日志);② 依赖倒置——注入一个抽象接口(如 IUserNotifier),生产环境实现成弹框,测试环境实现成记录调用。
生命周期方面:Model 应在 ViewModel 之前构造、之后析构;ViewModel 若持有 Model,用引用/智能指针且明确所有权(std::unique_ptr 成员或 QObject 父子关系),不要用裸指针跨模块传递。QML 里绑定的对象被销毁时,引擎会自动断开绑定与连接,这是 QObject 体系的一大便利。
Q5:Qt Widgets 项目(不用 QML)能落地 MVVM 吗?值不值得?
答:能,但要分档选择:(a)完整绑定方案——用 Qt 6 的 QProperty/QObjectBindableProperty 让 ViewModel 属性可绑定,再用 QBindable(控件, "属性名")(Qt 6.5+,要求该属性有 NOTIFY)把 QLineEdit::text 这类既有属性拉进依赖追踪,于是纯 C++ 也能做声明式绑定;(b)务实方案——ViewModel 照常写(真正的收益在「逻辑可测 + 状态集中」),View 侧只保留少量 connect 作为「手写的绑定」,一个 View 一个文件、逻辑绝不外溢;(c)过渡方案——QDataWidgetMapper 处理「表单 ↔ 模型行」的机械映射。
值得不值得?判断标准是界面是否有大量派生状态。表单/面板类界面值得;简单对话框不值得,直接用 (b) 方案足够。真正的推荐是:新项目优先 QML,存量 Widgets 项目用 (b) + 渐进引入 (a)。
需求:做一个「下载队列面板」的 MVVM 骨架(不需要真的下载,用 QTimer 模拟进度即可)。
面板要求:
验收标准(自己检查,不看答案):
totalProgressText() 与 canRetry() 的取值。QString 的显示格式(如 "%"、"已完成"),它们都应出现在 ViewModel。提示 1(结构):不要为每个任务单独建 ViewModel。让 ViewModel 暴露一个 QAbstractListModel 子类(或 QList<DownloadTaskData> + Q_PROPERTY)作为列表数据源,整体状态(总数/完成数/平均进度/是否有失败)由 ViewModel 用一条 recalc() 统一派生——所有派生属性都带 NOTIFY,列表内部变化用 dataChanged 通知。
提示 2(实现):任务进度用 QTimer 每 200ms 自增并在达到 100 时把状态改为「已完成」,随机让某个任务在 60% 时失败(QRandomGenerator 或写死模拟)。暂停用 Q_INVOKABLE void pauseAll() 停掉所有 timer,同时让 canResume() 属性自动翻转——注意检查你的 setter 里有没有做相等判断,以及暂停/恢复是否会引发绑定循环。
挑战加码(可选):给面板加一个「按状态筛选」下拉框(全部/进行中/失败),筛选逻辑放在 ViewModel 的 Q_INVOKABLE setFilter(...) + 重新派生的 visibleCountText(),从此你会真正体会到「一个状态变化如何自动牵动多个显示项」。
QObject 子类的类名以 ...ViewModel 结尾,里面有大量 Q_PROPERTY(X READ x WRITE setX NOTIFY xChanged),并且每个属性都带 NOTIFY。totalText、canSubmit、isEmpty),它们在 setter 里被统一重算,而不是在界面里现算。Q_INVOKABLE submit() / 槽函数),而不是内联在事件处理器里的一大段逻辑。今天我们用「属性绑定」解决了一对一/一对多的状态同步。但当应用长大到几十个模块时,新问题出现了:模块 A 想通知模块 B,但 A 不该认识 B(否则模块图变成蜘蛛网)——这就是跨模块解耦通信的问题。明天我们讲事件总线:如何用「发布-订阅 + 事件对象」把模块之间的直接依赖换成「向总线广播」,它和观察者模式(Day 18)到底差在哪(提示:差在谁是主题、事件是否有类型、是否跨线程),Qt 里怎么用 Q_GLOBAL_STATIC + 信号 + 单例总线落地,以及事件总线最容易被滥用的三条红线(全局可变状态、隐式调用链、无法静态追踪的订阅关系)。届时你会发现:MVVM 解决的是层内同步,事件总线解决的是层间/模块间通知——两者拼起来,才是完整桌面客户端架构的骨架。