客户端架构

今日学习:Qt MVVM 与依赖注入——从 Model/View 到大型客户端架构

2026-08-16 · 每日技术导师

01

为什么今天学这个主题

前几期我们解决了 Qt 的"零件问题":信号槽、事件循环、多线程、网络、Model/View、插件系统——但把零件组装成一台能长期演进的机器,是另一门学问。大型桌面客户端(IDE、IM、办公软件)真正的复杂度不在单点技术上,而在"界面逻辑与业务逻辑如何分离、如何让核心逻辑可测试、如何让 UI 技术栈可替换"。MVVM 与依赖注入正是这一层问题的标准答案,也是从"会用 Qt"跨向"能设计客户端"的关键一跃。

对你而言,这套心智模型几乎不用从零学:Vue 的 reactive/computed、React 的 props/单向数据流、Redux 的 action——MVVM 就是它们在桌面端的"官方形态"。今天要做的,是把散落在 Web 经验里的概念,重新安放到 C++/Qt 的坐标系里。

02

核心知识:MVVM 的三层职责与依赖方向

MVVM(Model-View-ViewModel)由微软 WPF 团队在 2005 年前后提出,理论源头是 Martin Fowler 的 Presentation Model。它把界面层拆成三个角色:

理解 MVVM 的关键不是"三层分别是什么",而是依赖方向:View → ViewModel → Model,单向向下。依赖只能从"离用户近的层"指向"离用户远的层",反向依赖一出现,架构立刻退化。为什么这个方向如此重要?因为依赖方向决定测试方向:ViewModel 只依赖纯逻辑的 Model 接口,就可以脱离 GUI 直接 new 出来测试;如果 ViewModel 里 new 了一个 QLabel,它就再也无法在无窗口环境下运行,可测试性归零。

与 MVC/MVP 对比,差异在"中间层是否认识 View":

模式中间层是否持有 View 引用View 如何刷新典型适用
MVCController通常持有Controller 直接操作传统 Widgets 小应用
MVPPresenter通过接口持有Presenter 调用 View 接口需要强可控的复杂交互
MVVMViewModel完全不知道View 主动订阅状态变化QML / 数据驱动界面

Qt 里 MVVM 的两种落地路线。QML 路线是天然 MVVM:View 是 .qml 文件,ViewModel 是暴露了 Q_PROPERTY 的 QObject,绑定表达式由 QML 引擎自动求值、自动刷新,ViewModel 对 View 零感知。Widgets 路线则需要手动搭桥:ViewModel 仍是 QObject + Q_PROPERTY + 信号,View 通过 connect 订阅信号刷新控件,或用 QDataWidgetMapper 做简单字段映射。区别在于:QML 的绑定是声明式的(属性变了 UI 自动变),Widgets 是命令式的(你要写 connect 和 setText),但 ViewModel 本身可以完全共用——这就是"UI 技术栈可替换"的由来。

依赖注入。MVVM 只解决了"展示与逻辑分离","逻辑与基础设施分离"要靠依赖注入:ViewModel 不自己 new QNetworkAccessManager、不自己开数据库,而是通过构造函数接收抽象接口。Qt 里用 Q_DECLARE_INTERFACE/Q_INTERFACES 宏声明接口 + qobject_cast 做接口查询,或直接定义纯虚基类。注入方式推荐构造函数注入:依赖显式、无法被遗忘、测试时直接传 mock。IoC 容器(自动装配)在 Qt 生态并不流行,多数大型项目选择在 Composition Root(main() 或应用启动处)手动组装:牺牲一点样板代码,换来依赖图完全可见、可 grep、可静态检查。

一句话原理:MVVM 的本质是把"界面状态"从 View 里抽出来放进 ViewModel,让状态可以脱离像素存在、可以被断言、可以被复用。依赖注入的本质是让"谁依赖谁"从代码里浮现出来,而不是藏在 new 表达式和全局单例里。

03

实际案例:大厂客户端为什么这样设计

