浏览器替你隔离了一切,Qt 里这些边界必须自己设计——架构师的第一课
2026-08-31 · 第 31 期前面几期我们解决了"怎么渲染、怎么跑事件循环、怎么并发"——这些都是技术问题;而架构师每天真正面对的是组织问题:这段代码该放哪个模块?谁允许依赖谁?为什么加一个缓存要改五个文件?在 Web 世界里,浏览器替你划好了边界(DOM、渲染、网络、存储各有各的层),Vue/React 又替你管好了组件树与状态;而 Qt 桌面应用从 main() 开始就是一张白纸,所有边界都要自己画。画错了,项目会在第 3 万行代码时开始报复你:改一个字段牵一发动全身、单测写不起来、新人不敢动老代码。
本期聚焦客户端架构的三大支柱:分层架构(代码该放哪)、依赖注入与依赖倒置(谁依赖谁)、信号槽解耦(怎么通信),最后延伸到插件化架构。学完你会获得一套判断标准:任何一次"代码放哪"的争论,都能用依赖规则一锤定音。
经典三层:表现层(QWidget/QML)→ 业务层(用例、服务、领域对象)→ 数据层(数据库/网络/文件/系统 API)。关键不是"分几层",而是 Robert Martin 在《Clean Architecture》里给出的依赖规则(Dependency Rule):源代码依赖只能从外指向内,内层绝不依赖外层。表现层可以依赖业务层,业务层可以依赖数据层的接口,但业务层头文件里不允许出现 QWidget、QSqlDatabase、QNetworkAccessManager——因为"用什么 UI 框架、什么数据库"都是外层细节,必须可以被替换而不惊动业务逻辑。判断分层是否健康的标尺只有一个:画一张模块依赖图,不允许出现环,不允许出现反向依赖。
依赖倒置原则:高层模块不依赖低层模块,二者都依赖抽象;抽象不依赖细节,细节依赖抽象。落地手段是依赖注入,三种形式:构造函数注入(推荐——依赖显式、不可变、天然可测试)、setter/属性注入(可选依赖、可后置,但对象可能处于"未装配完成"的中间态)、Service Locator(全局取服务,本质是隐藏依赖,被广泛视为反模式倾向)。C++ 没有反射,也没有 Spring 那样的容器,所以 Qt 世界的惯例是组合根(Composition Root):在 main() 或 App 启动代码里手工装配整棵依赖树——全程序只有这一个地方允许 new 具体实现,其余代码只认接口。注意与 QObject 生命周期机制的配合:QObject 用 parent 管理所有权,若注入的依赖本身是 QObject,要明确 parent 归属,避免"注入者析构了、被注入者还在用"的悬垂。
信号槽最大的价值不是"少写回调",而是它强制你区分两类语义:命令(command)用普通函数调用——同步、有返回值、调用栈完整、可单测;通知(notification)用信号——一对多、发送者不关心谁在听、跨线程自动退化为 QueuedConnection 排队投递。Qt 的 AutoConnection 把"线程安全通信"变成了语言特性(同线程同步、跨线程排队),但代价是调试时调用栈断裂:槽函数在接收者的事件循环里执行,断点回溯不到发送现场。另一个容易被忽略的点是连接的生命周期:receiver 析构时连接自动断开,但用 connect(sender, &T::sig, this, [=]{...}) 且 lambda 捕获了裸指针时,要确认捕获对象与 receiver 同生命周期;重复连接(同一对信号槽连两次)会让槽执行两次,是"莫名执行多次"的头号来源。
QWidget 世界主流是 MVC/MVP:QAbstractItemModel 是视图与数据之间的适配器,data()/rowCount()/beginInsertRows() 这套接口恰好对应前端里"状态提升 + selector"的职责——视图只读模型,写操作通过命令走业务层。QML 世界天然是 MVVM:ViewModel 暴露 Q_PROPERTY + NOTIFY 信号,View 用声明式绑定——这与你熟悉的 Vue 响应式几乎同构,但注意 Qt 的绑定默认单向(属性变化→UI 更新),双向绑定在 QML 里要显式写 onXxxChanged 回写,别用 Vue 的 v-model 心智直接套。
插件化的本质是运行时依赖反转:主程序在编译期不知道有哪些实现,运行期通过 QPluginLoader 按路径加载 .so/.dll 并 instance() 取插件对象。三个硬性要求:接口类必须继承 QObject 并声明 Q_DECLARE_INTERFACE(IMyPlugin, "com.example.imyplugin/1.0")(IID 是全局唯一契约);插件实现端写 Q_PLUGIN_METADATA(IID, FILE "meta.json");接口一旦发布,虚函数表顺序与 IID 永不可变——否则旧插件加载进新主程序,虚表错位直接崩溃。Qt Creator 的 ExtensionSystem 是官方范本:Core 定义 IPlugin 与扩展点,数百个功能插件通过接口互操作,插件之间不互相 include 具体类。这是"接口即契约、稳定优先于灵活"的极致体现。
Qt Creator:插件架构的教科书。Core 插件只提供窗口框架、文档模型与扩展点注册表,Clang 代码模型、构建系统、调试器全部是独立插件,通过 ExtensionSystem 交换对象。为什么这么做?因为 IDE 的功能边界天然是"可插拔"的——不同用户要不同工具集,社区插件生态(Vim 模拟、主题、语言支持)全靠这套契约活着。代价也很真实:启动要加载几十个 .so,插件间版本不匹配是常见故障源。它告诉你:插件化是为"第三方扩展"付费的架构,不是默认选项。
Chrome:分层 + 进程隔离的极致。浏览器进程内部严格分层(UI 层→IO 层→存储层),渲染进程内部是 Blink 的 DOM→样式→布局→合成管线;扩展系统用 JSON manifest + 事件 API 做契约,扩展永远碰不到渲染内核内部。Chrome 的设计原因很朴素:任何一层崩溃或卡顿都不能拖垮全局,边界即安全。
VS Code(Electron):主进程、渲染进程、扩展宿主三进程分离,扩展只能通过协议化 API 通信、不能直接碰 DOM——用进程强制分层。对比你未来写的 Qt 工具:没有浏览器帮你隔离,你就要自觉做到"UI 层不 import 数据层",否则迟早长成一座大泥球。
微信/QQ 桌面端:核心消息引擎、数据库、加解密以动态库形式共享,聊天、支付、小程序走独立进程隔离;主界面只做渲染与输入分发。**JetBrains IDEA**:插件市场 + openapi 接口 + 类加载器隔离,接口版本化演进。这些产品的共同规律只有一条:边界即契约,接口的稳定优先级永远高于实现的灵活——这正是你在 Web 团队里做前端基建(组件库 API 冻结、SDK 版本兼容)时早就验证过的道理,现在只是换成了 C++ 的 ABI 语言。
错误①:表现层穿透到数据层——在按钮的 lambda 里直接开 QSqlQuery 查库。原因:图快,跳过业务层。后果:换数据库、加缓存、加权限都要改 UI 代码;业务规则散落在每个控件回调里,无法单测。避免:UI 只调用 service 方法,数据访问永远藏在接口后面。
错误②:信号槽当万能胶水——信号连信号、跨三层 connect、槽函数里做重活(网络、解析、写库)。原因:connect 太"方便",让人忘了它在表达什么。后果:调用链断裂难以调试、槽里阻塞 UI、连接关系变成意大利面。避免:信号只发"事实",命令走普通函数;重活进工作线程(见第 30 期)。
错误③:全局单例泛滥——DBManager、ConfigManager、NetworkManager、ThemeManager 全是 Singleton::instance()。原因:任何地方都能"顺手拿到"。后果:依赖全部隐藏、初始化顺序炸弹(A 单例构造时用 B 单例,B 还没构造)、测试无法替换实现。避免:构造注入;单例只留给真正进程唯一的资源(QApplication 本身就是)。
错误④:QObject 所有权混乱——new 出来的对象不设 parent,裸指针跨层传递,业务层持有 UI 对象指针。原因:从 JS(垃圾回收)带过来的心智。后果:悬垂指针、double free、析构顺序未定义。避免:UI 对象一律挂 parent 树;跨层依赖用 std::unique_ptr/shared_ptr 或纯接口引用;QObject 依赖明确归属。
错误⑤:过度插件化/接口膨胀——几千行的内部工具也上插件框架,接口一上来就 20 个方法。原因:把"架构感"误当成"复杂度"。后果:ABI 被锁死、每个方法都是永久的承诺、迭代速度被契约拖垮。避免:单体优先,出现真正的第三方扩展点再拆;接口最小化(少而稳),用组合而非继承扩展。
纪律1:依赖方向永远指向业务核心。用:业务要被多个界面复用、UI 或数据源预期会换。不用:一次性脚本、原型验证。Trade-off:每多一层接口就多一层间接,小项目别为"可能的需求"付设计税。
纪律2:构造注入 + 组合根。用:依赖 ≥2 个、或核心逻辑需要单测。不用:QObject 内部的协作子对象(用 parent 树即可)。Trade-off:构造参数太多说明类职责过重,此时拆类而不是换注入方式。
纪律3:信号只发事实,命令用直调。用:一对多通知、UI 事件、跨线程回传结果。不用:需要返回值、需要保证执行顺序的调用。Trade-off:解耦换来了调试断裂——所以业务链路保持直调,只在边界处用信号。
纪律4:跨线程只传消息,不共享状态。数据拷贝进事件、结果经信号回主线程(第 30 期已详述);万不得已要共享,用 std::atomic/锁并写清楚注释。
纪律5:接口最小化且稳定。用:插件边界、对外 SDK、跨模块公共接口。不用:进程内临时协作(内部类直接可见即可)。Trade-off:接口稳定 = 演进受限,所以发布前多花时间设计,发布后少改。
| 解耦手段 | 语义 | 耦合度 | 调试 | 适用场景 |
|---|---|---|---|---|
| 普通函数调用 | 命令:同步、可返回 | 强(编译期绑定) | 调用栈完整 | 业务逻辑、命令链 |
| 信号槽(同线程) | 通知:一对多 | 弱(运行期连接) | 栈断裂 | UI 事件、模块解耦 |
| 信号槽(跨线程) | 消息:排队投递 | 极弱(线程解耦) | 在事件循环中执行 | 工作线程回传结果 |
组件树 ↔ QObject 对象树。Vue 组件的挂载/卸载由框架管理,你从不管内存;Qt 里 parent 即所有权,"谁创建、谁拥有、谁释放"是你必须亲手维护的新心智。写 new QWidget(parent) 时想想:这个 parent 对吗?
Vuex/Pinia ↔ Model/ViewModel。Vuex 的单一数据源 + mutation 约束,对应 QAbstractItemModel 的 data()/beginInsertRows() 接口——都是"数据变化必须走受控通道";Vue 的响应式绑定对应 Q_PROPERTY + NOTIFY 信号。区别:Vue 双向绑定是默认,Qt 默认单向,反而更接近 React 的单向数据流。
React props 单向流 ↔ 构造注入 + 信号向上通知。props 向下传数据、事件向上抛——和"依赖向下注入、信号向上通知"是同一张图。你在 React 里养成的"状态提升、容器/展示分离"直觉,直接平移过来就是分层架构。
provide/inject ↔ 隐式依赖。两者都是"省事但隐藏依赖"的手段,可测试性差、Review 看不清。在 Qt 里同样只适合跨层传递低频稳定的服务,别滥用。
Node 模块 ↔ 库/插件边界。这是最大的认知差:Node 的 require 是源码级组合,永远没有二进制兼容问题;C++ 的 .so/.dll 一旦发布就有 ABI 契约。所以前端可以"随时重构模块",C++ 模块边界一旦被多个二进制依赖,改动成本骤增——设计接口时的谨慎程度要上一个数量级。
Code Review 时,架构评审优先于代码风格评审,按这个顺序看:①依赖图——这次改动有没有引入反向依赖或环?(画 5 分钟依赖图比吵 1 小时风格有效)②单例与全局状态——新增的 instance() 是否必要?③信号连接生命周期——receiver 是否安全、有没有重复连接、跨线程连接是否被理解而非碰巧能用。④可测试性——核心业务能否不启动 UI 就单测?不能,说明边界画错了。
指导方法:写代码前先让团队交一页"分层 + 模块依赖"草图,评审通过再动手——架构决策前置,返工成本最低;用"模块边界"而非"代码行数"衡量进度;同时警惕架构炫耀症:架构复杂度必须匹配团队规模与业务复杂度,三人小工具上插件体系,和百人项目不分层,同样是失职。
1. 《Clean Architecture》(Robert C. Martin)——依赖规则与边界思想的源头,第 1、16、22 章必读,是所有"代码放哪"争论的裁判书。
2. Qt 官方 Model/View Programming 文档(doc.qt.io/qt-6/model-view-programming.html)——MVVM 的 Qt 正典,精读 QAbstractItemModel 四个纯虚函数与通知机制。
3. Qt Creator 源码(github.com/qt-creator/qt-creator)——重点读 ExtensionSystem 与 Core 插件,看真实世界的插件契约怎么设计、怎么版本兼容。
4. 《Large-Scale C++ Software Design》(John Lakos)——C++ 物理设计、循环依赖消除的经典,比 JS 世界的模块规范严苛得多,架构师必读。
Q1:为什么 Qt 官方示例中 QAbstractItemModel 从不直接持有数据库连接?如果持有,会丢失哪些能力?
Q2:信号连信号(chain)什么时候是合理设计,什么时候是意大利面的开端?给出你的判据。
Q3:插件 IID 不变但虚函数表变了,加载时会发生什么?如何用插件元数据(meta.json)做版本防护?
Q4:Vue 的 provide/inject 与构造注入,在可测试性和 Code Review 可读性上差异在哪?你会给团队定什么规矩?
Q5:你现在的项目(或脑中的新项目)里,哪些"单例"其实应该被构造注入取代?替换后的收益是什么?
用"分层 + 依赖倒置 + 构造注入"写一个可编译运行的 Qt6 Widgets 程序:业务层只认接口,数据层实现接口,表现层只依赖业务层,组合根在 main()。完成后做两个扩展:①加一个 FakeUserRepository(返回固定名字)替换注入,验证"换实现不动业务层";②把 UserService 和接口拆到独立头文件,确认它们不 include 任何 Qt Widgets 头。
// architecture_demo.cpp 分层 + 依赖倒置 + 构造注入(Qt6 Widgets)
// 编译:g++ architecture_demo.cpp -o demo $(pkg-config --cflags --libs Qt6Widgets)
#include <QApplication>
#include <QLabel>
#include <QVBoxLayout>
#include <QPushButton>
#include <memory>
#include <string>
struct IUserRepository { // 抽象接口:依赖倒置的锚点
virtual ~IUserRepository() = default;
virtual std::string displayName(int id) = 0;
};
class UserService { // 业务层:只依赖接口
public:
explicit UserService(std::shared_ptr<IUserRepository> repo)
: m_repo(std::move(repo)) {}
std::string greet(int id) {
return "你好," + m_repo->displayName(id);
}
private:
std::shared_ptr<IUserRepository> m_repo;
};
class DbUserRepository : public IUserRepository { // 数据层:可替换成 Fake
public:
std::string displayName(int id) override {
return "用户 #" + std::to_string(id); // 模拟数据库查询
}
};
class MainWindow : public QWidget { // 表现层:只依赖 UserService
public:
explicit MainWindow(UserService& svc, QWidget* parent = nullptr)
: QWidget(parent) {
auto* lbl = new QLabel("(点击按钮)", this);
auto* btn = new QPushButton("打招呼", this);
auto* lay = new QVBoxLayout(this);
lay->addWidget(lbl);
lay->addWidget(btn);
connect(btn, &QPushButton::clicked, this, [=] {
lbl->setText(QString::fromStdString(svc.greet(42)));
});
}
};
int main(int argc, char** argv) { // 组合根:唯一 new 具体实现处
QApplication app(argc, argv);
auto repo = std::make_shared<DbUserRepository>();
UserService svc(repo);
MainWindow win(svc);
win.show();
return app.exec();
}