客户端架构 Qt Model/View · Delegate · Proxy 数据驱动 UI · 与 Vue 响应式对照

Qt Model/View 架构深潜:数据驱动 UI 的「通知粒度」,以及它为什么比 Vue 的响应式更难也更值钱

2026-09-19 · 每日技术导师 · 接续 09-16《Qt 元对象系统内幕》与 09-04《插件化架构》,从「框架替我刷新」走到「我必须亲手声明变更」

01今日主题:UI 与数据之间那条契约

前面几篇我们把 Qt 的基建逐个拆开了:构建体系(09-13 现代 CMake)、语言能力(09-07 C++20 协程)、元对象系统(09-16 MOC 与信号槽)、扩展能力(09-04 插件化)。但真正决定一个桌面客户端「手感」和「可维护性」的,是数据与界面之间那条契约:数据变了,界面怎么知道?谁负责刷新?刷新多少?

在 Web 里这条契约由框架包办——Vue3 用 Proxy 做依赖收集,谁读了状态谁就自动被通知重渲染;React 让你换对象、靠引用比较和重新执行 render 发现变化。到了 Qt,这条契约变成一份你必须亲手履行、且没有编译器帮你检查的显式协议:QAbstractItemModel 的信号契约。学它的价值绝不只在于「会写表格」:只要你的界面里出现「数据条数远大于屏幕能显示的行数」(会话列表、日志、订单、文件树、设备列表),Model/View 就是 Qt 世界里唯一既能跑满帧率、又能单测的姿势。这一篇的目标是让你能回答一个具体问题——「我改了数据,视图到底重绘了多少东西?」

02核心知识:模型、索引、角色与通知粒度

2.1 三层解耦:模型(数据)/ 视图(可视范围)/ 代理(绘制与编辑)

Model/View 的第一性原理是「谁不认识谁」:

这条边界不是洁癖,是可测试性:模型层可以在没有 QApplication、没有窗口的进程里用 QTest 跑完整单测,就像 Redux 的 store 可以在没有 DOM 的环境里被测一样。你在 Web 里坚持「组件不直接 fetch、业务逻辑进 store/composable」,在 Qt 里对应的是「业务逻辑进模型,视图与 delegate 只做投影」。

2.2 QModelIndex:一次寻址的完整坐标,而且是「短命」的

QModelIndex 不是你的数据对象,它是一次寻址的结果,内部只有四样东西:行、列、所属模型指针、以及一个 internalPointer()/internalId()(由你决定,通常是指向真实节点的指针或稳定 ID)。list[index] 在 Web 世界里是「拿引用」,在 Qt 里是「拿一张临时坐标卡」——它只在当前这次请求周期内有效。树形结构靠 parent() 递归定位:链表式实现里把节点指针塞进索引的 internalPointer,parent() 再顺着节点里的父指针回推,这就是 Qt 树模型的标准写法。

这里有一条硬性要求,违反它不会编译报错,但会让选择状态、展开状态、QPersistentModelIndex 全部错乱:对同一个数据节点,模型每次调用 index() 返回的 internalPointer() 必须一致(Qt 文档明确要求模型自洽)。顺带一个小坑:QModelIndex::isValid() 是 Qt 5.11 才加的(老的 valid() 已弃用),代码库里混用两种写法说明这个模块换过几拨人。

2.3 role:一格数据其实是一本「QVariant 字典」

视图要的不是「第 3 行的数据」,而是「第 3 行、Qt::DisplayRole 的数据」。role 是这套架构最聪明的一步:Qt::DisplayRole(文本)、DecorationRole(图标)、CheckStateRole(勾选)、ToolTipRole、FontRole、BackgroundRole、TextAlignmentRole、SizeHintRole,再到你自己的 Qt::UserRole + n。好处有两个:一是同一格可以承载多份语义数据而不膨胀视图;二是 delegate 只认 role,因此「显示什么」和「数据是什么」可以各自演进。

实践中很常见的一个技巧:把真实业务对象整个塞进 Qt::UserRole + 1 的 QVariant(自定义类型要 Q_DECLARE_METATYPE),delegate 或上层直接拿到强类型对象——省掉「把 10 个字段拼成一个字符串、显示时再解析回来」这种既慢又易错的写法。

