数据与界面解耦的引擎——理解虚拟化渲染与信号契约,是通往大型客户端架构的下一块基石。
你已经掌握了信号槽、事件循环、多线程与内存管理,今天学习 Qt 最核心的架构模式——Model/View。它定义了"数据"与"界面"之间的契约,是文件浏览器、行情表格、联系人列表等一切大型列表类界面的地基,也是你理解 Qt Creator、交易终端等真实产品的钥匙。更重要的是:它和你熟悉的 Vue/React 数据驱动思想一脉相承,学会它等于同时加深了对前端响应式原理的理解。
Model/View 是 MVC 在 Qt 中的落地:View(QListView/QTableView/QTreeView)只负责"画";Model(QAbstractItemModel 的子类)只负责"存与取";Delegate(QStyledItemDelegate)负责"某个单元格怎么画、怎么编辑"。三个视图类只是同一套接口的不同呈现方式——这就像 React 里同一份 state 可以渲染成列表或表格。
QAbstractItemModel 是"契约接口",不是容器。核心方法只有四个维度:index(row, column, parent) 返回单元格句柄、parent() 描述层级、rowCount()/columnCount() 描述形状、data(index, role) 按角色取数据。注意:模型默认是按需查询的——视图滚动到哪一行,才调用哪一行的 data()。这就是虚拟化的本质:10 万行数据,屏幕上只有 20 行,data() 实际只被调用几十次。对比前端,这正是 react-window 这类虚拟列表在 Qt 里是内置默认架构的原因。
QModelIndex 是"短命的轻量句柄"。它由 (row, column, internalPointer/internalId + 所属模型指针) 组成,不是指针也不是迭代器。它不能长期保存:模型结构一旦变化,旧索引全部失效,继续使用会读到错位数据且不报错。这和 React 的 key "不能跨渲染复用"是同一个失效思想。
Role 机制:data() 的第二个参数决定"要什么"——DisplayRole 要显示文本、EditRole 要编辑值、DecorationRole 要图标、BackgroundRole 要背景色、UserRole 之后是自定义数据。一次查询只取一个角色,避免传输用不到的数据,就像 REST API 的字段选择。
信号契约(最重要):模型的数据/结构变化必须通过信号广播——dataChanged、rowsInserted/rowsRemoved、modelReset。视图是信号的订阅者,收到后增量重绘。beginInsertRows()/endInsertRows() 是"事务边界",必须包住真实的数据修改。如果绕过信号直接改数据,视图永远不知道——Model/View 就是一套手动版响应式系统,依赖追踪从"框架自动"变成了"你手动发通知"。
链式代理与共享:QSortFilterProxyModel 是中间层模型,输入一个模型、输出排序/过滤后的视图,源数据零污染;多个视图可以共享同一个模型(单一数据源)。同一套 QAbstractListModel 还能直接喂给 QML 的 ListView——Widgets 与 QML 共用一套数据层。
Qt Creator:左侧项目树、符号导航、打开文件列表全部基于 Model/View。代码库可能有数十万文件,如果用 QTreeWidget 一次性构建整棵树,启动就会卡死。真实做法是:目录模型按需加载,展开节点时才读取子目录——这就是虚拟化的"延迟展开"形态。
交易终端(Wind/同花顺/迅投 QMT):行情表格每秒刷新数千次。如果用 QTableWidget 全量重建,必然闪烁且吃满 CPU。真实产品用 QAbstractTableModel 包装行情缓存,tick 到来时只对变化的行 emit dataChanged(topLeft, bottomRight)——视图只重绘那两行,其余 9998 行纹丝不动。这是增量更新最经典的工程示范。
QQ/微信联系人列表:QListView + 自定义 QStyledItemDelegate 绘制头像、未读角标、最后一条消息;分组折叠用 QTreeView 或扁平模型 + 代理模型。Delegate 让"一行"像前端组件一样可定制渲染。
JetBrains IDE 与 VSCode 的文件树、代码大纲同样是"数据提供器 + 虚拟化列表"的思路——这是跨平台 GUI 领域的共识。它们的共同点:数据层独立可测、多视图共享、增量更新。这和前端 Redux/Vuex 的"单一数据源"哲学完全一致,而 Qt 在 2004 年就把这套内置了。
1. 插入/删除数据不包 begin/endInsertRows:视图不更新或直接崩溃。原因:视图只信信号不信内存,绕过信号等于数据与视图失联。✅ 把"改数据"和"发信号"绑成一对,封装成 insertRow() 小工具函数。
2. 长期保存 QModelIndex:模型一变,旧索引静默失效,读出来的是错位数据。✅ 用 QPersistentModelIndex,或只保存 row/ID,用时重新 index()。
3. 循环逐条 insertRow:每条都触发视图布局,插入 1 万行变成 O(n²) 信号风暴。✅ beginInsertRows(first, last) 一次包住整批。
4. 在 data() 里做重活:读文件、查数据库、拼接大字符串。滚动时 data() 高频调用,UI 直接卡顿。✅ data() 只做 O(1) 查表,重活预计算缓存或异步预取。
5. 大数据量用 QStandardItemModel:每个 item 都是一个堆对象 + 信号开销,100 万行直接内存爆炸。✅ 自定义 QAbstractItemModel 直接包装 QVector<struct>,按索引取值。
6. 跨线程直接改模型数据:数据竞争、崩溃、界面错乱。✅ 后台线程只算数据,通过队列信号回 UI 线程再改模型并发 dataChanged。
1. 自定义模型优先于 QStandardItemModel:数据结构是你自己定义的时候,继承 QAbstractListModel/QAbstractTableModel 包装容器,而不是塞进标准模型。何时不用:快速原型、数据量 < 1000 行、结构还没定型时,先用标准模型迭代。
2. Role 分层:DisplayRole 管"给人看",EditRole 管"给编辑器",UserRole+1 存对象 ID/指针。Trade-off:QVariant 有装箱开销,高频路径避免在 data() 里现构造 QString,预格式化缓存。
3. 排序过滤走 QSortFilterProxyModel:源模型保持原始顺序,视图层各自排序,互不污染。何时不用:数据本身已有序且无需过滤,或代理链超过两层影响性能时,直接在自定义模型里实现。
4. Delegate 按需使用:只有需要自定义绘制(进度条、图片、富文本)或自定义编辑器时才重写 delegate,优先 QStyledItemDelegate(支持样式表)。纯文本列表别重写 paint(),默认 delegate 又快又稳。
5. 大数据三件套:分批插入(begin/end 包整批)、data() 只读预计算缓存、视图 setUniformRowHeights(true) 加速布局;高频变化用精细 dataChanged 只刷变化行,绝不 modelReset(那等于全量重建)。
Vue 的 v-for + 响应式 ↔ Qt 的 View + Model:Vue 里修改 data 自动更新 DOM,靠的是 Proxy 依赖追踪;Qt 里修改数据必须手动 emit dataChanged——一个"自动"、一个"手动",本质都是"数据变化通知视图"。学 Qt 反而让你更懂 Vue 响应式的原理:手动版把机制暴露得一清二楚。
React 的 key ↔ QModelIndex:React 用 key 定位最小更新单元,Qt 用 (row, column) 定位单元格;key 不可跨渲染复用,QModelIndex 不可跨结构变化使用——同一个"失效"思想。理解了 QModelIndex 为什么会失效,就理解了 React 为什么警告 key 必须稳定。
虚拟 DOM ↔ 增量重绘:React 通过 diff 最小化 DOM 操作,Qt 通过 begin/end 信号让视图只重绘受影响矩形。两者都是"最小化更新范围",只是 React 在 JS 层做 diff,Qt 靠信号契约。
前端虚拟列表 ↔ Qt 虚拟化:react-window 的 itemSize/overscan 是前端性能优化的"高级技巧",在 Qt 里却是默认架构。理解 Model/View 后回头再看 react-window 源码会豁然开朗。
Redux/Pinia 单一数据源 ↔ 多视图共享 Model:状态提升、单向数据流,思想完全一致。差异在于:前端框架替你做了脏检查/依赖收集,Qt 把通知责任交给你——更底层、更可控、也更危险,这正是架构师价值的体现。
为什么 TL 必须懂:Model/View 是 Qt 客户端分层的第一课。不懂它,你无法评审数据流设计,也无法回答"为什么 QTableWidget 撑不住 10 万行"这类必现的性能问题。
Code Review 关注点:① 所有结构修改是否成对出现 begin/end;② data() 里有没有 IO 和计算;③ 业务层是否绕过模型信号直接改数据;④ 是否用 modelReset 代替精细 dataChanged;⑤ 线程边界是否清晰。
规范制定:约定"视图层只能通过 Model 接口读写数据,禁止直接访问业务数据结构";把核心列表的性能基准(10 万行滚动 ≥ 60fps)写进 Definition of Done。
培养新人:让新人先画数据流图(谁提供数据、谁监听、谁渲染)再写代码;用"行情表每秒刷新"作为入门任务,一天就能直观理解信号契约的价值。
1. Qt 官方文档 Model/View Programming(Qt 6 手册):权威定义,含完整 Role 表、代理模型说明与架构图。文档是最好的第一手资料。
2. Qt 官方 Examples 的 itemviews 模块:editabletreemodel、stardelegate、coloreditorfactory 等,覆盖模型编辑、委托绘制的完整范式,入门必读。
3. KDAB 博客 "Model/View" 系列:Qt 性能与架构领域最权威的工程团队,讲清了自定义模型、缓存、大数据场景的实战取舍。
4. 《Foundations of Qt Development》第 13~15 章:把 Model/View 从原理到实现讲得最透彻的经典书,适合系统阅读。
5. react-window 源码:对照理解虚拟化列表的 itemSize/overscan 设计——同一思想在前后端的不同形态。
为什么 QModelIndex 设计成"短命的值类型"而不是直接存对象指针?这和 React 的 key 机制有何异同?
100 万行的表格滚动时,data() 每秒可能被调用多少次?如果 data() 里有字符串格式化,怎么改造成 O(1)?
两个视图(表格 + 折线图)共享同一个模型,行情变化时如何让它们都只更新受影响部分?谁负责发信号?
QSortFilterProxyModel 排序后,视图行号与源模型行号不一致,编辑时如何正确映射回源数据?
Vue 用自动依赖追踪、Qt 用手动信号,为什么 Qt 不做成自动通知?手动通知的代价和收益各是什么?
用 QAbstractListModel 包装 100 只股票,QListView 显示;每秒随机更新 5 只价格,只对变化行发 dataChanged。代码刻意不写 Q_OBJECT(子类未声明新信号槽),因此无需 moc,一条 g++ 命令即可编译:
#include <QApplication>
#include <QListView>
#include <QAbstractListModel>
#include <QTimer>
#include <QRandomGenerator>
struct Stock { QString code; double price; };
class StockModel : public QAbstractListModel {
public:
explicit StockModel(QObject* parent = nullptr) : QAbstractListModel(parent) {
for (int i = 0; i < 100; ++i)
m_stocks.push_back({QString("600%1").arg(i, 3, 10, QChar('0')), 10.0 + i * 0.1});
}
int rowCount(const QModelIndex& parent = QModelIndex()) const override {
return parent.isValid() ? 0 : int(m_stocks.size());
}
QVariant data(const QModelIndex& idx, int role) const override {
if (!idx.isValid() || idx.row() >= int(m_stocks.size())) return {};
const Stock& s = m_stocks[idx.row()];
if (role == Qt::DisplayRole)
return QString("%1 %2").arg(s.code).arg(s.price, 0, 'f', 2);
if (role == Qt::TextAlignmentRole) return int(Qt::AlignCenter);
return {};
}
void tick() { // 模拟行情:每秒随机改 5 只,只发 5 个 dataChanged
for (int i = 0; i < 5; ++i) {
int r = QRandomGenerator::global()->bounded(int(m_stocks.size()));
m_stocks[r].price += QRandomGenerator::global()->generateDouble() * 0.4 - 0.2;
emit dataChanged(index(r), index(r), {Qt::DisplayRole});
}
}
private:
QVector<Stock> m_stocks;
};
int main(int argc, char** argv) {
QApplication app(argc, argv);
StockModel model;
QListView view;
view.setModel(&model);
view.resize(280, 420);
view.show();
QTimer timer;
QObject::connect(&timer, &QTimer::timeout, &model, [&model] { model.tick(); });
timer.start(1000);
return app.exec();
}
编译:g++ -std=c++17 -fPIC main.cpp $(pkg-config --cflags --libs Qt6Widgets) -o stockview(需已安装 Qt6 开发环境)。
验证:运行后每秒恰好 5 行价格变化,其余 95 行不动——这就是增量更新。加分项:① 把行数改成 10000,观察仍流畅;② 加第二个 QListView 共享同一模型,验证"单一数据源";③ 插入 QSortFilterProxyModel 按价格降序排列,思考编辑时的行号映射问题。