戰(zhàn):自定義QHeaderView實(shí)現(xiàn)與避坑指南)
簡介這份資源面向使用 Qt 進(jìn)行桌面開發(fā)的程序員聚焦 QTableView 多級表頭的實(shí)現(xiàn)難題。在 Qt 中QTableView 的水平表頭本質(zhì)是 QHeaderView 實(shí)例標(biāo)準(zhǔn)控件只支持單層表頭難以組織復(fù)雜數(shù)據(jù)結(jié)構(gòu)。資源通過繼承 QHeaderView 并重寫 paintSection、sectionSizeFromContents 等關(guān)鍵方法配合 QPainter 逐層繪制表頭文字與分隔線再將自定義表頭賦值給 horizontalHeader 或 verticalHeader從而構(gòu)建多級表頭效果。壓縮包共 12 個(gè)文件約 17KB包含 4 個(gè) cpp 源文件、4 個(gè) h 頭文件、2 個(gè) ui 界面文件及 pro 工程文件覆蓋多級表頭類、委托類與初始化示例等模塊結(jié)構(gòu)緊湊便于直接參考。目前已有 841 人學(xué)習(xí)下載。讀者可從中掌握表頭繪制、尺寸計(jì)算、模型同步與視圖刷新等排錯(cuò)思路理解 QAbstractItemModel 與 QHeaderView 的交互方式為構(gòu)建復(fù)雜表格界面提供可復(fù)用的實(shí)現(xiàn)參考。1. 多級表頭不是 QTableView 的原生能力但業(yè)務(wù)系統(tǒng)繞不開它做過工業(yè)上位機(jī)、MES 客戶端或者醫(yī)療設(shè)備配置界面的人大概率都遇到過這種需求一張表要按「設(shè)備 / 通道 / 采樣點(diǎn)」三層分組或者「年 / 季度 / 月」這種時(shí)間維度嵌套。QTableView 配 QStandardItemModel 做單層表頭很舒服一行setHorizontalHeaderLabels就完事可一旦要合并單元格式的多級表頭官方 API 里翻不到現(xiàn)成方法。這不是 Qt 的缺陷而是 Model/View 架構(gòu)把表頭抽象成了 QHeaderView它只負(fù)責(zé)一維的行列標(biāo)簽二維嵌套得自己動手。我最早在一個(gè)模擬項(xiàng)目里踩過這個(gè)坑客戶要求表格頂部顯示兩層分組第一層是「溫度 / 濕度 / 壓力」第二層是各自的「當(dāng)前值 / 上限 / 下限」。當(dāng)時(shí)圖省事直接拿兩個(gè) QTableView 上下拼結(jié)果列寬對不齊、滾動不同步、排序全亂套被測試打回來三次。后來老老實(shí)實(shí)繼承 QHeaderView 重寫 paintSection才算把這塊骨頭啃下來。這篇筆記就按我實(shí)際落地的路徑講先講清楚為什么不能靠 QStandardItemModel 硬撐再給一套可復(fù)現(xiàn)的自定義 QHeaderView 方案最后把參數(shù)、坑和驗(yàn)證方法攤開。適合已經(jīng)會用 QTableView 但被多級表頭卡住的 Qt 開發(fā)者新手跟著代碼能跑通熟手能直接拿去改。2. 為什么 QStandardItemModel 撐不起多級表頭2.1 表頭在 Qt 里的真實(shí)職責(zé)邊界要理解多級表頭為什么得自己寫先得把 Qt 里「表頭」這個(gè)角色的邊界劃清楚。QTableView 本身只負(fù)責(zé)數(shù)據(jù)區(qū)域的繪制和交互行列表頭由 QHeaderView 獨(dú)立管理。QHeaderView 的模型接口是QAbstractItemModel的 headerData 部分它拿到的只有 section第幾列、orientation水平還是垂直、role顯示文本、對齊、顏色等。換句話說表頭在框架眼里是一串一維的標(biāo)簽每個(gè) section 對應(yīng)一列沒有「父列」「子列」的概念。QStandardItemModel 的setHorizontalHeaderItem也好setHeaderData也好都只是往這個(gè)一維結(jié)構(gòu)里塞數(shù)據(jù)。你沒法告訴它「第 0 列和第 1 列屬于同一個(gè)父分組」因?yàn)?headerData 的返回值是 QVariant不是樹形結(jié)構(gòu)。有人嘗試用setSpan在表頭上合并單元格但 setSpan 作用在 QTableView 的 viewport 上對 QHeaderView 無效這是兩套繪制體系。所以結(jié)論很直接多級表頭不是「配置一下就能開」的功能它要求你接管 QHeaderView 的繪制邏輯自己維護(hù)一份層級關(guān)系再在 paintSection 里按層級算矩形、畫文字和分隔線。這不是繞遠(yuǎn)路而是唯一穩(wěn)的路。2.2 三種常見方案的取舍對比在動手寫代碼前我把當(dāng)時(shí)能想到的方案列了個(gè)表實(shí)際對比下來差距很明顯。方案實(shí)現(xiàn)方式優(yōu)點(diǎn)致命問題雙 QTableView 拼接上下兩個(gè) view上面當(dāng)表頭不用重寫繪制列寬不同步、滾動錯(cuò)位、排序失效QStandardItemModel setSpan在數(shù)據(jù)區(qū)模擬表頭代碼量少表頭區(qū)域無法合并滾動時(shí)錯(cuò)位自定義 QHeaderView繼承并重寫 paintSection列寬同步、滾動一致、可排序需要自己算層級矩形雙 view 方案我實(shí)測過橫向滾動條一動上下兩層的列偏移能差出十幾個(gè)像素因?yàn)閮蓚€(gè) view 的 viewport 寬度計(jì)算是獨(dú)立的。setSpan 方案更離譜表頭根本不在 viewport 的 span 范圍內(nèi)。自定義 QHeaderView 雖然要多寫一百多行但它是唯一能保證「表頭和數(shù)據(jù)區(qū)共用同一套列寬、同一套滾動偏移」的做法。選型上沒什么好猶豫的直接走第三條路。2.3 自定義 QHeaderView 的最小骨架先給一個(gè)能跑起來的最小骨架把繼承關(guān)系和關(guān)鍵虛函數(shù)占住位。這段代碼不畫多級只保證替換掉默認(rèn)表頭后表格還能正常顯示作為后續(xù)擴(kuò)展的基線。// MultiLevelHeaderView.h #pragma once #include QHeaderView #include QVector class MultiLevelHeaderView : public QHeaderView { Q_OBJECT public: explicit MultiLevelHeaderView(Qt::Orientation orientation, QWidget *parent nullptr); protected: // 核心繪制入口所有層級都從這里畫出去 void paintSection(QPainter *painter, const QRect rect, int logicalIndex) const override; // 告訴布局系統(tǒng)表頭需要多高 QSize sizeHint() const override; private: // 層級定義每層存該層每個(gè)葉子列的跨度 QVectorQVectorint m_levelSpans; int m_levelCount 1; };// MultiLevelHeaderView.cpp #include MultiLevelHeaderView.h #include QPainter MultiLevelHeaderView::MultiLevelHeaderView(Qt::Orientation orientation, QWidget *parent) : QHeaderView(orientation, parent) { // 關(guān)閉默認(rèn)的點(diǎn)擊高亮避免和自定義繪制打架 setSectionsClickable(true); setHighlightSections(false); } void MultiLevelHeaderView::paintSection(QPainter *painter, const QRect rect, int logicalIndex) const { // 基線版本先調(diào)用父類保證單層表頭正常 QHeaderView::paintSection(painter, rect, logicalIndex); } QSize MultiLevelHeaderView::sizeHint() const { // 每層按 28px 估算后續(xù)按字體實(shí)際高度調(diào)整 return QSize(QHeaderView::sizeHint().width(), m_levelCount * 28); }這段骨架的關(guān)鍵點(diǎn)有三個(gè)。第一paintSection是唯一需要重寫的繪制入口Qt 會為每個(gè)可見 section 調(diào)用它你在這里決定畫什么。第二sizeHint必須返回足夠的高度否則多出來的層級會被裁掉這是新手最容易翻車的地方——畫了但看不見。第三setHighlightSections(false)要關(guān)掉默認(rèn)的懸停高亮?xí)采w你畫的背景色導(dǎo)致層級分隔線時(shí)有時(shí)無。參數(shù)上m_levelCount控制表頭總層數(shù)m_levelSpans后面會用來存每層的合并跨度?;€版本先保證能替換默認(rèn)表頭且表格不崩下一步再往里填層級數(shù)據(jù)。3. 把層級數(shù)據(jù)喂給自定義表頭3.1 用跨度數(shù)組描述多級結(jié)構(gòu)多級表頭的本質(zhì)是一棵樹但繪制時(shí)我們只需要知道「每一層、每個(gè)葉子列屬于哪個(gè)分組、這個(gè)分組跨幾列」。用跨度數(shù)組比用樹節(jié)點(diǎn)更省事因?yàn)?paintSection 是按 logicalIndex 逐個(gè)調(diào)用的扁平結(jié)構(gòu)查起來快。假設(shè)有 6 個(gè)葉子列兩層表頭第一層兩個(gè)分組各跨 3 列第二層就是 6 個(gè)獨(dú)立列??缍葦?shù)組這樣表示// 第一層兩個(gè)分組各跨 3 列 // 第二層6 個(gè)葉子各跨 1 列 QVectorQVectorint spans { {3, 3}, // 第 0 層 {1, 1, 1, 1, 1, 1} // 第 1 層 };再配一份每層的顯示文本索引和跨度數(shù)組對應(yīng)QVectorQVectorQString labels { {溫度組, 濕度組}, {當(dāng)前值, 上限, 下限, 當(dāng)前值, 上限, 下限} };這里有個(gè)容易搞混的點(diǎn)跨度數(shù)組的每一層長度不一定相同。第 0 層只有 2 個(gè)元素第 1 層有 6 個(gè)。繪制時(shí)不能拿 logicalIndex 直接去索引第 0 層得先算出這個(gè)葉子列在第 0 層落在哪個(gè)分組里。我一般寫個(gè)輔助函數(shù)做映射避免在 paintSection 里塞一堆循環(huán)。3.2 從葉子列反查每層分組索引反查邏輯是從最底層往上推每層用累加跨度確定當(dāng)前列屬于哪個(gè)分組。下面這個(gè)函數(shù)返回一個(gè)數(shù)組第 i 個(gè)元素是 logicalIndex 在第 i 層所屬的分組下標(biāo)。// 輸入spans 跨度數(shù)組logicalIndex 葉子列號 // 輸出每層的分組下標(biāo)長度等于層數(shù) QVectorint MultiLevelHeaderView::groupIndexPerLevel( const QVectorQVectorint spans, int logicalIndex) const { QVectorint result; int remaining logicalIndex; // 從最底層往上逐層定位 for (int level spans.size() - 1; level 0; --level) { int acc 0; int group 0; for (int i 0; i spans[level].size(); i) { acc spans[level][i]; if (remaining acc) { group i; break; } } // 換算到上一層的列偏移 int offset 0; for (int i 0; i group; i) { offset spans[level][i]; } remaining offset; result.prepend(group); } return result; }這個(gè)函數(shù)的參數(shù)說明spans是前面定義的跨度數(shù)組logicalIndex是當(dāng)前正在繪制的葉子列號。返回值result的第 0 個(gè)元素對應(yīng)最頂層分組最后一個(gè)元素對應(yīng)最底層。邏輯上它做了兩件事先在當(dāng)前層用累加和找到分組再把「當(dāng)前列在該分組內(nèi)的偏移」換算成上一層需要定位的列號。這樣逐層往上每層都能拿到正確的分組下標(biāo)。有個(gè)邊界要注意如果 spans 某一層的累加和和葉子列總數(shù)對不上remaining 會越界畫出來的分組會錯(cuò)位。我一般會在設(shè)置 spans 時(shí)加一句斷言assert(累加和 columnCount())早發(fā)現(xiàn)比晚調(diào)試強(qiáng)。3.3 在 paintSection 里按層畫矩形拿到每層分組下標(biāo)后繪制就順了對每一層算出該分組的起始列和結(jié)束列把它們的 section 位置合并成一個(gè)矩形畫背景、邊框和文字。void MultiLevelHeaderView::paintSection(QPainter *painter, const QRect rect, int logicalIndex) const { if (m_levelSpans.isEmpty()) { QHeaderView::paintSection(painter, rect, logicalIndex); return; } QVectorint groups groupIndexPerLevel(m_levelSpans, logicalIndex); int levelHeight height() / m_levelSpans.size(); for (int level 0; level m_levelSpans.size(); level) { int group groups[level]; // 算該分組覆蓋的列范圍 int startCol 0; for (int i 0; i group; i) { startCol m_levelSpans[level][i]; } int endCol startCol m_levelSpans[level][group] - 1; // 用 sectionViewportPosition 拿列在視口中的 x 坐標(biāo) int x1 sectionViewportPosition(startCol); int x2 sectionViewportPosition(endCol) sectionSize(endCol); QRect cellRect(x1, level * levelHeight, x2 - x1, levelHeight); // 只畫當(dāng)前列負(fù)責(zé)的那部分避免重復(fù)繪制 if (logicalIndex ! startCol) { continue; } painter-save(); painter-fillRect(cellRect, QColor(0xE8, 0xEE, 0xF7)); painter-setPen(QColor(0xB0, 0xBE, 0xD0)); painter-drawRect(cellRect.adjusted(0, 0, -1, -1)); painter-setPen(Qt::black); painter-drawText(cellRect, Qt::AlignCenter, m_levelLabels[level][group]); painter-restore(); } }這段代碼有幾個(gè)關(guān)鍵參數(shù)和邏輯。levelHeight是每層的固定高度用總高度除以層數(shù)實(shí)際項(xiàng)目里我會按字體度量動態(tài)算避免高 DPI 下文字被壓扁。sectionViewportPosition返回的是列在視口中的實(shí)際 x 坐標(biāo)它已經(jīng)包含了橫向滾動偏移所以滾動時(shí)表頭和數(shù)據(jù)區(qū)能對齊這是自定義方案比雙 view 強(qiáng)的地方。if (logicalIndex ! startCol) continue;這行很重要每個(gè)分組只在它的起始列繪制一次否則同一個(gè)分組會被它覆蓋的每一列各畫一遍文字重疊成一團(tuán)。m_levelLabels就是前面那份文本數(shù)組索引方式和 spans 一致。繪制順序上先畫背景再畫邊框最后畫文字保證文字不被邊框壓住。顏色我用了偏冷的淺藍(lán)灰工業(yè)軟件里長時(shí)間看眼睛不累具體值可以按項(xiàng)目 UI 規(guī)范改。4. 列寬、排序和滾動的聯(lián)動處理4.1 列寬變化時(shí)重算分組矩形多級表頭最容易出問題的地方是列寬。用戶拖動某一列的分隔線那一列變寬它所屬的分組矩形也得跟著變。好在 paintSection 每次重繪都會重新調(diào)sectionViewportPosition和sectionSize所以只要在列寬變化時(shí)觸發(fā)一次 viewport 更新就行。// 在構(gòu)造函數(shù)里連接信號 connect(this, QHeaderView::sectionResized, this, [this](int, int, int) { // 列寬變了整個(gè)表頭視口重繪 viewport()-update(); });sectionResized信號帶三個(gè)參數(shù)logicalIndex、oldSize、newSize。這里不需要用它們做計(jì)算因?yàn)槔L制時(shí)是實(shí)時(shí)查 sectionSize 的只要觸發(fā)重繪即可。注意是viewport()-update()而不是update()QHeaderView 的實(shí)際繪制區(qū)域在 viewport 上調(diào)錯(cuò)對象會出現(xiàn)「數(shù)據(jù)變了但畫面沒刷新」的玄學(xué)現(xiàn)象我在這上面浪費(fèi)過半小時(shí)。4.2 排序開啟后表頭索引的坑如果表格要支持點(diǎn)擊表頭排序setSectionsClickable(true)和setSortIndicatorShown(true)都得開。但多級表頭下有個(gè)坑排序指示器默認(rèn)畫在 section 的右側(cè)而多級表頭里一個(gè)分組跨多列指示器會畫在分組中間某一列上看起來像畫錯(cuò)了位置。處理辦法是重寫paintSection時(shí)自己控制指示器或者干脆把setSortIndicatorShown(false)關(guān)掉在分組文字旁邊用一個(gè)小三角自繪。我一般選自繪因?yàn)榕判驙顟B(tài)和分組狀態(tài)的視覺關(guān)系需要自己掌控。// 在畫分組文字后如果該列是排序列補(bǔ)一個(gè)小三角 if (logicalIndex sortIndicatorSection()) { QPolygon arrow; int cx cellRect.right() - 12; int cy cellRect.center().y(); if (sortIndicatorOrder() Qt::AscendingOrder) { arrow QPoint(cx - 4, cy 3) QPoint(cx 4, cy 3) QPoint(cx, cy - 3); } else { arrow QPoint(cx - 4, cy - 3) QPoint(cx 4, cy - 3) QPoint(cx, cy 3); } painter-setBrush(Qt::darkGray); painter-drawPolygon(arrow); }sortIndicatorSection()返回當(dāng)前排序列sortIndicatorOrder()返回升序還是降序。這段代碼把三角畫在分組矩形的右邊緣內(nèi)側(cè)位置固定不會因?yàn)榉纸M跨度變化而漂移。參數(shù)上cx和cy控制三角位置12是距右邊緣的像素按實(shí)際邊距調(diào)。4.3 橫向滾動時(shí)的對齊驗(yàn)證滾動對齊是檢驗(yàn)多級表頭是否合格的硬指標(biāo)。驗(yàn)證方法很簡單把表格拉窄到出現(xiàn)橫向滾動條拖動滾動條觀察表頭分組邊界和數(shù)據(jù)區(qū)列邊界是否始終重合。如果出現(xiàn)偏移八成是繪制時(shí)用了sectionPosition而不是sectionViewportPosition。前者返回的是邏輯位置不含滾動偏移后者才是視口內(nèi)的實(shí)際位置。這個(gè)區(qū)別在單層表頭里不明顯因?yàn)槟J(rèn)繪制幫你處理了一旦自己畫就暴露出來。我一般會在開發(fā)階段加一個(gè)臨時(shí)的調(diào)試?yán)L制把每個(gè) section 的 viewport 位置畫成豎線和數(shù)據(jù)區(qū)的列線對比確認(rèn)無誤后再去掉。這個(gè)土辦法比盯著屏幕猜快得多。5. 避坑與常見問題排查5.1 表頭畫出來了但高度不夠被裁掉現(xiàn)象代碼里明明畫了三層界面上只看到兩層最上面一層被切掉一半。原因sizeHint返回的高度不夠或者沒重寫sizeHintQHeaderView 用了默認(rèn)的單層高度。解決重寫sizeHint返回層數(shù) × 每層高度并且每層高度要按QFontMetrics::height()加邊距算不能寫死 20 這種值高 DPI 屏上會翻車。5.2 分組文字重復(fù)繪制導(dǎo)致加粗發(fā)虛現(xiàn)象分組標(biāo)題看起來比預(yù)期粗像被描了兩遍。原因paintSection 對分組覆蓋的每一列都調(diào)用了如果沒加logicalIndex ! startCol的判斷同一個(gè)分組會被畫多次文字疊加。解決每個(gè)分組只在起始列繪制其余列直接 return。這個(gè)坑在跨度大的分組上特別明顯因?yàn)榀B加次數(shù)多。5.3 列寬拖動后表頭和數(shù)據(jù)區(qū)錯(cuò)位現(xiàn)象拖動列寬后表頭分組邊界和數(shù)據(jù)區(qū)列邊界對不上差幾個(gè)像素。原因繪制時(shí)用了緩存的列寬值沒實(shí)時(shí)查sectionSize。解決所有位置計(jì)算都走sectionViewportPosition和sectionSize并在sectionResized信號里觸發(fā)viewport()-update()。緩存列寬是性能優(yōu)化的常見做法但在多級表頭里得不償失。5.4 排序指示器畫在錯(cuò)誤的分組上現(xiàn)象點(diǎn)擊排序后小三角出現(xiàn)在分組中間某一列而不是分組邊緣。原因用了默認(rèn)的排序指示器繪制它按 section 定位不理解分組概念。解決關(guān)掉setSortIndicatorShown在 paintSection 里自繪三角位置按分組矩形算。自繪時(shí)注意三角的坐標(biāo)要基于cellRect不要基于rect因?yàn)閞ect是單個(gè) section 的矩形不是分組矩形。5.5 高 DPI 屏上表頭文字模糊現(xiàn)象在 4K 屏或縮放 150% 的環(huán)境下表頭文字發(fā)虛邊框線粗細(xì)不均。原因繪制時(shí)用了整數(shù)坐標(biāo)沒有考慮設(shè)備像素比。解決在 paintSection 開頭用painter-setRenderHint(QPainter::Antialiasing, true)和TextAntialiasing坐標(biāo)計(jì)算用qreal而不是int最后再取整。另外sizeHint里的每層高度要乘devicePixelRatio否則物理像素不夠文字會被壓縮。6. 把多級表頭封裝成可復(fù)用組件的一個(gè)技巧寫到這兒核心邏輯已經(jīng)齊了但每次新項(xiàng)目都復(fù)制一遍 paintSection 不現(xiàn)實(shí)。我的習(xí)慣是把層級定義抽成一個(gè)獨(dú)立的配置結(jié)構(gòu)讓 MultiLevelHeaderView 只認(rèn)這個(gè)結(jié)構(gòu)不認(rèn)具體業(yè)務(wù)。// 層級配置每層一個(gè)標(biāo)簽數(shù)組和一個(gè)跨度數(shù)組 struct HeaderLevel { QVectorQString labels; QVectorint spans; }; // 一次性設(shè)置所有層級 void MultiLevelHeaderView::setLevels( const QVectorHeaderLevel levels) { m_levelSpans.clear(); m_levelLabels.clear(); for (const auto lv : levels) { m_levelSpans.append(lv.spans); m_levelLabels.append(lv.labels); } m_levelCount levels.size(); // 觸發(fā)高度重算和重繪 updateGeometry(); viewport()-update(); }這個(gè)封裝的好處是業(yè)務(wù)代碼里只需要構(gòu)造QVectorHeaderLevel不用碰繪制邏輯。換項(xiàng)目時(shí)把 MultiLevelHeaderView 整個(gè)文件拷過去改一下顏色常量就能用。updateGeometry()會觸發(fā)sizeHint重新計(jì)算保證層數(shù)變化后高度跟著變這一步漏了會出現(xiàn)「設(shè)了三層但高度還是兩層」的問題。驗(yàn)證封裝是否成功我有個(gè)固定流程先設(shè)兩層各跨 2 列看分組邊界再改成三層看高度是否自動增加然后拖動列寬看邊界是否跟隨最后點(diǎn)排序看三角位置。四步都過這個(gè)組件才算能進(jìn)項(xiàng)目。說個(gè)我自己的教訓(xùn)。早期我圖快把層級數(shù)據(jù)直接寫死在 paintSection 里結(jié)果第二個(gè)項(xiàng)目要改分組數(shù)量我花了半天在繪制代碼里找硬編碼的索引。后來改成配置驅(qū)動同樣的需求十分鐘搞定。多級表頭這種和業(yè)務(wù)強(qiáng)耦合的 UI 組件繪制邏輯和數(shù)據(jù)結(jié)構(gòu)一定要分開不然維護(hù)成本會指數(shù)級上升。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取