☰
深入理解Qt Model/View架构:从基础概念到数据流与实战示例
2026/10/11 8:56:26 网站建设 项目流程

接触Qt有一段时间的朋友,大概率都经历过这样一个阶段:用QListWidget、QTableWidget往界面上塞数据,怎么塞都行,简单粗暴。但一旦数据量上来,或者同一份数据要在两三个界面里以不同形式同时展示,麻烦就来了——要么频繁刷新UI导致卡顿,要么业务逻辑和界面代码写成一团乱麻。这个系列的第一篇,先把Model/View的基础理论讲清楚:三要素怎么分工、QModelIndex到底是个什么东西、data()和信号是怎么串起一整条数据流的,最后再给两个最小可运行的示例,不熟悉的朋友可以照着一口气跑通。

1. Model/View架构到底解决了什么问题

1.1 从"界面绑数据"说起

先说痛点。用QListWidget的时候,我们习惯这样写:

ui->listWidget->addItem("张三"); ui->listWidget->addItem("李四");

看着很舒服,对不对?但问题很快出现:如果你有一个QList<Student>对象,里面装着几百条记录,列表界面只是它的一个展示入口,另外还有一个表格要显示同样这批人,你就要写两套"把数据往控件里塞"的代码。更麻烦的是,某个学生的名字改了,你得自己记得去两个控件里同步更新,漏一个就出bug。数据量到几万条的时候,addItem的方式还会明显卡UI。

Model/View的做法是把中间那层"搬运工"抽出来:数据待在它自己的容器里,界面通过一套约定好的接口去问数据要内容,界面自己只负责画出来。这样一来,换展示形式不用动数据代码,数据变了只要发一个信号,所有挂在这套数据上的视图自动刷新。

1.2 MVC的"瘦身版"

很多人听到Model/View,会想起经典的MVC(Model-View-Controller)。Qt这套东西可以理解为MVC的一个实用变体,把Controller的职责拆给了View和Delegate,再引入一个QAbstractItemModel作为统一接口。

整套架构的参与方就三个:

  • Model:持有数据,负责回答"总共有多少行多少列""某个格子显示什么文字、什么图标"这些问题。
  • View:负责绘制,QListView管列表,QTableView管表格,QTreeView管树,它只关心怎么把数据画出来,不关心数据从哪来。
  • Delegate:负责具体某个单元格的绘制和编辑,默认实现已经够用,想自定义进度条、复选框、下拉框样式的编辑器时,主要就是动它。

举个生活化的例子:Model就是仓库里的货架,每一格放着对应的货品;View是商场的陈列柜,把货架上的东西按自己的布局摆出来;Delegate是理货员,负责把某一格货品替换成新款、贴上新的价格签。货架本身不需要知道陈列柜长什么样,陈列柜也不需要知道自己摆的货品是从哪个仓库拉来的。

这个设计最大的收益是"解耦"。解耦带来的直接好处有三个:同一份数据可以同时挂列表、表格、树三种视图互不干扰;数据量很大时可以只在视图可见区域取数据,内存压力小;数据变更时通过信号全量通知,不会出现漏刷新的问题。

2. 核心概念拆解:Model、View、Delegate各自的分工

2.1 Model:数据背后的"翻译官"

Model在Qt里的基类是QAbstractItemModel,它不存数据,它只是定义了一套"数据的形状"和"数据的访问方式"。数据可以存在QStringList、QSqlQuery、QJsonArray甚至你自己的结构体数组里,Model做的事情是把这些千奇百怪的数据翻译成视图能理解的"表格坐标"。

Model必须实现或者说最常用到的接口有:

  • rowCount()/columnCount():告诉视图一共有多少行、多少列。
  • data():回答"坐标(row, column)这个位置,在某个角色下应该是什么内容"。
  • setData():接收视图传来的新值,写回数据源。
  • flags():告诉视图这个格子能不能编辑、能不能选中、能不能拖拽。
  • headerData():返回表头文字。
  • index()/parent():构建和定位索引,树形结构全靠这两个函数。

