C++工程中的MVC架构:分层实现与观察者模式实战
在 C 项目中讨论 MVC 架构很多人第一反应是“这是 Web 框架里的概念跟 C 有什么关系”。实际上只要一个程序需要把界面交互、数据维护和业务动作拆开管理MVC 在 C 里同样有很强的实战价值尤其适合桌面 GUI 客户端、工具类程序、游戏 HUD 和嵌入式界面模块。代码规模超过几千行后分层不清会让每一次需求变更都变成填坑过程。下面我会从一个最小例子开始把 MVC 在 C 工程里的概念、实现、排错和最佳实践逐层拆开。1. 先把 C 里的 MVC 放到正确坐标系1.1 MVC 三个角色在 C 项目里分别做什么MVC 全称是 Model-View-Controller核心目的不是制造三个文件夹而是把“数据是什么”“界面长什么样”“用户操作怎么处理”这三类变化频率不同的代码隔离开。在 C 工程里三个角色可以这样理解Model负责数据存储和业务规则。一个任务列表、一个玩家角色、一组传感器参数都属于 Model。Model 不应该关心数据最终显示在控制台还是 Qt 窗口里。View负责展示和输入入口。它把 Model 的数据渲染成用户可以理解的形式也负责接收用户点击、键盘输入。View 不应该直接写“任务不能重复添加”这类业务规则。Controller接收用户动作编排 Model 和 View。用户点了按钮Controller 判断需要调用哪个 Model 方法执行完后让 View 刷新数据。一句通俗总结Model 是数据仓库View 是显示屏幕Controller 是中间调度员。三者之间最重要的不是谁调用了谁而是依赖方向不能乱。推荐依赖方向是View 可以感知 ControllerController 依赖 Model 和 View但 Model 不依赖任何 View 和 Controller。这样 Model 可以独立测试也可以在没有界面的服务端复用。1.2 C MVC 与 Spring MVC、ASP.NET MVC 的差异网上一搜 MVC出现最多的资料是 Spring MVC 或 ASP.NET MVC。它们是 MVC 思想在 Web 场景下的实现和 C 桌面程序的 MVC 有几个明显区别。对比项Web MVCSpring/ASP.NETC 桌面程序 MVC生命周期一次 HTTP 请求对应一次动作请求结束即生命周期结束窗口常驻事件循环持续运行Model 在进程内存中长期存活View 形态服务端模板或前端页面渲染结果传给浏览器窗口控件、自绘界面、控制台输出View 是进程内对象Controller 触发方式Web 容器根据 URL 路由分发到 Controller 方法控件点击、键盘事件、定时器、网络消息等回调触发状态管理依赖 Session、数据库或缓存保持状态Model 本身就是内存中的对象数据变化直接驱动 View 刷新这个差异决定了两个关键设计第一C 桌面程序的 Controller 通常不是“一次调用后销毁”而是和窗口生命周期绑定需要显式管理注册和销毁第二Model 变化后需要主动通知 View而不是像 Web 模式那样由浏览器重新请求页面。这也是后面要实现的观察者机制存在的根本原因。1.3 什么场景适合在 C 里引入 MVC不是所有 C 代码都需要 MVC。判断标准很简单代码里是否存在明显分离的三类变化。适合引入 MVC 的场景Qt、wxWidgets、MFC 等 GUI 客户端界面层级多、业务规则复杂。游戏客户端中的 HUD、背包、任务面板UI 状态和游戏逻辑需要解耦。嵌入式设备上的界面模块数据采集和展示逻辑分离方便多个工程师并行开发。任何“后台线程更新数据主线程需要刷新界面”的程序。不适合硬套 MVC 的场景纯算法库比如加密、压缩、排序没有界面也没有运行时交互。性能极敏感的渲染循环内部为了分层把每帧数据复制多份反而得不偿失。一次性脚本或工具代码很少分开三层反而增加阅读成本。核心判断是当“数据变化需要驱动界面变化”这条链路出现两次以上且界面和业务都在增长MVC 才开始有投入价值。2. 环境准备与最小工程结构2.1 工具链和版本要求这一节的示例代码不需要任何第三方库使用 C11 标准即可运行。实操前先确认编辑器环境。项目推荐配置编译器g 8 以上clang 10 以上MSVC 2019 以上C 标准C11 或更高构建工具直接使用 g 命令后续可换 CMake操作系统Linux、macOS、Windows 均可检查 g 是否可用g --version如果输出中能看到版本号说明编译器就绪。2.2 目录结构设计为了直观展示三层职责我把示例工程按目录拆开。实际项目中目录命名可以不同但依赖方向要保持一致。task_mvc/ ├── main.cpp ├── model/ │ ├── Task.h │ └── TaskModel.h ├── view/ │ └── TaskView.h └── controller/ └── TaskController.h这里使用 header-only 是为了方便读者直接复制编译。真实项目里超过一定规模后建议把声明和实现拆成.h和.cpp否则一个文件改一点所有 include 它的文件都要重新编译增量编译成本很高。2.3 接口设计要点先确定各层之间的接口再写实现这是 C 工程里非常关键的习惯。接口层的设计原则是Model 对外暴露“数据查询”和“状态修改”方法比如tasks()和addTask()。Model 不返回内部容器的非常量引用防止外部绕过业务修改内部状态。View 只接收“已经格式化好的数据”或“可打印的数据集合”不感知 Model 类本身。Controller 持有 Model 和 View 的引用或指针负责串联。依赖方向是main.cpp创建 Model、View、Controller然后把它们组装起来。View 不直接 include Model 的头文件这样界面层的变化不会波及数据层。3. 动手实现一个最小任务管理程序需求定为一个控制台任务管理程序支持三条命令添加任务、完成任务、退出。演示环境是 Linux 终端使用标准输入输出。3.1 定义 Task 数据结构和 TaskModel先定义任务实体放在model/Task.h中#pragma once #include string struct Task { int id; std::string title; bool completed; };Task 是纯数据结构没有任何业务方法职责单一。接着实现model/TaskModel.h#pragma once #include vector #include functional #include algorithm #include Task.h class TaskModel { public: using Listener std::functionvoid(); void addTask(const std::string title) { int nextId tasks_.empty() ? 1 : tasks_.back().id 1; tasks_.push_back({nextId, title, false}); notify(); } bool completeTask(int id) { auto it std::find_if(tasks_.begin(), tasks_.end(), [id](const Task t) { return t.id id; }); if (it tasks_.end()) { return false; } it-completed true; notify(); return true; } const std::vectorTask tasks() const { return tasks_; } void addListener(Listener listener) { listeners_.push_back(std::move(listener)); } private: void notify() { for (const auto listener : listeners_) { listener(); } } std::vectorTask tasks_; std::vectorListener listeners_; };这里有两个容易忽略的点。第一addTask和completeTask中都调用了notify()这是 MVC 中 Model 驱动 View 刷新的核心机制。Model 不关心谁在听通知只负责“数据变化了广播一声”。这样以后把 View 换成日志模块、统计模块Model 一行代码都不用改。第二tasks()返回const std::vectorTask外部只能读取不能通过引用直接修改。否则调用方可以执行model.tasks()[0].title hack绕过所有业务规则。3.2 实现 TaskViewView 层放在view/TaskView.h中#pragma once #include iostream #include string #include vector #include ../model/Task.h class TaskView { public: void showTasks(const std::vectorTask tasks) { std::cout ---- Task List ---- std::endl; if (tasks.empty()) { std::cout (no tasks) std::endl; } else { for (const auto task : tasks) { std::cout (task.completed ? [x] : [ ] ) task.id . task.title std::endl; } } std::cout ------------------- std::endl; } std::string readCommand() { std::cout ; std::string line; std::getline(std::cin, line); return line; } };TaskView 只做两件事把任务列表渲染为文本读取用户的命令行输入。它不知道 TaskModel 的存在自然也就不会写“完成任务的 id 不存在”这类业务判断。业务判断应该放在 Controller 或 Model 里。View 中包含../model/Task.h是允许的因为 Task 只是数据实体View 需要知道任务有哪些字段才能渲染。但 View 不 includeTaskModel.h这是分层边界。3.3 实现 TaskControllerController 是整个程序的调度中枢放在controller/TaskController.h中#pragma once #include string #include sstream #include ../model/TaskModel.h #include ../view/TaskView.h class TaskController { public: TaskController(TaskModel model, TaskView view) : model_(model), view_(view) {} void start() { model_.addListener([this]() { refresh(); }); view_.showTasks(model_.tasks()); while (true) { std::string line view_.readCommand(); if (std::cin.eof()) { break; } if (!handle(line)) { break; } } } private: bool handle(const std::string line) { std::istringstream iss(line); std::string action; iss action; if (action add) { std::string title; std::getline(iss std::ws, title); if (title.empty()) { std::cout usage: add title std::endl; } else { model_.addTask(title); } return true; } if (action complete) { int id 0; if (iss id) { if (!model_.completeTask(id)) { std::cout task id not found: id std::endl; } } else { std::cout usage: complete id std::endl; } return true; } if (action exit) { return false; } std::cout unknown command: action std::endl; return true; } void refresh() { view_.showTasks(model_.tasks()); } TaskModel model_; TaskView view_; };Controller 的职责是解析用户命令、调用 Model 的修改方法、触发 View 刷新。注意一个细节model_.addListener使用了 lambda 捕获this所以在 Controller 销毁前必须保证不会再有 Model 的通知调用到已经销毁的 Controller。这个生命周期问题在第六章单独展开。3.4 main 函数组装与运行结果main.cpp只负责组装三层#include iostream #include model/TaskModel.h #include view/TaskView.h #include controller/TaskController.h int main() { TaskModel model; TaskView view; TaskController controller(model, view); controller.start(); return 0; }编译命令g -stdc11 -Wall -Wextra main.cpp -o task_mvc运行./task_mvc输入并查看结果---- Task List ---- (no tasks) ------------------- add 学习 C MVC ---- Task List ---- [ ] 1. 学习 C MVC ------------------- complete 1 ---- Task List ---- [x] 1. 学习 C MVC ------------------- add 阅读 Qt 文档 ---- Task List ---- [ ] 1. 学习 C MVC [x] 2. 阅读 Qt 文档 ------------------- exit添加任务后列表自动刷新完成任务后状态从[ ]变为[x]。整个流程已经形成了 MVC 的最小闭环用户在 View 输入命令Controller 解析并调用 ModelModel 修改数据并广播通知View 收到通知后重新渲染。需要说明的是这个示例是阻塞式命令行循环。真实 GUI 程序不会用while (true)等输入而是把 Controller 的方法注册成控件的点击回调或信号槽。核心分层思想是一样的Controller 不主动循环而是由事件循环驱动。4. 关键设计问题为什么 Model 不能直接持有 View4.1 观察者模式解决核心耦合如果直接让 Model 持有 TaskView 的指针代码会变成这样class TaskModel { TaskView* view_; public: void addTask(const std::string title) { // ... view_-showTasks(tasks_); } };这种写法看起来简单却带来三个问题Model 无法脱离 View 测试。想写一个单元测试必须先构造 TaskView哪怕这个测试只关心“id 是否正确自增”。添加新的展示方式必须改 Model。以后想加一个日志输出需要打开 Model 类插入代码。头文件依赖形成循环。TaskModel include TaskViewTaskView include Task如果 TaskView 未来需要知道任务类型头文件就会纠缠在一起。用观察者模式后Model 只保存一组std::functionvoid()回调它不知道这些回调来自 View、日志模块还是网络推送。这就是面向接口编程的本质依赖抽象而不是依赖具体类。4.2 Controller 不是“所有逻辑的收容所”另一个常见反模式是把 Controller 写成巨型类把业务规则、字符串解析、数据校验全部塞进去。比如在completeTask中检查 id 是否存在这段逻辑如果放在 Controller 里将来改数据存储方式时Controller 也要跟着改。正确习惯是“id 是否存在”“任务是否已完成”“标题长度是否合法”属于 Model 的业务规则放在 TaskModel 内部。“命令是 add 还是 complete”“参数怎么从字符串里拆出来”属于输入解析放在 Controller 层。“任务是显示成[x]还是[ ]”属于展示逻辑放在 View 层。Controller 的作用是编排不是把所有计算都搬进来。它更像一个翻译官把用户的输入翻译成 Model 能理解的动作再把 Model 的结果翻译成 View 能渲染的数据。4.3 C 特有的生命周期和内存管理问题C 的 MVC 和 Java、C# 一个显著区别是没有垃圾回收器对象销毁顺序必须自己控制。本项目里TaskController持有TaskModel和TaskView它们的生命周期由main函数统一管理。构造顺序是 Model、View、Controller析构顺序反过来所以main.cpp里三个对象按顺序声明是安全的。如果换成裸指针或者new出来的对象就要额外注意TaskModel* model new TaskModel(); TaskView* view new TaskView(); TaskController* controller new TaskController(*model, *view);最后释放时必须先释放controller再释放view和model。否则 Controller 已经在访问已销毁的 View 或 Model程序崩溃。更推荐的方案是使用std::shared_ptr和std::weak_ptr。Model 的监听器列表如果存的是共享指针需要注意循环引用导致内存泄漏。实践中最稳妥的方式还是用 RAII 管理对象生命周期并确保监听器在对象析构前被移除。4.4 回调、信号槽、事件总线三种替换方案观察者模式并不是唯一选择。在实际 C 项目中常见做法有三种。方案适用场景优点缺点回调函数列表小型程序、单线程实现简单依赖标准库线程安全需要自己处理信号槽QtQt 项目连接方式灵活支持自动断开强依赖 Qt 元对象系统事件总线模块较多的大型程序模块之间完全解耦全局数据流不直观排错难以 Qt 为例Model 可以用一个普通 QObject通过signal广播数据变化View 用slot接收通知。框架会自动处理连接断开但学习成本也比自写回调高。事件总线适合组件非常多、组件之间没有层次关系的场景但滥用会让代码变得难以跟踪查找“这行数据是谁改的”会非常痛苦。我的建议是小项目先用最简单回调复杂度上升后再考虑 Qt 信号槽或事件总线。5. 在 Qt、MFC 和游戏客户端中落地 MVC5.1 Qt 的 Model/View 框架Qt 提供的 Model/View 框架是 C GUI 中最成功的 MVC 变体。它并不是严格意义上的三层结构而是把 Model 和 View 作为核心Delegate 承担了部分 Controller 的职责。QAbstractItemModel负责数据组织和变化通知对应 MVC 的 Model。QListView、QTableView对应 View负责渲染。QStyledItemDelegate负责单元格编辑、绘制承担用户交互处理和 Controller 的一部分工作。Qt 的思路是让一个 Model 对接多个 ViewModel 通过dataChanged、rowsInserted等信号通知界面更新。写 Qt 项目时重点是让业务逻辑写在 Model 里而不是把表单控件绑定到数据库表的每个字段上。5.2 MFC 的 Document/View 结构MFC 的 Document/View 是另一种 MVC 变体。Document 对应 Model负责数据读写View 负责显示Frame 窗口和消息路由承担控制器功能。MFC 项目的常见问题是 View 中直接操作 Document 数据导致界面逻辑和数据逻辑耦合。较合理的做法是用户操作先进入消息处理函数消息处理函数调用 Document 的业务方法再让 View 从 Document 取数据刷新。5.3 游戏客户端中的 HUD 和状态管理游戏客户端的 HUD、背包、任务面板通常也适合 MVC 思想。玩家角色数据、任务状态是 ModelUI 控件是 View输入处理是 Controller。游戏客户端有一个额外复杂度逻辑线程和渲染线程分离。Model 在逻辑线程中更新View 在主线程渲染。如果 Model 直接调用 View 的刷新接口会在多线程环境下表现为偶发崩溃。解决方案一般是Model 广播事件时把事件投递到 UI 线程的消息队列由 UI 线程完成刷新。5.4 MVC、MVP、MVVM 怎么选模式核心特点适合场景MVCController 接收输入Model 主动通知 View传统桌面程序逻辑不算复杂MVPView 完全被动Presenter 负责所有交互逻辑对 View 层可测试性要求高MVVMView 与 ViewModel 数据绑定适合数据驱动 UIWPF、前端框架、需要双向绑定的场景C 项目里Qt 的 Model/View 和信号槽已经吸收了 MVVM 的很多思想。小工程用 MVC 足够如果界面状态特别多、绑定关系复杂可以自然过渡到 MVP 或 MVVM。不要为了“专业”硬套最重的模式。6. 常见问题与排查路径6.1 Model 更新了View 不刷新现象在命令行程序里输入add xxx没有出现新列表在 Qt 程序里修改了 Model 数据界面没有变化。排查顺序确认 Model 的修改方法真的被执行了。在addTask入口加日志或断点。确认notify()被调用。如果 Model 方法提前 return通知不会发出。确认监听器已经注册。addListener是否在start()之前调用。确认监听器捕获的对象没有被销毁。lambda 捕获了裸this如果 Controller 已析构回调执行会崩溃界面自然也不会刷新。解决方案确保注册监听器后没有手动解除连接使用弱引用或让监听器注册和反注册成对出现。6.2 界面卡顿或频繁重绘现象Model 一次批量修改了几千条数据View 跟着刷新几千次界面明显卡顿。原因每一次notify()都会触发 View 全量刷新。处理建议在修改方法中批量变更数据后只广播一次通知。View 内部设置脏标记同一帧内多次收到通知只重绘一次。大数据量场景使用时间分片或部分刷新接口。6.3 回调里悬空指针导致崩溃现象程序退出时在 Model 的通知回调中崩溃堆栈显示访问了已释放的地址。原因Model 的监听器列表保存了指向 Controller 或 View 的裸指针这些对象已经先于 Model 析构。检查方式把listeners_中的回调打印出来看崩溃时执行的是哪个 lambda。检查对象构造和析构顺序。解决方案用std::shared_ptr管理 View 和 Controller用std::weak_ptr捕获this回调前检查lock()是否成功。在 Model 析构前主动清空监听器列表。更严格的做法是引入连接令牌析构时调用disconnect(token)取消注册。6.4 编译期错误和依赖循环现象includeTaskView.h时提示找不到TaskModel.h或者提示某个类型未定义。常见原因View 中不必要地 include 了 Model而 Model 又 include 了 View形成循环。前向声明和实际 include 混用导致编译器只知道类是存在的却不知道成员函数签名。处理顺序去掉 View 对 Model 的 include改为在函数参数中使用const std::vectorTask。把 Controller 中对 Model 和 View 的依赖转换为头文件 include而不是前向声明因为 Controller 要调用具体方法。如果发生循环依赖用前置声明加std::unique_ptr成员并在.cpp中实现构造和析构。下面用一个表格汇总常见现象和处理方向。问题现象常见原因检查方式解决建议Model 改了 View 不变未调用 notify或监听器没注册在 notify 处打断点确认修改方法调用了 notify确认注册时机界面卡顿重绘频繁每次细微变化都全量通知统计 notify 调用次数批量修改只发一次通知View 加脏标记退出时回调崩溃监听器持有已析构对象打印回调地址和对象析构日志使用 weak_ptr析构前移除监听器编译报类未定义include 循环或前向声明误用查看编译器报错位置拆分头文件View 不依赖 Model7. C MVC 分层的最佳实践清单7.1 分层检查清单写 C MVC 代码时每过一段时间就用下面清单检查一遍Model 中没有任何std::cout、窗口句柄、控件指针。Model 的修改方法都会主动广播数据变化不需要 View 去轮询。View 不包含业务规则判断比如“id 必须大于 0”“任务不能重复添加”。Controller 只做调用和编排不实现核心业务算法。数据流向是单向的用户输入流向 ControllerController 流向 ModelModel 通知流向 View。对象生命周期有明确所有者监听器注册和反注册成对出现。多线程场景下Model 的数据修改有锁保护UI 刷新只在主线程执行。7.2 从学习环境到生产环境的差异示例程序是 header-only、单线程、控制台输出在实际生产 C 项目里还需要额外补齐以下能力。维度学习环境示例生产环境要求文件组织所有类都写在头文件声明和实现分离使用 CMake 管理内存管理main 栈上构造智能指针、对象池、明确的所有权线程安全单线程Model 增加互斥锁通知投递到 UI 线程错误处理只处理部分输入错误增加异常捕获、错误码、日志记录持久化数据在内存中增加数据库或文件读写模块测试手动运行命令为 Model 写单元测试View 用 mock 或集成测试日志无增加日志模块记录用户操作和系统异常7.3 继续深入的方向本文的最小示例是理解 MVC 的起点真正落地到项目还需要继续学习几个方向Qt Model/View 编程掌握QAbstractItemModel、QSortFilterProxyModel、Delegate 的使用。信号槽机制理解 Qt 元对象系统如何解决自动断开连接的问题。多线程事件模型学习std::mutex、std::condition_variable以及如何安全地跨线程发送 UI 更新消息。架构演进从 MVC 理解 MVP、MVVM思考 C 项目中哪一种分层更适合自己的团队。如果现在刚开始接触 C 架构建议先把这个最小任务管理器改成 Qt 版本把TaskView替换为一个QWidget表单把TaskController的while循环替换成按钮点击信号逐步体会 MVC 在真实 GUI 中的价值。