Day 25:模型-视图-视图模型(Model-View-ViewModel, MVVM)

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

架构模式 数据绑定 Qt/QML 可测试性 Q_PROPERTY

01

今日主题

一句话定义: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 信号)收集依赖。

02

为什么需要这个模式

先看一段你一定写过的代码——一个「订单录入」界面,需求是:改数量或单价时,下方合计要跟着变;合计为 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)。逐条看它违背了什么:

症结不在 connect,而在「谁认识谁」。MVC 已经切开了 Model 和 View,但 Controller 仍要同时握住两边并手工指挥刷新。MVVM 的答案很锐利:让界面自己去订阅状态——View 只在初始化时声明一次「我依赖哪些属性」,此后所有同步都是自动的,再也不用写第二遍 connect,也再也不会漏写。

03

核心思想

通俗解释:MVVM 把一次界面开发拆成三道问题——「数据与规则是什么」(Model)、「界面上有哪些可以显示/编辑的东西,它们怎么算出来」(ViewModel)、「这些东西画成什么样」(View)。关键在于第三问只允许声明,不允许运算:View 里不写计算逻辑,只写「这里显示 vm.totalText」。

生活类比——餐厅:把程序当成一家餐厅。

三个技术要点决定了 MVVM 能不能立住:

  1. 可观察状态(Observable State):ViewModel 的属性在变化时必须发通知。在 Qt 里就是 Q_PROPERTY(... NOTIFY xxxChanged);没有 NOTIFY 的属性是绑不上的。
  2. 声明式绑定(Declarative Binding):View 侧写下「依赖关系」而不是「同步动作」。QML 的 text: vm.totalText 是绑定;Widgets 里的 label->setText(...) 是命令。这是 MVVM 与 MVC 最本质的分水岭。
  3. 展示态转换(Presentation Logic):把「数字 33.0」变成「¥33.00」、把「total==0」变成「按钮置灰」——这类逻辑属于 ViewModel,因为它可测且与具体控件无关。

最后记住 MVVM 的一条铁律:依赖箭头是单向的。Model 不知道 ViewModel,ViewModel 不知道 View,View 知道 ViewModel(通过绑定)。任何一条反向引用(比如在 ViewModel 里 #include "MainWindow.h")都会立刻让这个架构退化成「更难维护的 MVC」。

04

角色关系与职责

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 命令模式在架构层的延续:把「请求」变成一等对象/一等接口。

05

最小 C++ 示例(不依赖 Qt)

为了看清 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 的父子/连接追踪,对象析构即自动断开,无需你手工清理)。

06

Qt 实战:QML + ViewModel(完整可编译)

Qt 的 MVVM 主战场是 QML:QML 本身就是声明式语言,绑定是语言级能力,而 Qt 的元对象系统天然提供 NOTIFY 信号——两者合起来正好就是绑定引擎需要的一切。下面这个示例做「订单小面板」,注意三个关键设计:ViewModel 用 QML_ELEMENT 注册到 QML 类型系统、每个可绑定属性都有 NOTIFY、展示态字符串在 C++ 侧算好(totalText),QML 只做拼接显示。

orderviewmodel.h

// 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;
};

orderviewmodel.cpp

#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));
}

ordermodel.h(Model:零 UI 依赖,可单独单元测试)

#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:只有声明,没有计算)

// 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 与 CMakeLists.txt

// 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; 去重,这两道防线缺一不可。

07

代码执行流程

