☰
Qt Model/View自定义代理:从paint到编辑器,手写进度条任务列表
2026/10/21 17:28:38 网站建设 项目流程

1. 什么时候必须自己写代理:默认代理的能力边界

做 Model/View 开发时,很多人以为掌握了 QAbstractItemModel 和 QTableView 就够用了,直到产品经理丢来一句"这一列要显示进度条,双击可以改进度",才发现**自定义代理(Delegate)**才是 Model/View 框架里真正决定用户体验的那块拼图。代理管的是单元格层级的绘制和编辑:同一个模型数据,交给默认代理只能显示成一行文本,交给自定义代理却能画成进度条、按钮、星标、缩略图甚至一个迷你折线图。

1.1 三层架构里最容易被忽略的"第三方"

Qt 的 Model/View 框架把界面拆成三个角色:Model 管数据、View 管布局、Delegate(代理)管单个条目的"长相"和"编辑方式"。很多人学这块时把注意力全放在 Model 和 View 上,直到某天需要在一列表格里画一个进度条,或者在单元格里塞个按钮,才发现默认代理根本搞不定。那一刻才是真正需要自定义代理的时候。

说个最直白的比喻:Model 是仓库,View 是货架,代理是货架上一个商品的具体摆法——标签贴哪、歪着放还是正着放、顾客能不能直接拿下来改标签。仓库只管存东西,货架只管把东西陈列出来,而每一件商品具体长什么样,由代理决定。

在 Qt 里对应的就是 QAbstractItemDelegate。QTableView、QListView、QTreeView 都自带一个默认代理,负责最基本的显示和编辑:文字、图标、选中高亮、双击弹出编辑器。绝大多数简单列表场景,默认代理够用了。但一旦你的数据需要"换一种视觉语言"展示,比如进度条、评分星星、状态圆点、缩略图、按钮,默认代理就无能为力了。

1.2 默认代理能做的三件事与做不了的几件事

默认代理(准确说是 QStyledItemDelegate)实际上做了三件事:

  • 根据模型的 Qt::DisplayRole 数据,调用当前 QStyle 绘制文本和图标;
  • 根据模型的数据类型自动选择合适的编辑器(字符串用 QLineEdit,整数用 QSpinBox,逻辑值用 QCheckBox);
  • 在合适的位置和时机弹出编辑器、回写数据。

它做不了的事就多了:无法把一个 int 画成半圆形的进度环,无法在一个单元格里画多个可点击的热区,无法在编辑器与显示区域之间做复杂的视觉过渡。更常见的是,你的数据是一个结构体或一个对象指针,显示层需要从里面取不同字段渲染成不同组件,默认代理这时候完全不知道该怎么表达。

1.3 一个常见误判:样式表能替代代理吗

有些同学第一反应是"我用 QSS 样式表搞定"。样式表确实能改颜色、改边框、改间距,但它只能作用于 Qt 已经画出来的元素。你没法用 QSS 把一个文本单元格变成进度条,也没法让单元格的某一部分响应鼠标点击。样式表是"化妆",代理是"整容",两者解决的问题基本不重叠。我在某个项目里为了图省事,试图用样式表模拟一个风险等级标签,结果折腾了两天还是回到了自定义代理,因为点击区域的命中测试根本无法用样式表表达。

判断标准很简单:如果这个单元格的展示方式不在 QStyle 的基础元素列表里(文本、图标、复选框、进度条等),或者需要自定义交互热区,就得写自定义代理。

2. 自定义代理的底层机制:六个虚函数的触发顺序与分工

自定义代理不是魔法,你只需要重写 QStyledItemDelegate 的六个核心虚函数。只要搞清楚每一个函数何时被调用、负责什么、返回值给谁用,代码就水到渠成了。这一节先把机制讲透,后面的实战才能写得顺。

2.1 显示链路:paint 与 sizeHint 如何配合

视口里每出现一个可见条目,视图都会问代理两个问题:"这个条目该画成什么样"和"这个条目需要多大空间"。前者对应 paint(),后者对应 sizeHint()。

paint() 接受三个关键参数:QPainter、QStyleOptionViewItem、QModelIndex。QPainter 是画笔,负责真正往屏幕上画;QStyleOptionViewItem 包含了当前条目的矩形、选中状态、悬停状态、焦点状态、是否可用等信息;QModelIndex 是你取数据的凭证,通过它调 model()->data(index, role) 就能拿到要渲染的数据。

