做报表的人应该都有过这种经历:需求方丢过来一张Excel原型,说“把这个做成PDF报表,部门名称要跨行合并,看着清爽点”。你用JasperReport打开模板,找了半天,发现并没有一个叫“合并单元格”的按钮,这几乎是每个JasperReport新人都会卡住的地方。JasperReport本身不是网格模型,它靠band流式布局渲染页面,所以原生并不支持像Excel那样把相邻单元格点一下合并。但这不代表做不了行内容合并,只是思路要换一下。这篇文章我会结合我做过的员工工资明细、订单汇总这类例子,把JasperReport里做行内容合并的常见方式、背后原理和实战坑都过一遍,顺便也解决跨页、边框、导出Excel这些后续问题。
1. 先搞清楚:JasperReport的“行合并”到底是什么问题
1.1 需求长什么样:一个能看懂的部门工资示例
我用一个最简单也最常见的场景来说明:员工工资明细表,数据结构大概是部门、员工号、姓名、岗位、基本工资、绩效、实发工资。需求方给的原型里,部门这一列做了跨行合并,比如“技术部”下面有5个员工,那部门列只在第一个员工对应的位置显示一次,其他4行是空白或者合并状态。整个表看起来是这样:
- 技术部
- 001 张三 开发 12000 3000 15000
- 002 李四 测试 10000 2000 12000
- 003 王五 架构 20000 5000 25000
- 产品部
- 004 赵六 产品 15000 4000 19000
你第一反应可能是:JasperReport是不是有个属性,让相同字段值自动合并?答案是没有。它只能通过“在哪个band放什么、什么条件下打印”来控制显示,最终在视觉上接近合并效果。理解这一点,后面的操作就不会走弯路。
1.2 为什么JasperReport没有“合并单元格”按钮
JasperReport的页面模型是若干个band按顺序堆叠而成的。Title、PageHeader、ColumnHeader、Detail、ColumnFooter、PageFooter,这些band从上到下排列,Detail带会针对数据源的每一条记录重复渲染。每个band里的textField、rectangle都是独立元素,它们不像HTML表格或者Excel那样有“跨行、跨列”的概念,结构上就不存在一个横跨多行记录的单元格。
所以,你需要接受一个事实:JasperReport里的“行合并”本质上是“控制某个字段在什么时候显示、什么时候隐藏”。说得再直白一点,你是在用打印条件模拟合并效果,不是在做真正的单元格合并。这个认知能帮你少走很多弯路,也能解释为什么后面所有方案都在围绕“显示一次”做文章。
1.3 三套主流方案的取舍对比
结合我自己的项目经验,JasperReport做行内容合并通常有三条路,适用面不同,工作量也不同。
| 方案 | 核心做法 | 适合场景 | 缺点 |
|---|---|---|---|
| Group分组 | 把合并列放到Group Header,Detail只放明细列 | 数据按某列分组,每组多行,是首选 | 合并列必须放在每组的头部,不够灵活 |
| PrintWhenExpression | 字段仍在Detail,通过计数变量控制只打印每组首行 | 合并列出现在明细行的中间位置,或需要保留每条原始记录 | 行高塌陷、边框断裂问题需要额外处理 |
| 数据源预合并 | 在SQL或Java层把多行内容拼接好,报表只做展示 | 需要把组内多行拼成一段文字,或做汇总类报表 | 丢掉部分明细数据,不适合需要下载明细的场景 |
我先说结论:90%的“按某列合并显示”需求,用第一种方案就够了,也是我现在最常用的做法。第二种方案适合没法把字段从Detail挪走的特殊情况。第三种方案则是很多新人不熟悉但非常实用的兜底手段,特别是遇到“把多条备注拼到一格里”的需求时。
2. 方案一:Group分组实现视觉合并(最推荐)
2.1 在Jaspersoft Studio里建Group并把合并列放进去
Jaspersoft Studio是目前JasperReport官方推荐的报表设计器,打开模板后,右侧Outline面板里找到报表根节点,右键选择Add Group,就会弹出Group配置窗口。关键是设置Group Expression,比如按部门合并,就填$F{deptName},意思是以部门名称作为分组键。点击Finish之后,设计区会自动多出Group Header和Group Footer两个band。
接下来把“部门”这个textField从Detail带移到Group Header带,Detail带只保留员工号、姓名、岗位、工资等字段。运行报表,你会发现同一个部门的记录会被连续渲染,部门名称只在Group Header出现一次,正好就是Excel原型里的合并效果。这个方案之所以稳,是因为它完全利用了band的结构,不需要写表达式去控制每条记录的显示逻辑,渲染开销也小。
手动改JRXML也可以,在<detail>上方加一个<group>节点,设置<groupExpression><![CDATA[$F{deptName}]]></groupExpression>,然后新建对应的<groupHeader>。但从实际效率上讲,可视化创建更快,JRXML自动生成的代码也规范,没必要手写。
2.2 组头与明细的边框怎么对齐
Group方案做出来后,最常见的问题是视觉效果不好看:组头那一行和Detail行之间边框断裂,或者左边框对不齐。原因是Group Header是一个独立的band,它和Detail带之间没有强制的高度关联,textField的边框也不会自动延续。
我的处理方法是:在Detail带里,原本部门列对应的位置放一个矩形或空白textField,设置和组头部门字段一样的边框,左右线宽保持一致;矩形高度设成和Detail行高一致,且不要勾选removeLineWhenBlank。这样,组头的部门字段加上Detail的占位矩形,看起来就是一条连续的边框,中间不会断。
这里提醒一下:组头band高度最好和Detail行高一致或者成整数倍。如果组头是20px,Detail行高是30px,那视觉上组头和明细的分割线会错位。我一般是先把组头band高度调整为Detail行高,再调整字段的上下padding,保证文字基线在视觉上对齐。
2.3 用Group Footer做组内合计
既然分了组,那组内合计几乎是顺带的需求。工程上一般是指在Group Footer里放一个“实发工资合计”之类的字段。具体做法:在Outline面板的Group节点上右键选择Add Group Footer,如果已经存在则直接使用。然后在这个band里放一个textField,值为实发工资的求和变量。
新建变量的步骤是:右键报表根节点,选Add Variable,设置Name为totalSalary,Value Class Name选java.lang.Decimal或java.math.BigDecimal,Calculation选Sum,Reset Type选Group,Reset Group选按部门创建的那个Group,Variable Expression填$F{actualSalary}。这样每组结束时会自动输出该组合计,到下一组自动清零重新累加。这里要记住Reset Type必须和你的分组一致,否则合计会在整张报表里一直累加,变成全局合计。
3. 方案二:PrintWhenExpression首行显示法
3.1 用计数变量标记每组的首行
有些需求不允许把部门列从Detail里拿出去,比如客户要求导出原始数据时每条记录都保留部门字段的值,或者合并列在明细行的中间位置,挪到Group Header会把表格结构弄乱。这时候可以用PrintWhenExpression方案。
思路很简单:给Detail带里那个要“合并”的textField加一个打印条件,只有当前记录是本组第一条时才打印,其他记录不打印。为了判断是否为首条,需要创建一个变量,比如命名为deptRowCount,Value Class Name选java.lang.Integer,Calculation选Count,Reset Type选Group,Reset Group选按部门的分组,Variable Expression填$F{deptName}。这样每条记录渲染时,该变量都会递增,第一行为1,第二行为2。
然后选中部门textField,在属性面板的Print When Expression里写:
Boolean.valueOf($V{deptRowCount}.intValue() == 1)预览效果和Group方案几乎一样,部门名称只在每组第一条记录上显示。需要注意:这个textField本身还在Detail带里,所以其他字段仍然逐条渲染,数据完整性和原始记录都能保留。
3.2 空行塌陷与占位解决的实战细节
这个方案最大的坑,就是变量计数没问题,但部门textField在第一条之外的记录上不打印后,Detail带的行高会塌陷。JasperReport默认情况下,一个band里如果某个textField因为PrintWhenExpression为false而不渲染,而这一行没有其他元素,整个band会缩放,导致其他列出现锯齿状错位,严重的时候整行高度被压缩掉。
我踩过一次很痛的坑:部门列显示正常,但员工工号列有时高有时低,看起来像乱码。排除了半天,最后发现是Detail带里只有部门textField不打印,工号字段虽然打印了,但因为列宽之间存在留白,触发了removeLineWhenBlank的隐式行为。解决办法有两种。
第一种,不勾选那个textField的removeLineWhenBlank属性。默认情况下这个属性是false,但很多人会在模板属性里误打开它。一旦勾选,textField内容为空或者不满足打印条件时,它会被移除,从而影响行高。
第二种更推荐:在Detail带中,把部门textField放到一个矩形底层上。矩形不设打印条件,始终渲染,并设置与textField相同的边框;textField则在矩形上面,用PrintWhenExpression控制是否显示文字。这样第一行显示部门名称和边框,后面几行虽然名称不显示,但矩形和边框还在,占位稳定,行高和边框都不会乱。
3.3 什么情况下选它而不是Group
我总结了一下,这类方案主要用在两种情况。第一种,合并列在明细行的中间,左边还有别的字段,右边也有别的字段,把它挪到Group Header会打断表格列的顺序。第二种,业务上需要导出CSV或者从报表对象里取数据时,每条原始记录仍然保留完整的字段值,如果用Group方案,部门名称只出现一次,导出的数据结构会缺字段,客户会认为是丢了数据。
另外还有一个小细节:PrintWhenExpression表达式里尽量不要直接用$V{REPORT_COUNT}来算首行,因为报表全局计数受分页、跨页影响,不稳定。用Group重置的计数变量是最可控的,Reset Group选对,变量就不会串组。
4. 方案三:数据源侧预合并
4.1 适合“多行拼一段”的合并场景
前面说的都是单列同值合并,还有一种需求是“把组内多行内容拼到同一个单元格里”。比如客户要求订单报表里,一个订单有多条商品明细,最后需要在“商品名称”列里把所有商品拼成“商品A、商品B、商品C”,一条订单只占一行。这种用Group和PrintWhenExpression都很难处理,因为要拼接的不只是一个字段的相同值,而是多条记录的多个值。
我的建议是不要死磕JasperReport模板,而是回到数据源去处理。在SQL查询或Java预处理阶段,把多行数据聚合成一行,报表这边就只是一个普通表格,渲染压力小,逻辑也清晰。
4.2 三种主流数据库的SQL拼接写法
以“按部门拼接员工姓名”为例,不同数据库语法不一样,我列一下常用的写法。
MySQL用GROUP_CONCAT:
SELECT dept_name, GROUP_CONCAT(emp_name SEPARATOR '、') AS emp_names, COUNT(*) AS emp_count, SUM(actual_salary) AS total_salary FROM emp_salary GROUP BY dept_name;Oracle用LISTAGG,注意有VARCHAR2长度限制,大概4000字符,如果拼出来的内容很长会报错:
SELECT dept_name, LISTAGG(emp_name, '、') WITHIN GROUP (ORDER BY emp_id) AS emp_names, COUNT(*) AS emp_count, SUM(actual_salary) AS total_salary FROM emp_salary GROUP BY dept_name;SQL Server 2017以上用STRING_AGG:
SELECT dept_name, STRING_AGG(emp_name, '、') WITHIN GROUP (ORDER BY emp_id) AS emp_names, COUNT(*) AS emp_count, SUM(actual_salary) AS total_salary FROM emp_salary GROUP BY dept_name;需要注意的是,SQL层预合并出来的结果已经聚合了,每条订单只有一行,无法再展开看明细。所以它更适合汇总统计报表,不适合需要下钻明细的场景。如果客户既要明细又要汇总,建议两条SQL分别查询,汇总部分用预合并,明细部分用Group方案。
4.3 在Java层预处理List的示例
如果数据源不是SQL,而是Java程序里拼好的List,也可以在Service层完成同样的聚合。比如有一个SalaryDO列表,要按部门把员工姓名拼起来,代码可以这么写:
Map<String, List<SalaryDO>> grouped = list.stream() .collect(Collectors.groupingBy(SalaryDO::getDeptName)); List<ReportRow> rows = new ArrayList<>(); grouped.forEach((dept, items) -> { ReportRow row = new ReportRow(); row.setDeptName(dept); row.setEmpNames(items.stream() .map(SalaryDO::getEmpName) .collect(Collectors.joining("、"))); row.setEmpCount(items.size()); row.setTotalSalary(items.stream() .mapToBigDecimal(SalaryDO::getActualSalary) .sum()); rows.add(row); });报表模板拿到List<ReportRow>后,直接遍历打印,不需要任何合并技巧。这里有一个经验:如果拼出来的名称很长,撑爆单元格,textField一定要开启Stretch With Overflow,并且检查相邻字段是否允许向下拉伸,否则会出现文字叠在下一行元素上的情况。我遇到过最典型的就是备注字段特别长,没有开Stretch,导出PDF后文字重叠成一片。
5. 细节与坑:跨页、边框、导出Excel
5.1 跨页时合并列要重复怎么办
第一页显示“技术部”,技术部的记录一直排到第二页,第二页的合并列要不要继续显示部门名称?需求方经常会有不同要求。默认情况下,Group Header只在每组开头出现一次,跨页之后第二页不会重复显示,这是很多人觉得“表格不完整”的原因。
如果你希望跨页后继续重复显示合并列,在Jaspersoft Studio里选中Group节点,打开Group Properties属性面板,勾选Reprint Header On Each Page。这样每页顶部都会重新显示部门名称,适合数据量很大的分组报表。如果不想让某组最后的几条记录单独出现在页面底部,影响阅读,可以设置Min Height To Start New Page,这个值表示当前页面剩余空间小于这个高度时,整个组换到新页开始,避免组头和组内数据被硬生生切开。
5.2 明明设了边框,显示出来却缺线
这是行内容合并最容易翻车的地方。Page Header里定义了边框样式,Group Header里的部门字段也有边框,Detail里也有边框,但PDF导出来,竖线不连续,或者线条粗细不一致。原因在于JasperReport的边框是围绕每个元素独立绘制的,两个band之间的元素如果没有严格对齐,边框就不会自然连接。
我的做法是:把所有涉及表格区域的textField、rectangle统一使用同一个样式,在样式的<box>里定义统一的线条宽度和颜色。比如在JRXML里写:
<style name="tableCell" forecolor="#000000"> <box> <pen lineWidth="0.5"/> <topPen lineWidth="0.5"/> <bottomPen lineWidth="0.5"/> <leftPen lineWidth="0.5"/> <rightPen lineWidth="0.5"/> </box> </style>然后给组头字段、Detail字段、汇总字段都应用这个样式,线宽一致之后,观感会好很多。不要为了省事让不同textField用不同边框颜色或线宽,肉眼扫过去很容易发现断线。
5.3 导出Excel后没有真正的合并单元格
说实话,JasperReport的XLSX导出引擎不会自动生成Excel的合并单元格。它只是把各个band的位置转换成Excel的行列坐标,Group Header那行在Excel里就是一个有值的单元格,下面的明细行对应位置是空的。数据没丢,但从视觉上看,和客户要的“合并单元格”还是有差距。
这个问题没有特别完美的纯JasperReport解法。如果你的交付物是PDF,那没问题,边框和合并效果都能保证;如果客户指定要.xlsx文件,并且要求Excel里出现真正的合并单元格,我一般会在报表导出后,用Apache POI做一次后处理,检查相邻行的相同列值,然后调用addMergedRegion合并。这块逻辑不复杂,但需要单独写一段处理代码,不在JasperReport模板范围内。另外,如果最终报表要通过飞书机器人推送给业务群,我建议直接推PDF版本,PDF对合并和边框的还原度远好于Excel,群里打开也方便,不需要二次加工。
5.4 大数据量下的性能细节
数据量过万时,方案一和方案二的性能差异会明显起来。Group方案只需要在分组切换时处理一次组头,PrintWhenExpression方案则要在每条记录上计算表达式,虽然单个表达式很快,但数据量大了之后,再加上变量维护,渲染时间会成倍增加。如果是对外导出的报表,用户对等待时间很敏感,我通常优先选Group方案。
还有一点,Group方案里如果每组数据条数很多,比如一组几千行,建议在Group属性里设置Start New Page为true,一个组单独占一页。这样做的好处是组内合计和明细不会跨页混淆,导出的PDF结构也清晰。但要注意,这样也会让PDF页数明显变多,需要根据实际业务场景平衡。
6. 常见问题排查速查表
6.1 合并列不显示或显示不全
我排查过很多次,原因集中在三个地方。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 合并列完全空白 | PrintWhenExpression条件恒为false | 临时去掉表达式测试,确认变量取到了正确值 |
| 只有第一组显示,后面不显示 | 变量的Reset Type设成了Report,没有按组重置 | 改为Reset Type=Group,并选中对应分组 |
| 显示一半文字,或被截断 | textField高度不够,未开启Stretch With Overflow | 勾选Stretch With Overflow,检查相邻元素是否允许下移 |
| 值为null时显示“null” | 字段可能为空 | 设置isBlankWhenNull或$F{field} == null ? "" : $F{field} |
6.2 合并后其他列错位
错位基本都是行高度被压缩导致的。最常见的触发点就是removeLineWhenBlank属性被误开启。这个属性一旦为true,textField为空时整行会被移除,在Detail带里会造成其他列上移。建议在需要占位的元素上,尤其是用了PrintWhenExpression的元素,关闭这个属性,或者用矩形作为底层占位。
另一个原因是组头band和Detail带高度不一致,导致Group Header那行和Detail行在视觉上不对齐。统一高度之后,错位基本消失。
6.3 组头跨页被截断或重复
如果你发现组头在页面底部单独出现,然后明细跑到了下一页,说明Min Height To Start New Page没有设置,或者Group的Start New Page属性没有按需要开启。反过来说,如果跨页后组头没有重复,但业务上要求重复,检查Reprint Header On Each Page是否勾选。这两个属性经常成对出现,我建议在项目里定一个统一规范:组头必须重复显示时,同时勾选Reprint Header On Each Page,并设置一个合理的Min Height To Start New Page,比如组头高度乘以2,保证组头不会孤立在页尾。
7. 写在最后:我的几点实操体会
做了这么多年报表,我的体会是:JasperReport的行内容合并,本质不是找“合并”按钮,而是理解band流式布局,然后用Group、变量、表达式来控制每个元素在哪个band打印、什么条件下打印。最稳定的组合,还是“Group Header显示合并列、Detail只放明细、Group Footer做小计”这套,简单、可控、性能也好。
最后再分享一个小技巧:调试PrintWhenExpression时,别急着反复预览整张报表。我通常会在Detail带里临时放一个文本框,把计数变量的值打出来,确认第一行是1、第二行是2,再把这个临时文本框删掉。一个条件表达式反复预览十几次,远不如这样一次定位来得快。等你真正上手做完一两个合并报表,就会发现这类需求其实都是从同一个思维模型出发的。