说点我自己的经验。最近做一个数据监控面板,表格部分的要求相当刁钻:同一列里既要有状态色块,又要有进度条,操作列还要放按钮,双击单元格弹出来的还得是自定义编辑器。刚开始我图省事,想靠 QTableView 原生能力硬凑,结果怎么调怎么别扭,直到静下心把 Model/View 里的自定义代理(Delegate)彻底过了一遍,才把这套逻辑真正理顺。今天就用这个项目里的一段实践,聊聊“自定义代理”在 Model/View 模型视图里到底扮演什么角色,以及一个能扛住真实业务场景的代理该怎么设计和落地。这个主题适合所有正在用 Model/View 架构做表格、列表、树形控件的开发者,尤其是发现“默认显示文本、默认双击编辑”满足不了业务需求,又不想把视图类重写得面目全非的朋友。哪怕你只是刚接触 Qt,只要理解了代理的职责,后面写业务代码会顺手很多。1. Model/View中的“代理”到底在解决什么问题1.1 模型、视图、代理三方职责先说个最容易混淆的点。Model/View 框架刚上手的时候,很多人以为只需要“模型提供数据,视图负责显示”就够了,等到需要自定义单元格内容时才发现,视图根本不该管“怎么画一个单元格”这种事。实际上 Model/View 把职责拆成了三层:模型(Model)只管数据本身,包括行列结构、角色数据、增删改查。视图(View)只管布局、滚动、选区、编辑触发方式这些宏观交互。代理(Delegate)管的是“单元格级别的显示和编辑细节”。用生活里的类比:模型像是仓库里的货架系统,视图是商场的陈列区,代理则是负责具体货架上的摆货员——把同一件商品摆成普通价格牌、促销标签、还是带扫码按钮的交互卡片,都归他管。这种拆分的好处是:数据层完全不知道界面长什么样,视图层不需要关心某个单元格到底渲染成文本还是按钮,代理可以单独复用、单独替换。你甚至可以让同一份数据在普通表格里显示成文本,在另一个界面里显示成图表。1.2 什么时候值得自己写代理原生代理(默认行为)实际上已经能完成不少事:文本显示、图标、复选框、双击编辑普通文本。但以下几类情况,默认行为几乎一定撑不住:想让单元格按数值大小显示不同背景色或阈值颜色。想显示进度条、星级评分、状态徽标、缩略图。想给单元格里放一个“操作按钮”,点击后触发业务动作。想为特定列提供自定义编辑器,比如下拉框、日期选择、颜色选择,或者带多个子控件的复合编辑器。想统一全局的绘制风格,不想在业务代码里到处写样式表。在这些场景下,自写代理几乎是标准解法。而且代理是继承关系,你可以做一个通用代理给整张表用,再用 setItemDelegateForColumn 单独给某一列指定更特殊的代理,两种方式还能叠加。1.3 从QStyledItemDelegate还是QItemDelegate开始刚开始写代理时最常踩的坑就是基类选错。QItemDelegate 是老一代实现,QStyledItemDelegate 会主动使用当前视图的样式表(QStyle),在配合 QSS、高分辨率缩放、主题切换时表现更稳定。所以我的建议很简单:新代码一律从 QStyledItemDelegate 继承。只有当你需要彻底脱离样式表、完全自定义绘制逻辑并且不想被全局样式干扰时,才考虑 QItemDelegate。2. 自定义代理的核心机制拆解2.1 必须从重写这些虚函数开始一个完整的自定义代理,核心入口并不神秘,主要就是下面几个虚函数:虚函数作用典型重写时机paint()负责绘制单元格内容基本上所有自定义代理都要重写sizeHint()返回单元格建议尺寸内容高度宽度比较特殊,或需要留边距时createEditor()创建编辑器控件需要自定义编辑器时setEditorData()把模型数据灌入编辑器配合 createEditorsetModelData()把编辑器内容写回模型配合 createEditorupdateEditorGeometry()设置编辑器在视图里的几何位置编辑器需要特殊位置时editorEvent()在编辑器创建前拦截鼠标/键盘事件处理按钮点击、复选框状态变化、拖拽类交互很多人一上来就盯着 paint 狂写,结果编辑器相关的方法一个不重写,等用户双击单元格时会发现编辑器还是默认的那几个类型。记住,代理负责的是“显示”和“编辑”两半,缺一半就不完整。2.2 paint:把单元格画成你想要的样子paint 是整个代理里信息量最大的函数。它的签名通常是:void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;这里有两个最容易忽略的点。第一,QStyleOptionViewItem 里带了你几乎需要的所有状态:索引数据、选区状态、鼠标悬停状态、焦点状态、启用状态、文本对齐方式、调色板、字体等等。你不需要自己去问视图“当前选中没有”,直接读 option.state 就行。第二,很多人在自定义绘制之前忘了调用基类的 paint 来画背景,导致选中单元格的背景永远是白的。正确处理方式是先让基类把背景处理掉,再在上面叠加自己的内容:void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); // 先交给基类绘制选中背景、焦点框等基础内容 QStyledItemDelegate::paint(painter, opt, index); // 再根据业务角色画自己的内容 int value = index.data(Qt::UserRole + 1).toInt(); // ... 绘制逻辑 }这里还有一个细节:initStyleOption 会把模型里所有标准角色(比如 Qt::DisplayRole、Qt::ForegroundRole、Qt::FontRole)填充到 option 里,如果你不调用它,很多样式信息会丢失。2.3 createEditor与数据回写当用户触发编辑(比如双击或按 F2),视图会调用 createEditor 创建编辑器。这里要注意两点:编辑器是“按需创建”的,不是表格初始化时一次性建好几百个控件。这样能大幅减少控件数量和内存占用。常见需求里,某个列的下拉框需要根据当前行的类型动态加载选项,createEditor 里的 index 可以帮你获取当前行的数据,从而动态填充。数据回写的链路也很关键:void setEditorData(QWidget *editor, const QModelIndex &index) const override { // 编辑器初始化:例如把模型里的值设置到 QSpinBox QSpinBox *spin = qobject_cast<QSpinBox*>(editor); if (spin) { spin->setValue(index.data(Qt::EditRole).toInt()); } } void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override { QSpinBox *spin = qobject_cast<QSpinBox*>(editor); if (spin) { model->setData(index, spin->value(), Qt::EditRole); } }很多人只写了 createEditor,忘记 setEditorData,编辑器打开后显示的数据就是空的;只写了 setEditorData 忘记 setModelData,用户改完数据模型没变化。这三个函数必须配套。2.4 editorEvent:在编辑器开启之前拦截事件editorEvent 是代理里最容易被忽略但实际很实用的函数。典型场景:一个“操作”列,不需要真正的编辑器,用户单击单元格里的按钮就应该触发业务逻辑。如果你只重写 paint 画了按钮样式,不处理 mouse 事件,点击就没反应。editorEvent 的流程大致是:鼠标或键盘事件先经过视图,视图把事件分发给当前代理,让代理有机会处理。代理收到事件后,可以用 option.rect 判断事件坐标是否落在某个区域,然后决定是启动编辑、切换状态、还是发业务信号。例如处理单元格内的一个“下载”按钮:bool editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override { if (event->type() == QEvent::MouseButtonRelease) { QMouseEvent *me = static_cast<QMouseEvent*>(event); if (option.rect.adjusted(0, 0, -10, 0).contains(me->pos())) { // 模拟一次数据更新,实际业务里可以发 signal 出来 model->setData(index, QVariant(true), Qt::UserRole + 3); return true; } } return QStyledItemDelegate::editorEvent(event, model, option, index); }这个函数处理完事件后,视图就不会再继续默认的编辑流程,所以返回 true 很重要。3. 实操:自己动手做一个多功能代理3.1 项目场景与需求原型我在那个数据监控面板里,表结构大概是:设备名称、运行状态、任务进度、最后更新时间、操作。设备名称是普通文本;运行状态要显示成红黄绿状态点加文字;任务进度要显示成进度条;操作列要放“查看”“重试”两个按钮。这个需求如果全靠默认代理,运行状态和进度条完全没法看,操作列更是只能靠编辑模式硬凑。所以我的做法是写一个继承自 QStyledItemDelegate 的通用代理类,在 paint 和 editorEvent 里按列索引分流处理。3.2 第一版:实现状态标签列状态列的核心是画一个圆点加文本。圆点的颜色根据数据值决定,文本内容仍然从标准 DisplayRole 拿。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); QStyledItemDelegate::paint(painter, opt, index); if (index.column() == StatusColumn) { int status = index.data(Qt::UserRole + 1).toInt(); QColor dotColor = statusColor(status); // 根据状态值返回颜色 QRect dotRect = opt.rect.adjusted(8, 0, -8, 0); QRect textRect = opt.rect.adjusted(28, 0, -8, 0); painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->setPen(Qt::NoPen); painter->setBrush(dotColor); int dotSize = 10; painter->drawEllipse(dotRect.x(), dotRect.center().y() - dotSize / 2, dotSize, dotSize); painter->setPen(opt.palette.text().color()); painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, index.data(Qt::DisplayRole).toString()); painter->restore(); } }这里用了 painter->save/restore 来保护绘制状态,避免影响后续单元格的绘制。颜色映射函数根据业务状态返回固定颜色,不会因为系统主题变化而乱掉。3.3 第二版:实现进度条列进度条列需要在单元格里画一个背景条和一个填充条。我会顺便加一个文本百分比,同时用 option.rect 计算内边距,保证任何列宽下都好看。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { // 先画基础背景 QStyleOptionViewItem opt = option; initStyleOption(&opt, index); QStyledItemDelegate::paint(painter, opt, index); if (index.column() == ProgressColumn) { double progress = index.data(Qt::UserRole + 2).toDouble(); progress = qBound(0.0, progress, 1.0); QRect progressRect = opt.rect.adjusted(4, 8, -4, -8); int x = progressRect.x(); int y = progressRect.y(); int w = progressRect.width(); int h = progressRect.height(); painter->save(); painter->setRenderHint(QPainter::Antialiasing); // 背景槽 painter->setBrush(opt.palette.midlight()); painter->setPen(Qt::NoPen); painter->drawRoundedRect(progressRect, h / 2.0, h / 2.0); // 进度填充 int fillW = qRound(w * progress); QRectF fillRect(x, y, fillW, h); painter->setBrush(QColor("#4C9BE8")); painter->drawRoundedRect(fillRect, h / 2.0, h / 2.0); // 百分数文本 painter->setPen(opt.palette.text().color()); painter->drawText(progressRect, Qt::AlignCenter, QString::number(progress * 100, 'f', 1) + QStringLiteral("%")); painter->restore(); } }做进度条代理时有个值得注意的点:不要直接 new 一个 QProgressBar 放在单元格上。在表格里成百上千行、不断滚动的情况下,为每个单元格塞控件会直接把界面卡死。绘制式进度条只是画几圈圆角矩形和文本,开销非常低。3.4 第三版:操作按钮列与点击处理操作列是两个按钮,在 paint 里分别计算两个按钮的几何区域,然后在 editorEvent 里根据鼠标点击的区域判断哪个按钮被按了。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { if (index.column() != ActionColumn) { QStyledItemDelegate::paint(painter, option, index); return; } painter->save(); QRect area = option.rect.adjusted(4, 4, -4, -4); QRect viewBtnRect = area.adjusted(0, 0, -6, 0); QRect retryBtnRect = area.adjusted(viewBtnRect.width() + 6, 0, 0, 0); // 画“查看”按钮 drawButton(painter, viewBtnRect, QStringLiteral("查看"), option); // 画“重试”按钮 drawButton(painter, retryBtnRect, QStringLiteral("重试"), option); painter->restore(); }画按钮时我建议直接用 QStyle 的 drawControl,这样才能和系统风格保持一致性。最简单的做法是构造一个 QStyleOptionButton,然后调用 style()->drawControl(QStyle::CE_PushButton, &btnOption, painter, nullptr)。editorEvent 里的事件判断逻辑:bool PaintDelegate::editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override { if (index.column() == ActionColumn && event->type() == QEvent::MouseButtonRelease) { QMouseEvent *me = static_cast<QMouseEvent*>(event); QRect area = option.rect.adjusted(4, 4, -4, -4); QRect viewBtnRect = area.adjusted(0, 0, -6, 0); QRect retryBtnRect = area.adjusted(viewBtnRect.width() + 6, 0, 0, 0); if (viewBtnRect.contains(me->pos())) { // 发信号或直接更新模型,业务侧自行处理 emit viewClicked(index); return true; } else if (retryBtnRect.contains(me->pos())) { emit retryClicked(index); return true; } } return QStyledItemDelegate::editorEvent(event, model, option, index); }有个小坑是:如果只监听 MouseButtonPress,用户点击后界面会卡在“按下未松开”的状态,回掉逻辑又执行,容易造成重复触发。所以我一般监听 MouseButtonRelease,至少保证按下和松开是一个完整动作。3.5 把代理挂载到视图代理写好后,挂载方式有三种层次:给整张表设置:table->setItemDelegate(delegate);单独给某一列设置:table->setItemDelegateForColumn(操作列索引, delegate);给某个行或某个索引临时设置:用 QAbstractItemView 的信号配合动态替换代理。实际项目中我往往先做一个最通用的代理实例给整张表,然后在内部用 column 分流。如果业务复杂到不同列差异极大,再拆成多个代理类分别挂载,这样维护起来比较清爽。4. 性能、响应与样式细节4.1 绘制式代理与控件式编辑器的性能对比很多初学者会把“自定义编辑器”和“自定义绘制”混在一起,以为每个单元格都是控件。这是性能问题的根源。绘制式代理只在 paint 的时候算一次绘制指令,没有控件对象,内存占用几乎为零。编辑器是“需要编辑时才创建,编辑结束就销毁”的临时控件,只有正在编辑的那一个存在。所以在表格里放 1 万行数据,用自绘代理基本毫无压力;如果用“每行放一个 QComboBox 或 QProgressBar”的思路,1 万行控件会让界面直接没法看。也许有人会说:“那我不放 1 万行,只放 100 行不就行了吗?”但表格支持滚动,控件数量乘以可见行数,依然很耗资源,而且滚动时控件复用也会带来很多心智负担。4.2 确保刷新与异步数据更新代理和模型之间通过数据角色同步。当数据源变化时,模型需要发出 dataChanged 信号,视图收到后会重绘对应索引。常见误区是:业务线程更新了数据,但 UI 线程没收到通知,代理画出来的内容还是老数据。解决方式很简单:数据更新后,用 QMetaObject::invokeMethod 或信号槽机制回到 GUI 线程,调用模型实例的:emit model->dataChanged(index, index, {Qt::DisplayRole, Qt::UserRole + 1});如果你没指定角色,视图只会对默认角色重绘,自定义角色可能不刷新。为了保险,刷新时把涉及的自定义角色一并传进去。4.3 适配高分屏与主题切换自绘代理最容易在高分屏上翻车的是圆角矩形边缘发虚和按钮文字挤到边界外。解决办法是尽量使用 QStyle 的 layout 相关接口,以及绘制时统一除以屏幕设备像素比。另外一个隐蔽问题是:如果代理里写死了颜色,系统从浅色切到深色主题时,单元格会非常突兀。建议从 option.palette 里取颜色,例如背景色用 opt.palette.base(),文本色用 opt.palette.text(),只有业务上有明确含义的色彩(比如状态红绿黄)才用固定色值。5. 高频问题速查与避坑记录5.1 问题排查速查表症状可能原因解决办法单元格选中背景消失paint 里没有先调用基类先 QStyledItemDelegate::paint 画背景,再叠加内容编辑器打开后内容为空只写了 createEditor,没写 setEditorData在 setEditorData 里根据 index 初始化控件值编辑完数据没变化没写 setModelData在 setModelData 里把控件值写回模型点击自绘按钮没反应没重写 editorEvent,或只处理了 Press 事件重写 editorEvent,监听 Release,正确判断区域进度条文字总被选中背景遮住绘制顺序错误先画背景、再画内容,或 restore 前再绘制文本表格行高不够,画的东西被裁切sizeHint 返回尺寸不合理重写 sizeHint,根据内容高度返回合适值编辑器的弹出位置不对没重写 updateEditorGeometry根据 option.rect 设置编辑器 geometry同一列不同行显示格式又不一样代理里逻辑耦合过多根据行类型或数据角色分支,抽出策略函数主题切深色后文字看不清固定用黑色画笔使用 option.palette 中的文字色5.2 我踩过的几个小的坑第一,editorEvent 里误返回 true 导致原本正常的双击编辑被吞掉。如果你只是处理按钮单击,一定要在判定区域外返回基类实现,否则普通双击单元格就永远无法进入编辑状态。第二,sizeHint 的高度不能总是返回固定行高。有些列内容多需要多行显示,有些列又希望行紧凑一点。我可以根据当前索引动态计算高度,但要注意缓存计算结果,否则数据一多,滚动起来 sizeHint 计算会成为性能瓶颈。第三,paint 里大量使用字体测量或字符串处理时,建议提前计算并缓存。比如百分比文本,一千行表格每次滚动都要调用 size 计算文本宽度,开销不小。可以用 QFontMetrics 或缓存文本宽度,让滚动体验更顺滑。第四,如果同时使用代理绘制和 QSS 全局样式,部分样式属性可能会被代理覆盖。遇到这种情况,不要和样式表硬刚,直接在代理里统一处理,或者在代理里手动读取 QApplication::style()。第五个建议是,代理类尽量设计成无状态的,也就是不要在代理成员变量里保存“当前编辑行”“当前点击列”之类的临时状态。代理实例会被视图持有,跨行复用,成员变量一旦残留,下一行就出 bug。把需要的数据都从 index 和 option 里现取,宁可多写几行,也别踩状态残留的坑。目前这套混合代理已经稳定跑在监控面板里,后面我还准备扩展一下:再加一个“轻量级列表代理”复用到另一个下拉选择界面里,因为整个交互模式完全一致,只是显示和编辑逻辑不同。如果你也在做类似的表格自定义,建议把代理独立成通用类,多花一点时间把列索引、角色号、颜色方案都做成可配置参数,这样后续换需求时只需要改配置,不需要再动绘制逻辑。
说点我自己的经验。最近做一个数据监控面板,表格部分的要求相当刁钻:同一列里既要有状态色块,又要有进度条,操作列还要放按钮,双击单元格弹出来的还得是自定义编辑器。刚开始我图省事,想靠 QTableView 原生能力硬凑,结果怎么调怎么别扭,直到静下心把 Model/View 里的自定义代理(Delegate)彻底过了一遍,才把这套逻辑真正理顺。今天就用这个项目里的一段实践,聊聊“自定义代理”在 Model/View 模型视图里到底扮演什么角色,以及一个能扛住真实业务场景的代理该怎么设计和落地。这个主题适合所有正在用 Model/View 架构做表格、列表、树形控件的开发者,尤其是发现“默认显示文本、默认双击编辑”满足不了业务需求,又不想把视图类重写得面目全非的朋友。哪怕你只是刚接触 Qt,只要理解了代理的职责,后面写业务代码会顺手很多。1. Model/View中的“代理”到底在解决什么问题1.1 模型、视图、代理三方职责先说个最容易混淆的点。Model/View 框架刚上手的时候,很多人以为只需要“模型提供数据,视图负责显示”就够了,等到需要自定义单元格内容时才发现,视图根本不该管“怎么画一个单元格”这种事。实际上 Model/View 把职责拆成了三层:模型(Model)只管数据本身,包括行列结构、角色数据、增删改查。视图(View)只管布局、滚动、选区、编辑触发方式这些宏观交互。代理(Delegate)管的是“单元格级别的显示和编辑细节”。用生活里的类比:模型像是仓库里的货架系统,视图是商场的陈列区,代理则是负责具体货架上的摆货员——把同一件商品摆成普通价格牌、促销标签、还是带扫码按钮的交互卡片,都归他管。这种拆分的好处是:数据层完全不知道界面长什么样,视图层不需要关心某个单元格到底渲染成文本还是按钮,代理可以单独复用、单独替换。你甚至可以让同一份数据在普通表格里显示成文本,在另一个界面里显示成图表。1.2 什么时候值得自己写代理原生代理(默认行为)实际上已经能完成不少事:文本显示、图标、复选框、双击编辑普通文本。但以下几类情况,默认行为几乎一定撑不住:想让单元格按数值大小显示不同背景色或阈值颜色。想显示进度条、星级评分、状态徽标、缩略图。想给单元格里放一个“操作按钮”,点击后触发业务动作。想为特定列提供自定义编辑器,比如下拉框、日期选择、颜色选择,或者带多个子控件的复合编辑器。想统一全局的绘制风格,不想在业务代码里到处写样式表。在这些场景下,自写代理几乎是标准解法。而且代理是继承关系,你可以做一个通用代理给整张表用,再用 setItemDelegateForColumn 单独给某一列指定更特殊的代理,两种方式还能叠加。1.3 从QStyledItemDelegate还是QItemDelegate开始刚开始写代理时最常踩的坑就是基类选错。QItemDelegate 是老一代实现,QStyledItemDelegate 会主动使用当前视图的样式表(QStyle),在配合 QSS、高分辨率缩放、主题切换时表现更稳定。所以我的建议很简单:新代码一律从 QStyledItemDelegate 继承。只有当你需要彻底脱离样式表、完全自定义绘制逻辑并且不想被全局样式干扰时,才考虑 QItemDelegate。2. 自定义代理的核心机制拆解2.1 必须从重写这些虚函数开始一个完整的自定义代理,核心入口并不神秘,主要就是下面几个虚函数:虚函数作用典型重写时机paint()负责绘制单元格内容基本上所有自定义代理都要重写sizeHint()返回单元格建议尺寸内容高度宽度比较特殊,或需要留边距时createEditor()创建编辑器控件需要自定义编辑器时setEditorData()把模型数据灌入编辑器配合 createEditorsetModelData()把编辑器内容写回模型配合 createEditorupdateEditorGeometry()设置编辑器在视图里的几何位置编辑器需要特殊位置时editorEvent()在编辑器创建前拦截鼠标/键盘事件处理按钮点击、复选框状态变化、拖拽类交互很多人一上来就盯着 paint 狂写,结果编辑器相关的方法一个不重写,等用户双击单元格时会发现编辑器还是默认的那几个类型。记住,代理负责的是“显示”和“编辑”两半,缺一半就不完整。2.2 paint:把单元格画成你想要的样子paint 是整个代理里信息量最大的函数。它的签名通常是:void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;这里有两个最容易忽略的点。第一,QStyleOptionViewItem 里带了你几乎需要的所有状态:索引数据、选区状态、鼠标悬停状态、焦点状态、启用状态、文本对齐方式、调色板、字体等等。你不需要自己去问视图“当前选中没有”,直接读 option.state 就行。第二,很多人在自定义绘制之前忘了调用基类的 paint 来画背景,导致选中单元格的背景永远是白的。正确处理方式是先让基类把背景处理掉,再在上面叠加自己的内容:void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); // 先交给基类绘制选中背景、焦点框等基础内容 QStyledItemDelegate::paint(painter, opt, index); // 再根据业务角色画自己的内容 int value = index.data(Qt::UserRole + 1).toInt(); // ... 绘制逻辑 }这里还有一个细节:initStyleOption 会把模型里所有标准角色(比如 Qt::DisplayRole、Qt::ForegroundRole、Qt::FontRole)填充到 option 里,如果你不调用它,很多样式信息会丢失。2.3 createEditor与数据回写当用户触发编辑(比如双击或按 F2),视图会调用 createEditor 创建编辑器。这里要注意两点:编辑器是“按需创建”的,不是表格初始化时一次性建好几百个控件。这样能大幅减少控件数量和内存占用。常见需求里,某个列的下拉框需要根据当前行的类型动态加载选项,createEditor 里的 index 可以帮你获取当前行的数据,从而动态填充。数据回写的链路也很关键:void setEditorData(QWidget *editor, const QModelIndex &index) const override { // 编辑器初始化:例如把模型里的值设置到 QSpinBox QSpinBox *spin = qobject_cast<QSpinBox*>(editor); if (spin) { spin->setValue(index.data(Qt::EditRole).toInt()); } } void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override { QSpinBox *spin = qobject_cast<QSpinBox*>(editor); if (spin) { model->setData(index, spin->value(), Qt::EditRole); } }很多人只写了 createEditor,忘记 setEditorData,编辑器打开后显示的数据就是空的;只写了 setEditorData 忘记 setModelData,用户改完数据模型没变化。这三个函数必须配套。2.4 editorEvent:在编辑器开启之前拦截事件editorEvent 是代理里最容易被忽略但实际很实用的函数。典型场景:一个“操作”列,不需要真正的编辑器,用户单击单元格里的按钮就应该触发业务逻辑。如果你只重写 paint 画了按钮样式,不处理 mouse 事件,点击就没反应。editorEvent 的流程大致是:鼠标或键盘事件先经过视图,视图把事件分发给当前代理,让代理有机会处理。代理收到事件后,可以用 option.rect 判断事件坐标是否落在某个区域,然后决定是启动编辑、切换状态、还是发业务信号。例如处理单元格内的一个“下载”按钮:bool editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override { if (event->type() == QEvent::MouseButtonRelease) { QMouseEvent *me = static_cast<QMouseEvent*>(event); if (option.rect.adjusted(0, 0, -10, 0).contains(me->pos())) { // 模拟一次数据更新,实际业务里可以发 signal 出来 model->setData(index, QVariant(true), Qt::UserRole + 3); return true; } } return QStyledItemDelegate::editorEvent(event, model, option, index); }这个函数处理完事件后,视图就不会再继续默认的编辑流程,所以返回 true 很重要。3. 实操:自己动手做一个多功能代理3.1 项目场景与需求原型我在那个数据监控面板里,表结构大概是:设备名称、运行状态、任务进度、最后更新时间、操作。设备名称是普通文本;运行状态要显示成红黄绿状态点加文字;任务进度要显示成进度条;操作列要放“查看”“重试”两个按钮。这个需求如果全靠默认代理,运行状态和进度条完全没法看,操作列更是只能靠编辑模式硬凑。所以我的做法是写一个继承自 QStyledItemDelegate 的通用代理类,在 paint 和 editorEvent 里按列索引分流处理。3.2 第一版:实现状态标签列状态列的核心是画一个圆点加文本。圆点的颜色根据数据值决定,文本内容仍然从标准 DisplayRole 拿。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); QStyledItemDelegate::paint(painter, opt, index); if (index.column() == StatusColumn) { int status = index.data(Qt::UserRole + 1).toInt(); QColor dotColor = statusColor(status); // 根据状态值返回颜色 QRect dotRect = opt.rect.adjusted(8, 0, -8, 0); QRect textRect = opt.rect.adjusted(28, 0, -8, 0); painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->setPen(Qt::NoPen); painter->setBrush(dotColor); int dotSize = 10; painter->drawEllipse(dotRect.x(), dotRect.center().y() - dotSize / 2, dotSize, dotSize); painter->setPen(opt.palette.text().color()); painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, index.data(Qt::DisplayRole).toString()); painter->restore(); } }这里用了 painter->save/restore 来保护绘制状态,避免影响后续单元格的绘制。颜色映射函数根据业务状态返回固定颜色,不会因为系统主题变化而乱掉。3.3 第二版:实现进度条列进度条列需要在单元格里画一个背景条和一个填充条。我会顺便加一个文本百分比,同时用 option.rect 计算内边距,保证任何列宽下都好看。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { // 先画基础背景 QStyleOptionViewItem opt = option; initStyleOption(&opt, index); QStyledItemDelegate::paint(painter, opt, index); if (index.column() == ProgressColumn) { double progress = index.data(Qt::UserRole + 2).toDouble(); progress = qBound(0.0, progress, 1.0); QRect progressRect = opt.rect.adjusted(4, 8, -4, -8); int x = progressRect.x(); int y = progressRect.y(); int w = progressRect.width(); int h = progressRect.height(); painter->save(); painter->setRenderHint(QPainter::Antialiasing); // 背景槽 painter->setBrush(opt.palette.midlight()); painter->setPen(Qt::NoPen); painter->drawRoundedRect(progressRect, h / 2.0, h / 2.0); // 进度填充 int fillW = qRound(w * progress); QRectF fillRect(x, y, fillW, h); painter->setBrush(QColor("#4C9BE8")); painter->drawRoundedRect(fillRect, h / 2.0, h / 2.0); // 百分数文本 painter->setPen(opt.palette.text().color()); painter->drawText(progressRect, Qt::AlignCenter, QString::number(progress * 100, 'f', 1) + QStringLiteral("%")); painter->restore(); } }做进度条代理时有个值得注意的点:不要直接 new 一个 QProgressBar 放在单元格上。在表格里成百上千行、不断滚动的情况下,为每个单元格塞控件会直接把界面卡死。绘制式进度条只是画几圈圆角矩形和文本,开销非常低。3.4 第三版:操作按钮列与点击处理操作列是两个按钮,在 paint 里分别计算两个按钮的几何区域,然后在 editorEvent 里根据鼠标点击的区域判断哪个按钮被按了。void PaintDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { if (index.column() != ActionColumn) { QStyledItemDelegate::paint(painter, option, index); return; } painter->save(); QRect area = option.rect.adjusted(4, 4, -4, -4); QRect viewBtnRect = area.adjusted(0, 0, -6, 0); QRect retryBtnRect = area.adjusted(viewBtnRect.width() + 6, 0, 0, 0); // 画“查看”按钮 drawButton(painter, viewBtnRect, QStringLiteral("查看"), option); // 画“重试”按钮 drawButton(painter, retryBtnRect, QStringLiteral("重试"), option); painter->restore(); }画按钮时我建议直接用 QStyle 的 drawControl,这样才能和系统风格保持一致性。最简单的做法是构造一个 QStyleOptionButton,然后调用 style()->drawControl(QStyle::CE_PushButton, &btnOption, painter, nullptr)。editorEvent 里的事件判断逻辑:bool PaintDelegate::editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override { if (index.column() == ActionColumn && event->type() == QEvent::MouseButtonRelease) { QMouseEvent *me = static_cast<QMouseEvent*>(event); QRect area = option.rect.adjusted(4, 4, -4, -4); QRect viewBtnRect = area.adjusted(0, 0, -6, 0); QRect retryBtnRect = area.adjusted(viewBtnRect.width() + 6, 0, 0, 0); if (viewBtnRect.contains(me->pos())) { // 发信号或直接更新模型,业务侧自行处理 emit viewClicked(index); return true; } else if (retryBtnRect.contains(me->pos())) { emit retryClicked(index); return true; } } return QStyledItemDelegate::editorEvent(event, model, option, index); }有个小坑是:如果只监听 MouseButtonPress,用户点击后界面会卡在“按下未松开”的状态,回掉逻辑又执行,容易造成重复触发。所以我一般监听 MouseButtonRelease,至少保证按下和松开是一个完整动作。3.5 把代理挂载到视图代理写好后,挂载方式有三种层次:给整张表设置:table->setItemDelegate(delegate);单独给某一列设置:table->setItemDelegateForColumn(操作列索引, delegate);给某个行或某个索引临时设置:用 QAbstractItemView 的信号配合动态替换代理。实际项目中我往往先做一个最通用的代理实例给整张表,然后在内部用 column 分流。如果业务复杂到不同列差异极大,再拆成多个代理类分别挂载,这样维护起来比较清爽。4. 性能、响应与样式细节4.1 绘制式代理与控件式编辑器的性能对比很多初学者会把“自定义编辑器”和“自定义绘制”混在一起,以为每个单元格都是控件。这是性能问题的根源。绘制式代理只在 paint 的时候算一次绘制指令,没有控件对象,内存占用几乎为零。编辑器是“需要编辑时才创建,编辑结束就销毁”的临时控件,只有正在编辑的那一个存在。所以在表格里放 1 万行数据,用自绘代理基本毫无压力;如果用“每行放一个 QComboBox 或 QProgressBar”的思路,1 万行控件会让界面直接没法看。也许有人会说:“那我不放 1 万行,只放 100 行不就行了吗?”但表格支持滚动,控件数量乘以可见行数,依然很耗资源,而且滚动时控件复用也会带来很多心智负担。4.2 确保刷新与异步数据更新代理和模型之间通过数据角色同步。当数据源变化时,模型需要发出 dataChanged 信号,视图收到后会重绘对应索引。常见误区是:业务线程更新了数据,但 UI 线程没收到通知,代理画出来的内容还是老数据。解决方式很简单:数据更新后,用 QMetaObject::invokeMethod 或信号槽机制回到 GUI 线程,调用模型实例的:emit model->dataChanged(index, index, {Qt::DisplayRole, Qt::UserRole + 1});如果你没指定角色,视图只会对默认角色重绘,自定义角色可能不刷新。为了保险,刷新时把涉及的自定义角色一并传进去。4.3 适配高分屏与主题切换自绘代理最容易在高分屏上翻车的是圆角矩形边缘发虚和按钮文字挤到边界外。解决办法是尽量使用 QStyle 的 layout 相关接口,以及绘制时统一除以屏幕设备像素比。另外一个隐蔽问题是:如果代理里写死了颜色,系统从浅色切到深色主题时,单元格会非常突兀。建议从 option.palette 里取颜色,例如背景色用 opt.palette.base(),文本色用 opt.palette.text(),只有业务上有明确含义的色彩(比如状态红绿黄)才用固定色值。5. 高频问题速查与避坑记录5.1 问题排查速查表症状可能原因解决办法单元格选中背景消失paint 里没有先调用基类先 QStyledItemDelegate::paint 画背景,再叠加内容编辑器打开后内容为空只写了 createEditor,没写 setEditorData在 setEditorData 里根据 index 初始化控件值编辑完数据没变化没写 setModelData在 setModelData 里把控件值写回模型点击自绘按钮没反应没重写 editorEvent,或只处理了 Press 事件重写 editorEvent,监听 Release,正确判断区域进度条文字总被选中背景遮住绘制顺序错误先画背景、再画内容,或 restore 前再绘制文本表格行高不够,画的东西被裁切sizeHint 返回尺寸不合理重写 sizeHint,根据内容高度返回合适值编辑器的弹出位置不对没重写 updateEditorGeometry根据 option.rect 设置编辑器 geometry同一列不同行显示格式又不一样代理里逻辑耦合过多根据行类型或数据角色分支,抽出策略函数主题切深色后文字看不清固定用黑色画笔使用 option.palette 中的文字色5.2 我踩过的几个小的坑第一,editorEvent 里误返回 true 导致原本正常的双击编辑被吞掉。如果你只是处理按钮单击,一定要在判定区域外返回基类实现,否则普通双击单元格就永远无法进入编辑状态。第二,sizeHint 的高度不能总是返回固定行高。有些列内容多需要多行显示,有些列又希望行紧凑一点。我可以根据当前索引动态计算高度,但要注意缓存计算结果,否则数据一多,滚动起来 sizeHint 计算会成为性能瓶颈。第三,paint 里大量使用字体测量或字符串处理时,建议提前计算并缓存。比如百分比文本,一千行表格每次滚动都要调用 size 计算文本宽度,开销不小。可以用 QFontMetrics 或缓存文本宽度,让滚动体验更顺滑。第四,如果同时使用代理绘制和 QSS 全局样式,部分样式属性可能会被代理覆盖。遇到这种情况,不要和样式表硬刚,直接在代理里统一处理,或者在代理里手动读取 QApplication::style()。第五个建议是,代理类尽量设计成无状态的,也就是不要在代理成员变量里保存“当前编辑行”“当前点击列”之类的临时状态。代理实例会被视图持有,跨行复用,成员变量一旦残留,下一行就出 bug。把需要的数据都从 index 和 option 里现取,宁可多写几行,也别踩状态残留的坑。目前这套混合代理已经稳定跑在监控面板里,后面我还准备扩展一下:再加一个“轻量级列表代理”复用到另一个下拉选择界面里,因为整个交互模式完全一致,只是显示和编辑逻辑不同。如果你也在做类似的表格自定义,建议把代理独立成通用类,多花一点时间把列索引、角色号、颜色方案都做成可配置参数,这样后续换需求时只需要改配置,不需要再动绘制逻辑。