成本提醒:视图画一格不是调一次 data()。它要文本、要图标、要颜色、要对齐、要尺寸提示——一次绘制可能触发 4~8 次虚函数调用,每次返回一个 QVariant(可能伴随分配)。Qt 6.0 专门为此引入 QModelRoleData 与 multiData(const QModelIndex &, QModelRoleDataSpan):视图预先准备一个「角色数组」,一次调用把多个 role 全部填满,并复用这批 QVariant 以减少分配。这就是「通知/取数粒度」从 API 层面被承认是性能关键的证据。

2.4 信号契约:这是「响应式」的人工版本

模型必须主动宣告发生了什么,视图才决定刷多少:

视图拿到这些信号后,才可能把「只重绘收到影响的那一小块矩形、必要时才重算布局」。通知粒度就是性能本身:单格改动传 {Qt::DisplayRole} 只会重绘那一格,而 beginResetModel 会让视图重建所有可见项、滚动条跳动、选中丢失。这也是本篇最实用的一条经验:不要用 reset 解决所有问题,reset 是推土机,不是手术刀。

2.5 代理模型:在数据与视图之间插管道

QSortFilterProxyModel 让「排序」和「过滤」不再污染数据模型:它自己是一个完整模型,只是把对外的索引通过 mapToSource()/mapFromSource() 映射回源模型。Qt 6 里过滤接口用 setFilterFixedString() / setFilterRegularExpression()(Qt 5 时代的 setFilterRegExp 已移除),setRecursiveFilteringEnabled(true) 可做树的递归过滤。QIdentityProxyModel 则适合做轻量变换(换格式、注入高亮、合并多模型)。

代价要说清楚:代理链每深一层,每次索引访问都多一次映射。「过滤 + 排序 + 高亮」三层代理在百万行数据上是可测的开销,而且 data() 在代理里的转发会让断点调试变啰嗦。规则是:尽量复用同一个代理实例(改过滤字符串),而不是重建模型或堆代理。

2.6 视图侧的大数据量武器(多数人不知道它们是内置的)

03实际案例:这些产品为什么都长成「模型 + 视图」的样子

Qt Creator 的定位器(Locator / 符号搜索):在一个几十万文件的代码库里输入几个字符就要出结果。它的做法是:后台线程建索引,界面侧用模型的过滤能力即时筛选,视图只渲染可见的十几行。设计原因很直白——输入延迟必须低于人类感知的 100ms,而全量匹配的结果集可能有上万条,所以「匹配」和「渲染」必须被拆开,且渲染必须惰性。

QFileSystemModel(Qt 官方实现):文件系统就是最典型的「数据远大于视口」场景。它的每个目录行都带有额外 role(路径、图标、类型、大小),canFetchMore 让未展开的目录保持「未扫描」状态,扫描在 worker 线程进行,完成后通过信号回到 UI 线程更新模型。结论:模型可以很重,但重活必须异步——这就是 Qt 给出的答案,也是它禁止你跨线程乱改模型的原因。

VSCode / Chrome 的虚拟列表:DOM 里只保留可视行(或只渲染可视区),和 Qt 视图惰性布局是同一个思想。差别在产品化路径上:Web 侧需要第三方库(react-window、vue-virtual-scroller、CDK virtual-scroll)并靠开发者自觉保证「行组件无副作用」,Qt 侧则把这个机制直接写进了视图基类,代价是你必须自己写模型的各种虚函数。一边是「框架给你反应式但你自己做虚拟化」,一边是「框架给你虚拟化但你自己做通知」——同一个问题的两种分工。

微信 / QQ 的会话列表:新消息只改变一行的「摘要 + 时间 + 未读数」。正确的做法是把这些字段放在不同 role 上,然后用 dataChanged(一行的左索引, 同一行的索引, {DisplayRole, UnreadRole}) 精确宣告;一旦偷懒用 reset 或整表刷新,滚动位置会跳、正在输入的状态会丢——用户会说「这个客户端很卡、很晃」,根因其实是一行通知没写准。