JetBrains 全家桶(IntelliJ IDEA)。IntelliJ Platform 是最值得解剖的大型客户端架构之一。它的 UI 层(Swing)与业务层(PSI、Project Model、语言引擎)之间横着一套 Action 系统:菜单、快捷键、工具栏按钮统统注册为 Action,UI 只发"动作请求",具体逻辑由 Action 分发给各服务。UI 完全不持有业务对象,业务层也不 import 任何 Swing 类——这正是"命令模式 + 接口隔离"的 MVVM 变体。代价是样板代码多,换来的是:几十个插件共享同一套 UI 契约、逻辑可以脱离 IDE 界面跑单元测试。

VSCode。VSCode 把"View 与 ViewModel 跨进程隔离"做到了极致:渲染进程(Web UI)与扩展宿主(Node)之间只有消息协议。UI 层能看到的不是扩展对象,而是 API 契约(commands、workspace 等命名空间),扩展逻辑对 UI 零依赖。这验证了一条架构真理:分离越彻底,越能独立演进、独立测试、独立崩溃。

Chrome。浏览器 UI 进程与渲染进程的隔离是进程级的 MVVM:UI 进程只通过 IPC 与渲染进程通信,渲染进程崩溃不影响浏览器外壳。迁移到桌面端的启示:如果你的客户端有"渲染/预览"这类高风险模块,进程级或线程级隔离 + 消息通道,就是 MVVM 在更大粒度上的复刻。

微信 / QQ 桌面端。消息列表、会话列表都是教科书级的 Model/View:数据在业务层管理,ListView 只是展示投影。而"发送消息"走的是命令链路:UI 点击 → 命令 → 业务层 → 状态变更 → 列表刷新。会话状态(未读数、草稿、置顶)本质就是一个巨大的 ViewModel 聚合,UI 只是它的投影。

Qt Creator 本身。项目树、导航、输出面板全部是 model 驱动,核心编辑器逻辑与界面通过接口解耦,插件才能以接口契约为边界自由扩展(呼应我们前几期学的插件系统)。

共同规律:这些产品都不是"为了 MVVM 而 MVVM",而是被四个现实需求逼出来的——① 团队可以并行开发 UI 与逻辑;② UI 技术栈可替换(Swing→Compose、Widgets→QML);③ 核心逻辑能脱离 GUI 跑单测;④ 高风险模块崩溃可隔离。

04

常见错误:把 MVVM 写成"三明治"

05

最佳实践:四条铁律与它们的边界

06

与 Web 技术的联系:你的经验已经值 80 分

对照表可以直接背:

Web 概念Qt/C++ 对应物差异点
Vue reactive/refQ_PROPERTY + NOTIFY 信号Web 自动追踪依赖,Qt 要手动声明 NOTIFY
Vue computed属性 getter 内实时计算Qt 无缓存依赖追踪,注意计算开销
React props 单向数据流ViewModel 暴露只读状态 + 命令方向一致:状态从上层流向下层
Redux action/reducer命令对象 / ViewModel 方法Redux 强制不可变,Qt 无此约束,靠纪律
Pinia/Vuex store全局服务 + 信号Web 靠框架约束,Qt 靠接口与注入约束
Node EventEmitter信号槽(connect)Qt 支持 context object 自动断连,更安全
浏览器渲染进程隔离QThread + 队列连接 / 进程都是"隔离 + 消息",粒度不同
组件状态提升(lift state up)状态收敛到 ViewModel同一个思想:共享状态往上放

迁移建议三条:① 写 Qt 前先画"状态图":界面上有哪些状态、来自哪个 ViewModel、谁能改它——就像设计 Vue 组件前先想清楚 data/computed;② 把"在 watch/effect 里写逻辑"的习惯换成"connect 信号里只做 UI 更新",逻辑全部留在 ViewModel 方法里;③ 你在 Web 里踩过的坑(全局状态失控、组件间乱传 props、异步竞态)在 Qt 里一个不落地重演,但 Qt 给的是更底层的工具,约束更少、更需要自律——这正是架构师存在的意义。

07

管理者视角:怎么带团队落地这套架构

作为 Team Leader,你不需要亲手写每一层,但你要能回答三个问题:依赖方向对不对、能不能测、状态好不好追。

08

延伸阅读

09

今日思考题

1. MVVM 在 Web 前端(Vue/React)大行其道,在 Qt Widgets 社区却争议不断;而到了 QML 时代它又流行起来。为什么同一个模式在不同 UI 技术上的命运不同?