里面还有一个很重要的概念叫角色(Role)。同一个格子的数据,在不同角色下可以返回不同形态的内容:

  • Qt::DisplayRole:显示的文字,比如"张三"。
  • Qt::DecorationRole:显示的图标。
  • Qt::EditRole:编辑时用的数据,通常和DisplayRole一致,也可以不同(比如显示"1.5 GB"、编辑时传"1536")。
  • Qt::ToolTipRole:鼠标悬停提示。
  • Qt::TextAlignmentRole:文字对齐方式。
  • Qt::BackgroundRole/Qt::ForegroundRole:背景色和前景色。

视图渲染每个格子时,会依次用各种角色去问Model要数据。这也是为什么自定义Model时data()里总是有一大串switch (role)的原因。

2.2 View:只负责"怎么显示"

View这边的基类是QAbstractItemView,平时用的子类就是那三个:QListView、QTableView、QTreeView。View本身维护了一些和显示状态强相关的东西:当前选中的是哪些格子、滚动条滚到哪了、哪些列被隐藏了、列宽排序怎么设置的。

View还有一个容易被忽略但很重要的成员,叫选择模型(SelectionModel)。每个View默认自带一个QItemSelectionModel,它记录的是"当前选中的索引集合"。你通过view->selectionModel()->currentIndex()拿到的当前项,和view->currentIndex()是同一个东西。

View和Model之间通过一个setModel()调用建立关系:

ui->tableView->setModel(myModel);

建立关系后,View会主动向Model索要以下信息:总行数、总列数、每个可见格子的DisplayRole内容、表头文字。所以Model如果写得不正确,视图上最常见的表现就是"一片空白"——不是没数据,是Model告诉视图"我没有数据"。

2.3 Delegate:编辑与绘制的"中间人"

Delegate的基类是QAbstractItemDelegate,常用子类是QStyledItemDelegate。它的核心函数有两个:

  • paint():负责把一个格子画出来。默认实现会用Model返回的各个Role去画文字、图标、背景。
  • createEditor()/setEditorData()/setModelData():负责创建编辑器(比如QComboBox、QSpinBox)、把Model里的值塞进编辑器、把编辑器改完的值写回Model。

什么时候你需要碰Delegate?最常见两类场景:一类是"格子里的东西不是纯文字",比如在单元格里画个进度条、画张小缩略图、画个状态圆点;另一类是"默认编辑器不好用",比如双击某个格子出来的是下拉框而不是输入框。

Delegate工作的时候,createEditor()返回的编辑器在界面上只是临时控件,双击进入编辑状态才出现,编辑完成或失焦就销毁或隐藏。这个机制保证了海量数据下界面不会为每个格子都创建一套编辑器。

3. 从零开始跑通第一个Model/View程序

3.1 用QListView展示QStringListModel

如果只想看列表,最省事的是直接用Qt内置的QStringListModel。在Qt Creator里新建一个QMainWindow项目,放一个QListView,然后写:

#include <QStringListModel> QStringListModel *model = new QStringListModel(this); QStringList data; data << "橙子" << "苹果" << "西瓜" << "葡萄"; model->setStringList(data); ui->listView->setModel(model);

这里ui->listView是你在qt designer界面设计里拖进去的控件。跑起来你会看到四项文字,鼠标点击能选中,但双击不能编辑——因为QStringListModel默认的flags()没给Qt::ItemIsEditable。

注意一点:QStringListModel是独占这份字符串列表引用的。如果你之后直接改原来的QStringList,Model并不会感知,正确方式是继续通过model->setStringList(newData)重新设置。这个细节很容易踩,很多人习惯"改了原数据就当界面会刷新",结果界面纹丝不动。

3.2 用QTableView配合QStandardItemModel

做表格型数据时,想少写代码就用QStandardItemModel。它内部就是一棵"普通的树",用setData()往格子里填值就行:

#include <QStandardItemModel> QStandardItemModel *model = new QStandardItemModel(4, 3, this); model->setHorizontalHeaderLabels({"姓名", "部门", "状态"}); model->setData(model->index(0, 0), "张三"); model->setData(model->index(0, 1), "研发部"); model->setData(model->index(0, 2), "在职"); model->setData(model->index(1, 0), "李四"); model->setData(model->index(1, 1), "测试部"); model->setData(model->index(1, 2), "休假"); ui->tableView->setModel(model);