以示例程序为例,从启动到用户改一次数量,完整经过四步:

  1. ① 装载与装配(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 规则。

  2. ② 首次求值建立依赖(Initial Binding Pass)
    QML 引擎逐条求值绑定表达式:读 vm.quantity、vm.unitPrice、vm.totalText、vm.canSubmit。关键在于「读」这个动作本身会被记录——表达式开始求值时引擎打开依赖收集,凡是求值过程中访问过的、带 NOTIFY 的属性(或 BINDABLE 属性的 QBindable::value())都被登记为这个表达式的依赖。求值结束,依赖表建立完成。这就是「不写 connect,却能自动同步」的全部秘密。

  3. ③ 用户操作 → 写回 → 通知
    用户在 SpinBox 上点加减 → onValueModified 执行 vm.quantity = value(这是 C++ 属性写操作,QML 通过元对象调用 setQuantity())→ setter 判断值确实变了 → 赋值 → emit quantityChanged() → recalc() 重算 m_totalText / m_canSubmit,各自有变化才 emit。

  4. ④ 传播与重绘
    引擎收到 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
                                                    │
                                              场景图更新 → 重绘
08

为什么这样设计(解耦点 / 扩展点 / 设计原则)

MVVM 的价值不在「少写代码」,而在把变化关进笼子。逐条拆解:

① 解耦点在哪里

② 扩展点在哪里

③ 对 SOLID 的支撑

原则在 MVVM 中的体现
SRPView 只管画、ViewModel 只管状态投影、Model 只管规则。一个类只有一个变化原因:改皮肤不动 vm,改规则不动 QML。
OCP绑定表达式是「数据驱动的扩展点」:新增显示/联动靠加属性加绑定,而不是改 refresh() 里的 if-else。
LSPViewModel 面向「可观察状态」抽象;换成另一个 ViewModel 实现(如离线模式/演示模式)对 View 透明。
ISP一个界面只依赖它需要的属性。Big FormViewModel 可以拆成多个小 ViewModel(Header/List/Footer),每个 View 只依赖自己那一小块。
DIPViewModel 依赖 Model 的抽象接口(Repository/Service),不依赖具体实现,因此可以注入 Fake 实现做测试。

④ 组合优于继承

经典 MVC 常被写成「让窗口类继承一个 Controller 基类」,于是「窗口」与「控制器」的继承体系纠缠;MVVM 的做法是组合:窗口持有一个 ViewModel 对象。OrderViewModel 可以是纯 QObject,不需要继承任何 UI 基类。好处是它可以放进任何容器(QML 类型、Widgets 成员、测试夹具),也可以在运行时替换整个实例(切换租户/主题/模式时整块状态一起换)。

⑤ 可测试性(MVVM 最现实的收益)

这是架构落地时最有说服力的一条。用 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 的失败信息可能是「控件没找到」。

09

不使用会怎样

不用 MVVM(或任何分层)的界面代码,结局通常是下面五种病症按顺序发作:

  1. connect 地狱(N×M 同步矩阵):界面上 N 个可编辑项、M 个派生显示项,最坏情况下要维护 N×M 条同步关系。加一个输入框,就要重新审一遍「它影响哪些显示项」,漏一条就是线上 bug。这类 bug 的典型表现是「改了数量,合计更新了,但提交按钮还亮着」——不是逻辑错,是忘了连那一条线。MVVM 把 N×M 降成 N 条绑定 + 1 处派生计算。

  2. if-else 膨胀:同一个界面上「A 视图模式显示 X、B 模式显示 Y、管理员还要多显示 Z」,很快演变成 refresh() 里 200 行的条件分支,且每个分支都要小心不要漏改某个控件。真实项目里这个函数经常成为全项目最长的函数之一。

  3. 类膨胀与复制粘贴:同一个订单逻辑出现在「创建订单页」「编辑订单页」「快速下单弹窗」三处。第一版复制粘贴很爽,第三版就开始漂移——某一个页面漏了「满 10 件 9 折」的修复,客诉来了才发现。MVVM 里三处界面共享同一个 ViewModel(或同一个 Model 规则),逻辑只有一份。

  4. UI 变更引发回归:只想把按钮从底部挪到顶部,却因为 refresh() 里夹杂着对文本格式和控件指针的操作,改动牵动业务代码,必须回归测试整条链路。分层正确的代码里,改布局不碰任何逻辑。

  5. 无法自动化测试,只能靠人点:CI 里跑不了 GUI 交互,最终所有验证退化成人肉点击清单。团队规模一上来,这直接决定发布节奏。反过来,一个干净 ViewModel 的单元测试可以覆盖 80% 以上的状态组合。

一句话总结危害:MVVM 解决的根本问题是「状态与界面之间的同步关系数量爆炸」。只要你的界面有一个以上派生显示项,你就在付这份维护成本——区别只是早付(写绑定声明)还是晚付(修线上 bug)。

10

何时使用

适合的场景

不适合的场景

过度设计提醒:不要为了「架构」而把每个属性都包一层 ViewModel。判断标准很具体——只要你能列出「属性 A 变化时需要同步刷新 B、C、D」,就有引入绑定的理由;如果每个控件都是独立的孤立输入(没有派生关系),那它只是一个 Value Object,套 ViewModel 是空转。另外,别做「ViewModel 套 ViewModel 套 ViewModel」——超过两层的投影通常意味着职责没划清(该拆成多个并列的小 ViewModel,而不是继续往深里包)。

11

与其他模式的区别

MVVM 常和 MVC、MVP 混着说,也会被误认为就是观察者模式。三者界线如下:

维度MVCMVPMVVM
中间层ControllerPresenterViewModel
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)
主要代价 同步代码易漏、难测 接口数量膨胀(每个视图动作一个接口方法) 绑定是「隐式控制流」,调试需要理解依赖收集;过度使用会有魔法感

