☰
Qt模型类实战指南:从现成模型到自定义实现与数据同步
2026/10/17 23:05:35 网站建设 项目流程

很多 Qt 学习者看《Qt Creator快速入门(第三版)》前面十几章都挺顺,一到第16章 Model/View 就有点卡壳,尤其是第16-2节讲到“模型类”的时候,概念一下子抽象起来。我当年这节看了三遍才真正缓过来,主要原因不是模型难,而是书里同时讲 Model、View、Delegate,一时反应不过来这三者到底怎么分。这篇文章就把“模型类”单独拿出来深挖一遍,讲清它是干什么的、现有几个子类怎么选、什么时候必须自己写,以及写完以后怎么让视图保持同步。适合刚看到这里开始发懵的读者,也适合已经用过 QTableWidget 但没写过自定义模型的开发者。

1. 模型类到底解决了什么问题:先把思维转过来

1.1 没有模型类之前的界面代码有多别扭

在接触 Model/View 之前,多数人的 Qt 界面写法是:new 一个 QTableWidget,然后 setItem 往里塞 QTableWidgetItem。这样做对原型非常友好,但一遇到“同一条业务数据要在多个界面里展示”就开始难受。比如某个项目里有一批任务数据,表格要展示所有任务,侧边面板要显示未完成数量、过期数量,底部还要有最近的几条记录。如果全用 QTableWidget 硬塞,同一个任务会在表格、侧边面板、底部记录里各生成一份 item,任务状态一变,你要在少则两三个、多则四五个地方同步修改 item——漏一个就是展示不一致,而且难查。

这种别扭的本质是:数据本身是“一份”,但每个界面都在维护自己的“副本”。传统做法把数据摊平成了界面里的 item,并没有一个统一的“数据源”。刚开始数据量小没感觉,等任务数量过万、要加排序加过滤的时候,item 的重复维护成本会直接把人拖垮。

1.2 模型、视图、委托为什么非要拆开

Qt 的答案是 Model/View 架构。三者的分工其实很朴素:Model 只回答“数据在哪里、数据长什么样”,负责把内部的数据结构翻译成统一接口;View 只负责把模型提供的内容画出来,处理选中、滚动、拖拽;Delegate 负责具体单元格的绘制和编辑控件。翻译成生活场景,模型是厨房后厨的食材台账,视图是餐厅的菜单,委托是具体的摆盘和传菜方式。菜单样式随便换,不需要去改台账;菜单上写的菜名变了,那是台账那边变了才导致的。

这里最关键的一句话是:模型类可以同时挂多个视图。一份任务数据,挂一个 QTableView 做详细列表,再挂一个 QTreeView 做分类树,模型里任意一条数据通过信号把变化广播出去,所有视图自动刷新。这就是模型类存在的最直接理由。刚接触这一节时,不需要把 Model/View/Delegate 全部理解透,只要先记住一件事:界面代码不应该直接抱着业务数据,而要隔着一层模型类去访问。

2. 四种现成模型类怎么挑:能拿来用就别急着自己写

2.1 最轻量的 QStringListModel

如果只是要显示一份字符串列表,比如历史搜索记录、日志等级列表,QStringListModel 是最快选择。它的内部数据结构就是 QStringList,用法只有几行:构造一个 QStringListModel 把列表传进去,再 view->setModel(model)。之后想整体替换内容,直接 setStringList(另一个列表),视图会自动刷新。要注意的是它是“整体替换式”更新,适合中小规模的静态列表,不适合频繁插入单条数据的场景。

2.2 QStandardItemModel 这个万金油

QStandardItemModel 是初学者最先遇到的模型类,因为它既能当表格又能当树,行列、父子、表头、图标、对齐都支持。用它对中小数据量界面开发来说非常省事,代码无非是 setHorizontalHeaderLabels 设置表头,然后拿 QStandardItem 填数据再 setItem。这里有个经常被忽略的规则:item 一旦交给 model,所有权就归 model 管理,你在外部不要再去 delete 它,否则模型内部还保留着一个已删除对象的指针,轻则显示异常重则崩溃。另外同一个 QStandardItem 不要同时塞给两个 model,那样两端状态会互相打架。

2.3 绑数据库的两个模型