JetBrains 系的增量索引:IntelliJ 把「读代码 → 建 PSI 索引 → 界面展示」严格分层,索引在后台增量更新,界面侧只消费稳定快照。它和 Qt 的对应关系是:「数据准备」与「呈现」之间必须有一个可被异步更新的中间层;在 Qt 里这个中间层就是模型(配合「worker 线程算、UI 线程改模型」的 queued 信号)。

04常见错误:编译器不报错,运行时崩溃或卡死

错误 1:在 data() 里做重活

表现:表格静止时 CPU 也在跳,滚动像幻灯片。原因:data() 是绘制路径上的虚函数,一次滚动会给可见的每一格打上几十次调用,而你在这里查了数据库、做了正则全文匹配或读文件。怎么避免:把 data() 约束成「纯内存读 + 极简格式化」;重计算放到数据入模型之前(预计算并缓存在行结构里),昂贵资源用异步加载 + 到位后发 dataChanged。判断标准:如果 data() 里出现了 I/O 或 new,就该改设计。

错误 2:改了内部容器却不发(或顺序颠倒的)begin/end

表现:偶尔崩在视图内部、行错位、选中项跳到别的行。原因:视图在收到 beginInsertRows 时会先记录旧状态、之后在 endInsertRows 里重算布局;你直接 push_back 又自己 emit,视图读到了「已变但未宣告」的模型。怎么避免:所有结构变更都封在模型的一个成员函数里,函数体开头 begin、结尾 end,中间只改容器,绝不直接 emit;把「直接改动容器」这条规则写进团队 Code Review 清单。

错误 3:长期持有 QModelIndex

表现:异步任务回来后用保存下来的索引更新数据,程序偶发崩溃或更新到错误的行。原因:索引是临时坐标卡;模型 reset 或行删除后它立刻变成无效(而无效索引不像空指针那样容易发现)。怎么避免:需要跨事件循环持有时用 QPersistentModelIndex(注意 Qt 6 里它是独立类型,不能当 QModelIndex 直接传,要显式构造),或者更稳的做法——保存业务主键(id),回来再查一次行号。跨线程传递绝对禁止。

错误 4:QTableWidget + setCellWidget 铺大表

表现:几百行就吃掉几百 MB、滚动掉帧。原因:每格一个 QWidget,意味着每个单元格都有事件分发、样式表解析和布局参与,虚拟化彻底失效。怎么避免:用 QTableView + QAbstractTableModel;需要进度条、按钮这类自定义外观时,在 delegate 的 paint() 里画(真要交互时只在编辑器打开的那一刻创建真实控件)。原型阶段用 QTableWidget 可以,评审时把 setCellWidget 当红灯。

错误 5:用 beginResetModel 处理一切变更

表现:滚动位置总是跳回顶部、展开的树自动收起、选中丢失。原因:reset 的语义是「整套数据换了」,视图会把所有索引、选择、展开状态作废并全部重建。怎么避免:能增量就增量(dataChanged / insertRows / removeRows),reset 只用于「整体替换数据源」(比如切换账号、重新加载文件);实在要 reset 时,主动保存并恢复滚动位置与选择。

错误 6:在 setData 里触发回环

表现:编辑一格后程序卡死(递归爆栈或事件风暴)。原因:模型改了数据 → 发信号 → 上层监听又调一次 setData(常见于「同步远程 + 本地」的双写逻辑)。怎么避免:用 guard 标志或 QSignalBlocker 切断回环,并把「写回」逻辑放到明确的命令路径上,而不是挂在「数据变了」的通知上。

05最佳实践:契约怎么履行,边界画在哪

实践 1:模型零 UI、零 I/O——它是可单测的领域层

怎么做:模型里不出现 QMessageBox、不出现 QNetworkAccessManager、不读 QSettings;它只接受「数据」和「变更请求」,对外只发信号。何时不该这么做:一次性脚本、不到 20 行数据的静态设置页——这种场景 QTableWidget 更好读,不要为表格而表格。Trade-off:多写一层模型的成本是几十行模板代码,换来的是「增删改排序过滤全部可测 + 视图可替换(QTableView 换 QTreeView 不动模型)」。数据规模超过一屏就划得来。

