从"能跑就行"到"架构清晰",工业项目的进化之路
一、一个真实的病历本
第一版 Aether:一个巨大的 main.cpp,所有代码堆在一起。
第二版 Aether:分出了 common/app/plugins,但耦合依然严重。
第三版 Aether:引入了 IoC 容器,插件间终于解耦了。
第四版 Aether:完整的 MVVM + 中间件 + 权限 + 主题。
每次重构都像在动手术。但每次手术后,项目都活得更好了。
这不是教你如何写出完美架构的文章。这是把 3 年挨过的刀、踩过的坑、流过的血,一条条摆在你面前。
二、V1 → V2:从单体到分层
改一个 bug 修 3 天
V1 的 Aether,核心逻辑全在一个main.cpp里,2 万多行。
// V1 main.cpp(节选)#include<QApplication>#include<QTcpSocket>#include<QSerialPort>#include<QCamera>#include<QSqlDatabase>// 全局变量大集合QCamera*g_camera=nullptr;QTcpSocket*g_comm=nullptr;QSqlDatabase g_db;intg_currentMode=0;// 2 万行代码,全部平铺// 相机初始化、通信协议解析、界面更新、// 数据库写入、异常处理...全混在一起问题在哪?改一行g_comm的协议解析,相机也跟着崩。因为全局变量谁都改,谁都不清楚改完的影响范围。
有一次修"界面卡死"的 bug,定位到g_comm->readAll()阻塞了 UI 线程。但通信、界面、数据库都在同一个函数里互相调用。改 1 个 bug,用了 3 天。因为不敢动,动一处要验证所有地方没炸。
分层后效率翻倍
V2 我们做了第一件正确的事:按职责分层。
V1 V2 aether/ aether/ └── main.cpp ← 2万行 ├── app/main.cpp ├── common/ │ ├── comm/ ← 通信模块 │ ├── camera/ ← 相机模块 │ └── database/ ← 数据库模块 └── plugins/ui/ ← 界面模块每个模块有自己的文件夹、命名空间、编译单元。模块之间通过接口调用,不碰对方的全局变量。
// V2 common/comm/comm_manager.h// 通信模块对外只暴露这一个接口classCommManager:publicQObject{Q_OBJECTpublic:boolsend(constQByteArray&data);voidonReceived(std::function<void(constQByteArray&)>cb);};以前改通信,要在 2 万行里找所有相关代码。现在改通信,只打开common/comm/就行。
效果:原来 3 个人改同一个文件天天冲突。分层后并行开发,6 周的工作 2 周交付。
三、V2 → V3:从分层到插件化
分层也没解决耦合
分层之后,新问题冒头了——模块间依赖还是太强。
改了camera_manager.cpp的参数类型,comm/和ui/各有三处调用了它,全要跟着改。
// V2 的问题:模块间直接调用,耦合太深voidCommManager::onDataReceived(constQByteArray&data){automsg=parse(data);// 直接调用 database 模块的接口DatabaseManager::instance().saveRecord(msg);// 直接调用 ui 模块的接口UIManager::instance().updateDisplay(msg);}分层的思路对了,但层与层之间还是硬编码耦合。
插件化:痛在接口设计
V3 引入插件架构:每个模块编译成独立 DLL,通过接口交互。
// V3 通信插件接口// plugins/interface/icommunication.hclassICommunication:publicQObject{Q_OBJECTpublic:virtual~ICommunication()=default;virtualboolconnect(constQString&endpoint)=0;virtualboolsend(constQByteArray&data)=0;virtualboolisConnected()const=0;signals:virtualvoiddataReceived(constQByteArray&data)=0;};最大的痛苦是接口设计。第一版ICommunication有 12 个方法。结果每个插件(TCP、串口、CAN)只实现了其中 6 个,剩下 6 个写return false。
接口太胖,插件架构就废了。后来拆成 3 个小接口,各取所需。
四、V3 → V4:从插件到 IoC + MVVM
插件间还是太依赖
插件化解决了"改一处崩全局"。但新问题来了——插件 A 依赖插件 B 的实例,加载顺序成了玄学。
// V3 的痛:加载顺序玄学auto*b=pluginManager->getPlugin<IPluginB>();if(!b)return;// 插件 B 还没加载b->doSomething();今天能跑,明天加个新插件,加载顺序变了又崩。
IoC 容器:真正解耦
V4 的核心变化:引入 IoC 容器,你需要的服务,容器给你注入,不用自己找。
// V4 - 容器自动注入依赖classServiceA:publicQObject{public:explicitServiceA(IServiceB*serviceB):m_serviceB(serviceB)// ← 容器自动注入{// 直接使用,不用关心 serviceB 什么时候初始化的connect(m_serviceB,&IServiceB::onEvent,this,&ServiceA::handleEvent);}private:IServiceB*m_serviceB=nullptr;};容器带来三个改变:不用关心加载顺序(容器按依赖图排序)、不用硬编码实现(换实现只改注册)、测试友好(注册 Mock 即可)。
MVVM 同时解决了 UI 和业务层的耦合:
// V4 - ViewModel 层classMainViewModel:publicQObject{Q_PROPERTY(QString status READ status NOTIFY statusChanged)public:explicitMainViewModel(ICameraService*camera,ICommService*comm):m_camera(camera),m_comm(comm){}Q_INVOKABLEvoidstartCapture(){m_camera->start();m_status="采集中...";emitstatusChanged();}private:ICameraService*m_camera;ICommService*m_comm;QString m_status;};View 只绑 ViewModel 的属性,ViewModel 通过容器拿服务。每个维度都解耦了。
五、5 个用血泪换来的教训
教训 1:不要过早抽象
V1 到 V2,我们最大的冲动是"先把框架搭好"。搭了一个极其通用的数据总线架构,设计了 8 个抽象层。结果业务写了两行发现用不上,绕开框架直接走硬编码。
抽象不是设计出来的,是从代码里长出来的。先写能跑的代码,再抽公共逻辑。V2 到 V3 我们换了这个策略——先在业务里发现重复模式,再抽接口。这次对了。
教训 2:接口变动要发版
V3 的插件接口改得很随意。今天加个方法,明天改返回值。插件开发者天天追着接口改,一个接口改了 5 次,6 个插件崩了 4 次。
后来强推语义化版本:
icommunication v1.0.0 ← 稳定版,不能改 icommunication v1.1.0 ← 加新方法,不破已有签名 icommunication v2.0.0 ← 破坏性变更,全员确认接口变动必须发版、写变更日志、通知所有消费者。这规矩定了之后,插件间的地震几乎绝迹。
教训 3:架构文档和代码同步
V2 到 V3 踩过最大的坑——代码重构了,文档没更新。新同事拿着 V2 的文档理解 V3 的代码,前两个月全在考古中度过。
我们的解法:架构图放进代码仓库、和代码一起 PR、每次重构先改文档再改代码。
教训 4:重构时测试先行
V2 到 V3 那次重构,我们没写测试就直接上。结果是——每个接口都能工作,但流程跑起来到处断链。
不写测试的重构不叫重构,叫赌博。
后来定了死规矩:重构前先给现有代码写测试,锁定接口行为。重构完跑一遍,全绿才算通过。
// 重构前锁定行为TEST_CASE("通信模块发送数据"){CommManager comm;REQUIRE(comm.send(QByteArray())==false);// 空数据返回 falseREQUIRE(comm.send(QByteArray("hello"))==true);// 合法数据返回 trueboolsignalReceived=false;QObject::connect(&comm,&CommManager::dataSent,[&](){signalReceived=true;});comm.send(QByteArray("hello"));REQUIRE(signalReceived==true);}教训 5:渐进式重构 > 推倒重来
4 次重构里最失败的是 V2 中期的"大重写"。花了 3 个月写了 80% 的新代码,然后发现——业务已经变了。3 个月白干。
推倒重来永远是最后的选择。正确的做法是用 Strangler Fig 模式:
- 在旧系统旁建新模块
- 新功能走新模块
- 逐步迁移老功能
- 老功能没人用了再拆掉
V3 到 V4 这么干的——IoC 容器和旧架构共存了 2 个月,等所有插件迁移完才删旧代码。
没有切换日,只有淘汰日。
六、现在的 Aether
┌────────────────────────────────────┐ │ View 层 (QML/QWidget) │ │ 只做展示,不写业务 │ ├────────────────────────────────────┤ │ ViewModel 层 │ │ 属性绑定 + 命令调度 │ ├────────────────────────────────────┤ │ Service 层 (IoC 容器管理) │ │ 相机 · 通信 · 数据服务 │ ├────────────────────────────────────┤ │ 插件层 (独立 DLL,接口交互) │ │ core · mvvm · container · home │ └────────────────────────────────────┘每一层只关心一件事,每一层都可以独立替换。
3 年,4 次重构,从一个 2 万行的 main.cpp 到今天。
如果你问我最大的感受是什么?
没有一劳永逸的架构,只有不断进化的代码。
下一次重构什么时候来?我不知道。但只要项目还活着,重构就不会停。
💬 评论区聊聊:
你的项目重构过几次?最大的教训是什么?是接口设计翻车了,还是测试没跟上?评论区说说你的经历。
🔄 觉得有用?
分享给你的团队——这些教训,一个人用血泪换来,十个人看到就能少走弯路。
⭐ 点个"在看"让更多人看到,也鼓励我继续写下去。
下一期预告:
下一篇,从 Windows 到 Linux——跨平台适配。
你以为 Qt 是跨平台的?光是文件路径的斜杠方向,就够你喝一壶的。
下篇聊聊 Aether 跨平台适配的真实经历。