QSqlTableModel 适合单表的增删改查界面,它 setTable 指定表名、select 执行查询后,QTableView 就能直接展示,用户改了单元格它还会帮你拼出 UPDATE 语句。QSqlQueryModel 则是只读的查询结果集,适合做报表、统计这类不需要回写数据的场景。把它们放在一起记很省力:一个是“能改库的”,一个是“只能看的”。实际项目里,管理后台多数用 QSqlTableModel,统计报表用 QSqlQueryModel,两者互补。

2.4 四选一怎么判断

选择模型类时,我是按这四个问题来的:数据结构是数组、表格还是树?数据量多大?需不需要用户编辑?现有数据是已经在内存容器里,还是在数据库或文件里?

我整理了一张对照表,方便卡在这一节的读者直接套用:

模型类数据结构默认可编辑适合数据量自定义成本
QStringListModel一维字符串列表否小无
QStandardItemModel表格/树节点是(item 支持)中低
QSqlTableModel数据库单表是较大低
QSqlQueryModelSQL 查询结果否较大低
自定义 QAbstractItemModel任意结构取决于实现任意高

不要一上来就自定义模型。能用 QStandardItemModel 撑住的项目,先用它跑通界面,等出现“数据量太大”“需要实时联动”“结构特殊”这些问题时再考虑写自己的模型。这也是看书时最容易走偏的地方:把自定义模型当成目的,其实它只是手段。

3. 自定义模型的五个核心函数:实现顺序和常见遗漏

3.1 什么时候非自定义不可

当内存里已经维护着一份数据结构,比如 QVector 或自定义类,而你又希望界面直接读这份结构、不额外复制一份的时候,就该自定义模型了。常见场景包括:需要展示几十万行日志、要按业务规则做动态角色显示、数据源是内存容器且实时变化。自定义模型的价值在于“零拷贝映射”:界面不复制你的数据,而是通过接口按需读取。

《Qt Creator快速入门 第三版》第16-2节把模型类的抽象结构讲得比较规范,但如果只照着书上的接口顺序看,容易忽略一个实操问题:到底先实现哪个函数。我的建议是先实现 rowCount 和 columnCount,让视图知道有多少行列;再实现 data,让视图能取到每个格子的内容;接着处理 headerData 让表头能显示文字;需要编辑再补 flags 和 setData。按这个顺序调试,每一步都能看到明确进度。

3.2 索引的语义:一个格子靠 row/column/parent 定位

模型类对外暴露的每个数据位置都是一个 QModelIndex。这个概念是最容易绕晕的地方,但拆开只有一条规则:row、column、parent 三个值唯一定位一个格子,其中 parent 用于区分“父级下的位置”。顶层数据项的 parent 是默认构造的空索引 QModelIndex()。视图在显示时并不知道你的数据到底是数组还是链表,它只拿着索引来问模型;模型内部把索引翻译成真实数据的访问路径。

createIndex 用来把模型的内部位置打包成索引发给视图,一般写作 createIndex(row, column) 就够。注意索引在使用时可能因为模型结构变化而失效,所以不要在视图里长期保存一个过期的 QModelIndex,要用的时候现取。

3.3 一个可直接套用的列表模型代码

我用一个任务表格模型举例,内部结构是 QVector 。定义 TaskItem 包含名称、是否完成、截止时间三个字段,然后让 TaskTableModel 继承 QAbstractTableModel,重写相应函数:

struct TaskItem { QString name; bool done = false; QDateTime deadline; }; class TaskTableModel final : public QAbstractTableModel { public: explicit TaskTableModel(QObject* parent = nullptr) : QAbstractTableModel(parent) {} int rowCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_tasks.size(); } int columnCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : 3; } QVariant data(const QModelIndex& index, int role) const override { if (!index.isValid() || index.row() < 0 || index.row() >= m_tasks.size()) return {}; const TaskItem& t = m_tasks.at(index.row()); if (role == Qt::DisplayRole || role == Qt::EditRole) { if (index.column() == 0) return t.name; if (index.column() == 1) return t.deadline.toString("yyyy-MM-dd hh:mm"); if (index.column() == 2) return t.done ? QStringLiteral("已完成") : QStringLiteral("未完成"); } if (role == Qt::TextAlignmentRole) { return index.column() >= 1 ? int(Qt::AlignCenter) : int(Qt::AlignLeft | Qt::AlignVCenter); } return {}; } QVariant headerData(int section, Qt::Orientation orientation, int role) const override { if (role != Qt::DisplayRole) return {}; if (orientation == Qt::Horizontal) { if (section == 0) return QStringLiteral("任务名"); if (section == 1) return QStringLiteral("截止时间"); if (section == 2) return QStringLiteral("状态"); } return section + 1; } bool setData(const QModelIndex& index, const QVariant& value, int role) override { if (!index.isValid() || index.row() >= m_tasks.size()) return false; TaskItem& t = m_tasks[index.row()]; if (role == Qt::EditRole) { if (index.column() == 0) t.name = value.toString(); if (index.column() == 1) t.deadline = value.toDateTime(); if (index.column() == 2) t.done = (value.toString() == QStringLiteral("已完成")); } emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole}); return true; } Qt::ItemFlags flags(const QModelIndex& index) const override { if (!index.isValid()) return Qt::NoItemFlags; return Qt::ItemIsEnabled | Qt::ItemIsSelectable; } QVector<TaskItem> m_tasks; };

