Qt Model/View 架构:数据与视图分离的基石

Vue 有响应式数据,React 有 state + 虚拟 DOM,Qt 的原生答案是 Model/View。它是列表、表格、树形界面的地基,也是理解 MVVM 与 Qt Quick 的必经之路。

目录

01

今日主题

从 Web 转向桌面客户端,最需要重建的不是语法,而是"数据如何驱动界面"的心智模型。你在 Vue 里写响应式数据 + 模板,在 React 里写 state + 虚拟 DOM,而 Qt 的原生答案就是 Model/View 架构——它是 Qt 中一切列表、表格、树形界面的地基,是 C++ model 与 QML 之间的桥梁,也是后续学习 MVVM、插件化、大型客户端架构的必经之路。

前六期我们依次攻克了 RAII、移动语义、信号槽、事件循环、多线程、内存管理,今天把知识汇聚到第一个"架构级"主题:理解 Model/View,你就拿到了 Qt 数据驱动世界的钥匙。

02

核心知识

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 决定投影长什么样。三者之间只通过"接口 + 信号"通信——谁都不该持有对方的指针。

03

实际案例

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 只是把这个模式固化成了框架级抽象——理解它,你就能同时看懂两个世界的设计。

04

常见错误

  • 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),只在请求时生成。
05

最佳实践

  • 列表/表格优先继承 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 全家桶,架构是为复杂度买单的。

06

与 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 提供、信号通知",两套知识就打通了。

07

管理者视角

为什么 Team Leader 必须懂:Model/View 是 Qt 项目分层的分水岭。用没用对,直接决定代码可测性、UI 可换肤性、列表可复用性,也决定你在 Code Review 时看什么。评审时重点看三点:model 是否纯净(头文件里有没有 widget 依赖)、信号是否完备(所有修改路径是否都发 dataChanged)、是否滥用 reset 或 QStandardItemModel。

落地三件事:① 用依赖方向约束代替口头约定——业务数据层禁止 include 任何 widget 头文件;② 统一 role 命名与 roleNames() 映射规范,写进团队文档;③ 新增列表必须附带 model 的单元测试——model 不依赖 UI,是 Qt 项目里最好测的一层,测试覆盖率从这打起。

带新人:先让 TA 用 QStandardItemModel 跑通"改数据 → 界面变",再重写成自定义 model,最后要求画一张数据流图(谁改数据、谁发信号、谁刷新)。能画清这张图的人,才算真正理解了数据驱动。

08

延伸阅读

  1. Qt 官方文档 Model/View Programming(doc.qt.io/qt-6/model-view-programming.html)—— 权威且完整,唯一必读,建议通读两遍。
  2. 《C++ GUI Programming with Qt 4》(Blanchette & Summerfield)的 Model/View 章节 —— 经典教材,讲得比官方文档更像人话,值得反复读。
  3. Qt 自带示例 Item Views(Qt Creator 里搜 "itemviews")—— 十几个可编译的小例子,自定义 model/delegate 的标准姿势都在里面。
  4. KDAB 博客(kdab.com,搜 model/view 与 performance)—— Qt 一线咨询公司的实战经验,性能与内存细节极其扎实。
  5. 《Qt 5 Cadaques》(qmlbook.github.io)的 Model/View 章节 —— 讲 C++ model 如何被 QML 消费,桥接必备。
  6. react-window 源码 —— 只有几百行,与 Qt 的虚拟化思想互证,跨领域对照利器。
09

今日思考题

QModelIndex 默认构造是"无效"的,设计者为什么不给它一个"指向某处"的默认值?这种显式无效的设计带来了什么安全性?

10 万行聊天记录,要求滚动到任意位置都流畅,你会怎么设计 model 与缓存?(提示:分页大小、LRU、索引二分)

Vue 的响应式是"自动"的,Qt 的 dataChanged 是"手动"的——自动化的代价是什么?哪些场景下手动反而更优?

在 MVVM 架构里,QAbstractItemModel 应该算 Model 还是 ViewModel?给出你的理由。

新消息到达时聊天列表要"自动滚到底部",但用户上翻历史时不能打扰——这个职责该放 View、Delegate 还是 Model?为什么?

10

今日实践任务

任务:实现一个"模拟行情"列表(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 打印,验证"只请求可见区域"的按需取数。
11

一句话总结

Model 是数据的唯一事实来源,View 只是它的投影——把"改数据"和"画界面"彻底分离,是 Qt 客户端架构的第一课。