这个写法里最关键的一行是model->index(row, column)。index()函数返回一个QModelIndex,它就是我们访问某个格子的"坐标凭证"。在QStandardItemModel里,index(0, 0)就是左上角第一格,后续所有setData()、data()操作都靠它定位。

QStandardItemModel默认是可编辑的,双击单元格就能改内容。为什么?因为它的flags()默认返回了Qt::ItemIsEditable,视图看到这个标志,就知道"允许我弹出编辑器"。这个对应关系值得记牢:能不能编辑,不是View说了算,而是Model的flags()说了算。

3.3 自定义Model必须重写哪些函数

QStringListModel和QStandardItemModel够用,但真实项目里数据往往长在自己的结构里,这时就该继承QAbstractTableModel写一个轻量自定义Model了。表格型数据其实只需要重写四个函数:

class StudentModel : public QAbstractTableModel { Q_OBJECT public: explicit StudentModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; };

实现起来也很直白:

int StudentModel::rowCount(const QModelIndex &parent) const { return m_students.size(); } int StudentModel::columnCount(const QModelIndex &parent) const { return 3; } QVariant StudentModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) return QVariant(); const Student &stu = m_students.at(index.row()); if (role == Qt::DisplayRole) { switch (index.column()) { case 0: return stu.name; case 1: return stu.department; case 2: return stu.status; } } else if (role == Qt::TextAlignmentRole) { return Qt::AlignCenter; } return QVariant(); } QVariant StudentModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role != Qt::DisplayRole) return QVariant(); if (orientation == Qt::Horizontal) { return QStringList{"姓名", "部门", "状态"}.at(section); } return QString::number(section + 1); }

注意data()里我写了index.column()的switch判断。自定义Model最核心的思维转变就在这里:Model不知道也不关心"张三"是View里第几行的什么东西,它只负责回答"index这个坐标,在第index.column()列上,要显示什么"。

rowCount()和columnCount()里的parent参数,在纯表格模型里直接忽略就行,那么写是因为基类接口签名要求。等以后做树形模型时,parent才有实际意义,那是另外一篇文章的事。

4. 索引机制与数据流:为什么Model/View跑得这么快

4.1 QModelIndex的本质

QModelIndex是Model/View架构里最核心也最容易被误解的概念。别看它像个对象,它实际上只是"行号 + 列号 + 内部指针 + 所属Model指针"的打包体,本身不保存任何数据。你可以把它理解成一张"坐标纸条",视图拿着纸条去Model里查内容,纸条本身没有意义,能查到东西才有意义。

QModelIndex有一个非常重要的特性:临时性。Model内部数据发生结构性变化(插入行、删除行、重置模型)后,之前发出去的所有QModelIndex都可能失效,继续拿着旧索引调用data()轻则返回QVariant(),重则访问越界。这也是为什么Qt官方文档反复强调:不要长时间保存QModelIndex,要用的时候现取,要长期持有数据位置就存行号或模型的internalId()。

parent()这个函数在树形模型里很关键,它回答的问题是"这个节点挂在哪个父节点下面"。对于根层级的节点,parent()要返回一个默认构造的QModelIndex(),也就是"无效索引",表视图里所有格子的parent都是这个无效索引,所以它们才是"平的"。

4.2 data()和setData()的调用链

当View要绘制一个可见区域时,它会做这样一串事情:

  1. 调用model->rowCount(QModelIndex())和columnCount(QModelIndex()),得出总规模。
  2. 对每个可见格子调用model->data(index, Qt::DisplayRole)拿显示文字。
  3. 再依次调用DecorationRole、ToolTipRole、TextAlignmentRole等,补齐其他视觉效果。
  4. 如果需要表头,调用headerData()。

整个过程是"视图主动拉取"的模式。View永远不知道自己背后是内存数组、数据库结果集还是文件目录树——它只认识model->data()这一个接口。这也是性能好的根本原因:View只捞它能看见的那一小块区域来问,滚到哪问到哪,不需要一次性把几十万行全部搬进界面。

反过来看编辑流程。用户双击格子,流程是这样的:

  1. View先问model->flags(index),确认Qt::ItemIsEditable存在,才允许进入编辑状态。
  2. View问model->data(index, Qt::EditRole),把当前值放进编辑器(比如QLineEdit)。
  3. 用户敲完回车,View调用delegate->setModelData(),最终触发model->setData(index, newValue, Qt::EditRole)。
  4. setData()写回数据源成功之后,Model要负责发dataChanged()信号,通知View"这个格子内容变了,请重画"。

