深入理解Qt项渲染:QStyledItemDelegate与自定义角色实战
1. 默认项渲染的局限从一行朴素文本说起用过一段时间的 Qt 后你会发现一个很有意思的现象QListView 和 QTreeView 拆开来看单论显示一条数据这件事默认能力其实非常单薄。创建好 model、塞进去几十条字符串QListView 展示出来的是朴素到不能再朴素的纯文本行QTreeView 也不过是在前面多了一个展开箭头和缩进层级。我最初做 Qt 项目时也遇到过这种尴尬。产品需求是做一个设备列表左边 QListView 展示设备名称右边一个堆叠区域放设备详情。开发到一半交互同事提出了一个看起来很简单的要求每行设备名称前面加一个在线状态圆点设备离线时整行文字变灰。我第一反应是修改 data() 里的前景色但圆点怎么办难道用一个字符●拼进去字符在不同字体下渲染宽度不一样而且变色规则一复杂字符串拼接就乱成一锅粥。类同的场景你大概率也碰到过列表里要显示缩略图、状态徽章、进度条或者树节点要显示多种不同样式的项。这些需求的本质都是一句话让一个项item按照业务需要渲染而不是按 QListView/QTreeView 的默认规则渲染。而要达成这个目标必须先搞清楚 Qt Model/View 架构里 item 的渲染路径到底是怎么走的。1.1 QListView 默认到底渲染了什么先抛开自定义不谈看看默认情况下一个 item 要经历哪些阶段才能出现在屏幕上。整个链路大致是view 向 model 发起请求请把第 row 行第 column 列的数据给我。model 在 data() 里根据传入的 role角色返回数据比如 Qt::DisplayRole 对应文本Qt::DecorationRole 对应图标。view 拿到这些数据后交给 delegate代理去绘制。QListView 默认使用 QStyledItemDelegate。delegate 在 paint() 里根据 style option 和 model 数据绘制背景、文本和图标。所以默认渲染实际上是由QStyledItemDelegate 的默认 paint() 实现决定的。它只处理有限的几种 roleDisplayRole 画文本DecorationRole 画图标ForegroundRole 画文字颜色BackgroundRole 画背景FontRole 控制字体。一旦需求超出了这几种 role 的表达范围比如状态圆点星标评分进度条默认 delegate 就无能为力了。这个链条里的关键词是delegate。很多人一提到自定义项下意识就去改 model改 data() 返回富文本或者研究 QListView::setStyleSheet。不能说完全没用但路子都偏了。正确的切入点应该是要么换一个更会画的 delegate要么让 model 提供更丰富的角色数据。两条路都不冲突甚至可以配合使用。1.2 自定义的三种层次数据、渲染、交互面对自定义项这个需求我的经验是先问自己一个问题我到底想改什么只想改数据内容比如某一行要显示名称 描述两段文字或是显示状态枚举对应的中文文本。这种需求改 model 的 data() 就够了让 DisplayRole 返回拼接后的文本或者自定义一个角色返回结构化数据。想改显示外观比如加圆点、加进度条、画一个圆角卡片背景。这种需求必须动 delegate重写 paint() 和 sizeHint()。想改交互行为比如单击行内某个区域触发操作、双击打开编辑器、拖拽排序。这类需求同样在 delegate 里处理重写 editorEvent()、createEditor() 等函数。三层需求不是互斥的实际项目中经常是组合出现。我的设备列表最终方案就是model 里自定义了一个 DeviceStateRole 返回设备状态delegate 里根据这个 role 决定前景色和是否画圆点。数据和渲染各管各的后续加需求时改动面也小。记住一个原则model 只管有什么数据delegate 只管怎么画出这些数据。把显示逻辑写进 data() 里返回 HTML或者把数据解析逻辑写进 paint() 里短期看能跑长期改起来一定头疼。2. QStyledItemDelegate 驱动paint 与 sizeHint 才是关键既然 delegate 是关键那自然要先把它讲透。Qt 里有两个 delegate 基类QItemDelegate 和 QStyledItemDelegate。很多人会困惑选哪个这里给一个非常明确的建议新代码一律用 QStyledItemDelegate。原因有三QStyledItemDelegate 的绘制走的是 QStyle 系统也就是说它自动适配不同平台的风格Windows 上是 Windows 风格macOS 上是 macOS 风格。QStyledItemDelegate 支持在 paint 时读取 style sheet 中的某些样式设置QItemDelegate 不行。QStyledItemDelegate 处理 QStyleOptionViewItem 的方式比 QItemDelegate 更规范后者更像是一个遗留的简化版本。两者在多数场景下表现一致但 QStyledItemDelegate 更正统。下面的示例代码都是基于 QStyledItemDelegate 写的。2.1 重写 paint像画家一样从零开始画一项paint() 是 delegate 里出场率最高的函数。它的任务非常直白用 QPainter 把当前项画到指定矩形里。默认实现做的事不多但自己重写时很多细节不能漏。看一个最常见的例子给列表项添加左侧状态圆点和状态文字。假设 model 里通过自定义角色 StateRole 返回一个枚举0 代表离线1 代表在线。void StateDelegate::paint(QPainter *painter, const QStyleOptionViewItem option, const QModelIndex index) const { // 先拷贝一份 option这是所有重写 paint 的第一步 QStyleOptionViewItem opt option; initStyleOption(opt, index); // 保存画家状态画完恢复避免影响其他项的绘制 painter-save(); // 1. 绘制选中态/悬停态背景 // 如果你完全不画背景选中效果会消失这在列表里非常违和 const QWidget *widget opt.widget; QStyle *style widget ? widget-style() : QApplication::style(); style-drawPrimitive(QStyle::PE_PanelItemViewItem, opt, painter, widget); // 2. 计算内容区域给圆点留出左侧位置 QRect contentRect opt.rect.adjusted(12, 0, -8, 0); // 3. 根据状态画圆点 int state index.data(StateRole).toInt(); QColor dotColor (state 1) ? QColor(#2ecc71) : QColor(#95a5a6); painter-setPen(Qt::NoPen); painter-setBrush(dotColor); int dotRadius 4; QPointF dotCenter(contentRect.left() dotRadius 2, contentRect.center().y()); painter-drawEllipse(dotCenter, dotRadius, dotRadius); // 4. 处理文字注意文字要避开圆点区域 contentRect.adjust(dotRadius * 2 6, 0, 0, 0); if (opt.state QStyle::State_Selected) { painter-setPen(opt.palette.color(QPalette::HighlightedText)); } else { painter-setPen(opt.palette.color(QPalette::Text)); } painter-drawText(contentRect, Qt::AlignLeft | Qt::AlignVCenter, opt.text); painter-restore(); }这里有几个必须注意的细节。第一永远不要直接使用传入的 option先拷贝再调用 initStyleOption。因为 option 里存了选中状态、背景色、字体色等一堆信息initStyleOption(opt, index) 会根据 model 数据把这些字段填充好比如 DisplayRole 的文本会进 opt.textDecorationRole 的图标会进 opt.icon。手动取数据画也行但 initStyleOption 能省掉大量重复劳动。第二选中背景要靠 QStyle 来画。常见错误是自己画一个 fillRect 把底色涂掉然后发现选中高亮不见了或者鼠标悬停效果消失了。正确做法是调用 style-drawPrimitive(PE_PanelViewItem) 或者 drawControl(QStyle::CE_ItemViewItem)。前一种方案简洁后一种更接近默认行为但要注意别重复绘制文本。第三painter-save() 和 restore() 一定要成对。列表里一个项画完painter 的状态颜色、字体、画刷、变换必须复原。否则下一个项画出来的颜色、坐标变换就是这个项遗留的。这个问题在绘制大量项时非常隐蔽因为你在单个项上看到的颜色是对的翻到下一屏才花掉。2.2 sizeHint你画的每一笔都需要一个预订的矩形很多人只重写 paint()忘了 sizeHint()结果列表项高度永远保持默认值画到 rect 边界外的圆点被行遮挡文字显示不全。sizeHint() 的作用是告诉 view这一项需要多大多大的空间。view 会根据这个返回值计算行高、列宽或者在图标模式下计算网格间距。QSize StateDelegate::sizeHint(const QStyleOptionViewItem option, const QModelIndex index) const { QStyleOptionViewItem opt option; initStyleOption(opt, index); // 让默认实现算一遍文本尺寸 QSize size QStyledItemDelegate::sizeHint(opt, index); // 为圆点和间距留出额外空间 size.setWidth(size.width() 28); // 保证最小行高避免太矮 size.setHeight(qMax(size.height(), 28)); return size; }注意paint 里怎么画sizeHint 里就要怎么预留。这是最容易出问题的地方。很多人画了图标、徽章、进度条但 sizeHint 还返回默认尺寸。view 分配的区域不够paint 里又没做裁剪直观表现就是内容互相重叠、文字被截断、鼠标点击区域与视觉位置对不上。判断这两者是否一致有一个简单方法把 paint 里的 contentRect 计算逻辑和 sizeHint 里的宽度增量逻辑放在同一个函数里比如都从 index 取出状态计算出固定增量再分别用。改一处时另一处跟着改不容易遗漏。2.3 图标模式与网格布局LayoutMode 的配合当 QListView 设置成 IconMode 时项的尺寸计算方式跟 ListMode 完全不同宽高都要预留而且图标模式下 view 是按网格布局的。此时光靠 sizeHint 还不够还需要给 QListView 设置 gridSize或者在 delegate 里把 sizeHint 返回一个接近正方形的矩形让网格排布美观。// 在 delegate 的 sizeHint 里做统一处理 if (QListView *lv qobject_castQListView *(option.widget)) { if (lv-viewMode() QListView::IconMode) { // 图标模式留出图标区 标题区 状态区 return QSize(96, 96 24); } }这个分支判断在混合视图模式下特别有用一张表可以在不同视图里呈现完全不同的项外观而 delegate 只需要根据 option.widget 判断一下当前视图模式即可。3. 数据模型里的角色设计让 item 会说话delegate 解决了怎么画但画的素材必须由 model 提供。很多人只在 delegate 里写死显示逻辑用 row() 或列号硬编码数据来源这违背了 Model/View 的设计意图。正确的做法是通过自定义 role让 model 把准备好的数据递给 delegate。3.1 Roles 是 Model 和 View 之间的“暗号”Qt 内置的角色有一堆DisplayRole、DecorationRole、EditRole、ToolTipRole、ForegroundRole、FontRole、TextAlignmentRole、SizeHintRole 等。理论上是够用的但业务数据的表达能力往往超出这些范围。比如设备的在线状态、文件的下载进度、星标评分这些都不是文本或图标能完美表达的。解决办法是自定义 role。按惯例从 Qt::UserRole 开始递增。// 定义在公共头文件里model 和 delegate 都要用到 enum DeviceItemRole { DeviceNameRole Qt::UserRole 1, // 设备名称 DeviceStateRole Qt::UserRole 2, // 在线状态int DeviceProgressRole Qt::UserRole 3, // 升级进度0-100 DeviceIconRole Qt::UserRole 4, // 设备类型图标QPixmap };在 model 的 data() 函数里按 role 分支返回QVariant DeviceModel::data(const QModelIndex index, int role) const { if (!index.isValid()) return QVariant(); const DeviceRecord rec m_records.at(index.row()); switch (role) { case Qt::DisplayRole: // 列表默认显示名称注意这里不return让后续逻辑取到 return rec.name; case DeviceStateRole: return rec.online ? 1 : 0; case DeviceProgressRole: return rec.upgradeProgress; // 0-100 case Qt::ToolTipRole: // 笔尖提示很多人在列表里忘记tooltip其实ToolTipRole就是顺便返回的 return rec.name QStringLiteral(\n固件版本%1).arg(rec.version); default: break; } return QVariant(); }用自定义 role 的核心好处delegate 拿数据不依赖位置只看角色。不管是列还是行挪了位置data 返回的数据跟着 model 的 index 走delegate 代码一行都不用改。3.2 子类化 QAbstractItemModel 还是继承 QStringListModel这个选择题看起来简单实际很多人选错。如果只是给 QListView 的一个列表展示字符串数组直接用 QStringListModel 就够了甚至更省事的 QListWidget 也行。但一旦你要做的项需要多维度数据名称、状态、进度、图标再往 QStringListModel 里塞就强人所难了。此时应该子类化 QAbstractTableModel或者 QAbstractListModel。核心要重写的接口就四个理解起来并不难rowCount() / columnCount()告诉 view 有多少行多少列。data()按角色返回该项数据。index()为row, column, parent创建一个 QModelIndex。QAbstractListModel 里根据 row 构造一列索引即可。parent()列表/表模型返回无效 QModelIndex树模型才需要返回真正的父节点。如果只是列表QAbstractListModel 已经帮你实现了 index() 和 parent()。真正需要你操心的只有 rowCount、data 和几个数据变更通知函数。3.3 动态增删时 beginInsertRows 与 endInsertRows 的用法自定义 model 里最容易被踩的坑不是返回数据出错而是数据变了但 view 不知道该变。现象是运行中往 model 里 insert 了一条记录列表却纹丝不动直到你鼠标滚轮滚动一下才刷新出来。或者更诡异你看代码明明调用了 model 的 insert但它完全不生效。原因是 view 依赖 model 发送的结构变更信号。凡是在已有数据结构里增加行、删除行都必须包裹在成对的 begin/end 函数里void DeviceModel::addDevice(const DeviceRecord rec) { int newRow m_records.size(); // 必须在这两个调用之间修改底层数据 beginInsertRows(QModelIndex(), newRow, newRow); m_records.append(rec); endInsertRows(); // end 会触发 rowsInserted 信号view 收到后刷新 }修改已有数据但行数不变时则发送 dataChanged。比如设备在线状态翻转void DeviceModel::setOnline(int row, bool online) { m_records[row].online online; // 注意只通知指定范围不要每次全表通知否则大列表会很卡 QModelIndex idx index(row, 0); emit dataChanged(idx, idx, {DeviceStateRole, Qt::DisplayRole}); }很多人把 dataChanged 的第三个参数忽略或者传 QVector ()。这会让 view 认为所有角色都变了被迫全部刷新。推荐只传变化的角色列表尤其当列表项很复杂、绘制开销大时这个细节能明显改善滚动性能。4. QTreeView 的树状项自定义层级与展开的那些事QTreeView 的自定义项与 QListView 有共同思路但也有自己的特殊问题层级缩进、展开箭头、列与项的关系。这些如果不处理树形结构显示出来总有一种原生态的糙感。4.1 树节点的缩进与图标indentation 与 DecorationRole树的缩进默认由视图中 setIndentation(int pixels) 控制。这个像素值对 UI 影响很大缩进太小树层次感弱缩进太大宽屏显示器上子节点要跑到屏幕外。经验做法是根据字体大小动态设置QFontMetrics fm(ui-treeView-font()); int indent fm.horizontalAdvance(QLatin1Char( )) * 4; ui-treeView-setIndentation(indent);如果你用的是 QSS样式表则有更直接的缩进控制字段。不过我更推荐在代码里设置因为 QSS 的缩进属性在不同平台风格下表现不完全一致。树节点的图标跟列表的思路一样在 model 的 data() 里返回 DecorationRoleview 自动取用case Qt::DecorationRole: if (rec.isDir()) return m_folderIcon; else return m_fileIcon;一个额外的注意点树结构下 DecorationRole 的图标最好统一尺寸比如 16x16否则奇形怪状的图标会让整棵树看起来非常杂乱。可以在加载图标时用一个 QIcon 实例从同一张图标源里按不同 size 提供。4.2 分支箭头通过 QProxyStyle 钩入绘制树节点左侧的展开/折叠箭头默认由 QStyle 的 drawPrimitive 处理delegate 的 paint 并不直接接管这块。如果你对箭头样式有要求比如颜色、粗细、是否自适应 DPI直接改 delegate 很难。解决思路是用 QProxyStyle 拦截 primative 绘制class TreeBranchStyle : public QProxyStyle { public: using QProxyStyle::QProxyStyle; void drawPrimitive(PrimitiveElement element, const QStyleOption *option, QPainter *painter, const QWidget *widget nullptr) const override { if (element QStyle::PE_IndicatorBranch) { // 画自定义分支箭头 drawCustomBranch(option, painter); } else { QProxyStyle::drawPrimitive(element, option, painter, widget); } } };在自定义箭头里最常画的两种图形直角形L折线和三角箭头。三角箭头可以拆成选中/展开时向下折叠时向右同时跟随 hover 状态改变颜色。起点就是 option-rect 的中心把三角路径用 QPainterPath 构造出来即可。注意QProxyStyle 的实例需要设置给 QApplication而不是 view 单独设置。qApp-setStyle(new TreeBranchStyle);因为 QStyle 全局只有一个把它挂到 app 上才能对所有 QTreeView 生效。如果只想影响某一个 tree不推荐用这个方案可以给该 tree 单独安装一个 QStyledItemDelegate但那样就绕开了系统默认箭头绘制实现成本反而高。4.3 列与 delegate 的映射setItemDelegateForColumn多列树的灵活度比单列树高得多。比如第一列显示设备名称和状态第二列显示版本号你不想让第二列也画圆点怎么办QTreeView 提供按列绑定不同 delegate 的接口ui-treeView-setItemDelegateForColumn(0, m_deviceDelegate); // 列0用复杂delegate ui-treeView-setItemDelegateForColumn(1, m_versionDelegate); // 列1只画版本文本 ui-treeView-setItemDelegateForColumn(2, m_progressDelegate); // 列2画进度条这是个非常强大的机制。树形结构里不同的项行有不同数据类型按列拆分 delegate 比在同一个 paint 里靠列号 if-else 要清晰得多。另外一个使用小技巧QTreeView 默认显示的是多列数据但列头header会占一块空间。如果你不想显示列头可以直接ui-treeView-header()-setVisible(false);隐藏列头后 QTreeView 就是一个纯粹的多列列表这种表达在设备管理类界面里很常用——第一列主信息、第二列备注或状态既优雅又不失层级。5. 实测中踩过的坑从图标错位到编辑失焦最后这部分我把自己在多个项目里实际踩过的坑汇总一下都是常规文档里不太会写明但实际操作时躲不开的问题。5.1 图标和文字错位DecorationRole 与 display 的 rect 重叠第一个坑是图标文字区域重叠。新手自定义 delegate 时通常会这样画QRect iconRect opt.rect; // 不对 iconRect.setWidth(20); painter-drawPixmap(iconRect, icon); QRect textRect opt.rect.adjusted(20, 0, 0, 0); painter-drawText(textRect, ...);看起来没问题但默认的 opt.rect 起点就是项矩形的最左边而 view 本身可能已根据 indentation 为图标预留了空间。在 QTreeView 里如果 indentation 不小且你的图标画在 rect 最左侧图标就会和缩进空白叠加看起来图标偏左或者文字和图标贴在一起。标准做法是使用 opt.rect 的左侧偏移或更稳妥地在 paint 开头调用 initStyleOption 后利用 opt.icon 和 opt.features 信息让 style 自己画图标然后把文字区域按实际剩余区域计算。QRect textRect style-subElementRect(QStyle::SE_ItemViewItemText, opt, widget);这个 subElementRect 返回的矩形是 style 认为文字应该放这里的区域。拿它做基础再 adjust图标不会乱跑。5.2 编辑项时编辑器一闪而过自定义 delegate 常常包含 createEditor、setEditorData、setModelData这个方法本身不难但编辑器打开就关闭是高频 Bug。原因通常是你的 paint 里把项区域画得太小鼠标点击时 view 认为点击区域在项外自动取消了编辑。或者 editorEvent 和 eventFilter 配合不当view 的鼠标事件被误判为外部点击。排查顺序先看 sizeHint 是否足够大再看 editorEvent 是否返回了不该返回的 true最后检查 eventFilter 是否在编辑器尚未完全弹出时就被调用。一个直观自测在 createEditor 里 qDebug() 打印返回的编辑器指针如果 createEditor 被调用但 createEditor 返回后编辑器立即被关闭多半是 eventFilter 或焦点问题如果 createEditor 根本没被调用检查 edit triggers 是否为双击/键盘触发。5.3 数据改了但界面没刷新或者刷新了不该刷的地方这个坑在自定义 model 里非常常见。前面我说过结构变化要用 beginInsertRows/endInsertRows数据变化用 dataChanged。但这两个信号的边界容易被混淆你往 model 里 insertRow 后必须用 beginInsertRows/endInsertRows否则 view 不感知行数变化。你只修改一行里的一个字段时用 dataChanged 通知最小范围不要调用 reset()。reset() 的伤害极大它会让 view 丢失选中状态、收起展开节点、滚动位置复位用户体验直接崩坏。我曾经在一个项目里为了省事每次状态刷新都调 dataChanged 时传了整个 table 范围结果只是改了一行的状态圆点几百行全部重绘滚动时肉眼可见的卡。后来改成只通知受影响的行卡顿症状直接消失。5.4 delegate 拿不到正确的主题色最后一个坑和 theme/stylesheet 有关。如果你在 delegate 里写死了背景色或文字颜色当用户切换系统深色模式时列表项要么黑底黑字要么白底白字。解决策是不要写死颜色尽量从 opt.palette 里取QColor textColor opt.palette.color(QPalette::Text); QColor selectedText opt.palette.color(QPalette::HighlightedText);如果确实需要业务状态色比如在线绿色、离线灰色这属于业务范畴可以从主题配置文件里取避免直接写 #2ecc71 这类常量。千万不要用QPalette::WindowText代替 Text 画普通文本我在树形自定义项里试过一次选中状态下前景色完全错乱排查了半天才意识到是取错了角色。6. 完整示例一个带状态圆点的设备树把所有思路串起来的完整示例是一个设备树树的每一行是第一列设备名称在线状态圆点第二列是升级进度条第三列是备注文本。这个例子覆盖了本文提到的大部分技术点。6.1 模型定义与实现class DeviceTreeModel : public QAbstractItemModel { Q_OBJECT public: enum Role { DeviceNameRole Qt::UserRole 1, DeviceStateRole Qt::UserRole 2, DeviceProgressRole Qt::UserRole 3 }; // ... 见下文实现 };QVariant DeviceTreeModel::data(const QModelIndex index, int role) const { if (!index.isValid() || index.parent().isValid()) return QVariant(); DeviceRecord rec records.at(index.row()); if (index.column() 0) { if (role Qt::DisplayRole) return rec.name; if (role DeviceStateRole) return rec.online ? 1 : 0; } else if (index.column() 1) { if (role DeviceProgressRole) return rec.progress; if (role Qt::DisplayRole) return QStringLiteral(%1%).arg(rec.progress); } else if (index.column() 2) { if (role Qt::DisplayRole) return rec.note; } return QVariant(); }注意这里的树 model 用了 QAbstractItemModel所以要额外实现 index() 和 parent()。如果只是单层列表用 QAbstractListModel 更省事但多列树场景 QAbstractItemModel 更合适。6.2 Delegate 的绘制分工状态列用第 2 节那个 StateDelegate进度列单独写一个进度条 delegatevoid ProgressDelegate::paint(QPainter *painter, const QStyleOptionViewItem option, const QModelIndex index) const { QStyleOptionViewItem opt option; initStyleOption(opt, index); int progress index.data(DeviceTreeModel::DeviceProgressRole).toInt(); progress qBound(0, progress, 100); painter-save(); // 画背景 QRect barRect opt.rect.adjusted(4, 8, -4, -8); painter-setPen(Qt::NoPen); painter-setBrush(opt.palette.midlight()); painter-drawRoundedRect(barRect, 4, 4); // 画进度部分 QRect fillRect barRect; fillRect.setWidth(barRect.width() * progress / 100); painter-setBrush(opt.palette.highlight()); painter-drawRoundedRect(fillRect, 4, 4); // 画百分比文本 painter-setPen(opt.palette.text()); painter-drawText(opt.rect, Qt::AlignCenter, QStringLiteral(%1%).arg(progress)); painter-restore(); }虽然 QProgressBar 有自己的自绘逻辑但是放进 delegate 里手绘进度条的好处是无需嵌套 widget列表项可以吃 view 的回收机制大量项滚动时不卡。这个思路可以延伸到很多一列里放控件的需求比如开关按钮、滑条、星级只要能画出来的理论上都可以用 delegate 实现没必要真的去 new 控件。6.3 挂载到 QTreeViewauto *model new DeviceTreeModel(this); auto *stateDelegate new StateDelegate(this); auto *progressDelegate new ProgressDelegate(this); auto *noteDelegate new QStyledItemDelegate(this); // 默认即可 ui-treeView-setModel(model); ui-treeView-setItemDelegateForColumn(0, stateDelegate); ui-treeView-setItemDelegateForColumn(1, progressDelegate); ui-treeView-setItemDelegateForColumn(2, noteDelegate); ui-treeView-header()-setStretchLastSection(true); ui-treeView-setSelectionBehavior(QAbstractItemView::SelectItems);这份代码跑起来就是一个能直接演示的项自定义实例覆盖 QTreeView 多列、角色数据、自定义 delegate、分列 delegate 挂载几条主线。7. 性能与滚动流畅度自定义项最容易忽略的瓶颈自定义项做出来了功能也对了但列表一长滚动流畅度直线下降。这种情况太常见了问题几乎都出在 paint 和 model 的暗中开销里。7.1 绘制开销save/restore 与 painter 状态切换在大量项滚动时delegate 的 paint 执行频率极高。几个最常见的性能杀手painter-setPen、setBrush 频繁切换颜色和画刷。save/restore 没有减少只做了无谓的状态保存。在 paint 里进行了中文字符串拼接、字体度量计算。优化方向静态局部缓存字体度量把耗时不高的字面量提取为静态 const避免在 paint 里做 qobject_cast 判断视图类型。// 典型优化缓存计算过一次的字符串宽度 static const int prefixWidth QFontMetrics(defaultFont).horizontalAdvance( );更重要的手段是减少每项绘图指令数。绘制圆点只需要 drawEllipse drawText 两条指令绘制进度条则 drawRoundedRect x2 drawText。如果不需要圆角用 drawRect 比 drawRoundedRect 快一点虽然差异不大但在几千行滚动时累计明显。7.2 Model 端的查询效率data() 内不要做重活data() 的调用频率比 paint 还高。一次完整的列表绘制view 会为每个可见项多次调用 data()DisplayRole、DecorationRole、ToolTipRole、SizeHintRole 等。我见过有人把 data() 写成这样QVariant DeviceModel::data(const QModelIndex index, int role) const { // 每次调用都从数据库重新查一次记录 auto rec loadFromDatabase(index.row()); ... }这当然是极端反例但 data() 里做复杂的字符串格式化、动态资源加载也不少见。比如case Qt::DecorationRole: return QIcon(QString(:/icons/%1.png).arg(rec.type)); // 每次创建QIconQIcon 构造需要解析文件、读取位图频繁调用开销巨大。正确做法是把 QIcon 缓存起来或者用 updateFile 的方式一次加载。所以 data() 的基本准则就一句话只返回你预先准备好的、内存里现成的数据。实在需要计算也要在批量插入/刷新时提前算好而不是 data() 被调用时现算。7.3 用 QListView::setLayoutMode 配合延迟布局如果列表项尺寸差异很大view 每次布局都要全部 sizeHint 一遍。QListView 提供三个 LayoutModeSinglePass一次性布局所有项简单但卡顿。Batched分批布局滚动时逐步补充体验稍好。Delayed先布局可见区域闲置时再补全适合超大列表。ui-listView-setLayoutMode(QListView::Batched); ui-listView-setBatchSize(50);不过说实话对正常开发来说只要 sizeHint 别做太重的逻辑主流设备上卡顿感已经很不明显。性能问题真正严重的往往还是出现在动态调整布局这一类场景里到那时才需要把 layout 模式换成较激进的分批策略。7.4 测试滚动性能的一个笨办法如果怀疑 delegate 拖慢了滚动用 Qt 的性能分析器固然好但我常用的办法更粗暴临时在 paint 里加一个计数器跑 200 行滚动后看 count 值异常大。再对比一行代码都不画时的 count。数值差异一变瓶颈立刻定位到具体绘图分支。我实际在一台办公机上测过默认 QStyledItemDelegate 滚动 100 行时 paint 调用了大概 1200 多次。如果单次 paint 多了哪怕 1 毫秒的额外开销滚动就会开始掉帧。这也是为什么我一直强调paint 里能省则省——别在绘图路径里做不该做的事。8. 从不单纯的材质引入网络图片与异步滚动的联动业务里列表项常常要显示网络头像或设备缩略图。这个需求一出现item 自定义就多了一个维度数据何时就绪、图片如何更新。8.1 异步加载与 dataChanged 的回刷最简单方案model 里维护一个图片加载状态字段。发起异步请求时字段为 0请求完成后字段改成 1并把 QImage 存入 map最后 emit dataChanged。map 的 key 用行号。networkMgr-get(url, [](const QImage img) { m_images[row] img; QModelIndex idx index(row, 0); emit dataChanged(idx, idx, {Qt::DecorationRole}); });delegate 绘制时一旦发现 DecorationRole 返回的图片是 valid 的就画不是则先画一个占位灰色方块。这套逻辑的难点不在实现而在于要防止行号错乱——如果 model 在图片返回后插入了新数据缓存的行号可能指向别的记录。更稳妥的方案是用一个独立于行号的 key比如设备 ID来做图片缓存 key。data() 里通过记录 ID 查 map而不是通过行号。这样即使列表增删图片也不会张冠李戴。8.2 滚动时图片闪烁的成因异步加载常见的问题还有滚动时图片闪烁。原因是加载完成的 dataChanged 通知了 viewview 重绘该项而该项恰好在滚动中处于可见状态由于图片是异步加载的重绘时图片已经在了正常不闪。但如果有先显示占位图再切真实图的逻辑且占位图是一次性 png每次重绘都重新从磁盘读取 png就会出现极短暂的卡顿和闪烁。优化方式是用一个全局缓存的占位 QPixmap只创建一次paint 里直接取用。static const QPixmap placeholder QPixmap(:/images/placeholder.svg).pixmap(64, 64);注意是 static 局部不是每个项创建。8.3 Hirarchical list 中异步数据的活学活用树形结构里这个异步加载的场景会更复杂一点——父节点加在、子节点数据未到时通常不渲染展开箭头。常见实现是 data() 返回 hasChildren 标志true 时显示箭头false 则不显示。异步加载子节点完成后发送 layoutChanged 或 beginResetModel 通知整个树结构变化。但 layoutChanged 和 reset 一样都会让树的展开状态丢失除非你抢救当前展开路径。Qt 对这个问题有专门解法在发 layoutChanged 前保存展开路径之后用 expandRecursively 或 restoreExpanded 恢复。搞不定时更简单的方式是每次加载完子节点发 dataChanged 而不是 layoutChanged这样展开状态保住了缺陷是新增的节点可能需要用户再次点击展开才看得到体验略有妥协。9. 最后再分享几个实际项目中积累的小技巧既然做了这么多自定义项收尾就讲几个我实际用下来很顺手的操作方法。让 model 和 delegate 共享一个公共结构体。定义返回 struct 的自定义角色固然可行但通过 QVariant 传 struct 需要注册类型用起来繁琐。更推荐的方式是 model 本身提供一个按行取原始记录的方法delegate 内通过 index 拿到 model 指针转成具体类型。struct DeviceRecord { QString name; bool online false; int progress 0; QString note; };delegate 里画圆形时取不到记录可以先取 modelDeviceTreeModel *m static_castDeviceTreeModel *(index.model()); DeviceRecord rec m-recordAtRow(index.row());这比通过 QVariant 反复打包拆包强得多。Delegate 的 update 条件要细。我在 delegate 里经常通过检查 option.state 判断是否处于选中态/悬停态从而改变绘制颜色。但如果某项的状态变化没有触发 dataChanged 或者 view 重绘鼠标悬停效果就不出来。此时可以强制 view 在 mouseMoveEvent 时更新整个 viewportui-treeView-viewport()-update();注意别无条件每帧更新只在鼠标位置发生变化时才update否则又会导致性能退化。用 edit triggers 控制交互细节。自定义 delegate 常会遇到单击选中、双击编辑的场景。QAbstractItemView 提供的 edit trigger 枚举里比较常见的是 DoubleClicked | EditKeyPressed | SelectedClicked这个搭配可以配合你在 editorEvent 里的判断让单击行内某一小块区域触发编辑。ui-treeView-setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);有时候单击就打开下拉框的需求来自用户习惯也不要硬改 triggers在 delegate 的 editorEvent 里通过 option.rect 判断点击区域才是更精细的控制面。回头看我第一版设备列表那时候还是用 QListWidget setItemWidget 硬塞自定义 widget 进去实现的200 行数据时滚动卡到没法看。后来彻底改成 QListView QAbstractListModel QStyledItemDelegate 组合再在 delegate paint 里画圆点、画状态色整个交互变得很顺滑。这大概就是项自定义这条路最值得花时间的地方你掌握的不只是几个 API而是理解了 Qt 视图层数据-渲染的协作方式。如果你现在遇到怎么让某个 item 显示得更丰富一点的问题先别急着搜样式表技巧。把 model 的角色梳理好把 delegate 的 paint 和 sizeHint 对齐然后老老实实把 expand/select 这些交互细节补全。稳妥的方案做完列表的质感和扩展性都会上一个台阶。