实践 2:role 定义集中管理,绝不散落魔法数字

怎么做:在模型的头文件里写 enum Role { NameRole = Qt::UserRole + 1, ScoreRole, ObjectRole };,全项目只用这些名字。原因:Qt::UserRole + 3 散落在 delegate、视图、过滤器和测试里,是这类代码库最常见的腐化方式——某天有人改了枚举顺序,界面就开始显示错列,而且没有任何编译错误。Trade-off:把业务对象整个塞进 ObjectRole 很方便,但注意 QVariant 里存对象意味着拷贝;大对象考虑存 QSharedPointer。

实践 3:通知永远取最小粒度

怎么做:单格改动 → dataChanged(idx, idx, {roles});连续多行 → 一段矩形一次性发;插入删除 → 成对 begin/end;reset 留作最后手段。Trade-off 很关键:精确 roles 需要你自己维护一致性,写错就是「数据变了界面不变」这种最难查的 bug(比整表刷新更难发现)。所以成熟团队的做法是——先写单测断言「改了 X 字段后,收到 dataChanged 且 roles 包含 XRole」,用测试兜住这个一致性,而不是靠人眼。

实践 4:过滤与排序永远在代理层,不在模型里做第二套顺序

怎么做:排序交给视图(setSortingEnabled(true) 底层就是代理在排),过滤交给 QSortFilterProxyModel,换过滤条件只改字符串不重建模型。何时不用代理:你的「过滤」其实是语义查询(按标签、按时间范围组合)时,直接用数据库/索引层过滤再 reset 更清晰——代理只适合「在已有内存数据集上做视图态筛选」。Trade-off:代理链每层都有索引映射开销,超过 2~3 层就该考虑自己写一个合并语义的代理。

实践 5:大数据量三件套 + 增量加载

怎么做:树形列表开 setUniformRowHeights(true);用 canFetchMore()/fetchMore() 做滚动分页;批量修改时 setUpdatesEnabled(false) 包一层。何时不用:数据量 < 1000 行时这些都是噪音,只会让代码更难读。Trade-off:增量加载会引入「总数未知/滚动条长度动态变化」的复杂度,需要产品上接受(或显示「已加载 N 条」而不是总数)。

实践 6:线程边界写在架构图上,而不是靠自觉

怎么做:模型属于 UI 线程(QObject 亲和性),worker 线程只产出数据,通过 queued 信号回到 UI 线程再改模型。若数据量巨大、拷贝成本高,就让 worker 持有数据并在完成时发一份不可变快照(或合并后的差异列表),UI 侧据此做增量更新。绝对不要:后台线程一边改模型内部容器、UI 线程一边调 data()——这不是「偶尔崩」,而是迟早崩。

06与 Web 技术的联系:自动响应式 vs 显式契约

你在 Vue3 里从没写过「我改了 name,请重新渲染这一格」——因为 Proxy 拦住了属性读写,谁在渲染时读了它,谁就被记进依赖表(track),属性变化时精确触发(trigger)。C++ 里做不到这件事:没有运行时拦截成员访问的廉价机制(代理类要改调用方写法、宏会污染)、编译期反射也才刚起步。Qt 的替代方案就是把「谁关心什么」显式化:role 是「关心什么」,信号是「什么变了」。同一目标(最小刷新),一个靠自动收集,一个靠开发者履行契约。

这背后是两种语言的性格差异:前端把复杂度放在运行时与框架,C++ 把复杂度放在编译期与开发者约定。理解这点,你对「为什么 Qt 写起来更啰嗦」就不会再抱怨,而会把它当成一种可优化项——啰嗦的地方,正是你能比同事更快定位性能问题的地方。