很多新手写setData()时把数据写进了自己的数组,却忘了发dataChanged()。结果就是:数据源确实改了,界面上纹丝不动,怎么看都像是没改成功。这个坑我踩过不止一次,排查思路永远是先怀疑"Model到底有没有发出信号"。

4.3 信号与槽:数据变更如何通知界面

Model/View架构里的信号集中体现在Model侧,最常用的是三个:

  • dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles):表示某个矩形范围内的数据变了。注意要传两个索引,代表"左上角到右下角",支持一块区域一次性通知。
  • rowsInserted(...)/rowsRemoved(...):表示行被插入或删除。View收到后会重新计算总行数并重新布局。
  • modelReset():表示整个模型被重置,所有索引统统失效,View会从头再来一遍。

自定义Model里要插入一行数据,正确的姿势是这样:

void StudentModel::addStudent(const Student &stu) { beginInsertRows(QModelIndex(), m_students.size(), m_students.size()); m_students.append(stu); endInsertRows(); }

beginInsertRows()和endInsertRows()这对组合是固定格式,必须在实际修改数据之前调用beginInsertRows(),在真正插入完成之后调用endInsertRows()。这段时间内Qt会维护内部的一致性状态,如果忘了或者顺序反了,轻则视图刷新错乱,重则崩溃。这个"begin/end包裹数据修改"的模式,在removeRows、resetModel里也一样。

数据频繁变化时还有一个性能心得:尽量把可以合并的信号合并。比如一次循环里改100个格子的值,不要在循环里发100次dataChanged(),最好攒一批连续格子,用一对topLeft/bottomRight索引发一次。这样视图只重画一次,帧率差别肉眼可见。

5. 常见问题与排查技巧实录

5.1 表格不显示数据的几大原因

Model/View初学者遇到最多的问题就是"我明明setModel了,视图为什么一片空白"。我把它整理成一张排查表,按出现频率排序:

现象常见原因处理方式
视图完全没有网格rowCount()返回0检查数据容器是否为空
有行但格子空白data()没处理DisplayRole,或返回QVariant()在data()里补上DisplayRole分支
只有第一列有字columnCount()返回1检查是否写成固定值,忘记返回真实列数
表头全是数字没重写headerData()补headerData(),返回列名
数据改完界面不变忘记发dataChanged()setData()末尾补信号
插入行后界面乱beginInsertRows()/endInsertRows()没配对检查插入代码是否被这对函数包裹

还有一种隐蔽情况:你自己写了一个MyModel,在构造函数里把m_students填充好了,却忘了在Model创建后调用任何"数据加载"逻辑,因为View是在setModel()之后才来问rowCount()的,你只要保证"rowCount()被调用时数据已经就位"就行。这就是为什么很多人把加载数据的动作放在View的setModel()之前,结果还行,放在之后,反而出问题的原因——不是顺序问题,而是你需要保证查询时机数据存在。

5.2 编辑失效、委托不见的坑

编辑相关的典型症状是:双击格子没反应。排查顺序是:

  1. 先用一个小测试脚本打印model->flags(index),看返回值里有没有Qt::ItemIsEditable。没有,那就是Model的问题,老老实实加flags。
  2. 如果有flags但还是不能编辑,检查View有没有设置editTriggers。某些默认配置下,需要按F2或单击选中后再点一下才能进入编辑态。在代码里显式设置更稳妥:
ui->tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
  1. 如果编辑框弹出来了,但内容不对,检查EditRole是否返回了正确的值。默认QStyledItemDelegate是从EditRole取初始值,不是从DisplayRole取。两个角色值不同的场景,最典型的就是"界面显示单位,编辑时传原始值"。

Delegate"没生效"的坑也常见。你自己写了一个继承QStyledItemDelegate的类,重写了paint(),也调用了setItemDelegate(),但界面上毫无变化。这时候优先怀疑paint()里的option.state判断和父子关系对不对。还有一个非常容易忽略的点:如果委托里要获取Model的数据,一定要拿index.model()->data(...)去取,而不是持有外部Model指针——因为View背后挂的模型可能中途被换掉,委托里的旧指针就悬空了。

