Qt Model/View 架构:数据与视图分离的基石
Vue 有响应式数据,React 有 state + 虚拟 DOM,Qt 的原生答案是 Model/View。它是列表、表格、树形界面的地基,也是理解 MVVM 与 Qt Quick 的必经之路。
今日主题
从 Web 转向桌面客户端,最需要重建的不是语法,而是"数据如何驱动界面"的心智模型。你在 Vue 里写响应式数据 + 模板,在 React 里写 state + 虚拟 DOM,而 Qt 的原生答案就是 Model/View 架构——它是 Qt 中一切列表、表格、树形界面的地基,是 C++ model 与 QML 之间的桥梁,也是后续学习 MVVM、插件化、大型客户端架构的必经之路。
前六期我们依次攻克了 RAII、移动语义、信号槽、事件循环、多线程、内存管理,今天把知识汇聚到第一个"架构级"主题:理解 Model/View,你就拿到了 Qt 数据驱动世界的钥匙。
核心知识
Model/View 是 Qt 对 MVC 模式的一次务实裁剪。三个角色分工明确:Model 持有数据,对外暴露统一的索引接口;View 负责把数据呈现出来并处理用户交互;Delegate(委托)负责具体单元格的绘制与编辑。Qt 刻意去掉了传统 MVC 的 Controller——桌面应用里"控制器"逻辑通常很薄,强行拆分只会增加间接层。用户输入由 View 直接捕获,需要改数据时调用 model 的接口(如 setData()),形成"视图 → 模型"的写路径;模型变更通过信号反向通知视图,形成"模型 → 视图"的读路径。两条路径合在一起,就是一条完整的单向数据流。
QAbstractItemModel 是这套架构的抽象核心。它用四个几何方法描述任意形状的数据:rowCount() / columnCount() 描述维度,index() 通过行列号返回 QModelIndex,parent() 支持递归,从而让"列表、表格、树"三种形态统一到同一个接口之下;data() 则按角色(role)返回数据。角色是 Qt 的精妙设计:同一个数据位置,通过 Qt::DisplayRole 拿到显示文本、Qt::EditRole 拿到可编辑原值、Qt::ForegroundRole 拿到颜色、Qt::DecorationRole 拿到图标——一个接口同时服务显示、编辑、排序、拖拽、剪贴板,整套交互栈都建立在它之上。
QModelIndex 是理解这套架构的钥匙:它是"句柄",不是数据。它是一个轻量值类型,内部持有 model 指针、行、列和一个 internalPointer,指向 model 内部数据结构的节点。它不拷贝数据,因此只在所属 model 的生命周期与结构状态内有效——model 一旦增删行列,旧 index 立即失效。视图从不自己构造 index,而是通过 index() 向 model 请求;model 作者在内部则用 createIndex() 生成。需要长期跨操作持有引用时,必须使用 QPersistentModelIndex。
视图如何知道数据变了?靠信号。修改数据要发射 dataChanged(first, last),插入/删除行必须用 beginInsertRows()/endInsertRows() 包裹(begin 系列会同步维护视图内部的索引结构)。视图收到信号后只刷新受影响区域——这是变更驱动的增量渲染,与浏览器虚拟 DOM 的 diff 是同一思想,只不过 Qt 把"diff 的职责"交给了 model 的作者:你手动声明变化范围,框架替你精确重绘。
按需取数,就是桌面版的虚拟滚动。View 永远不会遍历整个 model,它只请求当前可见区域(可见行数 + 缓冲余量)的数据,滚动时持续请求新行。配合 canFetchMore() / fetchMore() 可以实现真正的懒加载:聊天记录、日志、文件系统这类无限增长的源,数据只在被"看"到时才加载。
| 角色 | Qt 中的职责 | 前端对应物 |
|---|---|---|
| Model | 持有数据、暴露索引接口、发射变更信号 | Redux store / Vue 的 data |
| View | 请求可见区数据、渲染、转发交互 | React 组件 / Vue 模板 |
| Delegate | 单元格的绘制与编辑 | 组件内部的 render / slot 函数 |
一句话记住
Model 是数据的事实来源(唯一真源),View 是它的投影,Delegate 决定投影长什么样。三者之间只通过"接口 + 信号"通信——谁都不该持有对方的指针。
实际案例
Qt Creator——项目树与符号浏览器。它的数据源是文件系统加编译索引,符号数量以百万计,绝无可能一次性灌进 model。Qt Creator 的做法是自定义 model 包装索引数据,树节点按需展开、子节点按需加载——这正是 parent() 递归 + fetchMore() 懒加载的组合拳。如果当初用 QStandardItemModel 一把梭,启动时间和内存占用都不可接受。
微信 Windows 客户端——聊天列表的 Delegate 经典场景。每条消息要混合排版头像、昵称、摘要、时间,还要叠加未读红点、免打扰图标。若把这些拼成一个 QString 再 setText(),渲染和样式就全完了。正确做法是自定义 QStyledItemDelegate:在 paint() 里用 QPainter 自由绘制,用 sizeHint() 告诉视图行高。这就是为什么 IM 类客户端的消息列表全是同一个套路——复杂的视觉交给 delegate,数据层保持纯粹。
KDE Dolphin 文件管理器——"活 model"的示范。它直接用 QFileSystemModel:文件系统本身就是活数据源,目录一变它自动发射信号,视图自动刷新,无需任何轮询。反观很多自研软件用定时器轮询目录再整体 reset,架构高下立判。
与前端互证。VS Code 的 TreeView API、react-window 的虚拟列表、Chrome 的按需布局,本质都是"按需渲染 + 变更通知"。Model/View 只是把这个模式固化成了框架级抽象——理解它,你就能同时看懂两个世界的设计。
常见错误
- Model 里塞 QWidget / View 指针。原因:图省事,把显示对象直接放进数据层。后果:model 无法复用、无法单元测试、跨线程直接崩溃。避免:model 只存纯数据,一切显示走 role 与 delegate。
- 改了数据忘记发
dataChanged(),界面"不刷新",于是 resort 到beginResetModel()/endResetModel()全量重置。原因:reset 是"核弹",会重建视图全部索引,丢失选中与滚动位置;数据量大时直接卡死。避免:尽量用精确到范围的dataChanged,只有结构剧变(如清空重建)才 reset。 - 在
data()/rowCount()里做耗时操作。原因:滚动、绘制时视图高频调用这些方法,一屏就是几百次,放数据库查询、网络请求必卡。避免:数据预处理 + 缓存,data()只做 O(1) 读取。 - 跨线程直接改 model。原因:
QAbstractItemModel不是线程安全的,子线程改数据并发信号是未定义行为。避免:工作线程只操作自己的数据副本,通过信号(QueuedConnection)回到主线程再更新 model。 - 长期持有
QModelIndex。原因:它是易失句柄,model 结构一变就失效,继续使用是悬垂访问。避免:长期引用用QPersistentModelIndex,或保存业务键(如 id)事后重新查找。 - 大数据量用
QStandardItemModel。原因:每个单元格对应一个堆分配的QStandardItem对象外加信号连接,几十万行内存爆炸、插入 O(n)。避免:自定义 model,数据放连续容器(QVector),只在请求时生成。
最佳实践
- 列表/表格优先继承
QAbstractListModel/QAbstractTableModel,只有树才碰QAbstractItemModel。接口更少,踩坑面更小;需要树形再升级,重构成本可控。 - 最小信号原则。改一格发
dataChanged(index, index),改一片发范围;insert/remove 必须用 begin/end 系列包裹。这是正确性与性能的双保险,也是 Code Review 的第一检查点。 - 10 万行级的大列表必须自定义 model + 缓存 + 按需取数。数据放连续容器,配合
canFetchMore()/fetchMore()做懒加载,data()保持 O(1)。 - 与 QML 对接时重写
roleNames(),把 role 映射成语义化名字(如 "name"、"price"),让 QML 端 ListView 直接绑定。model 保持"语言无关",C++ 与 QML 共用一套数据契约。 - 显示复杂用
QStyledItemDelegate:paint()自由绘制 +sizeHint()定行高 +createEditor()定制编辑。显示逻辑永远不进data()。
Trade-off:Model/View 的代价是样板代码多、间接层多。数据量小、一次性静态展示、无复用需求时,用 QListWidget/QTreeWidget 反而更快更省事——不要在小场景强行上 MVC 全家桶,架构是为复杂度买单的。
与 Web 技术的联系
Vue 响应式 ≈ 信号槽的"手写版"。Vue 用 Proxy 代理数据,访问时自动收集依赖、修改时精确触发更新;Qt 的信号槽则要求你手动决定何时发 dataChanged、手动声明失效范围。Qt 更繁琐,但性能边界完全可控——你确切知道一次改动会重绘哪几格,不会有"依赖收集"的隐藏开销与陷阱。
React setState + 协调 ≈ dataChanged。React 每次 setState 都要对组件树做 diff(虽有 Fiber 优化),而 Qt 的 dataChanged 直接给出变化范围,连 diff 都省了。代价是 Qt 要求 model 作者维护"变更契约"——漏发信号就是 bug,这和前端受控组件忘记调 onChange 是同一类错误。
虚拟滚动:react-window、vue-virtual-scroller 要自己管"渲染哪些行",Qt 里这是 view 的内建能力,你只需正确实现 model 接口。思想同源:只渲染可视区。Node 的 EventEmitter 与信号槽同构,理解一个就理解另一个。
Redux 的单一数据源 ≈ Model。前端把状态收进 store,桌面端把数据收进 model,组件/视图都降级为"投影"。最大的思维迁移是:前端习惯"组件内自带数据"(useState 写在组件里),Qt 则要求数据上移、视图变纯。把"props 下传、事件上抛"换成"model 提供、信号通知",两套知识就打通了。
管理者视角
为什么 Team Leader 必须懂:Model/View 是 Qt 项目分层的分水岭。用没用对,直接决定代码可测性、UI 可换肤性、列表可复用性,也决定你在 Code Review 时看什么。评审时重点看三点:model 是否纯净(头文件里有没有 widget 依赖)、信号是否完备(所有修改路径是否都发 dataChanged)、是否滥用 reset 或 QStandardItemModel。
落地三件事:① 用依赖方向约束代替口头约定——业务数据层禁止 include 任何 widget 头文件;② 统一 role 命名与 roleNames() 映射规范,写进团队文档;③ 新增列表必须附带 model 的单元测试——model 不依赖 UI,是 Qt 项目里最好测的一层,测试覆盖率从这打起。
带新人:先让 TA 用 QStandardItemModel 跑通"改数据 → 界面变",再重写成自定义 model,最后要求画一张数据流图(谁改数据、谁发信号、谁刷新)。能画清这张图的人,才算真正理解了数据驱动。
延伸阅读
- Qt 官方文档 Model/View Programming(doc.qt.io/qt-6/model-view-programming.html)—— 权威且完整,唯一必读,建议通读两遍。
- 《C++ GUI Programming with Qt 4》(Blanchette & Summerfield)的 Model/View 章节 —— 经典教材,讲得比官方文档更像人话,值得反复读。
- Qt 自带示例 Item Views(Qt Creator 里搜 "itemviews")—— 十几个可编译的小例子,自定义 model/delegate 的标准姿势都在里面。
- KDAB 博客(kdab.com,搜 model/view 与 performance)—— Qt 一线咨询公司的实战经验,性能与内存细节极其扎实。
- 《Qt 5 Cadaques》(qmlbook.github.io)的 Model/View 章节 —— 讲 C++ model 如何被 QML 消费,桥接必备。
- react-window 源码 —— 只有几百行,与 Qt 的虚拟化思想互证,跨领域对照利器。
今日思考题
QModelIndex 默认构造是"无效"的,设计者为什么不给它一个"指向某处"的默认值?这种显式无效的设计带来了什么安全性?
10 万行聊天记录,要求滚动到任意位置都流畅,你会怎么设计 model 与缓存?(提示:分页大小、LRU、索引二分)
Vue 的响应式是"自动"的,Qt 的 dataChanged 是"手动"的——自动化的代价是什么?哪些场景下手动反而更优?
在 MVVM 架构里,QAbstractItemModel 应该算 Model 还是 ViewModel?给出你的理由。
新消息到达时聊天列表要"自动滚到底部",但用户上翻历史时不能打扰——这个职责该放 View、Delegate 还是 Model?为什么?
今日实践任务
任务:实现一个"模拟行情"列表(30~60 分钟)
用 QAbstractListModel 存 3 只股票的代码与涨跌幅,QListView 展示,通过 ForegroundRole 实现"红涨绿跌",QTimer 每秒随机变动价格并发出精确 dataChanged。运行后观察滚动流畅度;再把 dataChanged 换成 beginResetModel()/endResetModel(),对比行为差异。
// main.cpp —— 完整可编译(Qt6 Widgets)
#include <QApplication>
#include <QListView>
#include <QAbstractListModel>
#include <QTimer>
#include <QRandomGenerator>
#include <QColor>
#include <QString>
struct Stock { QString code; double change; }; // change: 涨跌幅%
class StockModel : public QAbstractListModel {
public:
int rowCount(const QModelIndex& = {}) const override { return stocks.size(); }
QVariant data(const QModelIndex& idx, int role) const override {
if (!idx.isValid()) return {};
const Stock& s = stocks.at(idx.row());
if (role == Qt::DisplayRole)
return QString("%1 %2%").arg(s.code).arg(s.change, 0, 'f', 2);
if (role == Qt::ForegroundRole) // A股习惯:红涨绿跌
return s.change >= 0 ? QColor(220, 60, 60) : QColor(40, 170, 90);
return {};
}
void tick() { // 模拟行情推送
for (Stock& s : stocks)
s.change += QRandomGenerator::global()->generateDouble() * 2 - 1;
emit dataChanged(index(0), index(stocks.size() - 1)); // 精确局部刷新
}
private:
QVector<Stock> stocks{{"600519", 1.2}, {"000001", -0.8}, {"300750", 2.5}};
};
int main(int argc, char** argv) {
QApplication app(argc, argv);
StockModel model;
QListView view; view.setModel(&model);
view.resize(320, 260);
view.show();
QTimer timer;
QObject::connect(&timer, &QTimer::timeout, &model, &StockModel::tick);
timer.start(1000);
return app.exec();
}
编译运行:
g++ -fPIC main.cpp -o quote $(pkg-config --cflags --libs Qt6Widgets) && ./quote
- 挑战 ①:把 ForegroundRole 改为自定义 QStyledItemDelegate 的 paint(),在单元格里画出"涨跌幅进度条",体会 Delegate 的职责边界;
- 挑战 ②:把 dataChanged 换成 beginResetModel()/endResetModel(),对比滚动位置、选中状态与卡顿差异,理解"最小信号"原则;
- 进阶:把数据源改成每 50ms 推送 100 条随机行情,并在 data() 里 qDebug 打印,验证"只请求可见区域"的按需取数。