做 Qt 开发到现在,我自定义过至少三四个 QAbstractItemModel 的子类。要说哪个类是 Qt 框架里最值得花时间研究、却又最容易被绕开的,我第一个提名 QAbstractItemModel。它是整个 model/view 架构的地基,但官方文档给出来的示例大多停留在列表和表格层面,一碰到真正的树形业务数据,大多数人第一反应是套 QStandardItemModel,等性能和数据量出问题之后,才回头啃这个抽象类。
我第一次被逼着写自定义模型,是在做一个跨平台的设备管理工具。设备分区域、分组、再挂实时监控节点,界面要求同一个数据源同时支持树形、表格和曲线图三种视图。用 QStandardItemModel 硬拼,业务层每次推送状态变化都要先找到对应的 QStandardItem,再一层层刷新;数据量到了几千个节点,界面明显卡顿,而且业务对象和界面模型被强耦合在一起,改需求几乎要重写。后来我把模型层整体换成 QAbstractItemModel 的子类,才真正体会到这套架构的威力:模型只负责把业务数据翻译成 view 能理解的索引结构,视图切换、排序、筛选全部解耦,数据更新通过信号精准通知,几千个节点的实时刷新也能稳定在几十毫秒以内。
这篇的定位是给已经用过 QTableView 或 QTreeView,但还没写过完整自定义模型的人。我会把 QAbstractItemModel 里必须实现的几个核心方法逐个拆开,讲清楚它们之间的配合关系,再给出一个能跑的树形模型完整实现,最后把我踩过的坑和排查经验一并整理出来。
1. 为什么要自己实现 QAbstractItemModel
1.1 内置模型的局限性
QStandardItemModel 和 QStringListModel 确实覆盖了不少常规场景。列表、表格、简单的树形展示,用内置模型几行代码就能搭起来,这也是大部分教程教的做法。但一旦你的数据源不是简单的字符串数组,而是数据库查询结果、JSON 配置、或者一套自己的业务对象树,麻烦立刻就会出现。
以 QStandardItemModel 为例,它要求你把业务数据先转换成 QStandardItem 对象,再塞进模型里。这意味着你有两份数据:一份是业务逻辑里的真实数据,另一份是界面模型里为显示而生的副本。两份数据一旦不同步,界面就是错的。更麻烦的是,业务层更新一个字段,你得手动找到对应的 QStandardItem,调用 setData 或者 setText 去改,数据量小的时候无所谓,几千个节点以后,光是“找节点”这一步就是 O(n) 甚至 O(n²) 的开销。
还有一个很多人忽略的问题:QStandardItemModel 继承了 QAbstractItemModel,但它为了让通用性更好,内部用了大量的动态分配和链表结构。树越深、节点越多,内存碎片和指针跳跃就越严重。我自己测过,5 万个节点的树,QStandardItemModel 构建一次要几百毫秒,而自定义模型因为直接索引业务对象,构建时间几乎为零——因为压根不需要复制数据。
1.2 模型层在 MVC 中的位置
Qt 的 model/view 把界面拆成三个角色:模型管数据、视图管显示、代理管编辑。很多人写代码时只把模型当成“给 view 提供数据的容器”,这个理解太浅了。模型真正的职责,是给业务数据建立一套统一的、可寻址的“坐标系统”——也就是 QModelIndex。视图不关心你的数据是存在数组、链表还是数据库里,它只通过 QModelIndex 问模型“这个格子显示什么”“这个节点有没有子节点”“能不能编辑”,模型负责把这些请求翻译成业务数据的访问操作。
这套设计最值钱的地方在于:一份模型可以被多个视图同时使用。同一个数据源,左边 QTreeView 看层级,右边 QTableView 看平铺,下面 QChart 画曲线,三个视图共享一个模型实例,数据更新时模型发一个信号,所有视图自动刷新。如果走 QStandardItemModel 的路子,这种场景要么复制数据,要么写一堆同步逻辑。自定义模型没有这个问题,因为业务数据本来就是唯一的一份,模型只是它的解释器。
2. 自定义模型的骨架:四个必写方法
2.1 index() 和 parent() 的配合逻辑
QAbstractItemModel 是抽象类,继承它之后有一组纯虚函数必须实现。其中最关键的是 index() 和 parent(),这两个方法决定了模型能不能表达树形结构。理解它们之前,先要明白 QModelIndex 是什么:它本质上是一个指向模型内部某个节点的句柄,由行号、列号和一个内部指针(internalPointer)组成。
index() 的职责是:给定父节点的 QModelIndex,以及子节点的行和列,返回那个子节点的 QModelIndex。如果父节点是无效索引,表示要的是顶层节点。这个函数的关键在于 internalPointer,我通常直接把业务节点的指针塞进去,这样后面 data() 拿到 QModelIndex 后,用 index.internalPointer() 就能直接拿到业务对象,一步到位,不需要任何查找操作。
parent() 恰好反过来:给定一个子节点的 QModelIndex,返回它的父节点索引。顶层节点的父索引必须返回 QModelIndex(),也就是无效索引。视图正是靠这两个方法的反复配合,才能在界面上把树形结构画出来:先调用 index(row, col, invalid) 拿到顶层节点,再对顶层节点调用 rowCount() 决定展开几行,然后对每个子节点调用 index() 往下一层推进。
这里有个常见的错误:parent() 实现不好会导致树展开时节点消失、层级错乱,甚至崩溃。我自己写模型时,坚持让每个业务节点都保存自己的 parent 指针,这样 parent() 只需要两步:拿到当前节点的 parent 指针,然后调用 createIndex() 创建父级索引。createIndex() 的第一个参数是父节点在它自己父节点里的行号,第二个是列号,第三个就是父节点指针。这个行号一定要算对,否则视图展开下级时会发生错位。
2.2 rowCount() 和 columnCount() 的边界处理
rowCount() 用来告诉视图某个节点下有几个子节点,columnCount() 告诉视图每个节点有几列。这两个方法看起来简单,但边界情况很考验人。最核心的边界就是父索引无效的情况:这意味着视图在问顶层数据有几行,此时你必须返回顶层节点的数量。
为了统一处理,我习惯把整个模型管理的数据设计成“一个虚拟根节点”,它不对应任何业务数据,只作为所有顶层节点的容器。这样 rowCount() 的实现可以统一写成:取出 parent 对应的节点(无效索引就取虚拟根节点),返回它的子节点数量。columnCount() 同样统一处理,返回节点里列数据的个数。这个手法让我少写了很多 if-else 分支,也让代码逻辑更清晰。
另一个要注意的是性能。视图在布局和绘制时会频繁调用 rowCount(),尤其是树形视图,滚动时几乎每个可见项都会触发。所以 rowCount() 里一定不要做耗时操作,比如不要在每次调用时去数据库查一遍子节点数量。正确做法是数据加载时就把每个节点的子节点列表建好,rowCount() 只返回一个 QVector 的 size() 或者一个缓存的整数,这样单次调用开销控制在几个纳秒级别。
3. 把数据显示到界面:data() 与 headerData()
3.1 role 参数到底在干什么
data() 是视图消费数据的主要入口,它的签名是 data(const QModelIndex &index, int role)。很多新手不理解 role 的意义,会直接写“不管 role 是什么都返回字符串”,这样界面上可能能显示,但一涉及排序、编辑、提示文本、对齐方式就全乱了。role 本质上是视图在问“这个位置的数据在不同场景下应该是什么样”。
最常用的是 Qt::DisplayRole,它表示“用户在界面上看到的文本”,QTreeView 和 QTableView 默认用它来渲染单元格。Qt::EditRole 表示“编辑状态下使用的原始数据”,通常是真实的数值或对象,而不是格式化后的字符串。Qt::ToolTipRole 是悬停提示,Qt::TextAlignmentRole 控制对齐方式,Qt::FontRole 控制字体,Qt::BackgroundRole 控制背景色。一个设计良好的 data() 应该至少处理 DisplayRole、EditRole 和 ToolTipRole,其他 role 按需补充。
我的习惯是先用 switch 把 role 分好类,再在里面按照列号去分发数据。比如设备管理工具里,第 0 列显示设备名称,第 1 列显示设备状态,第 2 列显示最后一次通信时间。DisplayRole 下,状态列我会把枚举值翻译成“在线”“离线”“告警”这种友好文本;EditRole 下则直接返回原始枚举值,这样编辑代理拿到的就是真实数据,而不是显示文本。
3.2 用 internalPointer 直达业务数据
data() 的实现质量直接决定模型性能。最高效的写法是:拿到 index.internalPointer() 转成业务节点,然后按 index.column() 返回对应的数据字段。整个过程是一次指针转换加一次数组下标访问,没有查找、没有遍历,复杂度 O(1)。这个设计其实在 index() 那一步就已经定了:index() 里塞什么指针,data() 里就拿什么指针。
再有就是 headerData()。这个方法处理表头和树头的显示文本,参数是 section(列号或行号)、orientation(水平或垂直)和 role。很多人图省事,直接在 headerData() 里写死“列1、列2、列3”,我建议还是从一个字符串列表里取,后续加列、调整列名只需要动一处。headerData() 里也要注意 orientation 的判断,不要横竖不分,Qt 的某些内部组件会对两个方向都调用这个方法,乱返回会出怪问题。
4. 让模型可编辑:flags() 和 setData()
4.1 flags() 决定视图的“交互权限”
默认情况下,自定义模型的所有项都是不可编辑的,因为 QAbstractItemModel 的 flags() 默认只返回 Qt::ItemIsEnabled 和 Qt::ItemIsSelectable。视图检查到某个索引没有 ItemIsEditable 标志,就不会把点击变成编辑状态。所以想让用户能双击修改单元格,必须在 flags() 里加上 Qt::ItemIsEditable。
flags() 还有个容易踩坑的点:如果你返回的标志里漏掉了 ItemIsEnabled,这个单元格会变灰且无法点击;如果漏掉了 ItemIsSelectable,虽然能显示但无法被选中。我的做法是先取默认 flags,再针对特定列追加或移除标志。比如状态列可以只读,我就只在名称列上追加 ItemIsEditable,这样用户只能编辑名称,状态列仍然只读,交互逻辑很清晰。
4.2 setData() 里做数据校验与信号发射
setData() 是编辑流程的落点。视图捕获到用户的编辑结果后,会调用 setData(index, value, Qt::EditRole)。如果返回 true,视图认为自己编辑成功,会刷新显示;返回 false,编辑会被放弃。
常见的错误是漏发 dataChanged 信号。setData() 里修改完业务数据之后,必须手动发射 dataChanged 信号,通知视图这个区域的数据变化了。信号要带两个 QModelIndex 参数,表示变化范围,如果只改一个格子,两个参数都传同一个 index 就行。不发射信号的后果是:数据在底层其实已经改了,但界面上的显示不会更新,白白浪费一次 setData。
我还会在 setData() 里做一层校验。比如设备名称不允许为空字符串,如果用户清空了输入框,我直接返回 false,让视图保持原值。对于一些范围受限的数值字段,在 setData() 里做边界裁剪也比较方便,毕竟这里比代理层更适合承载数据完整性逻辑。不过要注意,setData() 可能被多个视图同时调用,如果有跨线程操作,得自己加锁,QAbstractItemModel 本身不是线程安全的。
5. 动态增删数据和树的常见误区
5.1 beginInsertRows 和 endInsertRows 的前后顺序
动态增删数据是自定义模型的进阶要求,也是新手最容易写崩的地方。关键在于 Qt 规定:插入行之前必须先调用 beginInsertRows(parent, first, last),插入完成后再调用 endInsertRows()。这两个调用之间的代码负责真正往业务数据结构里加数据。
这个顺序不是随便定的。beginInsertRows 会让视图在内部结构里预留出新行的位置,同时建立必要的索引映射,之后 endInsertRows 会触发视图执行布局更新。如果顺序反了,比如先改了业务数据再调 beginInsertRows,视图的索引状态和模型实际数据会对不上,轻则显示错乱,重则崩溃。删除行同理,必须先 beginRemoveRows 再删数据,最后 endRemoveRows。
我在实际项目中吃过一次亏:在 beginInsertRows 之前先往节点的 children 里 push 了新节点,结果调试的时候发现树视图总是多出一行空白,位置还不固定。后来查了半天才意识到是信号顺序的问题。从那以后我把这套规则写成注释贴在模型类头部,每次改增删逻辑都先过一遍“信号顺序四步检查”。
5.2 树形操作要沿着 parent 层层传递
树形模型的增删比表格复杂,因为新节点可能插在任何层级。insertRows() 的参数里有一个 parent 索引,表示“在哪个节点下面增加行”。你必须根据这个 parent 找到对应的业务节点,再往它的 children 里插入数据。千万不要假设新行只会加在顶层,布局和视图展开状态都依赖你正确处理 parent。
另一个容易忽略的是 dataChanged 的使用场景。增删行有专门的 begin/end 系列信号,但纯修改数据不用增删行,只需要 dataChanged。有一种情况比较隐蔽:如果你修改了一个节点的属性导致它在树里的位置需要变化(比如排序规则变了),最稳妥的做法是把这个节点对应的父级区域发 dataChanged,或者干脆调用 beginResetModel/endResetModel 做一次整体重置。重置虽然粗暴,但至少能保证视图一致性,数据量不大时完全可以接受。
6. 排序、筛选的正确打开方式
6.1 QSortFilterProxyModel 的桥接作用
很多人自定义模型后,想给视图加排序,第一反应是在模型里写排序逻辑。这个做法不是不行,但会把模型搞得很重。我更推荐把排序、筛选交给 QSortFilterProxyModel:它在原模型和视图之间再插一层,原模型保持原始数据顺序,代理负责排序和过滤。
用法很简单:需要排序的视图把数据源设成代理模型,代理模型的 setSourceModel() 指向你的自定义模型,再调用 setSortRole() 指定排序用的 role。比如我在设备表里让用户按“最后通信时间”排序,就调用 setSortRole(Qt::UserRole + 1),然后在自定义模型的 data() 里对这个 role 返回时间戳。这样排序发生的时候,代理拿到的就是数值而不是显示文本,排序结果自然正确。
6.2 动态排序的一个坑:排序键必须稳定
代理模型会在数据变化时重新排序,但它判断“数据变没变”靠的还是你发的 dataChanged 信号。如果你修改了某个字段,却没有发 dataChanged,代理不会知道需要重排,视图上就会显示乱序。反过来,如果你发的 dataChanged 范围太大,代理会频繁重排,性能会下降。所以用代理模型时,信号粒度要尽量精准。
还有个细节:QAbstractItemModel 的 data() 返回 QVariant,代理在比较两个 QVariant 时,如果类型不一致或者没有重载比较运算符,排序结果会不可预期。我的经验是排序键尽量统一用 double 或者字符串,不要混着来。时间戳就用 qint64,状态枚举就转成 int,保持类型干净,代理工作起来就不会闹脾气。
7. 常见问题与排查实录
7.1 无效索引和野指针引发的崩溃
自定义模型最常见的崩溃原因,就是视图拿着一个过期或者无效的 QModelIndex 来调用 data()。最常见的是各种 TreeView 的展开/折叠动画里,视图会缓存一批 index,然后在滚动或刷新时重新请求数据,如果模型已经删掉了底层节点,internalPointer 指向的就是野指针。
我的防守套路有两条。第一,在模型开始删除节点前,先调用 beginResetModel 和 endResetModel 重置整个模型,让视图丢弃所有缓存的 index。代价是视图会整体刷新,但没有崩溃风险。第二,在 data() 和 setData() 入口做防护:先检查 index.isValid(),再检查 internalPointer 是否为空。虽然不能完全防住野指针,但能过滤掉很大一部分无效请求。
排查这类问题时,我强烈建议用 Qt 自带的调试输出。在 index() 里打印每次创建索引时的行、列和内部指针值,在 data() 里打印收到的行列信息,对照视图操作步骤,很快就能定位是哪一次操作产生了不一致的索引。很多次我以为模型写错了,最后发现是忘记调 beginResetModel。
7.2 多视图共享模型的刷新问题
一份模型挂多个视图时,数据更新的信号要格外小心。dataChanged 只能携带一个矩形区域(由两个 index 限定),如果你的数据更新是稀疏的(比如几百个分散节点同时变化),一个个发 dataChanged 会非常慢。这种场景我一般拆两个方向处理:如果变化密度高,直接发区域范围大的 dataChanged;如果密度低,可以考虑在代理层做局部过滤缓存。
还有一种经验是:不要试图在模型里做任何跟 UI 线程相关的事。模型的 data()、rowCount() 这些方法由视图在 UI 线程调用,但模型自身的数据更新来自业务线程时,要用信号把更新动作切回 UI 线程再改数据、再发通知。直接在业务线程改数据会引发竞态,界面表现时好时坏,很难排查。我处理过的一个典型问题就是状态流线程直接写了模型的数据成员,结果偶发性崩溃,加了线程切换之后才稳定下来。
7.3 模型调试的三个实用技巧
第一个技巧是重写 model() 方法之外,额外加一个 debugNode() 辅助函数,把某个 QModelIndex 对应的业务节点的关键内容打印出来。调试时在断点里手动调用,比看一堆局部变量方便太多。第二个技巧是用 QAbstractItemModelTester,这是 Qt 自带的一个模型测试类,能自动校验模型的一致性,比如检查 index/parent 是否对称、rowCount 是否一致等。我在写完模型之后都会跑一遍这个测试器,它能抓出很多肉眼看不出来的逻辑漏洞。第三个技巧是给模型加一个 stats() 方法,返回节点总数、层数、最近操作耗时等内部统计,性能调优时直接看数字,不用猜。
8. 一些实操心得
8.1 先设计业务节点结构,再写模型类
我写自定义模型时有个习惯:先设计好业务节点结构,再动手写模型类。业务节点里存什么字段,决定了 data()、columnCount()、setData() 这些方法怎么写。比如设备节点,我会把节点类型、显示名称、状态、时间戳、子节点列表全部放在一个结构里,模型只是这个结构在 Qt 视图世界的“投影”。这样写出来的模型通常很薄,每个方法都只是在做翻译工作,不容易出 bug。
节点结构里一定要保存 parent 指针。这个字段对 parent() 的实现是决定性的,没有它,你就得在模型里维护一张子到父的映射表,既麻烦又容易泄漏。children 容器用 QVector 而不是 QList 或 std::list,因为 QVector 的连续内存对缓存更友好,而且在大量小对象场景下,QVector 的访问速度明显更快。
8.2 模型和视图解耦带来的长期收益
当初把设备管理工具的数据层替换成自定义模型,前后花了两周,但后续一年多的维护里,每次需求变更都轻松了很多。加一列状态历史曲线,只需要给模型加一个列的数据访问逻辑,视图层加一个 QChart;调整排序规则,改一个排序 role;新增一种视图,什么都不用改,直接 new 一个 view 挂到同一个模型上。这种解耦带来的收益,是直接用 QStandardItemModel 堆功能时完全体会不到的。
要说还有什么建议,那就是别怕读 Qt 源码。QAbstractItemModel 的源码并不复杂,读一遍你就明白为什么 index() 里要区分有效父节点和无效父节点,为什么 begin/end 系列信号必须成对出现。读源码是理解这套架构最直接的方式,比翻十篇教程都有用。我就是从读源码开始,才算真正把手上的模型写“活”了。