维度Vue 3ReactQt Model/View
变更如何被发现Proxy 拦截读写 + 依赖收集换对象 + 引用比较 + memo开发者显式 emit 信号
刷新粒度响应式属性 → 组件级组件级(子树重渲染)行 + role 级(可精确到单格单字段)
列表虚拟化第三方(vue-virtual-scroller)第三方(react-window)视图基类内置惰性布局
数据与界面绑定v-model / computedprops + 回调(单向数据流)model + roles + delegate
可单测的边界store / composablereducer / 纯组件纯模型类(无需 QApplication)
跨线程/异步更新的姿势await 后直接改响应式状态setState(批处理)queued 信号回 UI 线程再改模型

还有两个能直接迁移的经验:其一,「视图是无状态渲染器」这个原则两端通用——React 要求组件纯、无副作用才能被安全地重渲染,Qt 要求 data() 是纯函数才能被视图随时调用几十次,本质是同一条纪律。其二,「数据准备与呈现解耦」是两端一致的架构决策——把 Redux 的 selector 换成 Qt 的 proxy model,把 saga/thunk 换成 worker 线程 + queued 信号,你的分层思维可以 1:1 平移。

07管理者视角:怎么审、怎么拆、怎么验收

Code Review 清单(可以直接抄进模板):模型里有没有 UI 依赖或 I/O?begin*/end* 是否成对、顺序正确?role 是否有集中 enum(不存在裸的 Qt::UserRole + 4)?dataChanged 是否传了精确 roles?是否存在长期持有的裸 QModelIndex?模型层有没有单测(尤其是「变更后通知是否正确」)?

怎么指导新人:让他按「只读模型 → 加代理过滤 → 加编辑与 setData → 加拖放」四步走,不要一上手就写可拖拽的树;同时明确告诉他把 QTableWidget 限定在原型阶段。这套路径的价值是每步都能独立验证,两周内可从「会写表格」到「能重构别人的表格」。

怎么拆任务:模型 / delegate / 视图三块可以三人并行,接口就是「role 枚举 + 模型 API」——这和前端把 store、组件、样式分离是同一套管理逻辑,用接口先冻结来消除并行开发的冲突。另外,把性能写成验收项:大数据列表在需求评审时就要定「10 万行下滚动帧率 ≥ 55fps、首屏 ≤ 300ms」,而不是等用户投诉后再加班优化——Model/View 的性能问题 90% 是架构决策,越晚改越贵。

08延伸阅读(附理由)

  1. Qt 官方《Model/View Programming》概览 + QAbstractItemModel 类文档——契约原文。dataChanged 的 roles 语义、索引自洽要求、begin/end 的使用规则都在里面,是唯一权威,值得逐段读而不是查。
  2. QModelRoleData / QModelRoleDataSpan 文档(Qt 6.0+)——理由:它是「取数粒度也是性能问题」的官方证据,看完你会重新审视自己的 data() 实现。
  3. Qt 源码 src/corelib/itemmodels/(尤其 QFileSystemModel)——看工业级模型怎么写懒加载、怎么和后台线程协作,比自己猜强得多。
  4. Vue 3 的 @vue/reactivity 源码(effect / track / trigger)——把自动依赖收集读一遍,你才会明白 Qt 那份「手工契约」换来了什么(零运行时拦截、可精确控制开销)。
  5. Martin Fowler《Presentation Model》——二十年前就把桌面客户端里「状态与视图分离」讲清楚了,读完你会发现 MVVM/Vue 的店其实都从这里出发。

09今日思考题

1. 如果要在 C++/Qt 里做「自动依赖收集」,最小可行方案是什么(代理类 / 宏 / 代码生成三选一)?它会带来哪些代价,和反射、调试体验的关系是什么?
2. dataChanged 传空 roles(全部)与传精确 roles,在「10 万行、拖拽排序、每帧都有更新」的场景下差距有多大?你会怎么设计一个可复现的测量方案?
3. 为什么 beginInsertRows 必须在真正修改容器之前调用?如果顺序反了,视图内部哪一步会读到不一致的状态,最终表现为什么现象?
4. Vue 的 v-model 双向绑定在 Qt 里对应哪几个调用?Qt 为什么默认不做「自动写回」,这对数据一致性是好事还是负担?
5. 翻一翻你手上的客户端代码:有没有「模型里弹了对话框」「delegate 里发了网络请求」这类越界?你打算把边界重新画在哪一层,迁移成本如何分阶段摊掉?

