客户端架构 · 架构设计 · Qt6

客户端架构:分层、依赖注入与信号槽解耦

浏览器替你隔离了一切,Qt 里这些边界必须自己设计——架构师的第一课

①

今日主题:为什么现在学架构

前面几期我们解决了"怎么渲染、怎么跑事件循环、怎么并发"——这些都是技术问题;而架构师每天真正面对的是组织问题:这段代码该放哪个模块?谁允许依赖谁?为什么加一个缓存要改五个文件?在 Web 世界里,浏览器替你划好了边界(DOM、渲染、网络、存储各有各的层),Vue/React 又替你管好了组件树与状态;而 Qt 桌面应用从 main() 开始就是一张白纸,所有边界都要自己画。画错了,项目会在第 3 万行代码时开始报复你:改一个字段牵一发动全身、单测写不起来、新人不敢动老代码。

本期聚焦客户端架构的三大支柱:分层架构(代码该放哪)、依赖注入与依赖倒置(谁依赖谁)、信号槽解耦(怎么通信),最后延伸到插件化架构。学完你会获得一套判断标准:任何一次"代码放哪"的争论,都能用依赖规则一锤定音。

②

核心知识:从分层到依赖,再到 Qt 的解耦哲学

2.1 分层架构:依赖规则是唯一的硬约束

经典三层:表现层(QWidget/QML)→ 业务层(用例、服务、领域对象)→ 数据层(数据库/网络/文件/系统 API)。关键不是"分几层",而是 Robert Martin 在《Clean Architecture》里给出的依赖规则(Dependency Rule):源代码依赖只能从外指向内,内层绝不依赖外层。表现层可以依赖业务层,业务层可以依赖数据层的接口,但业务层头文件里不允许出现 QWidget、QSqlDatabase、QNetworkAccessManager——因为"用什么 UI 框架、什么数据库"都是外层细节,必须可以被替换而不惊动业务逻辑。判断分层是否健康的标尺只有一个:画一张模块依赖图,不允许出现环,不允许出现反向依赖。

2.2 依赖倒置(DIP)与依赖注入(DI)

依赖倒置原则:高层模块不依赖低层模块,二者都依赖抽象;抽象不依赖细节,细节依赖抽象。落地手段是依赖注入,三种形式:构造函数注入(推荐——依赖显式、不可变、天然可测试)、setter/属性注入(可选依赖、可后置,但对象可能处于"未装配完成"的中间态)、Service Locator(全局取服务,本质是隐藏依赖,被广泛视为反模式倾向)。C++ 没有反射,也没有 Spring 那样的容器,所以 Qt 世界的惯例是组合根(Composition Root):在 main() 或 App 启动代码里手工装配整棵依赖树——全程序只有这一个地方允许 new 具体实现,其余代码只认接口。注意与 QObject 生命周期机制的配合:QObject 用 parent 管理所有权,若注入的依赖本身是 QObject,要明确 parent 归属,避免"注入者析构了、被注入者还在用"的悬垂。

2.3 信号槽:解耦"通知",而不是"调用"

信号槽最大的价值不是"少写回调",而是它强制你区分两类语义:命令(command)用普通函数调用——同步、有返回值、调用栈完整、可单测;通知(notification)用信号——一对多、发送者不关心谁在听、跨线程自动退化为 QueuedConnection 排队投递。Qt 的 AutoConnection 把"线程安全通信"变成了语言特性(同线程同步、跨线程排队),但代价是调试时调用栈断裂:槽函数在接收者的事件循环里执行,断点回溯不到发送现场。另一个容易被忽略的点是连接的生命周期:receiver 析构时连接自动断开,但用 connect(sender, &T::sig, this, [=]{...}) 且 lambda 捕获了裸指针时,要确认捕获对象与 receiver 同生命周期;重复连接(同一对信号槽连两次)会让槽执行两次,是"莫名执行多次"的头号来源。

2.4 MVVM 在 Qt 中的真实形态

QWidget 世界主流是 MVC/MVP:QAbstractItemModel 是视图与数据之间的适配器,data()/rowCount()/beginInsertRows() 这套接口恰好对应前端里"状态提升 + selector"的职责——视图只读模型,写操作通过命令走业务层。QML 世界天然是 MVVM:ViewModel 暴露 Q_PROPERTY + NOTIFY 信号,View 用声明式绑定——这与你熟悉的 Vue 响应式几乎同构,但注意 Qt 的绑定默认单向(属性变化→UI 更新),双向绑定在 QML 里要显式写 onXxxChanged 回写,别用 Vue 的 v-model 心智直接套。

2.5 插件化:把"运行时组合"变成架构能力

插件化的本质是运行时依赖反转:主程序在编译期不知道有哪些实现,运行期通过 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 事件、模块解耦
信号槽(跨线程)消息:排队投递极弱(线程解耦)在事件循环中执行工作线程回传结果
⑥

与 Web 技术的联系:把前端心智迁移过来

组件树 ↔ 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++ 模块边界一旦被多个二进制依赖,改动成本骤增——设计接口时的谨慎程度要上一个数量级。

⑦

管理者视角:Team Leader 如何带好架构

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:你现在的项目(或脑中的新项目)里,哪些"单例"其实应该被构造注入取代?替换后的收益是什么?

⑩

今日实践任务:30-45 分钟,搭一个最小分层骨架

用"分层 + 依赖倒置 + 构造注入"写一个可编译运行的 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();
}
⑪

一句话总结

分层定"代码放哪",依赖倒置定"谁依赖谁",信号槽定"怎么通信"——把边界画在代码之前,桌面客户端的复杂度就永远是可控的。