ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JasperReport行内容合并实战:从JRXML配置到Excel导出

JasperReport行内容合并实战:从JRXML配置到Excel导出 做过报表开发的人应该都有印象JasperReport跑出来的表格样式很整齐但真要做“行内容合并”比如把同一部门在每一行都重复的部门名合并成一个单元格问题就来了——很多人在JasperSoft Studio里翻遍了工具栏发现根本没有Excel那种“合并单元格”按钮。也有人用文本框输出重复字段PDF里看着还行一导出Excel又全乱了。这篇内容就围绕JasperReport制作行内容合并的表格这件事把底层原理、JRXML配置、完整实操步骤和几个高频踩坑点一次讲清楚。适合正在用JasperReports/JasperSoft Studio做报表的Java开发也适合刚被“合并”需求折磨的报表新手。1. 需求拆解与方案选型为什么行内容合并这么麻烦1.1 报表里的“合并”到底是什么要理解JasperReport的行合并先要跳出Excel的使用习惯。Excel里“合并单元格”是一种物理操作选中一片区域右键合并区域就变成一个跨行跨列的矩形网格。数据存放在合并后的首个单元格里边框也统一收拢。JasperReport不是这个逻辑。它是带状带区Band流式排版引擎模板由Title、PageHeader、ColumnHeader、Detail、ColumnFooter、PageFooter等带区组成。数据源有多少行Detail带区就从上到下重复渲染多少次。每个单元格本质是一个Text Field文本框它们各自独立彼此之间没有“合并”这个概念。所以在JasperReport里谈“行内容合并”工程上的含义是让某一列的值在连续多行中只显示一次从视觉上形成合并效果。如果导出到Excel还需要额外告诉导出器“这些视觉上隐藏的重复格要在网格模型里真正合并成一个物理单元格”。这个区别非常关键因为很多报表工程师一开始选择在导出后用POI去改Excel结果发现JasperReport生成的坐标对不齐越改越乱。理解了底层是“重复渲染控制显示”后续的方案就清晰了。1.2 三条技术路线隐藏、去重与真实合并做行内容合并业界普遍有三条路线我按推荐程度和使用频率排一下。路线A隐藏重复值。JasperReport的Text Field元素上有一个属性叫isHideRepeatedValue设置为true后系统在填充数据时会自动比较相邻两行的当前字段值如果相同当前行就不绘制该单元格。这是绝大多数“合并”需求的首选方案配置极简PDF、HTML效果很稳定。路线B分组变量加条件控制。利用Group分组再配合报表变量和printWhenExpression手动判断“当前行是组内第几行”只在组内第一行显示该字段。这个方案的优点是可控性强适合那些相同值不连续出现、或者需要对首行做特殊样式的场景。路线C导出后处理。报表先正常输出再用Apache POI等库读取生成的Excel文件定位坐标后调用addMergedRegion合并单元格。适合旧项目或者JasperReport导出功能实在搞不定的情况但缺点很明显——坐标计算受字体、行高、横向拉伸影响维护成本高。三条路线各有利弊我建议新项目优先考虑A方案B方案作为补充C方案只做救急用。1.3 方案怎么选按场景对号入座具体选哪条路要看你最终输出的格式和业务要求。如果报表只出PDF或者HTML直接无脑用路线A。把需要合并的列设置成isHideRepeatedValuetrue再保证数据源按合并字段排好序基本就完成了。如果报表需要导出Excel并且业务要求Excel里必须是真正的合并单元格那么路线A依然可以用但要配合JasperReport的Excel导出参数开启merge.cell。这一步很多人不知道导致导出的Excel里那列是空白的还以为隐藏重复值属性失效了。如果合并逻辑比较复杂比如“同一个部门下再按岗位合并岗位不同时部门也要重新显示”这种嵌套关系用路线B更清晰。你可以把部门和岗位都定义成Group然后在Detail里通过变量控制显示条件。如果项目已经有一套跑了几年的JasperReport突然要加复杂合并又不想动原有模板那就走路线C。导出的Excel交给一个独立的工具类处理逻辑和报表模板解耦至少不会炸掉原有功能。2. JRXML核心配置解析从字段到带宽度的联动2.1 理解Band渲染模型想要配置不出错先得把Band的渲染模型刻进脑子里。JasperReport渲染报告时数据源一行一行地喂给Detail Band。每个Band都是一块固定宽度的区域里面放着若干个Text Field。你可以把它想象成针式打印机报表模板是打印头每来一条数据打印头就把Detail带区里的所有元素按坐标“打”一遍。不同行的文本输出在纵向上错开看起来就是一张表格。所以行合并的本质就是控制“打印头”在某个字段上是否输出。JasperReport提供了两个层面的控制一个是显示层面是否绘制此文本框一个是空间层面是否保留空位。isHideRepeatedValue控制的是前者它只不画但位置还在。如果你想连空位一起消掉还要配合元素的isRemoveLineWhenBlank属性这个在实际项目中很重要我后面会提。2.2 隐藏重复值的核心属性JRXML里给一个Text Field开启隐藏重复值写法如下textField reportElement x0 y0 width100 height20 isHideRepeatedValuetrue isRemoveLineWhenBlankfalse/ textFieldExpression![CDATA[$F{deptName}]]/textFieldExpression /textField关键就是reportElement上的isHideRepeatedValuetrue。它生效的前提是重复值必须连续出现。如果数据源里同一个部门的值中间被其他部门隔开等到它再次出现时系统会认为是一个新的序列又会显示一次。这导致很多新手踩坑明明设置了隐藏部门名还在重复显示。一查数据根本没有按部门排序。所以使用这个属性之前必须在SQL里ORDER BY或者在Java代码里先把数据排好序保证相同值紧挨着。另外注意isHideRepeatedValue比较的是字段最终渲染出来的值不是原始数据。如果部门名字尾带空格或者有换行符肉眼看不出来但系统认为它们不一样隐藏就不会生效。2.3 配合Group实现分组内合并隐藏重复值适合“全局连续”的场景但真实报表经常要按层次分组比如部门下面分岗位。这时最稳妥的做法是定义Group然后让Detail里的合并字段配合分组边界工作。一个典型的JRXML分组结构类似这样jasperReport ... group namedeptGroup groupExpression![CDATA[$F{deptName}]]/groupExpression groupHeader band height20 textField reportElement x0 y0 width100 height20/ textFieldExpression![CDATA[$F{deptName}]]/textFieldExpression /textField /band /groupHeader /group detail band height20 textField reportElement x0 y0 width100 height20 isHideRepeatedValuetrue/ textFieldExpression![CDATA[$F{deptName}]]/textFieldExpression /textField /band /detail /jasperReport这里是两个思路的组合分组时如果你把部门名放到Group Header里它天然只显示一次因为Group Header只在每组开头渲染一次。Detail里的部门名设置成隐藏重复值是为了防止在某些导出场景下Group Header和Detail的第一行内容叠加造成两边都有字的尴尬。还有一个进阶技巧如果你希望合并后的文字垂直居中于整组区域不能用Group Header因为它的高度是固定的不会随组内行数变化。这时候就需要用printWhenExpression配合变量来实现了。类似这种variable namedeptRowIndex classjava.lang.Integer resetTypeGroup resetGroupdeptGroup variableExpression![CDATA[$V{deptRowIndex} null ? 1 : $V{deptRowIndex} 1]]/variableExpression /variable然后在Detail里的部门列上写textField reportElement x0 y0 width100 height20 printWhenExpression![CDATA[$V{deptRowIndex} 1]]/printWhenExpression /reportElement textFieldExpression![CDATA[$F{deptName}]]/textFieldExpression /textField这样只有每个部门组的第一行会输出部门名后续行为空。如果再结合stretchType和positionType就能模拟出文字跨行垂直居中的效果。这种方式在各种导出器下表现都比较一致我个人的经验是遇到“合并单元格里要居中”的需求优先用这套组合而不是死磕隐藏重复值。2.4 导出Excel时的物理合并参数这是很多人的知识盲区。JasperReport在PDF里隐藏重复值表现很好但在Excel里如果不额外配置导出后隐藏掉的单元格会变成空格并不会真的合并。要让XLS/XLSX导出时真正合并单元格需要在JRXML根节点加一个属性jasperReport xmlnshttp://jasperreports.sourceforge.net/jasperreports xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd nameMergeDemo property namenet.sf.jasperreports.export.xls.merge.cell valuetrue/ ... /jasperReport对应XLSX导出器则设置property namenet.sf.jasperreports.export.xlsx.merge.cell valuetrue/如果是在Java代码里动态导出Excel也可以这样设置SimpleXlsxReportConfiguration configuration new SimpleXlsxReportConfiguration(); configuration.setMergeCell(true); exporter.setConfiguration(configuration);开启这个参数后JasperReport会把相邻且值相同的单元格在导出时合并成一个物理合并区域。注意这个功能依赖隐藏重复值属性已经生效两者是配合关系不是替代关系。还有一点经验XLS和XLSX对合并单元格的规则略有差异XLS格式复杂样式多了容易出兼容问题建议优先用XLSX。3. 实操一个“员工工时汇总表”的行合并完整实现3.1 准备数据源纸上谈兵没有用我直接用实际例子走一遍。假设要做一张员工工时汇总表列分别是部门、员工号、姓名、工时。业务要求部门列在Excel里按相同值合并员工号和姓名正常每行显示最右侧统计总工时。数据源我用Java的ListMapString, Object方便演示ListMapString, Object rows new ArrayList(); rows.add(Map.of(deptName, 研发部, empNo, E1001, empName, 张伟, hours, 40)); rows.add(Map.of(deptName, 研发部, empNo, E1002, empName, 李娜, hours, 36)); rows.add(Map.of(deptName, 市场部, empNo, E2001, empName, 王强, hours, 42)); rows.add(Map.of(deptName, 市场部, empNo, E2002, empName, 赵敏, hours, 38)); rows.add(Map.of(deptName, 市场部, empNo, E2003, empName, 刘洋, hours, 35));这里我刻意没有做排序待会儿会说明为什么必须先处理顺序。接下来把这份数据塞进JRBeanCollectionDataSource即可JRDataSource dataSource new JRBeanCollectionDataSource(rows);3.2 编写JRXML布局与属性配置新建一个deptMergeDemo.jrxml核心配置如下?xml version1.0 encodingUTF-8? jasperReport nameDeptMergeDemo xmlnshttp://jasperreports.sourceforge.net/jasperreports xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd property namenet.sf.jasperreports.export.xlsx.merge.cell valuetrue/ field namedeptName classjava.lang.String/ field nameempNo classjava.lang.String/ field nameempName classjava.lang.String/ field namehours classjava.lang.Integer/ columnHeader band height30 staticText reportElement x0 y0 width120 height30 property namenet.sf.jasperreports.style valueheader/ /reportElement text![CDATA[部门]]/text /staticText staticText reportElement x120 y0 width80 height30/ text![CDATA[员工号]]/text /staticText staticText reportElement x200 y0 width100 height30/ text![CDATA[姓名]]/text /staticText staticText reportElement x300 y0 width80 height30/ text![CDATA[工时]]/text /staticText /band /columnHeader detail band height25 textField reportElement x0 y0 width120 height25 isHideRepeatedValuetrue box pen lineWidth0.5/ /box /reportElement textFieldExpression![CDATA[$F{deptName}]]/textFieldExpression /textField textField reportElement x120 y0 width80 height25 box pen lineWidth0.5/ /box /reportElement textFieldExpression![CDATA[$F{empNo}]]/textFieldExpression /textField textField reportElement x200 y0 width100 height25 box pen lineWidth0.5/ /box /reportElement textFieldExpression![CDATA[$F{empName}]]/textFieldExpression /textField textField reportElement x300 y0 width80 height25 box pen lineWidth0.5/ /box /reportElement textFieldExpression![CDATA[$F{hours}]]/textFieldExpression /textField /band /detail /jasperReport这个模板的关键点一是根节点加了net.sf.jasperreports.export.xlsx.merge.cell属性二是部门的Text Field开启了isHideRepeatedValuetrue。每一格都加了最细的边框线lineWidth0.5因为合并单元格在Excel里很容易出现边框缺失后面我会详细讲。还有一个细节我给表头单元格加了一个自定义样式属性net.sf.jasperreports.style这个属于锦上添花实际开发中你可以直接用style标签定义全局样式再通过style属性引用。3.3 编译、填充与导出用Java API编译、填充、导出完整代码如下public static void main(String[] args) throws Exception { // 1. 编译模板 JasperReport jasperReport JasperCompileManager.compileReport(deptMergeDemo.jrxml); // 2. 填充数据 MapString, Object params new HashMap(); ListMapString, Object rows buildRows(); JRDataSource dataSource new JRBeanCollectionDataSource(rows); JasperPrint jasperPrint JasperFillManager.fillReport(jasperReport, params, dataSource); // 3. 导出PDF JasperExportManager.exportReportToPdfFile(jasperPrint, deptMergeDemo.pdf); // 4. 导出XLSX并开启合并单元格 JRXlsxExporter exporter new JRXlsxExporter(); exporter.setExporterInput(new SimpleExporterInput(jasperPrint)); exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(deptMergeDemo.xlsx)); SimpleXlsxReportConfiguration config new SimpleXlsxReportConfiguration(); config.setMergeCell(true); exporter.setConfiguration(config); exporter.exportReport(); }这里最容易被忽略的是SimpleXlsxReportConfiguration.setMergeCell(true)。如果你漏了这行XLSX里部门列会是一堆空格不仅没合并反而比不隐藏还难看。编译前有一个前置条件数据源必须按部门排好序。上面buildRows()里如果直接用原始数据虽然用户看起来是“研发部、研发部、市场部、市场部、市场部”确实连续但在复杂报表中不保证。稳妥的做法是在SQL里加ORDER BY deptName, empNo或者在Java里用Stream排序rows.sort(Comparator.comparing(r - r.get(deptName).toString()));3.4 效果验证与细节微调跑完上面的代码PDF里看到的部门列只显示一次部门区域从视觉上连成一块。打开XLSX点击部门列的单元格你会看到Excel自动识别出跨行合并区域选中“研发部”那个格子它会高亮覆盖两行“市场部”覆盖三行。这个状态就对了。如果发现哪一组没有合并先回去检查排序如果发现合并了但某个单元格的值显示在最后一行而非第一行多数是isHideRepeatedValue起作用的时机和数据填充顺序不匹配回到数据源排查。实际操作中还会碰到两个小问题。一是合并且垂直居中的需求比如“研发部”三个字希望显示在它覆盖的两行中间位置。JasperReport默认按第一行的位置渲染最简单的办法是给这个字段设置verticalAlignmentMiddle并增加元素高度让它在垂直方向上覆盖整个组的高度reportElement x0 y0 width120 height50 isHideRepeatedValuetrue property namenet.sf.jasperreports.export.xls.merge.cell valuetrue/ /reportElement但这种方式要手工计算高度数据行数变化时极容易翻车。我的建议是如果必须动态居中就用前面讲的printWhenExpression方案配合stretchTypeRelativeToTallestObject来做稳定性好很多。二是组间分界线。合并之后Excel里相邻两个组之间可能缺少一条水平边框线。解决方式是在Group Footer里放一个高度1像素的横线或者给每个Text Field都设置完整的Box边框保证每组最后一行下边框仍然存在。4. 常见问题与排查技巧实录4.1 重复值没有被隐藏我问过不少同事他们最常遇到的就是这个。排查顺序按这四步走。先看数据是否排序。isHideRepeatedValue只对连续重复值有效字段值相同但中间隔了别的组它一定会再次显示。解决办法是在SQL或Java里按合并字段排好序。再看属性有没有写到reportElement上。很多人不小心把isHideRepeatedValue写到了textField上或者漏掉了。JRXML对这个属性的写法只认reportElement属性。然后检查值是否完全一致。肉眼看不到的空格、全角空格、换行符都会导致比较失败。排查时可以在表达式里包一层TRIM写成![CDATA[$F{deptName}.trim()]]。最后还要看你是不是在Detail里用了一个计算结果字段。如果字段表达式是$F{totalHours} * 2这种动态计算值相邻两行计算结果相同也能隐藏但如果每行计算精度不一致同样会失效。这个我遇到过明明是同一个字段导出后一会儿隐藏一会儿不隐藏最后发现是BigDecimal的equals和compareTo语义差异导致的。4.2 Excel导出单元格没有真正合并PDF正常、Excel不合并这是另一个高频问题。原因基本都是没开导出配置。XLS、XLSX、CSV三种导出格式里只有XLS和XLSX支持物理合并CSV天然不支持这没办法。在JRXML根节点加属性是一种方式在Java代码里用SimpleXlsReportConfiguration或SimpleXlsxReportConfiguration配置是另一种方式。要注意你用的是哪个导出器类XLS用JRXlsExporterXLSX用JRXlsxExporter两者配置类不同写混了不会报错但不生效。还有个隐蔽的坑如果同一个报表在模板上同时设置了net.sf.jasperreports.export.xls.merge.cell和net.sf.jasperreports.export.xlsx.merge.cell而你在代码里用的导出器是XLSX那它只会读xlsx对应的属性。有些同事图省事只加了一个结果导出格式换一下就出问题。我建议两个属性都写上或者干脆统一在Java代码里配置。4.3 合并后行高、边框错乱行高和边框是JasperReport合并单元格的老大难问题。Excel的合并单元格在没有边框的单元格上经常出现“中间没线、四周有线”的鬼样子就是因为JasperReport导出时隐藏的那几行单元格本身没有边框数据。解决办法很土但有效所有参与合并的Text Field都加上完整的Box边框且四边线宽一致。关键属性一定要写在每个元素上不能只写在第一个元素上因为隐藏的单元格在网格模型里依然存在它们需要自己的边框定义。行高问题则多半出在stretchType。如果组内其他行因为文字过长被拉伸而合并列的Text Field没有跟随拉伸就会露出一段空白。建议把参与合并的Text Field设置成stretchTypeRelativeToTallestObject并与同组的最高元素保持同一垂直方向。这里还有一个经验JasperSoft Studio里拖出来的报表元素高度往往是固定值写代码时要注意Float类型的字段可能导致行高计算偏差。把字段类型定义成java.lang.Integer或java.math.BigDecimal行高会更稳定。4.4 热词里的真实用户困扰每次报表导出后用户常用的操作是复制到Excel、转发到企业微信或者飞书。关于这些场景网上吐槽的热词特别多比如“excel表格无法复制粘贴”“offer表格无法复制其内容”“word表格列宽无法拖动”。这些问题的根源大多是合并单元格格式限制或者表格被设置了保护。放在JasperReport场景里同样的逻辑也成立。如果你导出的XLSX里有大量合并单元格用户在别处复制数据再粘贴时会发现格式错乱因为合并区域的行列索引和普通表格不一样。解决办法是如果报表只追求查看效果就保留合并如果用户后续要二次编辑表格建议提供一个“不合并版”的导出按钮或者把合并逻辑限制在主要展示列上给用户留可编辑空间。还有读者问“飞书机器人发送表格后样式丢失”。这里要明确一点IM机器人发送的表格本质是重新解析一份新的表格文件对合并单元格的支持取决于对方的解析引擎。我实测下来飞书机器人发送XLSX合并单元格会变成普通单元格看起来像“样式丢失”其实数据都在。如果业务要求对方收到的文件也保留合并效果那就得让机器人发送附件本身而不是把内容以摘要表格的形式推送。顺带提一句那些“vue多个表格导出一个excel”“element-plus表格行合并”“ant design vue表格合并列”的问题本质和JasperReport一样都是在处理“表格网格模型下的行列坐标合并”只是前端用前端的方式后端用后端的方式。理解了一件事合并的原理就通了先确定相邻区域再告诉渲染引擎“这个区域内的内容只保留一份”。最后再分享一个我自己的习惯每次在JasperSoft Studio里做完行合并我都会专门用三条数据的测试样本验证第一行、中间行、最后一行的边框完整性。因为合并单元格的边框在PDF里几乎不暴露问题Excel里很容易缺边或者多出细线。把边框统一设置为完整Box开启合并导出参数再肉眼检查一遍基本能避开80%的报表投诉。这个习惯看着笨但在真实项目里帮你省下的返工时间绝对值得。
返回列表