sizeHint() 则是视图计算布局时被调用的,返回值决定行高、列宽的初始大小。注意它不是只在初始化时调用一次,数据变化、视图尺寸变化时都可能重新触发,所以不要在 sizeHint 里做重活,尤其不要在里面做大量字符串截断、图片缩放这类开销不小的操作。视图、代理、模型三者的调用链条可以归纳成一张通用表格:

函数触发时机核心职责
paint条目进入可见区域绘制条目的视觉外观
sizeHint布局、尺寸变化时返回条目的建议尺寸
createEditor用户发起编辑时创建编辑器控件
setEditorData编辑器创建完成后把模型数据填入编辑器
setModelData用户提交编辑时把编辑器数据写回模型
updateEditorGeometry编辑器展示前设置编辑器位置与尺寸

2.2 编辑链路:从双击到写回模型的完整生命周期

一个完整的编辑流程是这样的:

  1. 用户双击(或按 F2,或视图设定的其他编辑触发方式),视图调用代理的 createEditor(),拿到一个编辑器控件;
  2. 视图随后调用 setEditorData(),把模型里当前的值填进编辑器;
  3. 用户改完,按回车或点击其他地方,视图通过 commitData() 信号触发代理的 setModelData(),把编辑器里的值写回模型;
  4. 视图再通过 closeEditor() 信号关闭编辑器,编辑结束;
  5. 编辑器出现的位置和大小由 updateEditorGeometry() 决定,默认取 option.rect。

这五个环节里,createEditor 和 setEditorData 负责"从模型到编辑器",setModelData 负责"从编辑器到模型",updateEditorGeometry 负责"位置"。写回模型后,视图会收到 dataChanged 信号自动刷新,paint 重新执行,新数据立刻出现在界面上。

2.3 为什么要继承 QStyledItemDelegate 而不是 QItemDelegate

网上很多老教程写的是继承 QItemDelegate,但新代码建议都继承 QStyledItemDelegate。区别在于:QStyledItemDelegate 绘制时走 QStyle,会自动跟随当前程序的主题、调色板、样式表;QItemDelegate 则大量使用自己的硬编码绘制逻辑,样式表对它的影响很有限。除非有非常特殊的兼容需求,否则不要碰 QItemDelegate。记住这一点能省掉很多"为什么我的样式换了它不变"的排查时间。

3. 实战:手写一个带进度条、可原地改值的任务列表代理

理论讲完了,来做一个能直接抄作业的例子。场景是一个任务管理列表:QTableView 展示多行任务,第一列任务名,第二列进度(0 到 100),第三列负责人,第四列状态,第五列操作按钮。目标效果:进度列显示一条带百分比的进度条,双击这一列时弹出 QSpinBox 直接改数值。

3.1 需求定义与类骨架

因为进度列和状态列的展示逻辑完全不同,建议把展示和编辑能力放进同一个代理类里,在 paint 和 createEditor 中用 index.column() 分派。这样只挂载一次代理,代码集中,好维护。类骨架如下:

// TaskListDelegate.h #pragma once #include <QStyledItemDelegate> enum TaskColumn { ColName = 0, // 任务名 ColProgress = 1, // 进度 ColOwner = 2, // 负责人 ColStatus = 3, // 状态 ColAction = 4 // 操作按钮 }; class TaskListDelegate : public QStyledItemDelegate { Q_OBJECT public: explicit TaskListDelegate(QObject* parent = nullptr); void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override; void setEditorData(QWidget* editor, const QModelIndex& index) const override; void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override; void updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem& option, const QModelIndex& index) const override; };

模型中进度值建议存在 Qt::UserRole + 1 这个自定义角色里,DisplayRole 存的是纯文本说明,两边的数据用途不同,避免角色混淆。

3.2 paint 里的绘制细节:从背景到进度条到文字

paint 是这次自定义代理的重头戏。核心逻辑分四步:先画单元格背景,再画进度条槽,然后画进度填充,最后画百分比文字。

void TaskListDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); const QWidget* widget = opt.widget; QStyle* style = widget ? widget->style() : QApplication::style(); // 进度列走自定义绘制 if (index.column() == ColProgress) { // 1. 画标准背景,保证选中态、悬停态不丢 style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, widget); // 2. 从模型取进度值 bool ok = false; int progress = index.data(Qt::UserRole + 1).toInt(&ok); if (!ok) progress = 0; // 3. 画进度条槽 const QRect barRect = opt.rect.adjusted(8, 10, -8, -10); painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(Qt::NoPen); painter->setBrush(QColor(0xEC, 0xEC, 0xEC)); painter->drawRoundedRect(barRect, barRect.height() / 2.0, barRect.height() / 2.0); // 4. 画进度填充 if (progress > 0) { QRect fillRect = barRect; fillRect.setWidth(barRect.width() * qBound(0, progress, 100) / 100); // 满进度用绿色,未满用主题蓝 const QColor fillColor = (progress >= 100) ? QColor(0x52, 0xC4, 0x1A) : QColor(0x2D, 0x8C, 0xF0); painter->setBrush(fillColor); painter->drawRoundedRect(fillRect, fillRect.height() / 2.0, fillRect.height() / 2.0); } // 5. 画百分比文字 const QString text = QString::number(progress) + QStringLiteral("%"); painter->setPen(Qt::white); painter->drawText(barRect, Qt::AlignCenter, text); painter->restore(); return; } // 其他列走默认绘制 QStyledItemDelegate::paint(painter, opt, index); }

有几个细节值得单独强调。

第一,一定要先调用 initStyleOption(&opt, index),它会把 View 传下来的 option 补全为标准条目,包括选中状态、可用状态、文本、图标等。如果你跳过这步直接拿原始 option 画背景,就会丢失选中高亮,这也是新手最容易踩的坑。

第二,进度条矩形用 adjusted 留出边距,行高和文字才不会挤在一起。第三,绘制前 save、结束后 restore,防止画笔属性污染后面的绘制。第四,文字颜色这里统一用了白色,但实际项目中如果进度值很小,白字会被灰底吃掉,更稳的做法是判断填充区域宽度是否足够,不够时把文字画在进度条右侧并用深色,这个属于细节优化。

3.3 用 QSpinBox 作为单元格编辑器

展示搞定后,编辑逻辑就四个函数的事。先看 createEditor 和 setEditorData:

QWidget* TaskListDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const { if (index.column() == ColProgress) { QSpinBox* editor = new QSpinBox(parent); editor->setRange(0, 100); editor->setSuffix(QStringLiteral(" %")); editor->setAlignment(Qt::AlignCenter); return editor; } return QStyledItemDelegate::createEditor(parent, option, index); } void TaskListDelegate::setEditorData(QWidget* editor, const QModelIndex& index) const { if (index.column() == ColProgress) { QSpinBox* spinBox = qobject_cast<QSpinBox*>(editor); if (spinBox) spinBox->setValue(index.data(Qt::UserRole + 1).toInt()); return; } QStyledItemDelegate::setEditorData(editor, index); }

setEditorData 的意图很明确——把模型里的数据搬到编辑器里。这一步不做的话,弹出来的编辑器永远是默认值,用户一改就把原有数据覆盖了。

写回的部分才是真正和模型打交道的:setModelData 负责把编辑器里的值通过 setData 写回模型。写回后视图会自动刷新,paint 重新执行,进度条就更新了。

void TaskListDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const { if (index.column() == ColProgress) { QSpinBox* spinBox = qobject_cast<QSpinBox*>(editor); if (spinBox) model->setData(index, spinBox->value(), Qt::UserRole + 1); return; } QStyledItemDelegate::setModelData(editor, model, index); }

3.4 在 QTableView 里挂载代理

最后挂载视图。你有两种选择:setItemDelegate 设置整表统一代理,或者 setItemDelegateForColumn 单独给某一列挂代理。上面这个例子因为各列逻辑都分派在同一个 paint 里了,所以直接整表挂载即可:

ui->tableView->setModel(taskModel); ui->tableView->setItemDelegate(new TaskListDelegate(ui->tableView)); ui->tableView->setSelectionBehavior(QAbstractItemView::SelectRows); ui->tableView->verticalHeader()->setVisible(false);

注意代理的父对象。我一般直接传给 view,让 Qt 负责生命周期,不需要自己手动 delete。给某列单独挂代理时也一样,父对象设为 view 即可。到这里,一个带进度条、双击可改值的自定义代理已经能跑了。先别急着扩展别的列,把上面这套跑通,你已经掌握了 80% 的委托开发套路。

4. 踩坑实录:自定义代理最常见的四个翻车现场

这些年见过太多人在自定义代理上翻车,我自己也踩过。下面四个问题,几乎每个做代理的人都会遇到至少一个,提前看能省不少时间。

4.1 翻车一:选中态与悬停态莫名消失