与观察者模式(Day 18)的关系

MVVM 不是观察者模式的替代品,而是观察者模式的架构级应用:NOTIFY 信号是观察者机制的载体,绑定表达式是「订阅者」,依赖收集是「自动注册订阅」。可以说 MVVM = 观察者 + 求值引擎 + 展示态投影规则。理解了 Day 18,你就已经握住了 MVVM 的引擎——今天补的是「谁来订阅、谁来做派生计算」的架构约定。

与命令模式(Day 14)的关系

ViewModel 暴露的 Q_INVOKABLE submit() 把「用户意图」变成可调用的一等接口,View 侧不再内联业务动作。这与命令模式把「请求」封装成对象的动机一致:可测试、可复用、可撤销(QUndoStack 与 ViewModel 结合是桌面客户端的常见做法)。

与策略模式(Day 20)的关系

ViewModel 内部常把「格式化策略」「校验策略」抽成可替换组件:同一份数据,实体店模式显示含税价、电商模式显示包邮价。MVVM 负责结构,策略负责可换的算法,两者互补不冲突。

12

Qt 源码与框架中的体现

① QML 引擎:Qt 自带的绑定引擎

QML 的整个属性系统就是一套 MVVM 绑定引擎(源码位于 qtdeclarative/src/qml/qml/):绑定表达式求值时收集依赖,属性 NOTIFY 发出时把依赖它的表达式标记为脏、批量调度重算,并检测绑定循环(Binding loop detected 警告)。这正是 QQmlBinding / 依赖追踪要做的事,也是为什么「Q_PROPERTY 必须带 NOTIFY」是硬性要求——绑定引擎要靠通知来触发重算。

② QProperty / QBindable / QObjectBindableProperty(交给 Widgets 的绑定)

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 的关键工具。

③ QAbstractItemModel:Model 侧的标准接口

Qt Model/View 里,QAbstractItemModel 就是标准化的 Model(数据 + 角色),QTableView/QListView 是 View,delegate 负责绘制单元——整体是「委托版 MVC」:View 通过 dataChanged 信号自动刷新,与 MVVM 的「通知驱动刷新」同源。把 QAbstractItemModel 作为 ViewModel 的 Q_PROPERTY 暴露给 QML,是桌面/移动端列表界面的标准做法(列表数据是 Model,列表的标题/空态/是否可加载属于 ViewModel)。