5.3 性能优化经验

数据量过万以后,Model/View的体验和QTableWidget完全不在一个量级,但自定义Model写得糙,一样会卡。我总结了几条实测有效的优化方向,按收益排序:

  • 用角色区分取数逻辑。不要把大段计算塞进DisplayRole分支。比如动态算出的提示语,放到ToolTipRole里,平时根本不会被调用。
  • 避免在data()里做数据库查询。每渲染一个格子查一次数据库,哪怕走连接池也是灾难。正确做法是一次性把当前页数据缓存进内存,data()只做内存读取。
  • 关闭不必要的功能。如果不需要编辑,就不要给flags()加ItemIsEditable;不需要拖拽就不要加ItemIsDragEnabled、ItemIsDropEnabled。View内部会为这些特性做额外工作。
  • 用setUniformRowHeights(true)。当每行高度一致时,View可以做整块区域计算和绘制,滚动性能提升明显。
  • 需要懒加载时用canFetchMore()/fetchMore()。这对接口专门用来做"数据量巨大时分批加载",非常适合联动滚动加载更多的场景。

这些优化点还有个共同逻辑:Model/View的响应速度,取决于你让View为了画一个格子付出了多少代价。每画一格调一次data()是免不了的,但data()内部的逻辑越轻,整体就越快。

6. 什么时候别用Model/View

6.1 简单场景直接用控件

Model/View虽好,也不是所有地方都该上。如果你的界面固定只有几十条静态数据,而且永远不会动态增删、不会有多视图同步需求,那用QListWidget加addItem()、QTableWidget加setItem()就是最合理的方案——代码短、可读性好、没必要引入额外抽象。

一个很现实的判断标准是:当Model/View带来的"多写一个Model类"成本,大于它带来的解耦收益时,就别用。比如做一个"设置页",左边一个固定列表,右边一个固定表格,两项都不会变,纯手工怼数据就行。等到你发现自己在写"同步两份控件数据"的代码,或者界面开始卡顿,再回头重构Model也不迟。

6.2 混合方案

很多成熟项目会采用混合方案:简单静态页面直接塞控件,核心业务数据用自定义Model,界面复杂到一定程度再引入Delegate定制展示。这种渐进式演进方式比"一上来所有界面全上Model/View"更稳妥,也更容易让团队里的新手接受。

我自己在项目里的习惯是:凡是数据可能被两处以上界面引用,或者来源是外部接口返回的结构化数据,一律上自定义Model。凡是临时测试页、一次性展示页,直接上QStandardItemModel配合ui->tableView。这样既控制了代码总量,又保证了核心模块的灵活度。

另外提一句,Model/View这套设计思想不只在QWidget世界有用。Qt Quick里的QQuickItem配合model属性、ListView的delegate机制,底层思路是一模一样的。把这里的索引、角色、信号通知三个概念吃透,后面接触QML的ListView和C++的QAbstractListModel互通时,会发现全是老朋友。

写在最后的实操体会

这篇作为系列第一篇,刻意没有展开太深的自定义Delegate和树形模型,因为基础不牢的话,那些高级特性越看越晕,反而打击信心。我建议拿到文章后按这个顺序做练习:先跑通QStringListModel那个例子,然后把它换成QStandardItemModel,最后自己写一个StudentModel,在data()里故意删掉DisplayRole分支,观察表格空白的样子,再补回来。这个过程能让你真正理解View和Model之间是"一问一答"的关系。

还要啰嗦一句排查基本功:界面异常时,先判"是Model的问题还是View的问题"。最快的定位方式是打印rowCount()、columnCount()、data()返回值,确认Model这半边数据是好的,再去怀疑View的配置和Delegate。十个Model/View相关的bug,九个出在Model侧,剩下一个出在忘了发信号。记住这句话,能省下大量凭空猜时间。

下一篇我会接着写QAbstractItemModel的完整自定义实战,包含树形结构的parent()和index()到底怎么实现,以及如何给同一个Model挂上QListView、QTableView、QTreeView三种视图让它们实时联动。到时候我们拿一个真实的文件目录例子走一遍,把这份基础理论真正焊死在实际代码里。

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

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

立即咨询