10今日实践任务(30–60 分钟)

任务:用纯代码生成 3000 行数据,写一个 QAbstractTableModel + 一个 QSortFilterProxyModel(过滤 + 排序)+ QTableView,亲手体验三件事:①视图只问可见区域要数据;②过滤/排序在代理层做,不碰源模型;③单格单 role 的增量通知不会引起滚动跳动(把自动通知改成 beginResetModel 再跑一次,对比手感差异——这就是本篇的核心结论)。

main.cpp(可直接编译):

#include <QApplication>
#include <QAbstractTableModel>
#include <QSortFilterProxyModel>
#include <QTableView>
#include <QHeaderView>
#include <QList>

struct Row { int id; QString name; double score; };

class ScoreModel : public QAbstractTableModel {
public:
    explicit ScoreModel(QObject *p = nullptr) : QAbstractTableModel(p) {
        for (int i = 0; i < 3000; ++i)
            rows.push_back(Row{ i, QStringLiteral("用户%1").arg(i), (i * 37 % 1000) / 10.0 });
    }
    int rowCount(const QModelIndex & = QModelIndex()) const override { return int(rows.size()); }
    int columnCount(const QModelIndex & = QModelIndex()) const override { return 3; }
    QVariant data(const QModelIndex & idx, int role) const override {
        if (!idx.isValid() || role != Qt::DisplayRole) return {};
        const Row & r = rows[idx.row()];           // 纯内存读,data() 里绝不做 I/O
        switch (idx.column()) { case 0: return r.id; case 1: return r.name; default: return r.score; }
    }
    QVariant headerData(int sec, Qt::Orientation o, int role) const override {
        if (role != Qt::DisplayRole || o != Qt::Horizontal) return {};
        static const char *h[] = { "ID", "名称", "得分" };
        return QString::fromUtf8(h[sec]);
    }
    // 最小粒度的增量通知:一行、一个 role
    void setScore(int row, double v) {
        if (row < 0 || row >= rows.size()) return;
        rows[row].score = v;
        emit dataChanged(index(row, 2), index(row, 2), { Qt::DisplayRole });
    }
private:
    QList<Row> rows;
};

int main(int argc, char **argv) {
    QApplication app(argc, argv);
    ScoreModel model;
    QSortFilterProxyModel filter;              // 排序 + 过滤都在代理层
    filter.setSourceModel(&model);
    filter.setFilterKeyColumn(1);
    filter.setFilterFixedString("1");          // 只留名称含 "1" 的行
    filter.setSortRole(Qt::DisplayRole);

    QTableView view;
    view.setModel(&filter);
    view.setSortingEnabled(true);              // 点表头 → 代理排序,源数据不乱
    view.verticalHeader()->setDefaultSectionSize(24);   // 行高一致,布局成本可控
    view.resize(720, 480);
    view.show();

    model.setScore(0, 99.9);                   // 观察:只重绘一格,滚动位置不动
    return app.exec();
}

CMakeLists.txt:

cmake_minimum_required(VERSION 3.19)
project(mv_demo LANGUAGES CXX)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
qt_standard_project_setup()
qt_add_executable(mv_demo main.cpp)
target_link_libraries(mv_demo PRIVATE Qt6::Widgets)

运行:cmake -S . -B build && cmake --build build && ./build/mv_demo(Windows 多配置生成器加 --config Release)。

进阶实验(若还有时间):把 setScore 换成 beginResetModel(); ... endResetModel();,观察滚动位置与选中状态的变化;再给 3000 行各加一个 QProgressBar(setIndexWidget)感受一下内存曲线——你会立刻明白错误 4 的含义。

11一句话总结

Vue 用运行时拦截替你做响应式,Qt 把「最小刷新」变成一份必须亲手履行的信号契约——模型只管数据与通知,视图只管可视区域,代理只管筛选与绘制;把通知粒度掌握在手里,你才真正拥有大列表的性能与可维护性。