④ QDataWidgetMapper:Qt 早期的「穷人版绑定器」

Qt Widgets 自带的 QDataWidgetMapper 能把 model 的某一行的列映射到一组控件的属性上(addMapping(lineEdit, 0)),并支持 SubmitPolicy 决定何时写回。它是「手写 MVVM 时代的过渡产物」:能省掉一部分同步代码,但需要手工设置映射、手工 toNext()/submit(),没有表达式级的依赖追踪。

⑤ 实践建议:别用 setContextProperty 注入 ViewModel

很多老教程用 engine.rootContext()->setContextProperty("vm", &vm) 把 ViewModel 塞进 QML。它的问题在于:绕过了类型系统(拼错属性名只在运行时报警告、IDE 无法补全重构)、生成的属性不具备静态类型信息、性能与可维护性都较差。Qt6 的推荐做法是 QML_ELEMENT 注册类型,或把 ViewModel 做成 QML_SINGLETON(配合 qt_add_qml_module),让 QML 侧 import 后直接实例化、类型可检查。示例代码用的正是这种现代写法。

13

面试常见问题

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)。

14

今日练习(15–30 分钟)

需求:做一个「下载队列面板」的 MVVM 骨架(不需要真的下载,用 QTimer 模拟进度即可)。

面板要求:

  • 顶部:总进度文本(如「2/5 已完成 · 平均 42%」)、一个「暂停全部」按钮。
  • 中部:任务列表(每项:文件名、百分比、状态:等待/下载中/已完成/失败)。
  • 底部:「全部已完成」提示,仅当所有任务成功完成时显示;只要有失败项,就显示「重新下载失败项」按钮。

验收标准(自己检查,不看答案):

  • 把某个任务标记为失败后,底部按钮自动出现,顶部文本自动改变,且你没有写任何额外的 connect 或手动 refresh。
  • 能写出一个不需要创建任何窗口就能运行的测试:给定任务进度序列,断言 totalProgressText() 与 canRetry() 的取值。
  • Model 层里不能出现 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(),从此你会真正体会到「一个状态变化如何自动牵动多个显示项」。

15

今日总结

一句话记忆:MVVM 就是「把界面状态抽出来做成可观察属性,然后让界面自己去订阅」——View 不干活只声明,ViewModel 不知 View 只投影,同步交给绑定引擎。

代码特征信号(看到这些,大概率就是 MVVM)

今天要带走的三句话

  1. 绑定的前提是通知:Q_PROPERTY 没有 NOTIFY 就绑不上,setter 没有判等就会引起无用刷新甚至绑定循环。
  2. 单向依赖是铁律:Model ← ViewModel ← View。ViewModel 一旦认识 View,架构立刻坍塌,测试性也随之消失。
  3. 收益的落点是测试:如果引入 MVVM 后你的 ViewModel 依然测不了,那说明逻辑还留在界面里,只是换了个文件名。

明日预告:Day 26 —— 事件总线模式(Event Bus / Publish-Subscribe)

今天我们用「属性绑定」解决了一对一/一对多的状态同步。但当应用长大到几十个模块时,新问题出现了:模块 A 想通知模块 B,但 A 不该认识 B(否则模块图变成蜘蛛网)——这就是跨模块解耦通信的问题。明天我们讲事件总线:如何用「发布-订阅 + 事件对象」把模块之间的直接依赖换成「向总线广播」,它和观察者模式(Day 18)到底差在哪(提示:差在谁是主题、事件是否有类型、是否跨线程),Qt 里怎么用 Q_GLOBAL_STATIC + 信号 + 单例总线落地,以及事件总线最容易被滥用的三条红线(全局可变状态、隐式调用链、无法静态追踪的订阅关系)。届时你会发现:MVVM 解决的是层内同步,事件总线解决的是层间/模块间通知——两者拼起来,才是完整桌面客户端架构的骨架。