2. QML 的属性绑定是自动的,Widgets 的刷新要靠 connect——如果让你设计一个"Widgets 版绑定层",你会怎么做?QDataWidgetMapper 够用吗?

3. 你经历过的 Web 状态管理演进(全局变量 → store → 组合式 API)本质上在解决什么问题?Qt 桌面端会经历同样的演进吗?

4. undo/redo 应该放在 ViewModel 里还是独立的命令层?如果放命令层,命令与 ViewModel 如何协作才不破坏单向依赖?

5. 异步操作(网络请求)在 MVVM 里应该由谁发起、谁持有状态?对比你在 Web 里用 async/await + loading 状态的做法,迁移到 Qt 信号/槽模型有什么坑?

10

今日实践任务(约 45 分钟)

目标:写一个不依赖任何 UI 类型的计数器 ViewModel,并用 QTest 验证它的行为——体验"脱离像素的界面逻辑"。三份文件如下:

// counterviewmodel.h —— 纯逻辑 ViewModel,禁止 include 任何 UI 头文件
#pragma once
#include <QObject>

class CounterViewModel : public QObject {
    Q_OBJECT
    Q_PROPERTY(int count READ count NOTIFY countChanged)
    Q_PROPERTY(int step READ step WRITE setStep NOTIFY stepChanged)
public:
    explicit CounterViewModel(QObject* parent = nullptr) : QObject(parent) {}
    int count() const { return m_count; }
    int step() const { return m_step; }
    void setStep(int s) { if (s == m_step) return; m_step = s; emit stepChanged(); }
    Q_INVOKABLE void increment() { setCount(m_count + m_step); }
    Q_INVOKABLE void decrement() { setCount(m_count - m_step); }
    Q_INVOKABLE void reset() { setCount(0); }
signals:
    void countChanged();
    void stepChanged();
private:
    void setCount(int c) { if (c == m_count) return; m_count = c; emit countChanged(); }
    int m_count = 0;
    int m_step = 1;
};
// test_counter.cpp —— QTest 用例:无窗口、无 UI,纯逻辑断言
#include <QtTest>
#include "counterviewmodel.h"

class TestCounter : public QObject {
    Q_OBJECT
private slots:
    void incrementEmitsSignalAndUpdates() {
        CounterViewModel vm;
        int signalCount = 0;
        QObject::connect(&vm, &CounterViewModel::countChanged, [&] { ++signalCount; });
        vm.increment();
        QCOMPARE(vm.count(), 1);      // 值正确
        QCOMPARE(signalCount, 1);     // 通知恰好一次
    }
    void stepControlsDelta() {
        CounterViewModel vm;
        vm.setStep(5);
        vm.increment();
        QCOMPARE(vm.count(), 5);
        vm.decrement();
        QCOMPARE(vm.count(), 0);
    }
    void resetWorks() {
        CounterViewModel vm;
        vm.increment(); vm.increment();
        vm.reset();
        QCOMPARE(vm.count(), 0);
    }
};
QTEST_MAIN(TestCounter)
#include "test_counter.moc"
# CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(counter_vm_demo LANGUAGES CXX)
find_package(Qt6 REQUIRED COMPONENTS Core Test)
qt_standard_project_setup()
qt_add_executable(test_counter test_counter.cpp counterviewmodel.h)
target_link_libraries(test_counter PRIVATE Qt6::Core Qt6::Test)
enable_testing()
add_test(NAME counter_vm COMMAND test_counter)

执行步骤:① 建目录放入三份文件;② cmake -B build && cmake --build build 编译;③ QT_QPA_PLATFORM=offscreen ctest --test-dir build 跑测试,三个用例全绿;④ 加练(挑战):给 ViewModel 增加一个 maxValue 上限属性(超出则 increment 无效并发出 limitReached 信号),补一条测试用例——这一步会让你真正体会"规则在 ViewModel、展示在 View"的分工。

11

一句话总结

好的客户端架构不是"怎么用框架",而是让界面只做展示、让逻辑可被测试、让依赖方向清晰可见——MVVM 与依赖注入,就是把这三件事固化成代码结构。