这段代码演示了常规模型类的完整骨架。要特别说明两点:data() 里的 DisplayRole 和 EditRole 通常返回同一份数据,这样界面显示和编辑框都能拿到正确内容;如果你只想让某列可编辑,flags() 里按列做判断即可,不用全部开放编辑。

3.4 新手最容易漏的三个点

第一个是只实现了 DisplayRole,结果程序里 QToolTip、字体、勾选框全都无效,因为不同角色对应不同界面表现。第二个是忘记实现 headerData,表格出现 1、2、3 的默认列名,看起来非常业余。第三个是 flags() 不返回 Qt::ItemIsEnabled,导致格子虽然显示了但不可选不可点,在调试期容易被当成“模型没生效”。这三个点都是我看书时踩过、后来在实际模型代码里反复确认过的细节。

4. 界面不刷新、插入就崩溃:模型通知信号的成对规矩

4.1 数据改了视图不知道,根因是没有发信号

自定义模型最常见的困惑是:我在代码里改了 m_tasks[i].name,界面上却纹丝不动。原因很简单:视图不会主动轮询模型,它依赖模型发出的数据变更信号。如果你绕过 setData 直接改内部容器,视图根本不知情,自然不刷新。正确的做法是改完数据后调用 emit dataChanged(起始索引, 结束索引),让视图更新对应区间。

不要小看这个细节。我在写第一个自定义模型时,曾经在一个业务回调里直接改结构体数组然后用 update() 重绘界面,结果只有被重绘的格子偶尔变一下,悬停、选中都乱套。后来把改动收敛进 setData 并统一 emit dataChanged,才彻底正常。

4.2 行数变化必须 begin/end 成对出现

如果数据行数发生了增删,光发 dataChanged 是不够的,必须调用 beginInsertRows 和 endInsertRows,或者对应的 beginRemoveRows / endRemoveRows。这两个函数的作用是通知视图“行结构即将变化”,让视图在插入前后维持一致的索引状态。中间不能有其他事件循环打断,也不能乱顺序:beginInsertRows 之前先改内部数据会导致视图在通知前就看到了不一致的行数,endInsertRows 之后才改又会让视图访问到越界行。这两个函数的配对原则是全部 Qt 模型编程里最重要的规矩。

代码示例:

bool TaskTableModel::insertRow(int row, const QModelIndex& parent) { if (row < 0 || row > m_tasks.size()) return false; beginInsertRows(parent, row, row); m_tasks.insert(row, TaskItem()); endInsertRows(); return true; }

4.3 整体重置与批量更新的取舍

当数据整体变化时,用 beginResetModel / endResetModel 直接通知视图“全部重来”,简单粗暴但开销大。逐条 dataChanged 虽然精细,但逐条发信号在几千行数据量下也够让 UI 卡一下。我的经验做法是:数据量小就整体重置;数据量大且只是部分字段变化时,尽可能把能合并成连续区间的合并发出;如果是排序、过滤这种结构性变化,就用 QSortFilterProxyModel,而不是手动挨个发信号。这样写出来的模型,界面响应速度会顺滑很多。

5. 树形模型与父子索引:parent() 的实现思路

5.1 什么时候需要真正的树模型

QStandardItemModel 已经能表达简单的树结构,但如果你的数据本身就是一棵动态变化的树,比如目录树、组织架构、分类层级,而且节点数量很大,自己写模型是更合适的方案。第16章讲到模型类时把树模型也归入范畴,但很多读者会卡在“树模型到底比表模型多实现什么”这一点上。其实就多了一个关键成员:parent() 函数,以及 index() 函数里对 parent 的处理。

5.2 parent() 与 index() 的配合逻辑

