首页 / 资讯中心 / 文章详情

Qt自定义报表控件开发:MVC分层与QPainter分页打印导出

Qt自定义报表控件开发:MVC分层与QPainter分页打印导出 ★ FEATURED ARTICLE
做过桌面端的人对报表这两个字大概都有点复杂情绪。一开始需求总是很朴素把数据库里的单子列出来能滚动、能排序就行。于是你拉了个QTableWidget两天交差。过了一个月业务方拿着打印出来的 A4 纸来找你表头怎么只出现在第一页这一列数字怎么被截断了分组的小计跑哪去了能不能导出一份给财务——到这一步你会发现手里这个控件叫表格而对方要的东西叫报表两者之间隔着的不是一点点样式而是一整套排版、分页、打印和导出的体系。这篇内容讲的就是 QT 自定义报表控件开发这件事怎么用 MVC 架构把数据、排版、渲染三层拆开怎么用 QPainter 把一张报表精确地画到屏幕、打印机和 PDF 文件上以及打印导出这条链路上那些文档里不会写的坑。适合已经写过 Qt 界面、被 QTableView 或 QTextDocument 的排版能力卡住过的开发者如果你只是想做个小表格展示这篇可能有点重但里面的分页和坐标换算思路迟早用得上。1. 现成的表格控件为什么撑不起一张真正的报表1.1 从能滚动的表格到能交差的报表需求差在哪我先把话说死一点QTableView是一个交互控件它的设计目标是在有限的可视区域内高效浏览大量数据而不是把数据排布到有限尺寸的纸面上。这两个目标的约束条件完全相反——前者可以滚动、可以只渲染可见行、列宽可以固定、内容溢出可以省略号后者不能滚动、必须一次算清全部内容的高度、列宽要按内容自适应、内容溢出必须换行且要能跨页续上。具体拆一下一张能拿去交差的报表通常要满足这些条件表头每页重复而且页与页之间的表头高度必须完全一致否则页码会飘。分组小计与总计这些行不是数据行但在视觉上要占据表格行位还要参与分页计算。合并单元格尤其是行分组的第一列跨多行合并这直接破坏了QTableView的row/column二维索引模型。内容自动换行 行高自适应一行文字可能折成三行行高必须动态算出来。跨页断行一个折成三行的单元格正好卡在页底你得决定是整行推到下一页还是把这个单元格拆成两半一半留在本页底部、一半续到下一页顶部。页码与总页数第 2 页 / 共 7 页这种必须有而总页数只有在把整张报表排完一遍之后才知道。页眉页脚与打印边距标题、打印时间、操作人、公司抬头这些东西不属于数据但属于报表。导出保真导出的 PDF 必须和屏幕上看到的一模一样导出的 xlsx 至少要保证列和汇总对得上。这九条里QTableView的原生能力一条都给不了。QTableView能给你的只有数据 交互剩下的排版工作必须自己做。1.2 三条技术路线的真实取舍在动手之前路线选择比写代码重要得多。我前后试过三种做法各自的边界很清晰路线实现方式优势硬伤适用场景表格控件 DelegateQTableView 自定义QStyledItemDelegate屏幕交互免费排序过滤现成分页、跨页断行、表头重复全部做不了打印要另写一套只做屏幕展示不打印富文本文档QTextDocument HTML/QTextTable排版引擎成熟换行、分页、表格合并都是现成的列宽控制粒度粗大数据量下构建 HTML 字符串极慢样式受限于富文本子集数据量小、排版花哨图文混排的合同、通知自绘QAbstractTableModelQPainter直接画完全可控屏幕/打印/PDF 共用一套绘制代码保真度 100%工作量大换行、合并、分页都得自己写数据量大、格式严格、必须打印和导出我最终选的是第三条。原因不是自绘多优雅而是它解决了一个关键问题同一份绘制代码可以喂给不同的QPaintDevice。屏幕上的QWidget、打印时的QPrinter、导出时的QPdfWriter本质上都是QPaintDevice只要你的绘制函数签名写成void render(QPainter *painter, const Layout layout)屏幕预览和打印输出就是同一段逻辑不存在屏幕上对、打印出来错位这种经典事故。QTextDocument那条路我不是没用过。做过一个图文混排的通知单HTML 拼一拼QTextDocument::print()直接出片非常爽。但换成一张 30 列、每列宽度要按内容自适应、还要跨页合并单元格的明细表QTextTable的列宽策略会让你抓狂——它按百分比分配剩余宽度你没法精确控制这一列最少 80 像素、最多 200 像素、优先给内容最长的列。提示如果你只是需要打印一个能用的表格QTextDocument生成的 HTML 配合QPrinter三十行代码就能跑起来。真正逼你自绘的通常是列宽策略和合并单元格这两件事。2. 把报表切成三块Model、Layout、Renderer2.1 Model 只回答数据是什么不回答长什么样MVC 这三个字母在网上被讲烂了但在报表场景里它有非常具体的含义Model层不允许出现任何一个像素值。这是我在第二个项目里定下的硬规矩违反这条规矩的代码最后都会变成一坨改不动的面条。具体到实现ReportModel继承QAbstractTableModel它对外只提供三类信息结构信息行数、列数、每一列的标题、列的类型文本/数字/日期/金额、对齐方式、是否参与汇总。这些通过headerData()和一个自定义的ColumnDef结构暴露。单元格数据data()返回DisplayRole时给原始展示文本返回自定义 role 时给单元格级别的样式覆盖比如负数标红、超限加粗。分组与汇总信息哪些行属于同一组、每组的汇总值是多少。这部分不适合塞进rowCount/columnCount的二维模型我用一个独立的groupAt(row)接口暴露。关键在于Model不知道列宽是多少、不知道一页能放几行、不知道字体多大。它只知道第 3 行第 2 列的文字是 1234.56类型是金额右对齐。这些信息是排版无关的屏幕和打印都能用。2.2 Layout 层把逻辑行翻译成页这是整个架构里最容易被忽略、但价值最高的一层。ReportLayout的职责是吃进一个 Model、一份纸张参数宽高、边距、页眉页脚高度、一份字体和列宽策略吐出一组Page对象。每一页Page里装的是绘制指令而不是数据struct CellRect { QRectF rect; // 该单元格在本页内的绝对矩形 QString text; // 已经断好行的文本片段 Qt::Alignment align; bool bold false; bool isGroupRow false; int rowSpan 1; // 合并行数 int colSpan 1; // 合并列数 }; struct Page { QVectorCellRect cells; // 本页所有单元格 QVectorLine lines; // 表格线 int pageIndex 0; int totalPages 0; QString headerText; // 页眉 QString footerText; // 页脚含页码 };为什么要把排版和绘制拆开因为分页计算和总页数统计是需要反复迭代的第一遍排完你才知道总页数总页数知道了才能回填第 X 页 / 共 Y 页而页脚文字的宽度变化又可能影响页脚高度……如果把这些逻辑和QPainter的绘制混在一起写你会陷入边画边算、算完重画的泥潭。拆成 Layout 之后流程变成算一遍 → 得到完整页序列 → 绘制两遍第一遍只为测总页数第二遍回填页码后正式画。2.3 Renderer一份绘制代码喂给三种设备ReportRenderer只有一个公开函数void ReportRenderer::paint(QPainter *painter, const ReportLayout layout);它内部按页循环对每页画背景、画表头、画单元格文本、画表格线、画页眉页脚。屏幕预览的QWidget::paintEvent调它打印的QPrinter调它PDF 导出的QPdfWriter调它。三种设备的差异全部在调用方处理// 屏幕预览 void ReportPreview::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); p.setRenderHint(QPainter::TextAntialiasing, true); m_renderer.paint(p, m_layout); } // 打印 void doPrint(ReportLayout layout, QPrinter *printer) { QPainter p(printer); p.setRenderHint(QPainter::TextAntialiasing, true); ReportRenderer r(printer-pageRect(QPrinter::Millimeter)); r.paint(p, layout); }这里有个必须注意的细节屏幕和打印机的逻辑坐标系单位不一样。屏幕的QWidget默认单位是设备像素打印机的QPrinter默认单位是打印机的点通常 1/600 英寸。如果你在 Layout 里直接按像素算宽度打出来会缩成指甲盖大小。我的做法是Layout 内部统一用**毫米mm**作为逻辑单位渲染时由 Renderer 根据 device 自己做一次缩放变换qreal scaleX painter-device()-width() / painter-device()-widthMM(); qreal scaleY painter-device()-height() / painter-device()-heightMM(); painter-scale(scaleX, scaleY); // 之后所有坐标都按毫米算这样 Layout 里写的QRectF(20, 30, 170, 8)就真的是距左 20mm、距顶 30mm、宽 170mm、高 8mm无论输出到哪都是同一个物理尺寸。3. 报表 Model 的落地细节3.1 索引映射与自定义 role 的设计QAbstractTableModel的四个必备函数实现起来不难难的是 role 的划分。我踩过一次坑早期把是否加粗是否标红直接塞进Qt::FontRole和Qt::ForegroundRole结果发现这两个 role 的语义是整格样式覆盖一旦某个单元格需要文字加粗但底色保持斑马纹就表达不了了。后来改成两层enum ReportRole { RawValueRole Qt::UserRole 1, // 原始值数字型用于排序和汇总 CellTypeRole, // 列类型Text / Number / Money / Date StyleFlagRole, // 位标记Bold / Red / Strike / MergeStart }; QVariant ReportModel::data(const QModelIndex idx, int role) const { if (!idx.isValid()) return {}; const Row row m_rows.at(idx.row()); const ColumnDef col m_columns.at(idx.column()); switch (role) { case Qt::DisplayRole: return formatValue(row.cells.at(idx.column()), col.type); case RawValueRole: return row.cells.at(idx.column()).raw; case CellTypeRole: return int(col.type); case StyleFlagRole: return int(computeStyleFlag(row, idx.column())); case Qt::TextAlignmentRole: return int(col.align | Qt::AlignVCenter); default: return {}; } }RawValueRole的存在是为了汇总。DisplayRole返回的是带千分位和两位小数的字符串1,234.56你拿它去做加法会得到灾难性的结果。汇总必须走原始值这一点在sum()里体现得特别明显——直接遍历RawValueRole累加最后再统一格式化输出误差和性能都更可控。3.2 分页拉取还是全量加载一张表 20 万行时的选择这个问题没有标准答案取决于你的报表是否必须一次看到全部。我经手过的场景里两种都用过屏幕浏览用分页拉取。滚动条滚到第 500 行附近时触发一次LIMIT 200 OFFSET 400的查询把窗口补满。rowCount()返回总数单独一条COUNT(*)这样滚动条长度是准的。代价是滚动时可能有短暂白屏解决办法是提前预取——判定当前可视区间到了已加载区间的 70% 就异步拉下一页。打印和导出必须全量。因为分页计算需要知道每一行的实际折行高度而高度又依赖内容你没法猜第 300 页长什么样。所以打印前我会用一个独立的PrintDataProvider去全量查一次只保留渲染需要的最小字段集去掉图片、备注这些大字段20 万行大概占用 40MB 左右内存可接受。这里有个易错点屏幕浏览用的数据和打印用的数据必须是同一份 Model 派生出来的否则用户看到的和打印出来的对不上。我的做法是让ReportModel支持一个materializeAll()方法把所有分页数据按顺序拼回一个完整数组拼装过程中校验主键连续性一旦发现缺行就直接报错——总比打出一张少了两行数据的报表强。3.3 分组小计行怎么塞进二维模型这大概是整个 Model 层最别扭的地方。假设原始数据是按部门分组的销售明细每组末尾要有一行部门小计整张表末尾还要有总计。这行在小计在视觉上是表格的一行但它不是任何一条业务数据。有两条路路线 AModel 里插虚拟行。rowCount()返回数据行数 组数 1data()里根据行号判断这一行是数据行还是小计行是小计行就返回格式化后的汇总字符串。优点是渲染层完全无感Layout 把它当普通行处理缺点是行号与业务数据的索引映射变得复杂排序、过滤、编辑全部要重写。路线 BModel 只管数据分组信息单独暴露。ReportModel保持纯粹的数据行另外提供一个GroupInfo { int startRow; int endRow; QHashint,double sums; }的列表。Layout 在排版时按行遍历遇到组末就插入一条小计行。我选的是 B。理由是报表的汇总规则往往很复杂——有的列求和、有的列求平均、有的列要显示最大值还有的列要算占比。把这些规则塞进data()的 switch 里代码会变成一坨。独立出来之后汇总逻辑可以单独写单元测试也方便业务方后续改口径。struct GroupInfo { int startRow; int endRow; QString label; // 显示在小计行第一列的文字 QHashint, QVariant sums; // 列号 → 汇总值 }; QVectorGroupInfo ReportModel::groups() const { return m_groups; }Layout 遍历的时候大概是这样for (const GroupInfo g : model.groups()) { for (int r g.startRow; r g.endRow; r) layoutRow(r, /*isGroupMember*/true); layoutSummaryRow(g.label, g.sums); // 小计行样式稍重 }4. QPainter 绘制的硬骨头列宽、换行、合并、分页4.1 列宽分配先量内容再分剩余列宽策略是决定报表好不好看的第一要素。全固定宽度会出现备注列文字被截成三行、日期列大片空白全平均分配更糟。我用的是一个两轮算法第一轮测量每列内容的自然宽度不换行时的最大宽度用QFontMetricsF::horizontalAdvance()QVectorqreal natural(cols.size(), 0); for (const auto row : rows) { for (int c 0; c cols.size(); c) { QFont f fontFor(row, c); QFontMetricsF fm(f); qreal w fm.horizontalAdvance(textFor(row, c)) 2 * padding; natural[c] qMax(natural[c], w); } }第二轮按列配置的约束收拢每列有minWidth比如 15mm保证表头不被压成两行和maxWidth比如 60mm超过就换行。把natural[c]夹到[minWidth, maxWidth]区间里得到demand[c]。所有demand之和如果小于可用宽度剩余空间按权重分掉。权重我通常设成该列内容的平均长度占全部平均长度的比例这样长文本列会多分到一些。如果大于可用宽度按比例压缩但压缩后不能低于minWidth如果压到最低还放不下就整体缩小字号——这最后一步很关键A4 横向 30 列的报表10 号字是绝对放不下的。这套算法有个隐藏前提表头文字也要参与测量。我见过太多报表数据列宽算得好好的结果表头累计销售金额含税被压成三行整页高度全乱了。4.2 自动换行与跨页断行换行不用自己写算法QTextLayout能帮你干这个活而且它给出的行信息是后续跨页断行的基础QTextLayout layout(text, font); layout.setCacheEnabled(true); QTextOption opt; opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); opt.setAlignment(align); layout.setTextOption(opt); layout.beginLayout(); QVectorQTextLine lines; qreal y 0; while (true) { QTextLine line layout.createLine(); if (!line.isValid()) break; line.setLineWidth(cellWidth - 2 * padding); line.setPosition(QPointF(0, y)); y line.height(); lines.append(line); } layout.endLayout();对中文而言WrapAtWordBoundaryOrAnywhere是必须的——中文没有空格WrapAtWordBoundary会让一整段文字变成一个不可断的词直接溢出单元格。Anywhere保证按字符断行。真正的难点在跨页。假设一行数据折成了 4 行文字页底只剩下能容纳 2 行的空间。三种处理策略整行推移这一行整体挪到下一页本页底部留白。实现最简单视觉效果最整齐代价是页底可能有较大空白。行内切断按QTextLine切前两行留在本页后两行续到下一页续页时重复表头并在行首加续标记。视觉最紧凑实现最复杂。不允许切断在 Layout 阶段就把行高限制住超长内容直接省略号。不推荐财务类报表绝对不能丢字。我用的是策略 1附加一个优化如果这行的高度超过整页可用高度的 40%就退化成策略 2否则留白太浪费。判断逻辑写在 Layout 的分页循环里qreal remain pageBottom - cursorY; if (rowHeight remain) { placeRow(row); // 放得下 } else if (rowHeight pageHeight * 0.4) { splitRowAcrossPages(row); // 太高必须切 } else { newPage(); // 挪到下一页 placeRow(row); }4.3 合并单元格与每页表头重复合并单元格的处理顺序很关键先划分网格再合并矩形最后画内容。如果一边算行高一边合并行高会被合并逻辑反复篡改。我的做法是三步走第一步算出每一行的基准行高该行所有单元格折行后的最大高度第二步扫描合并标记把(r1,c1)到(r2,c2)的矩形合并合并后的高度是各基准行高之和把这个高度回填到组内每一行的记录里第三步才真正开始画。表头重复相对简单因为它在 Layout 里就是每页开头插入一段固定高度的行void ReportLayout::startPage() { Page page; page.pageIndex m_pages.size() 1; // 页眉区 placeHeaderBlock(page); // 表头行 qreal y m_marginTop m_headerHeight; for (int c 0; c m_columns.size(); c) { page.cells.append(makeHeaderCell(c, y)); } m_cursorY y m_tableHeaderHeight; m_pages.append(page); }注意m_cursorY的重置点必须精确到表头下边线差 0.5mm 都会导致后半页的所有内容整体偏移——这种偏移在屏幕上根本看不出来打出来才发现最后一页少了一行。5. 打印与导出的复用链路5.1 QPrinter 与 QPdfWriter 的坐标换算QPrinter和QPdfWriter都能产出 PDF选择上有个细节差异QPrinter的好处是它同时支持真的送去打印机和输出 PDF一份代码两种用途QPdfWriter更轻量不依赖打印子系统适合服务端无界面导出。但两者的坐标行为有微妙差别。QPrinter默认的分辨率跟打印机驱动走可能是 600 也可能是 1200QPdfWriter可以在构造时指定分辨率QPdfWriter writer(report.pdf); writer.setResolution(300); // 300 dpi writer.setPageSize(QPageSize(QPageSize::A4)); writer.setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout::Millimeter); QPainter p(writer); // 此时 painter 的逻辑坐标单位是 1/300 英寸 qreal unitsPerMm writer.resolution() / 25.4; // ≈ 11.81 p.scale(unitsPerMm, unitsPerMm); // 之后按毫米画这里最容易犯的错是把边距设了两遍。QPdfWriter已经有setPageMargins了而我的 Layout 里也自己留了 20mm 的页边距结果 PDF 打开一看内容缩在中间一小块。解决办法二选一要么让 writer 的边距全部为 0由 Layout 自己控制要么 writer 设边距、Layout 从 0 开始画。我选前者因为 Layout 里还要处理页眉页脚统一管理更清晰。还有一个小坑QPrinter的pageRect()在不同单位下返回的数值不一样QPrinter::Millimeter返回的是可打印区域的毫米尺寸QPrinter::DevicePixel返回的是像素。打印机的可打印区域通常比你设置的纸张小一圈物理不可打印边距所以 Layout 必须按pageRect的实际可用区域算而不是按 A4 的 210×297 算否则右边和下边会被裁掉。5.2 打印预览与总页数的两遍渲染QPrintPreviewDialog的用法很直接连接paintRequested(QPrinter*)信号即可。但这里有个必须先想清楚的问题预览对话框在打开时会立刻触发一次paintRequested而此时你的 Layout 可能还没算完总页数。我的处理顺序是先调layout.build()完成全部分页计算此时每页的pageIndex已确定但totalPages字段是空的。layout.build()内部最后一步是遍历所有页把totalPages回填。回填之后页脚文字长度变了但页脚区域高度是固定的所以位置不用重算——这是我在设计页脚时故意留的高度冗余就是为了避免回填总页数后再触发一次全文重排。预览对话框触发paintRequested时Layout 已经是最终态直接画。connect(preview, QPrintPreviewDialog::paintRequested, this, [this](QPrinter *printer) { QPainter p(printer); p.setRenderHint(QPainter::TextAntialiasing, true); ReportRenderer renderer; renderer.setPageGeometry(printer-pageRect(QPrinter::Millimeter)); renderer.paint(p, m_layout); // m_layout 已 build 完毕 }); preview-exec();如果页脚高度实在不够、必须动态调整那就得走两遍build()第一遍算出页数第二遍带上正确页数重新排。这时候要注意第二遍的页数可能又变了因为页脚变高导致每页行数减少所以要循环到稳定通常 2 到 3 轮收敛。我一般避免这种设计宁可页脚区域留宽一点。5.3 导出 xlsx别用 CSV 糊弄导出 Excel这个需求十个里有八个最后变成导出了一个 CSV。CSV 能用但坑非常多而且每一个坑用户都会算在你头上问题表现处理方式中文乱码双击打开全是问号写 UTF-8 BOM 头\xEF\xBB\xBF长数字变科学计数订单号1234567890123456显示成1.23E15无解必须用 xlsx 并显式设成文本格式前导零丢失工号007变成7同上含逗号或引号的字段列错位字段整体加双引号内部双引号转义成两个分隔符冲突某些区域设置下默认分隔符是分号导出时提供分隔符选项别写死我的判断标准很简单如果用户要拿着这份文件做二次计算求和、透视、vlookup必须走 xlsx如果只是发给人看看CSV 也凑合。xlsx 需要引入第三方库比如一些轻量的 xlsx 写入库但换来的是格式、列宽、合并单元格、数字格式全都能保住——报表导出的价值一大半就在这些格式上。如果是走 xlsx 路线有几个细节值得提前规划列宽要按字符宽度换算xlsx 的列宽单位是标准字符数不是像素需要按字体做一次近似换算。合并单元格支持但合并区域内只有左上角的单元格能写值其余必须留空否则文件打开会报错。数字格式用#,##0.00这类格式串别把格式化后的字符串直接写进去否则用户想改小数位数都改不了。如果是自绘报表转 xlsx还有个概念映射的问题屏幕上跨页的行在 xlsx 里是一整行所以导出的数据源必须是完整的 Model而不是已经分好页的 Layout。这也是前面强调打印和导出必须全量数据的原因之一。6. 实测踩坑清单6.1 高分屏下的线宽与字体错位这个问题坑了我整整两天。开发机是普通屏报表画得横平竖直换到 4K 屏的机器上一看表格线粗了一圈相邻两列的线还会错位半个像素。根因是QPen的宽度。当你写painter.setPen(QPen(Qt::black, 1))时1 是逻辑单位在 200% 缩放的屏幕上会被放大成 2 个物理像素视觉上就是粗线。而错位是因为坐标系做了非整数缩放QRectF的边界落在半个像素上渲染时被分别向上向下取整相邻两个矩形就出现了缝隙或重叠。两个办法我两个都用// 方法一用 cosmetic pen宽度永远 1 物理像素不受缩放影响 QPen pen(Qt::black); pen.setWidthF(0); // 0 表示 cosmetic painter.setPen(pen); // 方法二绘制前把坐标对齐到设备像素网格 qreal dpr painter-device()-devicePixelRatioF(); auto snap [dpr](qreal v) { return std::round(v * dpr) / dpr; };方法一解决线宽方法二解决错位。注意方法二只在画线和画矩形时用画文字千万别 snap否则字体会因为亚像素定位丢失而变得毛糙。还有个相关的坑字体大小。屏幕上用QFont(Microsoft YaHei, 9)看着正好打印出来偏小。因为打印机的 DPI 远高于屏幕同样的 point size 在不同 DPI 下物理尺寸是一样的point 是物理单位但视觉上会觉得小。我的做法是 Layout 里所有字号统一用pointSizeF并且针对打印单独乘一个 1.1 的系数——这是纯粹的经验值纸张上留白多字大一点更舒服。6.2 大数据量下的绘制卡顿屏幕预览直接调renderer.paint()画全部页200 页的时候滚动会卡成幻灯片。原因很简单每帧都在重画所有页的文本而QTextLayout的布局计算是很重的操作。三个层次的优化按收益排序第一层只画可见页。把多页预览拆成一次显示一页 翻页的模式paintEvent里只调当前页的绘制。这是收益最大的一步直接砍掉 99% 的绘制量。第二层缓存 Layout 结果。QTextLayout算出来的行位置在内容不变的情况下是固定的把它连同文本片段一起缓存进CellRect重绘时直接用不重复beginLayout/endLayout。实测一页 40 行 × 12 列的报表单页绘制从 8ms 降到 1.2ms 左右。第三层缓存整页到 QPixmap。如果一页内容完全不变化可以把它渲染成QPixmap缓存起来滚动时直接drawPixmap。注意缓存要跟着devicePixelRatio走否则高清屏上会糊QPixmap cache(pageSize.toSize() * dpr); cache.setDevicePixelRatio(dpr); cache.fill(Qt::white); QPainter p(cache); renderer.paint(p, page); p.end();这一层的代价是内存一页 A4 在 200% 缩放下大概 5MB缓 20 页就是 100MB。所以要设个上限用 LRU 淘汰。还有一个隐蔽的性能杀手在绘制循环里反复构造QFont。QFont的构造和比较不便宜而QFontMetricsF的构造更贵。我的做法是在 Layout 阶段就把每种样式正文、表头、小计、总计的QFont和QFontMetricsF预先构造好存成枚举索引绘制时按索引取。这个改动本身不显眼但在 200 页 × 40 行的量级上能省掉几万次构造。6.3 几个容易忽略的边界最后列几个我实际撞过的边界情况都不难处理但不知道就会花时间空数据。rowCount()为 0 时报表不能什么都不画至少要画表头和一行暂无数据。这个判断要放在 Layout 里而不是 Renderer 里否则导出的 PDF 会是一张白纸。单行超高。某一行的备注写了 800 字折行后高度超过整整一页。前面说的超过 40% 就切的策略在这里要特判如果切完第一段还是超过一页就必须继续切形成这一行占了三页的极端情况。实现上要允许placeRow递归调用。列宽超限。所有列的最小宽度之和超过了纸张可用宽度。这时候必须有降级策略先缩字号还不行就允许横向压缩到最小值以下最后的手段是改纸张方向为横向。我做的报表里超过 16 列的默认建议用户选横向代码里也做了自动判断。打印机的可打印区域。有些虚拟打印机返回的pageRect比 A4 小很多Layout 按 A4 算出来的内容会被裁。解决办法是在paintRequested里实时读printer-pageRect()用它去重建 Layout而不是复用屏幕上那份。字体缺失。报表指定了某个字体目标机器上没装Qt 会静默回退到别的字体宽度全变原本算好的列宽全部失效。我的做法是只用系统保证存在的字体族并且在 Layout 阶段用QFontInfo::family()检查实际生效的字体不一致就记录一条警告。7. 关于这套架构的一点使用体会这套三层结构跑到第三个项目的时候我开始觉得它真正的价值不在报表而在任何需要精确排版的输出。后来我把ReportLayout抽成了一个独立的排版引擎同一个引擎既渲染报表也渲染过发货单和质检单——后者甚至没有表格全是表单式的键值对加签名栏但因为走的是同一套逻辑单位 分页 同一份绘制代码的路径实现成本比重新用 HTML 拼一遍低得多。如果让我重新做一遍有两件事我会提前定死。第一是逻辑单位从第一天就用毫米不要先用像素然后再补缩放换算那个改造过程会牵动所有坐标计算。第二是 Layout 的输出结构一开始就设计成可以序列化的纯数据结构别在CellRect里塞QFont指针这种运行时对象不然后面想做排版结果缓存到磁盘、下次直接加载就没法做。至于列宽算法和分页策略不用一开始就想得很完美先把固定列宽 整行推移跑通能打出第一张纸然后拿着真实数据去调。我做第一个版本时在列宽分配上花了一周最后发现业务方真正在意的只有两件事汇总数字对不对、表头每页有没有重复。列宽好不好看反而是最不影响验收的那一项。
阅读完成 · 觉得有帮助?
咨询建站