表现:写完 paint 之后,选中单元格时高亮背景不见了,鼠标悬停也没有反馈。

原因:paint 里没有调用 initStyleOption,直接把传入的 option 用来画背景。View 传进来的 option.state 里的 State_Selected、State_MouseOver 都没被解析成实际背景。

修复:在 paint 开头统一执行一句 initStyleOption(&opt, index),再基于 opt 画。这样选中、悬停、可用状态全部恢复正常。还要注意:如果你在自定义绘制里完全没有调用 style->drawControl(QStyle::CE_ItemViewItem...),那选中态的背景就要自己填充,此时可以用 opt.palette 里的 highlight 色,不要自己硬编码一个蓝色。

一个经验法则:自定义 paint 永远先调用 initStyleOption,再判断要不要走默认绘制。这条能避免的问题数量超乎想象。

4.2 翻车二:编辑器位置对不齐、尺寸像"假肢"

表现:双击进度列弹出 QSpinBox,但输入框比单元格小一圈,位置还偏上,看起来像是给单元格贴了块补丁。

原因:没有重写 updateEditorGeometry,编辑器默认按 option.rect 放置。如果你的单元格内部有边距(paint 里留了 8 像素),编辑器就会显得不对劲。

修复:重写 updateEditorGeometry,让编辑器的几何区域和画出来的"可编辑视觉区域"保持一致:

