报表这个东西,凡是做过管理系统的都绕不开。我上一次接到的需求是给一套内部数据汇总程序加一个报表模块:屏幕上看要能排序、筛选、高亮异常行,打印出来要带页眉页脚和页码,还要能一键导出 PDF 和表格文件给业务侧归档。最开始图省事直接拿 QTableWidget 堆,两百来行数据的时候一切正常,等真实数据灌到几万行、列数涨到三十多列、还要求跨页打印保持表头的时候,卡顿、错位、打印断页全来了。后来推倒重做,走的是 Qt 原生 Model/View 那套 MVC 架构,自己写模型、自己写委托、自己算分页,才把这套 QT 自定义报表控件开发从屏幕渲染到打印导出的完整链路跑通。这篇就把整个过程拆开讲:为什么不用现成控件、模型层怎么写才不卡、表头合并和冻结列怎么处理、打印时毫米和像素怎么换算、分页算法怎么推、导出 PDF 和表格文件各自踩了什么坑。适合已经会用 Qt 画界面、但一碰到大数据量表格和打印就发怵的开发者,也适合正在评估报表方案的技术负责人,代码片段基本可以直接抄去改。
1. 需求拆解与技术路线选型
1.1 报表控件真正要解决的四个问题
很多人一提报表控件,脑子里第一反应是"一个能显示表格的控件"。这个理解偏差会把后面的设计带歪。实际项目里,一个能用的报表控件至少要同时扛住四件事,而且这四件事的技术诉求是互相冲突的。
第一是数据规模。屏幕上的表格动辄几千上万行,用户还要滚动、排序、筛选。如果每一行都对应一个真实的控件对象,光是对象创建和布局计算就能把主线程吃满。第二是表现力。报表不是纯文本表格,它有合并单元格、有多级表头、有按阈值变色的数值、有千分位和百分比格式化、有合计行加粗。第三是输出一致性。屏幕上看到的列宽比例、对齐方式、颜色标记,打印到纸上或者导出成文件之后必须还在,不能屏幕是一套、纸上是另一套。第四是可扩展。今天要 A4 纵向,明天要 A3 横向,后天要按部门分组小计,这些变化不应该每次都要动核心渲染代码。
把这四点摆在一起看,就能明白为什么现成控件撑不住。QTableWidget 本质是"用控件堆出来的表格",它把数据和视图焊死在一起,天生不适合大数据量;而 QTableView 加自定义模型是"数据与视图分离",把复杂度转移到了模型层,视图只负责取它当前可见的那几十行。这个分离是整套方案的地基,后面所有的性能优化和打印复用都是从这儿长出来的。
我在做技术选型的时候画过一张对照表,把当时考虑过的四条路线摆在一起比了一遍,最后选了第二条。这个对比过程值得展开说,因为很多人卡在选型上,选完了才发现方向错了,返工成本极高。
1.2 四条技术路线的横向对比
| 路线 | 实现方式 | 优点 | 致命短板 | 适用场景 |
|---|---|---|---|---|
| 控件堆叠 | QTableWidget 逐格 setItem | 上手最快, demo 半小时能出 | 万行级必卡,样式与数据耦合 | 行数固定在百级以内的小工具 |
| 模型视图 | QAbstractTableModel + QTableView + 自定义委托 | 大数据量流畅,屏幕与打印可共用同一份数据源 | 需要自己实现排序、合并、分页 | 绝大多数业务报表 |
| 图元自绘 | QGraphicsView + QGraphicsScene 全手绘 | 排版自由度最高,复杂图表混排友好 | 交互逻辑(选中、编辑、键盘导航)全部自己写 | 以图形为主、表格为辅的看板 |
| 网页渲染 | 内嵌网页组件渲染 HTML 表格 | 前端生态成熟,样式容易做得漂亮 | 与原生程序的数据交互成本高,打印控制权不在自己手里 | 已有前端报表体系、只做展示 |
这张表里最关键的一行判断是打印控制权。第三条和第四条路线在屏幕上都能做得很好看,但一旦要求"打印时表头每页重复、页码居中、页脚带导出时间",控制权就必须在自己手里。图元自绘可以,但要额外写一套打印渲染器;网页渲染基本没戏,只能依赖对方提供的打印接口,参数调不动。
所以最终方案是:QAbstractTableModel 承载数据,QTableView 负责屏幕呈现,QStyledItemDelegate 负责单元格外观,QPrinter + QPainter 负责打印,导出复用打印的绘制逻辑。这套组合的好处是打印和屏幕走同一份模型数据,格式化的逻辑只写一次,两边自动一致。
注意:不要为了"打印好看"而在视图层做额外的样式处理。样式一旦写进视图,打印时就得再写一遍,两边迟早会漂移。所有格式化统一收敛到模型的 Qt::DisplayRole 里,视图和打印都只消费结果。
1.3 分层结构与模块边界
定了路线之后,我把整个控件拆成五层,每一层只依赖它下面那一层,不允许跨层调用。这个约束听起来教条,但在报表这种"需求天天变"的场景里,它能救命。
数据层是纯业务结构体,一个 Row 就是一组单元格值加一个状态标记(正常、预警、异常),不继承任何 Qt 类,不掺任何界面逻辑。这一层的好处是单元测试好写,业务侧改字段不影响界面。
模型层继承 QAbstractTableModel,负责把 Row 数组翻译成 Qt 认识的角色数据。它是唯一知道"第几列叫什么、用什么格式、怎么对齐"的地方。
视图层是 QTableView 加几个自定义表头组件,只做三件事:显示、滚动、把用户的排序筛选请求转发出去。
委托层继承 QStyledItemDelegate,处理那些模型表达不了的视觉效果,比如进度条单元格、状态圆点、合并单元格的边框。
输出层是打印和导出,它不碰视图,直接从模型取数据,自己算页面坐标。这一层是整套设计里最费劲的部分,也是最能体现功力差距的地方。
分层之后有个直接好处:需求变更时能迅速定位到该改哪一层。要加一列,改数据结构和模型的列定义;要改颜色规则,改模型的 BackgroundRole;要改打印页边距,只动输出层的一个常量。我在项目中期经历过一次"所有数值列改成右对齐、小数点统一两位"的调整,因为格式化的定义都集中在列定义结构体里,改了一个循环就完事了,没有满世界找 setAlignment。
2. 模型层的设计细节与性能红线
2.1 三个必须实现的虚函数与两个容易漏的
QAbstractTableModel 是个抽象类,不实现纯虚函数编译都过不去。真正需要动脑子的是 rowCount、columnCount、data 这三个,以及两个经常被忘掉但会引发诡异问题的:headerData 和 flags。
class ReportModel : public QAbstractTableModel { Q_OBJECT public: enum Roles { RawValueRole = Qt::UserRole + 1, RowStateRole, SpanRole }; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_rows.size(); } int columnCount(const QModelIndex &parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_columns.size(); } QVariant data(const QModelIndex &idx, int role) const override; QVariant headerData(int section, Qt::Orientation o, int role) const override; Qt::ItemFlags flags(const QModelIndex &idx) const override { if (!idx.isValid()) return Qt::NoItemFlags; return Qt::ItemIsEnabled | Qt::ItemIsSelectable; } };parent.isValid()那个判断千万别省。表格模型是扁平结构,只要 parent 有效就说明有人把你当树模型在用,此时必须返回 0,否则会触发无限递归或者越界崩溃。我见过一个 bug 排查了大半天,最后发现是有人套了两层代理模型,外层把内层当树查询,而内层没做这个判断,直接数组越界。
flags返回里如果没有 Qt::ItemIsEnabled,单元格会显示成灰色,点击没反应。如果返回了 Qt::ItemIsEditable 却没实现 setData,用户双击进入编辑态后改完一回车数据就消失,体验极差。报表控件一般是只读的,老老实实给 Enabled 加 Selectable 就够了,别手贱加 Editable。
headerData处理的是行号和列标题。有个细节:竖直表头(行号)默认显示 1、2、3 这种序号,如果业务上需要显示"第 N 行"或者干脆隐藏,都在这里控制。返回空 QVariant 表示没有表头文字,但那一列的位置依然占着,想要彻底移除得调verticalHeader()->setVisible(false)。
2.2 用角色承载对齐、颜色与原始值
data()是整个模型里调用最频繁的函数,视图每刷新一次就会对每个可见单元格调好几个角色。它的实现质量直接决定滚动流畅度。
QVariant ReportModel::data(const QModelIndex &idx, int role) const { if (!idx.isValid() || idx.row() >= m_rows.size()) return {}; const Row &row = m_rows.at(idx.row()); const ColumnDef &col = m_columns.at(idx.column()); switch (role) { case Qt::DisplayRole: { const QVariant raw = row.cells.value(idx.column()); return col.formatter ? col.formatter(raw) : raw; } case Qt::TextAlignmentRole: return int(col.align | Qt::AlignVCenter); case Qt::BackgroundRole: return row.state == RowState::Error ? QBrush(QColor(255, 235, 235)) : row.state == RowState::Warn ? QBrush(QColor(255, 248, 220)) : QVariant(); case Qt::ForegroundRole: return row.state == RowState::Error ? QBrush(QColor(180, 30, 30)) : QVariant(); case RawValueRole: return row.cells.value(idx.column()); default: return {}; } }这里有几个必须讲清楚的点。第一,DisplayRole返回的是格式化之后的字符串,而RawValueRole返回原始值。为什么要留原始值?因为排序和导出都要用。视图上显示"1,234.50",如果排序时拿这个字符串比,会得到 "999.00" > "1,234.50" 这种荒谬结果。导出到文件时也一样,业务侧要的是数字不是带千分位的文本。
第二,TextAlignmentRole必须返回 int 类型,返回 Qt::Alignment 枚举在某些 Qt 版本上会被当成不认识的类型,导致对齐失效。这个坑很隐蔽,因为代码能编译、运行不报错,就是对齐不对,你会以为是样式表覆盖了。
第三,BackgroundRole返回 QVariant 而不是 QBrush 的空构造。返回一个默认构造的 QBrush(画刷样式为 NoBrush)和返回空 QVariant 在多数情况下效果一样,但在某些平台上默认 QBrush 会被画成黑色,非常难看。养成返回 QVariant() 的习惯。
格式化函数我建议做成函数指针存在 ColumnDef 里,而不是在 data() 里写一长串 if-else 判断列索引。前者加列时零成本,后者每加一列就要动一次核心函数,早晚出事。
2.3 多级表头与单元格合并的实现取舍
这两个需求几乎每个报表都会提,而 Qt 原生一个都不支持。QHeaderView 只能显示一层标题,QTableView::setSpan 能做合并但是静态的,数据变了得手动重算。我试过三种方案,最后是组合着用的。
多级表头的第一种做法是自绘表头。继承 QHeaderView,重写paintSection,在 paint 里根据当前 section 的位置自己算层级矩形、画文字、画分隔线。这方案灵活度最高,能做出任意层级的树状表头,代价是要自己处理列宽变化、滚动偏移、点击命中的区域映射。我实际写下来大概两百多行,难点在坐标换算:sectionViewportPosition(section)给出的是相对于视口的位置,画的时候要减去滚动量。
第二种做法是把表头当成模型的第一行。在 data() 里判断 row() == 0 时返回表头文字,然后用 setSpan 把跨列的格子合并起来。这个方案实现快,缺点是表头会跟着内容一起滚动,需要额外固定住;而且选中、复制这些交互要专门排除第一行,逻辑上很脏。
第三种是用一个独立的小 QTableView 只显示一行,放在主表格正上方,两个视图共享同一个横向滚动条和列宽。这个方案在项目里最常见,因为实现简单、视觉效果好、维护成本低。我的做法是主表格的horizontalHeader()直接隐藏,上面放一个高度固定的 QTableView,列宽变化时用sectionResized信号同步过去。
单元格合并用setSpan就够,但必须记住一点:span 信息不属于模型,属于视图。数据重置之后 span 会全部丢失,必须在modelReset或者数据刷新完成后重新计算一遍。我的一般做法是写一个rebuildSpans()函数,遍历数据找出需要合并的连续区间,先把旧的 clearSpans,再逐个 setSpan。注意 setSpan 的顺序有讲究,先设置跨列大的再设置小的,或者干脆不重叠,否则会出现合并区域互相覆盖、边缘绘制错乱。
2.4 万行数据下的响应速度怎么保住
性能这块,我踩过的坑比写过的代码还多。总结下来就三条红线,破了任何一条,滚动就开始掉帧。
第一条:data() 里不许做重活。不要在这里查数据库、不要做字符串拼接循环、不要调用会分配大量内存的格式化函数。我见过有人在 data() 里对每个单元格调一次正则替换,五万行三十列,滚一下卡三秒。正确的做法是把格式化结果在数据装载时算好缓存起来,data() 只做取值。
第二条:批量更新必须用 beginResetModel/endResetModel,不要逐行 insertRows。逐行插入意味着每插一行都要发一次 rowsInserted 信号,视图要重新布局一次。两千行就是两千次布局,肉眼可见地卡。
void ReportModel::setRows(QVector<Row> rows) { beginResetModel(); m_rows = std::move(rows); endResetModel(); }第三条:行高列宽固定,别开自动适应。resizeRowsToContents()和resizeColumnsToContents()会遍历所有行所有列计算理想尺寸,十万个单元格算下来就是几秒钟。我一般在数据装载完成后只算一次,把结果缓存进 QHeaderView,之后切到 Fixed 模式。
view->horizontalHeader()->setSectionResizeMode(QHeaderView::Fixed); view->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); view->verticalHeader()->setDefaultSectionSize(28); view->setWordWrap(false);setWordWrap(false)这一行容易被忽略,但对性能影响很大。开着自动换行的时候,视图要计算每个单元格的文字折行位置,这个计算量跟文本长度成正比。报表通常列宽固定、文字截断加省略号就够了,关掉换行能省下不少开销。
排序这块还有个坑。用 QSortFilterProxyModel 直接代理,默认走的是 DisplayRole 的字符串比较,数字列会排错。解决办法是继承代理模型重写 lessThan,用原始值比。
bool NumberAwareProxyModel::lessThan(const QModelIndex &l, const QModelIndex &r) const { const QVariant lv = l.data(RawValueRole); const QVariant rv = r.data(RawValueRole); if (lv.isValid() && rv.isValid() && lv.userType() == QMetaType::Double && rv.userType() == QMetaType::Double) return lv.toDouble() < rv.toDouble(); return QSortFilterProxyModel::lessThan(l, r); }这里用userType()而不是老的type(),因为后者在新版本里已经标记弃用,编译会报警告。这种小细节迁移的时候特别烦,建议一开始就写新接口。
3. 视图与委托:让表格长成报表的样子
3.1 委托里做单元格渲染的边界在哪
QStyledItemDelegate 能做的事情很多,但我给自己定了个规矩:委托只处理模型表达不了的视觉,不处理数据逻辑。什么算表达不了?进度条、状态圆点、带箭头的涨跌标识、合并单元格的内边框、单元格内的多行文本,这些都需要拿到 QPainter 自己画,模型给不了。而数字格式化、单位换算、颜色阈值判断,这些都属于数据逻辑,必须放模型。
委托的核心是两个函数:paint和sizeHint。
void ReportDelegate::paint(QPainter *p, const QStyleOptionViewItem &opt, const QModelIndex &idx) const { QStyleOptionViewItem o(opt); initStyleOption(&o, idx); const QVariant raw = idx.data(RawValueRole); if (idx.data(CellTypeRole).toInt() == CellType::Progress && raw.isValid()) { p->save(); const QRect r = o.rect.adjusted(4, 6, -4, -6); p->setRenderHint(QPainter::Antialiasing, true); // 底槽 p->setPen(Qt::NoPen); p->setBrush(QColor(0, 0, 0, 18)); p->drawRoundedRect(r, 3, 3); // 进度 const double ratio = qBound(0.0, raw.toDouble(), 1.0); QRect fill = r; fill.setWidth(int(r.width() * ratio)); p->setBrush(ratio > 0.9 ? QColor(200, 60, 60) : QColor(52, 152, 219)); p->drawRoundedRect(fill, 3, 3); // 百分比文字 p->setPen(QColor(60, 60, 60)); p->drawText(o.rect, Qt::AlignCenter, QString::number(ratio * 100, 'f', 0) + "%"); p->restore(); return; } QStyledItemDelegate::paint(p, o, idx); }这段代码里最容易被低估的是initStyleOption。它会从模型把文字、对齐、颜色、字体全部填充到 QStyleOptionViewItem 里,省去你手动取的麻烦。忘了调它,你会发现自绘的部分正常、默认绘制的那部分全都变成空白。
p->save()和p->restore()成对出现是硬要求。曾经有一次我漏了 restore,结果后面所有单元格的文字颜色全变成了红色,排查了半天才想起来是画笔状态没还原。
还有一个性能注意点:setRenderHint(QPainter::Antialiasing)只对当前这次绘制有效,每次 paint 都要重新设。但如果整张表都是进度条,每次都开抗锯齿会有开销,可以改成只在圆角矩形这个分支里开。
sizeHint相对简单,但对性能也有影响。如果所有行都返回同一个高度,可以在视图层直接用setDefaultSectionSize固定,让委托的 sizeHint 根本不被调用,速度更快。
3.2 冻结列与自适应列宽的实操
冻结列是报表的高频需求,尤其是第一列是编号或者名称的时候,横向滚动过去就看不见了,用户会骂人。
新一些的 Qt 版本里 QTableView 提供了setFrozenColumnCount,一行代码搞定。但如果项目用的版本比较老,就得自己搭。我用的是双视图方案:左右各一个 QTableView,左边只显示前 N 列,右边显示剩下的,两个视图共享同一个模型,左边关闭横向滚动条和竖直表头,右边同步竖直滚动条。
void syncScrollBars() { // 竖直滚动条双向同步 connect(m_left->verticalScrollBar(), &QScrollBar::valueChanged, m_right->verticalScrollBar(), &QScrollBar::setValue); connect(m_right->verticalScrollBar(), &QScrollBar::valueChanged, m_left->verticalScrollBar(), &QScrollBar::setValue); // 选中行同步 connect(m_left->selectionModel(), &QItemSelectionModel::selectionChanged, this, [this] { m_right->selectionModel()->select( m_left->selectionModel()->selection(), QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); }); }双视图方案的坑在于左右两边的行高必须完全一致,否则滚动到后面会错位。解决办法是给两边都设成 Fixed 模式并且用同一个 defaultSectionSize。另外左边视图的horizontalScrollBar()->setVisible(false)之后,它的宽度会变化,导致右边视图的布局跟着动,需要在 resize 事件里重新调整两边的宽度比例。
自适应列宽是另一块。用户拖拽列宽、双击分隔线自动适应、窗口 resize 时按比例分配,这三件事要协调好,否则会打架。我最终采用的是权重分配算法,每个列定义里带一个权重值,窗口宽度变化时按权重分。
void distributeColumnWidths(QHeaderView *header, int totalWidth) { const int n = header->count(); if (n <= 0) return; double sum = 0; QVector<double> weights(n); for (int c = 0; c < n; ++c) { weights[c] = columnWeight(c); // 每列预设权重,最小 0.5 sum += weights[c]; } int used = 0; for (int c = 0; c < n - 1; ++c) { const int w = qMax(50, int(totalWidth * weights[c] / sum)); header->resizeSection(c, w); used += w; } // 最后一列吃掉所有舍入误差,保证总宽精确等于可用宽度 header->resizeSection(n - 1, qMax(50, totalWidth - used)); }那个"最后一列吃掉舍入误差"的处理非常关键。整数除法的累积误差在列数多的时候能有十几个像素,表现出来就是表格右边永远有一道白缝,或者横向滚动条始终处于激活状态,用户滚动一下发现右边还有些空白,体验很怪。
另外qMax(50, ...)这个下限也得有。窗口缩得很小的时候,按权重算出来的宽度可能是负数,直接传给 resizeSection 会有警告甚至崩溃。
3.3 屏幕与打印的一致性怎么保证
这是整套方案里我最看重的一点。很多报表做完之后的毛病是:屏幕上一套配色、打印出来另一套;屏幕上有斑马纹,打印出来全白;屏幕上列宽是 3:2:5,打印出来挤成一坨。
根因是渲染路径分裂了。屏幕走视图和委托,打印走另一套手写逻辑,两边各写各的格式化,不一致是必然的。
我的做法是抽出一个纯函数式的绘制单元,输入是模型指针、列定义、当前矩形,输出是画在 QPainter 上的内容。屏幕渲染时委托调用它,打印时打印循环调用它。
void ReportPainter::drawCell(QPainter *p, const QRect &rect, const ReportModel *model, int row, int col, bool forPrint) const { const QModelIndex idx = model->index(row, col); const QString text = idx.data(Qt::DisplayRole).toString(); const int align = idx.data(Qt::TextAlignmentRole).toInt(); // 斑马纹只在屏幕上画,打印为了省墨不画 if (!forPrint && row % 2 == 1) { p->fillRect(rect, QColor(248, 249, 251)); } // 背景色(异常行高亮)屏幕和打印都要 const QVariant bg = idx.data(Qt::BackgroundRole); if (bg.isValid()) p->fillRect(rect, bg.value<QBrush>()); // 文字 p->setPen(idx.data(Qt::ForegroundRole).value<QBrush>().color()); p->drawText(rect.adjusted(6, 0, -6, 0), align | Qt::TextSingleLine, text); // 边框:屏幕用浅灰,打印用深灰,否则打印机上浅灰会消失 p->setPen(forPrint ? QColor(150, 150, 150) : QColor(230, 230, 230)); p->drawLine(rect.bottomLeft(), rect.bottomRight()); }forPrint这个开关是我后来加的,一开始想完全复用,实际打印出来发现两个问题:一是斑马纹的浅灰在激光打印机上根本印不出来,白白浪费碳粉;二是屏幕上的分隔线颜色(230,230,230)太浅,纸上完全看不见,表格结构散了。所以保留一个开关,只在"配色"这个维度上分叉,布局和文字内容严格共用。
这个开关的粒度要控制好。如果分叉太多,又回到了两套逻辑的老路。我的原则是:凡是跟墨、纸、色彩还原相关的允许分叉,凡是跟数据、格式、对齐、列宽相关的绝不允许分叉。
4. 打印与导出的完整链路
4.1 坐标系统换算:毫米、像素与缩放
打印这块第一道坎就是单位。业务方给的要求是"A4 纵向,上下左右边距各 15 毫米,表格内容区宽度占满",而 QPainter 默认工作在设备坐标系里,单位是像素,且这个像素是打印机的物理像素。
A4 是 210 × 297 毫米,如果按 1200 DPI 计算,一毫米对应 1200 / 25.4 ≈ 47.24 像素,整页就是 9921 × 14031 像素。所有尺寸都拿这个数量级去算,代码里全是六位数,改一个边距要重新算一遍,根本没法维护。
我的做法是在 QPainter 上套一层缩放,把逻辑坐标重新定义成"0.1 毫米一个单位"。
QPrinter printer(QPrinter::HighResolution); printer.setPageSize(QPageSize(QPageSize::A4)); printer.setPageOrientation(QPageLayout::Portrait); printer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter); printer.setColorMode(QPrinter::GrayScale); QPainter painter; if (!painter.begin(&printer)) { qWarning() << "打印机不可用"; return; } // 逻辑坐标:1 单位 = 0.1 毫米 constexpr double kUnitsPerMm = 10.0; const double sx = printer.logicalDpiX() / (25.4 * kUnitsPerMm); const double sy = printer.logicalDpiY() / (25.4 * kUnitsPerMm); painter.scale(sx, sy);换完之后整个坐标系变得非常直观:页面宽度 2100 单位,高度 2970 单位,页边距 150 单位,可用内容宽度 1800 单位,行高 60 单位就是 6 毫米。所有常量都是三位数,肉眼看就能判断合不合理。
这里有个细节要注意:logicalDpiX()和logicalDpiY()在绝大多数打印机上是相等的,但保险起见还是分开算。曾经遇到过一台老式打印机横向和纵向 DPI 不一致,如果用一个值算,打印出来整个表格会被拉伸,行高看着没问题但宽度不对,非常难查。
还有一个坑是QPrinter::HighResolution和QPrinter::ScreenResolution的选择。HighResolution 会给出打印机的真实分辨率(通常是 600 或 1200),ScreenResolution 会给出屏幕的 96 左右。用 ScreenResolution 打印出来的文字会有明显的锯齿,因为它把 96 DPI 的位图放大到了 1200 DPI 的纸上。除非是为了出草稿看排版,否则一律用 HighResolution。
4.2 分页算法逐行推演
分页是整套打印逻辑的核心,也是最容易出错的地方。核心思路其实简单:从上往下逐行累加高度,加不下了就换页。但细节上一堆坑。
先明确几个常量。页面高度 2970 单位(297 毫米),上下边距各 150,可用高度 2670。其中表头占 80,页脚占 70,那么正文可用高度是 2670 - 80 - 70 = 2520 单位。行高固定 60,那么每页最多能放 2520 / 60 = 42 行。
但实际不能写死 42。因为有的行会因为内容多而变高,有的报表要插入小计行,高度不固定。所以还是得逐行累加。
struct PageLayout { int startRow; int endRow; int usedHeight; }; QVector<PageLayout> paginate(const ReportModel *model, int contentHeight) { QVector<PageLayout> pages; const int rows = model->rowCount(); if (rows == 0) return pages; int start = 0; int used = 0; for (int r = 0; r < rows; ++r) { const int h = rowHeight(model, r); // 单行高度,含小计行加高 // 关键:如果这一行单独就超过一页,必须强制放下,否则死循环 if (h > contentHeight) { if (used > 0) { pages.append({start, r - 1, used}); start = r; used = 0; } pages.append({r, r, h}); start = r + 1; used = 0; continue; } if (used + h > contentHeight) { pages.append({start, r - 1, used}); start = r; used = 0; } used += h; } if (used > 0) pages.append({start, rows - 1, used}); return pages; }这段代码里最容易被忽略的是那个h > contentHeight的分支。如果某一行(比如一个带了大量备注文字的合并行)自身高度就超过了正文区高度,不加这个判断的话,used + h > contentHeight永远成立,每次都会开新页但一行都没放进去,最后无限循环,程序直接假死。我在测试阶段用一条两米长的备注文字把这个 bug 触发过,主线程卡死,只能强杀。
另一个细节是页尾的孤行控制。如果某页的最后一行恰好是一个分组的第一行,而它的兄弟行都在下一页,打印出来会很别扭。业务上要求不高的话可以不管,要求高的话得在分页之后做一次回扫:检查每页末尾行是否属于一个跨页的分组,如果是,把整个分组挪到下一页。这个逻辑我实现过,大概是分页之后再做一轮调整,复杂度不低,项目里最终没上,因为业务侧说"能看就行"。
分页算完之后,绘制循环就清晰了:
int pageNo = 0; for (const PageLayout &page : pages) { if (pageNo > 0) printer.newPage(); ++pageNo; drawPageHeader(&painter, pageNo, pages.size()); int y = pageTopMargin + headerHeight; for (int r = page.startRow; r <= page.endRow; ++r) { int x = pageLeftMargin; for (int c = 0; c < model->columnCount(); ++c) { const int w = columnWidth(c); drawCell(&painter, QRect(x, y, w, rowHeight(model, r)), model, r, c, true); x += w; } y += rowHeight(model, r); } drawPageFooter(&painter, pageNo); }列宽的分配跟屏幕上用的是同一个权重算法,只是总宽度换成了纸面可用宽度 1800 单位。这样屏幕上看到的比例关系和纸上完全一致。我在前面 3.2 节写的那段distributeColumnWidths稍微改一下就能直接复用,把 QHeaderView 换成纯数组计算。
4.3 页眉页脚与页码的绘制时机
页眉页脚看着简单,实际有两个坑。
第一个是绘制时机。新手常见的写法是:先把所有内容画完,再统一回去画页脚。这个在 PDF 输出上可能没问题,但在真实打印机上,一旦调用newPage(),上一页的内容就已经被 flush 到设备了,你再也回不去了。所以页脚必须在当前页绘制结束前画完。
第二个是内容溢出覆盖。如果正文区的高度算错了,最后一行压到了页脚区域,页脚文字会跟表格内容叠在一起,糊成一团。
我的处理办法是给页眉页脚各留出固定的高度预算,正文区的高度严格等于总高度减去这两块,从算法层面杜绝重叠。绘制顺序就是上面代码里的:页眉 → 正文 → 页脚,都在同一页的生命周期内完成。
void drawPageHeader(QPainter *p, const QString &title, const QString &subtitle, int pageWidth) { QFont f = p->font(); f.setPointSizeF(11); f.setBold(true); p->setFont(f); p->drawText(QRect(0, 40, pageWidth, 60), Qt::AlignHCenter | Qt::AlignVCenter, title); f.setPointSizeF(8); f.setBold(false); p->setFont(f); p->setPen(QColor(110, 110, 110)); p->drawText(QRect(0, 100, pageWidth, 40), Qt::AlignHCenter | Qt::AlignVCenter, subtitle); }字体这块要特别注意中文。QPainter 默认字体在不同系统上差异很大,打印中文可能出现方框或者缺字。稳妥的做法是显式指定一个确定存在的字体族,并且在导出 PDF 时确认字体被嵌入。设置f.setFamily()之后建议再调一次QFontInfo(f).family()检查实际匹配到的字体,如果匹配失败了会返回一个后备字体名,能提前发现。
页码的格式也有讲究。"第 N 页 / 共 M 页"这种格式需要先知道总页数,所以必须先把分页算完再开始绘制,不能边画边数。这也是为什么我把 paginate 单独拆成一个函数,先算出完整的页数组,再去绘制。
4.4 PDF、CSV 与电子表格的导出差异
导出这块,不同格式的坑完全不一样,分开说。
PDF最简单,因为 QPrinter 直接设置输出格式就行。
printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(filePath);打印的整套逻辑一行不用改。唯一的注意事项是中文案例里提到的字体嵌入。Qt 在生成 PDF 时默认会嵌入字体子集,但如果你指定了一个系统上不存在的字体,它会回退到默认字体,而默认字体在某些环境下不带中文字形,导出来就是一片空白。我遇到过一次,屏幕预览正常,导出的 PDF 打开全是空白,查了半天是字体回退导致的。
CSV表面简单,实际全是陷阱。第一是编码,直接用 UTF-8 写文件,用办公软件打开会乱码,必须加 BOM 头。第二是分隔符,逗号在数值的千分位表示里会出现,如果导出的是格式化后的"1,234.50",用逗号分隔就会把一列拆成两列。所以导出 CSV 时一律用原始值,不用 DisplayRole。
QFile f(filePath); if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) return; // UTF-8 BOM,缺了这仨字节办公软件打开就是乱码 f.write("\xEF\xBB\xBF"); QTextStream out(&f); out.setCodec("UTF-8"); for (int c = 0; c < model->columnCount(); ++c) { if (c) out << ','; out << escapeCsv(model->headerData(c, Qt::Horizontal).toString()); } out << '\n'; for (int r = 0; r < model->rowCount(); ++r) { for (int c = 0; c < model->columnCount(); ++c) { if (c) out << ','; const QVariant raw = model->index(r, c).data(RawValueRole); out << escapeCsv(raw.toString()); } out << '\n'; }escapeCsv要做的是:如果字段里包含逗号、双引号或者换行,就把整个字段用双引号包起来,并把内部的双引号翻倍。这个规则看着简单,漏了就会导致表格错列,而且错得很隐蔽——打开文件看着还行,往下翻几行数据全错位了。
电子表格文件(xlsx)麻烦在于没有官方库。两条路:一是通过 COM 自动化调用本机装好的办公软件,好处是格式支持全,坏处是依赖本机环境、速度慢、进程容易残留;二是引第三方库直接写文件,好处是快、无依赖,坏处是要多引一个依赖,且 API 风格跟 Qt 差别大。
我选的是后者。用的时候注意两点:一是数字单元格要写数值类型而不是字符串,否则导出的文件里单元格左上角带个绿三角,用户想求和的时候发现公式不起作用;二是大文件导出要考虑内存,几万行一次性构建对象树会吃掉几百兆,得考虑分批写或者用流式 API。
5. 踩过的坑与排查速查表
5.1 五类高频问题的定位思路
类型一:滚动卡顿。定位方法很直接,在 data() 里加一个静态计数器,滚一次看它被调了多少次。正常情况下滚动一屏应该是可见行数乘列数乘角色数,比如 40 × 15 × 4 = 2400 次左右。如果这个数上了十万,说明发生了什么让视图反复全量刷新的事情,最常见的是在 data() 里调了会触发 modelReset 的操作,或者 selectionModel 的信号处理里做了重活。
类型二:打印内容错位。表现是列宽跟屏幕对不上、行高不一致、最后一页多出空白页。先查两件事:一是屏幕的 distributeColumnWidths 和打印的列宽计算是不是用了同一套权重,二是分页函数返回的页数组里有没有used == 0的空页。多出空白页十有八九是分页循环最后多 append 了一次。
类型三:导出文件打不开或者内容乱码。CSV 先查 BOM,xlsx 先查文件是否完整写入(没 close 就返回了)。PDF 查字体是否嵌入,最简单的验证方法是用阅读器打开,看文档属性里的字体列表,如果显示的是"未嵌入"就要改字体设置。
类型四:表头与内容错位。这个几乎只出现在冻结列的双视图方案里。检查两点:两边视图的 defaultSectionSize 是否一致、两边的 horizontalHeader 高度是否一致。表头高度不一致的话,第一行的垂直位置就差了几个像素,滚动到后面累积起来就很明显。
类型五:程序退出时报错或者卡住。报表模块涉及大量 QObject 连接,视图、模型、代理、委托之间的生命周期关系要理清。我的处理原则是:模型的父对象设成主窗口,视图和委托的父对象也设成主窗口,三者的析构顺序由 Qt 的父子关系保证。代理模型必须设父对象,否则容易忘记 delete。另外打印用的 QPrinter 如果是栈对象,必须保证 QPainter::end() 在它析构之前调用,否则会崩溃,这在异常路径里特别容易漏。
5.2 常见问题速查表
| 现象 | 最可能的原因 | 快速定位手段 | 处理方式 |
|---|---|---|---|
| 万行数据滚动掉帧 | data() 里做了格式化/查询 | 在 data() 里加调用计数 | 格式化前置缓存,data() 只取值 |
| 数字列排序错乱 | 代理按 DisplayRole 字符串比较 | 点一下表头看排序结果 | 自定义 lessThan 用原始值比较 |
| 表头与内容左右错一位 | 冻结列双视图列宽不同步 | 对比两侧 sectionSize | 监听 sectionResized 同步 |
| 打印多出空白页 | 分页循环末尾多 append | 打印页数组大小 | 判断 used > 0 再 append |
| 某行内容超高导致死循环 | 单行高度超过正文区高度 | 打印日志逐行输出高度 | 加 h > contentHeight 强制单页分支 |
| 打印中文变方框或空白 | 字体未指定或未嵌入 | 查看 PDF 字体属性 | 显式指定字体族并确认嵌入 |
| CSV 打开乱码 | 缺 UTF-8 BOM | 十六进制看文件头 | 写入 EF BB BF |
| CSV 数据整体错列 | 字段含逗号未转义 | 搜文件里的逗号数量 | 加引号包裹与双引号转义 |
| 单元格双击后数据消失 | flags 返回了可编辑但没实现 setData | 检查 flags 返回值 | 只读场景移除 ItemIsEditable |
| 退出时崩溃 | QPainter 在 QPrinter 之后析构 | 看崩溃栈 | 保证先 end() 再析构 |
表里最后一条我想再补一句。QPrinter 是 QPaintDevice,QPainter 在它上面 begin 之后,如果 painter 的生命周期超过了 printer,painter 析构时会往一个已经销毁的设备上写数据,直接段错误。我一般的写法是把 painter 声明在 printer 之前,利用栈的逆序析构保证 painter 先亡;或者在函数出口显式调用painter.end()。打印函数里如果有多个 return 分支,很容易漏掉 end,用 RAII 包一层更稳。
5.3 几个不太正经但很有用的经验
经验一:打印预览用一个假的 QPaintDevice。不要每改一次页边距就真打一张纸。QPrinter 设成 PdfFormat 输出到临时目录,再用程序内的查看器打开,迭代速度能快十倍。更极致的做法是继承 QPaintDevice 做一个纯内存的模拟设备,只统计每个绘制指令落到了哪个坐标,用来自动化验证分页是否正确。
经验二:给分页结果加一个可视化的调试开关。我在开发阶段加过一个宏,开启之后每一页的外框、页眉区、正文区、页脚区各画一个彩色的半透明矩形,一眼就能看出哪里超界了。上线前把这个宏关掉。这个技巧在任何涉及版面计算的地方都好使。
经验三:行高不要用 sizeHint 一遍遍算。如果行高是固定的,直接在视图上设 defaultSectionSize;如果行高依赖内容,在数据装载的时候一次性算好存进模型,别指望委托每次去算。委托的 sizeHint 会被高频调用,任何在里面做字符串测量的实现都是性能杀手。
经验四:导出大文件时给出进度反馈。几万行导出 xlsx 可能要好几秒,这期间界面完全卡死,用户以为程序挂了会疯狂点击。正确做法是把导出放到工作线程,主线程显示一个进度对话框,并且允许取消。注意导出过程不能直接操作模型,要在工作线程里先把数据快照出来,否则会跟主线程的模型访问冲突。
最后再分享一个小技巧。这套报表控件的代码量最后到了六千多行,我一个人维护有点吃力,后来做了一件事:把列定义、样式规则、打印参数全部抽成一份配置文件(我用的就是 JSON),界面和打印两头都从这份配置读。好处是业务侧提"这一列改成红色、宽度加倍、打印时不显示",我不用改代码,改配置重新加载就行。缺点是配置的校验要做好,写错了要给出明确的错误提示,不能静默失败。这个设计后来在另一个项目里直接复用,省了大量的重复开发。如果你手上的报表需求还在频繁变动,建议一开始就把这层配置化考虑进去,比事后重构划算得多。