对视图来说,它拿到任意一个索引后会调用 parent(index) 去追问“这个节点的上一级是谁”,也会调用 index(row, column, parent) 去展开“parent 下面的第 row 行第 column 列是谁”。所以这两个函数必须协同工作:index(parent) 里要能根据 parent 返回对应子项,parent(index) 要能根据 index 返回它的上级。根节点统一返回空索引 QModelIndex()。如果数据结构是一棵用节点指针串起来的树,实现时可以借助 createIndex 的第三个参数把节点指针或节点 ID 存进索引,这样后面定位子节点、找父节点都很快。

5.3 树模型里最常见的内存陷阱

createIndex 支持把内部指针存进去,方便索引携带额外信息。这带来一个风险:如果数据节点的生命周期不由模型管理,节点被释放后索引还指向旧内存,视图一展开就崩溃。我实际遇到过一次:在一个模拟的磁盘目录树项目里,刷新目录时先释放了旧节点,模型却还持有旧节点指针,用户一展开某个分支程序就崩。后来改成在刷新前先调用 beginResetModel,让所有外部索引全部失效,再重建数据节点,endResetModel 之后重新获取索引;必要时还要在 beginResetModel 之前清掉视图的选中状态,避免视图拿着过期索引做选区计算。

5.4 树模型性能的另一个关键

树模型里常见性能瓶颈是行数为 0 的判断。有些实现会在 rowCount 里遍历整棵子树统计节点数量,几万节点时每次展开都卡一下。比较好的做法是节点结构里直接缓存子节点数量,增删子节点时同步更新计数。这样 rowCount 就是 O(1) 级别的查询,界面展开会明显跟手。这一条属于常规文档不会写的优化细节,但实际项目里非常值钱。

6. 角色数据、排序与过滤:模型类的延伸玩法

6.1 用角色给视图传递更多信息

data() 里除了 DisplayRole 和 EditRole,还常搭配 Qt::ToolTipRole、Qt::TextColorRole、Qt::FontRole、Qt::CheckStateRole。比如任务列表里,把过期未完成的行用红色显示,用 CheckStateRole 让用户直接勾选完成。这些表现逻辑全部收在模型里,视图不需要额外分支,换一个视图仍然保持同样的表现,这是模型类带来的典型收益。还是用前面的 TaskTableModel 举例,data() 里再加一段代码即可实现“未完成且已过期显示成红色”:

if (role == Qt::TextColorRole) { if (!t.done && t.deadline < QDateTime::currentDateTime() && t.deadline.isValid()) return QColor(Qt::red); return {}; }

6.2 用 QSortFilterProxyModel 实现排序过滤

排序过滤不该直接写进数据模型,否则模型既得负责存取又得负责排序,职责一多就容易乱。Qt 给的做法是在模型和视图之间插一个 QSortFilterProxyModel,视图挂这个中间模型,中间模型底层再挂真实模型。代码大致是:

QSortFilterProxyModel proxy; proxy.setSourceModel(&model); view->setModel(&proxy); view->sortByColumn(1, Qt::AscendingOrder);

注意两点:排序要生效,data() 里对应列要返回 Qt::EditRole,或者给 proxy 设置 setSortRole;过滤规则通过 setFilterFixedString 或重写 filterAcceptsRow 实现。更重要的是,从视图中拿到的行号是中间模型的行号,要真正访问源模型数据必须用 mapToSource 把索引转换回去,否则你在视图里看到的行号和源模型的行号对不上,选中数据容易串。

6.3 更大数据量的两个实用建议

一个是尽量让 data() 只做数据读取,不在里面做耗时计算。比如字符串格式化、日期转换这类操作,如果放在 data() 里,视图每次重绘都要重算一次,几百行还没事,上万行时滚动就肉眼可见地卡。另一个是在需要高频局部刷新时,把“最近变更的索引区间”缓存下来,集中发送一次 dataChanged,而不是每次改动就立刻发一条。这两个建议是我在两个项目里用实际交互体验换回来的教训,写在这里给看到这一节的读者省点调试时间。

我自己回头看,模型类最让人怕的其实是它把“界面”和“数据”这两件原本绑在一起的事拆开了,这个拆开的瞬间思维要转个弯。只要转过来了,后面 rowCount、data、信号配对这些反而都是能一步步查到头的东西。刚开始写模型时别追求一步到位,先用现成模型把界面跑起来,再针对一个痛点换成自定义模型,最后把树形和中间筛选层加进来,这一章就算是真正吃透了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询