void TaskListDelegate::updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem& option, const QModelIndex& index) const { if (index.column() == ColProgress) { QRect rect = option.rect; // 覆盖进度条视觉区域附近,留出少量边距 rect.adjust(8, 4, -8, -4); editor->setGeometry(rect); return; } QStyledItemDelegate::updateEditorGeometry(editor, option, index); }

核心思路是:编辑器应该盖住用户预期可以点击的区域,而不是机械地等于整个单元格。如果你在 paint 里画了一个边距很大的自定义控件,编辑器却只盖住整个格子,视觉上也会很怪。两边留白对齐,体验才顺滑。

4.3 翻车三:回车后数据没写回或编辑器不关闭

表现:用户改完数值,按回车,编辑器留在原地,数据没写回模型。

原因:标准编辑流程依赖视图的编辑请求信号和代理的 commitData/closeEditor 信号协同。如果你的代理里重写了 eventFilter,并且在里面拦截了 KeyPress 却忘了调用基类逻辑,Tab 和 Enter 键的默认提交行为就没了。另外,如果你在自定义编辑时用了非标准编辑器并且自己接管焦点,也可能导致写回链路断掉。

修复:大部分场景下,不要自己拦截键盘事件,把事件过滤交给 QStyledItemDelegate 的默认实现。如果确实需要自定义(比如 Enter 表示确认、Esc 表示取消),推荐在编辑器的 eventFilter 里只处理特殊情况,然后手动调用 emit commitData() 和 emit closeEditor()。注意:这两个信号是 QAbstractItemDelegate 提供的,调用时机要放在数据真正准备好之后。附一个常见的回车过滤范本:

bool TaskListDelegate::eventFilter(QObject* object, QEvent* event) { if (event->type() == QEvent::KeyPress) { QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); QWidget* editor = qobject_cast<QWidget*>(object); if (editor && keyEvent->key() == Qt::Key_Return) { emit commitData(editor); emit closeEditor(editor, QAbstractItemDelegate::SubmitModelCache); return true; } } return QStyledItemDelegate::eventFilter(object, event); }

4.4 翻车四:多列共用一个代理导致行为错乱

表现:代理只给第二列写了特殊逻辑,但第一列和第三列也出现了异常绘制,或者双击第一列时弹出了 QSpinBox。

原因:paint 和 createEditor 里没有按列分派,或者列判断写错了。如果你用 setItemDelegate 挂整表,那么代理会被每一列调用,所有列都会走你的代码路径。调用的列不一样,行为和画法当然不一样。

修复:在 paint、createEditor、setEditorData、setModelData、updateEditorGeometry 五个函数里都加上 column 判断,或者干脆每个列单独写一个代理,用 setItemDelegateForColumn 挂载。我个人推荐后者,代理类职责单一,后期扩展和排错都轻松。如果你只改一个列的展示而其余全部走默认,那么一个代理加 column 判断也完全可以,注意代码可读性即可。

5. 进阶技巧:按钮列代理、动态行高与高DPI适配

基础功能跑通后,再给真正项目里会遇到的三个进阶需求收个尾。

5.1 给任务加一个"开始/暂停"按钮列:绘制+命中测试

进度列之后很多人想加一个操作列,放"开始/暂停"按钮。按钮列的难点不再是画,而是让单元格内的按钮真正响应点击。思路分两步:paint 里画一个 QStyleOptionButton 样式的按钮,然后在 editorEvent 里做命中测试。

画按钮直接用 QStyle 的画法,保证按钮的按下、悬停、禁用等视觉状态和系统一致:

void TaskListDelegate::paintActionButton(QPainter* painter, const QStyleOptionViewItem& option, const QRect& buttonRect, const QString& text, bool isRunning) const { QStyleOptionButton button; button.rect = buttonRect; button.text = text; button.state = QStyle::State_Enabled; if (isRunning) button.state |= QStyle::State_On; // 这里用单元格悬停近似按钮悬停, // 更精细的做法是在编辑器事件里记录鼠标位置 if (option.state & QStyle::State_MouseOver) button.state |= QStyle::State_MouseOver; QStyle* style = option.widget ? option.widget->style() : QApplication::style(); style->drawControl(QStyle::CE_PushButton, &button, painter, option.widget); }

命中测试在 editorEvent 里做最干净。editorEvent 是很多新手忽略的虚函数,单击、双击、鼠标移动事件都会先经过它,返回 true 表示事件已被消费:

bool TaskListDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) { if (index.column() == ColAction) { if (event->type() == QEvent::MouseButtonRelease) { QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event); QRect btnRect = actionButtonRect(option.rect); if (btnRect.contains(mouseEvent->pos())) { // 切换任务运行状态,写入模型 const bool running = index.data(Qt::UserRole + 2).toBool(); model->setData(index, !running, Qt::UserRole + 2); return true; } } } return QStyledItemDelegate::editorEvent(event, model, option, index); }

注意:editorEvent 里的 option.rect 是整个单元格的矩形,按钮矩形需要自己在内部计算并留出边距。每列逻辑不一样,千万别把整个单元格都当成按钮。

5.2 根据内容动态调整行高

任务描述长短不一的时候,固定行高会让长文本溢出。动态行高的做法是在 sizeHint 里根据文本实际宽度计算:

QSize TaskListDelegate::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const { QSize size = QStyledItemDelegate::sizeHint(option, index); if (index.column() == ColName) { const QString text = index.data(Qt::DisplayRole).toString(); QFontMetrics fm(option.font); const int textWidth = fm.horizontalAdvance(text); const int maxWidth = 400; if (textWidth > maxWidth) { // 预留 20 像素边距 + 一行的高度 int neededHeight = fm.height() * (textWidth / maxWidth + 1); size.setHeight(qMax(size.height(), neededHeight + 12)); } } return size; }

动态行高的性能和正确性都要兼顾。如果只是偶尔几个长文本,可以只对超长的单元格做两行显示逻辑;如果每行都要算,建议把 textLayout 或换行结果缓存起来,避免每次 sizeHint 都重复计算。视图在数据变化后会重新查询 sizeHint,所以数据变了行高会自动更新,不需要手动触发。

5.3 高DPI下的绘制缩放与字体清晰度

高DPI 已经不是小众需求了。如果你的代理里用了大量硬编码像素值,在 2 倍缩放屏上就会显得又小又糊。我的建议有三条:

  • 绘制矩形坐标里避免硬编码大边距,改用 option.fontMetrics 的 height 比例计算,比如 verticalMargin = fm.height() / 4;
  • painter 开启抗锯齿,圆角矩形在高 DPI 下更平滑;
  • 字体大小不要自己 setPixelSize(12),直接用 option.font 派生的字体,系统缩放会帮你处理好。

图表、进度条这类纯绘制的组件,Qt 的坐标系统会自动做缩放,但如果你在代码里把字体和间距写死,缩放后就只剩"小"和"更小"两个结局。另外,低 DPI 和高 DPI 下 drawText 的字体渲染差异,往往不是代理本身的问题,而是没有正确设置设备像素比,确认程序开启高 DPI 缩放后,代理绘制质量基本就稳了。

回过头看,自定义代理的门槛其实不在 API 数量,而在于"你明确知道视图、模型、代理三者之间的数据流什么时候发生"。把六个虚函数的调用时机想清楚,剩下的就是绘制上的功夫。如果让我给初学者一个建议:先别急着写炫酷的绘制,先跑通一个最朴素的 paint + createEditor,再往上加效果,比什么都管用。

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

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

立即咨询