ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Jaspersoft Studio 6.21.3 报表开发实战:JRXML、子报表与导出避坑

Jaspersoft Studio 6.21.3 报表开发实战:JRXML、子报表与导出避坑

简介:Jaspersoft Studio 6.21.3 是面向 Java 报表开发与 BI 实施人员的开源报表设计工具,基于 Eclipse 构建,可设计 JasperReports 模板、连接多种数据源并预览导出常用文档。压缩包 1762 个文件、约 404.37MB,含 jar、class、dll、xml、properties 与 license 等,对应运行库、核心类、本地依赖和配置文档,目录完整,便于部署或离线留存。已有 1162 人学习下载。完整发行文件便于搭建本地设计环境,查看模板结构、依赖与许可信息,排查插件冲突、字符集和导出兼容问题,也可用于二次集成与版本对照。

1. Jaspersoft Studio 6.21.3 能干什么:从一份对账单 PDF 说起

上个月帮一个做仓储的团队把对账单从手工排版搬进报表引擎,需求只有一句话:按客户分组、每页带页眉页脚、末尾出一张汇总、PDF 和 Excel 各一份。这类活我以前用代码拼 HTML 再转 PDF,改一次格式要动一次代码;换成 Jaspersoft Studio 6.21.3 之后,版式在可视化设计器里拖,取数逻辑写在 JRXML 里,业务代码只负责给数据源。它本质是 JasperReports 引擎的 Eclipse 外壳,真正跑数的是 jasperreports 的 jar:Studio 负责画,引擎负责填。适合谁?被 Word 模板和 Excel 拼接折磨过的后端、需要交付固定版式报表的交付工程师、以及想把报表从代码里剥离出来的小团队。后面按「装 → 画 → 传参 → 排错 → 上服务」的顺序拆,能照抄。

2. 装完先别急着拖控件:工作区、JDK 与版本对齐

拿到安装包之后最容易犯的错,是双击打开就开始往 detail 带上扔 TextField,画了半小时发现预览报一堆NoClassDefFoundError,或者中文全是横杠。问题不在控件,在环境。6.21.3 这条线的版本号里其实藏了三个坐标,先把它们对齐,后面所有玄学都会少一半。

2.1 版本号的三个坐标:Studio、引擎 jar、JDK

Jaspersoft Studio 的版本号跟 jasperreports 库的版本号是同一条线的,6.21.3 对应的就是引擎 6.21.3。Studio 自己是个 Eclipse RCP 应用,6.20 之后底层 JDK 换到了 17 这一档,6.21.3 沿用;而服务端跑你 jrxml 的那个 jasperreports jar,字节码兼容性要按你的应用服务器来定。所以最常见的翻车场景是:设计器用 17 跑得好好的,导出 jrxml 丢到还在用老 JDK 的应用里,运行时抛UnsupportedClassVersionError。

我一般的做法是先定服务端,再倒推设计器。服务端 JDK 版本定下来,就把 jasperreports 的 jar 版本和 Studio 对齐到同一个 6.21.3,别一边 6.20 一边 6.21.3——引擎在 6.21 的小版本里改过导出器的默认行为,混着用会出现「设计器预览正常、服务端导出列宽错位」这种情况。

坐标取值建议不对齐的典型症状
Studio 版本6.21.3预览与线上渲染不一致
jasperreports jar与 Studio 同版本导出器参数不识别、报Unknown property
编译目标 JDK与运行服务端一致UnsupportedClassVersionError
表达式语言Java(默认)Groovy 表达式在老环境里无类可用

表里最后一行值得多说一句:jrxml 里的language属性可以选 Java 或 Groovy。Groovy 写$F{a}?.b这种空安全表达式很顺手,但它要求运行时有 groovy 的 jar。跨团队交付、对方环境你控制不了的时候,老老实实写language="java",表达式里自己做 null 判断,少一个依赖就少一个事故点。

2.2 首次启动要改的四项设置

装完第一次进来,有四件事我每次都会改,位置都在Window > Preferences里:

第一项是工作区编码。Studio 默认跟随系统,Windows 上经常是 GBK,而你的 jrxml 是 UTF-8 存的,结果就是设计器里中文正常、导出的 PDF 里中文变问号。改General > Workspace > Text file encoding为 UTF-8,同时把General > Editors > Text Editors > Spelling关掉——拼写检查会在中文标签下面画一堆红线,纯干扰。

第二项是 JDK。Java > Installed JREs里确认指向的是你要用的那个 JDK,而不是 Studio 自带的那个。自带的能编译,但编译出来的 class 版本可能跟服务端不一致。

第三项是内存。Studio 默认堆不大,画复杂报表时点一下预览就转圈。改根目录下的Jaspersoft Studio.ini:

# 关闭拼写检查之外的启动优化;即改即生效,需要重启 Studio -vmargs -Xms512m -Xmx2048m -XX:+UseG1GC

-Xmx给到 2G 一般够单张报表预览用;如果你在 Studio 里直接连生产库跑几十万行预览,加到 4G 也只是把 OOM 推迟,正确做法是第 5 章讲的虚拟化,不是无脑加堆。-vmargs之后所有参数才会传给 JVM,写在它前面是无效的,这个坑我踩过——改了没反应,以为是 ini 没被读到。

第四项是编译输出。Java > Compiler里把 compiler compliance level 设成跟服务端一致,比如服务端是 11 就设 11。

2.3 新建工程的目录约定

Studio 新建一个 JasperReports Project 之后,目录结构大概是这样的:

MyReports/ ├── src/ │ └── reports/ │ ├── statement.jrxml # 设计文件,进版本库 │ ├── statement.jasper # 编译产物,不进版本库 │ └── sub/ │ └── detail.jrxml ├── JasperReports Library/ # Studio 挂的引擎 jar └── .project

这里有个约定必须定死:.jrxml进版本库,.jasper不进。.jasper是编译产物,二进制,进了库之后每次改完 jrxml 都要重新提交一遍二进制,冲突了还没法合。顺带一提,.jasper留在 src 目录下正是第 5.4 节「改了没生效」的根源之一。

子报表放独立子目录(比如sub/)也不是洁癖。主报表里引用子报表的路径是相对 classpath 或者相对 jrxml 的,放在同一层,主报表和子报表一旦重名,加载顺序就成了黑匣子。分层之后路径明确写sub/detail.jrxml,谁引用谁一目了然。

3. 写一份能跑的 JRXML:band 结构与表达式

画布拖控件很爽,但真正决定报表长什么样的,是 jrxml 里那几段 band 的堆叠顺序和高度。很多人第一次做出来的报表「页脚跑到第二页去了」「汇总金额是 0」,基本都是 band 的语义没搞清楚,而不是控件用错了。

3.1 report 根元素的尺寸参数怎么算

打开一个空 jrxml,根元素是jasperReport,里面那堆尺寸属性是最该先算清楚的:

<?xml version="1.0" encoding="UTF-8"?> <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="StatementReport" language="java" pageWidth="595" pageHeight="842" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" columnWidth="555" whenNoDataType="AllSectionsNoDetail"> <!-- field / parameter / variable / band 依次往下写 --> </jasperReport>

这几个数字之间有一条恒等式:pageWidth - leftMargin - rightMargin = columnWidth。595 减 20 减 20 正好是 555,对得上。你如果手动改成 560,Studio 不会报错,但渲染时会按最小的那个来裁,右边界的内容直接被切掉,而且没有提示——这种静默失败最难查。

pageWidth=595 / pageHeight=842是 A4 的纵向点数(1 英寸 72 点,A4 是 210×297 毫米,换算过来就是 595×842)。要横向就换成 842×595,别只改一个。

whenNoDataType我固定写成AllSectionsNoDetail。默认值是NoPages,查出来 0 条数据时导出一个空 PDF,业务方拿到文件打不开,就会来问「系统坏了?」。设成AllSectionsNoDetail之后,即使没数据,标题带和页眉页脚照样输出,界面上能看出「这是本月没有记录」,而不是一个空白文件。

band 的执行顺序是固定的,记不住就按这个顺序背:

title → pageHeader → columnHeader → (groupHeader → detail → groupFooter)* → columnFooter → pageFooter → lastPageFooter → summary

两个容易混淆的点:columnFooter在每一列每一页都出,pageFooter是页脚,多列报表里两者位置不同;summary只在整张报表的最后出现一次,做「合计」应该放 summary 而不是 pageFooter,否则每页都给你算一遍总数。

3.2 从数据源到 $F{}:字段声明与查询绑定

Studio 支持好几种数据源,日常用得最多的是 JDBC 连接和 JavaBean 集合。先把 JDBC 这条走通。

在 Repository 面板里新建一个 Data Adapter,选 Database JDBC Connection,填驱动类、URL、账号。然后在报表的Dataset and Query对话框里写 SQL:

-- 按客户分组取对账明细,注意不要在 SQL 里做排序以外的聚合 SELECT c.customer_name, s.bill_no, s.bill_date, s.amount, s.status FROM statement_detail s JOIN customer c ON c.id = s.customer_id WHERE s.bill_date >= $P{startDate} AND s.bill_date < $P{endDate} ORDER BY c.customer_name, s.bill_date

写完点Read Fields,Studio 会连库执行一次(带默认参数值),把结果集的列名和类型反填成<field>。反填出来的类型经常是java.lang.String,哪怕库里是 numeric——因为某些驱动在getMetaData时返回的类型码不够精确。金额字段务必手动改成java.math.BigDecimal,否则后面用 Sum 变量求和时会得到字符串拼接的结果(这个坑在第 5.2 节还有更狠的版本)。

字段声明长这样:

<field name="customer_name" class="java.lang.String"/> <field name="bill_no" class="java.lang.String"/> <field name="bill_date" class="java.sql.Timestamp"/> <field name="amount" class="java.math.BigDecimal"/> <field name="status" class="java.lang.Integer"/>

class必须和 Java 侧的取值类型严格对应。库里是date,驱动返回java.sql.Date,你写成java.util.Date也能跑,但格式化的时候 pattern 会失效。这类不匹配不会在编译期报,只在运行时抛ClassCastException,而且栈里指向的是引擎内部,排查成本很高。

如果是 JavaBean 数据源,字段名就是 getter 去掉get再首字母小写,getBillNo()对应$F{bill_no}还是$F{billNo}?取决于你的字段声明写哪个,引擎是按名字反射找 getter 的,写bill_no就找不到getBillNo。用 JRBeanCollectionDataSource 时我统一用驼峰命名,跟 Java 侧一致,别混。

3.3 变量、求值时机与 resetType

变量是 jrxml 里最容易用错的部分。它不是普通赋值,而是「在特定事件点按特定计算方式累加」。

<!-- 每页小计:每页开始时重置,累加 amount --> <variable name="pageTotal" class="java.math.BigDecimal" calculation="Sum"> <expression><![CDATA[$F{amount}]]></expression> <initialValueExpression><![CDATA[java.math.BigDecimal.ZERO]]></initialValueExpression> <resetType>Page</resetType> </variable> <!-- 全程合计:报表开始重置,最后在 summary 里输出 --> <variable name="grandTotal" class="java.math.BigDecimal" calculation="Sum"> <expression><![CDATA[$F{amount}]]></expression> <initialValueExpression><![CDATA[java.math.BigDecimal.ZERO]]></initialValueExpression> <resetType>Report</resetType> </variable>

calculation可选Nothing / Count / Sum / Average / Lowest / Highest / StandardDeviation / Variance / System,resetType可选None / Report / Page / Column / Group,配合resetGroup指定分组名。

三个必须记住的求值规则:

一,变量在 band 的after阶段求值,所以你在 detail 带里直接引$V{grandTotal},拿到的是「上一行处理完之后」的值,永远是滞后的。要在循环里显示累计,得把变量放进evaluationTime="Band"的 textField 里,或者干脆放到 groupFooter/summary 里输出。

二,calculation="Sum"遇到 null 会直接抛 NPE,或者返回 null 让后续格式化炸掉。initialValueExpression给个零值兜底是必须的,尤其数据库字段可空的时候。

三,文本字段的evaluationTime属性默认是Now,也就是渲染那一刻取。对于$V{},如果它跨越了 reset 点,你在页脚引用的可能是已经被重置的值。要么改evaluationTime="Page",要么把输出位置挪到正确的事件点。

把这三条吃透,报表里 80% 的「数字不对」都能自己定位。

4. 参数、子报表与分组:把一张大报表拆开

单表查询 + detail 带就能出报表,但真实需求永远是多条件的、分组的、带明细附表的主从结构。这一层是 Studio 拉开差距的地方,也是最容易写出安全问题的地方。

4.1$P{}与$P!{}:一个感叹号决定 SQL 会不会被打穿

参数在 jrxml 里用$P{paramName}引用,在查询里同样能引用。但有两个写法,行为完全不同:

<!-- 写法 A:绑定参数,走 PreparedStatement 的 ? 占位 --> <parameter name="startDate" class="java.sql.Timestamp"/> <!-- 在 SQL 中写:WHERE s.bill_date >= $P{startDate} --> <!-- 写法 B:文本替换,参数值被原样拼进 SQL 字符串 --> <parameter name="orderBy" class="java.lang.String"/> <!-- 在 SQL 中写:ORDER BY $P!{orderBy} -->

$P{}会被改写成?并绑定到 PreparedStatement,参数值不会进入 SQL 语法层面;$P!{}是纯字符串替换,引擎把你给的值原封不动塞进查询文本。

$P!{}不是设计缺陷,它解决的是「占位符不能出现的位置」——比如动态排序字段名、动态表名、动态 where 片段,这些地方 JDBC 的?是放不进去的。但它同时意味着:如果这个参数的值来自前端表单,攻击者传一段带分号的字符串,你的库就危险了。

我的处理原则是三条:

第一,能用$P{}的地方绝不用$P!{}。日期范围、状态码、客户 ID 这类值型参数,全部走绑定。

第二,必须用$P!{}的位置(排序字段、可选过滤片段),在 Java 侧做白名单映射,绝不把用户原始输入直接塞进去:

// 排序字段只接受白名单里的枚举名,映射到真实列名 private static final Map<String, String> ORDER_COLUMNS = Map.of( "date", "s.bill_date", "amount", "s.amount", "name", "c.customer_name" ); public String resolveOrderBy(String userInput) { // 未命中白名单时退回默认排序,而不是抛错或原样返回 return ORDER_COLUMNS.getOrDefault(userInput, "s.bill_date"); }

Map.of是不可变映射,键集固定,用户传什么都只能命中这三项之一,命中不了就退回默认值。这样即便ORDER BY $P!{orderBy}是字符串拼接,拼进去的也只可能是这三段常量之一。

第三,可选过滤片段用「参数是否为 null」在 SQL 里做分支,而不是拼字符串:

WHERE s.bill_date >= $P{startDate} AND s.bill_date < $P{endDate} AND ($P{customerId} IS NULL OR c.id = $P{customerId})

这种写法在部分数据库上会走不了索引,数据量大的时候性能会退化,属于「安全换性能」的取舍。数据量确实大的时候,我宁可写两条 jrxml 分别对应「带客户过滤」和「不带」,也不用拼接。

提示:$P!{}拼接失败时引擎不会报错,只会把拼出来的 SQL 原样发给数据库,报错栈指向的是驱动的executeQuery,从那一层反推回 jrxml 很费时间。改完$P!{}之后,先把结果 SQL 打日志看一眼。

4.2 subreport 的数据源传递与参数回传

主从结构(一张对账单下面挂多行明细)有两种做法:一种是用分组带 + 同一份数据源,一种是子报表。前者简单,后者灵活——子报表可以独立查询,独立版式,还能在主报表里多次复用。

主报表里引用子报表大概这样写:

<subreport> <reportElement x="0" y="0" width="555" height="20"/> <!-- 子报表文件路径,相对 classpath --> <subreportExpression><![CDATA["sub/detail.jrxml"]]></subreportExpression> <!-- 关键:子报表用什么数据源 --> <dataSourceExpression><![CDATA[ new net.sf.jasperreports.engine.data.JRBeanCollectionDataSource( $P{detailList}) ]]></dataSourceExpression> <!-- 把主报表的参数传给子报表 --> <subreportParameter name="billNo"> <subreportParameterExpression><![CDATA[$F{bill_no}]]></subreportParameterExpression> </subreportParameter> <!-- 把子报表里算出来的变量回传给主报表 --> <returnValue subreportVariable="subTotal" toVariable="billTotal"/> </subreport>

三个地方值得单独讲。

dataSourceExpression是子报表的数据来源。如果子报表自己带 SQL(在它自己的 jrxml 里写了 query),那这里要传的不是集合,而是连接:$P{REPORT_CONNECTION}。REPORT_CONNECTION是引擎内置参数,主报表用什么连接连库,子报表就能拿到同一个。传错了的典型症状是子报表一行都不出,也不报错——因为空集合填充出来就是空。

subreportParameter是显式传参,主报表的 field、parameter、variable 都能传。注意子报表里必须先用<parameter name="billNo" class="java.lang.String"/>声明,否则传过去会被静默丢弃。这个「静默」是引擎的设计,不是 bug,但排查起来很要命。

returnValue是把子报表里的变量值回传到主报表。subreportVariable是子报表里的变量名,toVariable是主报表里必须已经声明好的变量名,两边类型要一致。回传发生在子报表填充完成的那一刻,所以主报表里引用这个变量,位置要放在子报表之后。

子报表还有一个常见需求:主报表没数据时子报表也要出。这时候给dataSourceExpression换成new net.sf.jasperreports.engine.JREmptyDataSource(1),造一行空数据出来,子报表的静态部分就能渲染。

4.3 分组带与汇总变量的配合

分组是把「一张平表」变成「一份报表」的关键。语法不复杂:

<group name="byCustomer"> <groupExpression><![CDATA[$F{customer_name}]]></groupExpression> <groupHeader> <band height="24"> <textField> <reportElement x="0" y="0" width="300" height="20"/> <textFieldExpression><![CDATA[$F{customer_name}]]></textFieldExpression> </textField> </band> </groupHeader> <groupFooter> <band height="24"> <textField> <reportElement x="350" y="0" width="100" height="20"/> <textFieldExpression> <![CDATA["小计:" + $V{customerTotal}]]> </textFieldExpression> </textField> </band> </groupFooter> </group> <!-- 配合的分组级变量:每换一个客户就重置 --> <variable name="customerTotal" class="java.math.BigDecimal" calculation="Sum"> <expression><![CDATA[$F{amount}]]></expression> <initialValueExpression><![CDATA[java.math.BigDecimal.ZERO]]></initialValueExpression> <resetType>Group</resetType> <resetGroup>byCustomer</resetGroup> </variable>

resetType="Group"配resetGroup="byCustomer"这一对必须同时写。只写前者,引擎不知道按哪个组重置,运行时会抛「reset group not found」或者干脆不重置——取决于版本,6.21.3 上我见过的是直接报错,还算友好。

isStartNewPage="true"加在<group>上,可以让每个客户从新页开始,这是对账单类报表的标配。但注意它和isReprintHeaderOnEachPage是两回事:前者控制分页,后者控制分组头在跨页时是否重印。两个都开,客户多的时候页数会涨得很快,打印机那边会有意见。

分组的另一个细节是minHeightToStartNewPage。它规定「当前页剩余高度小于这个值时,提前换页」,避免分组头孤零零留在页底、明细全跑到下一页。这个值设成「分组头高度 + 两行明细高度」比较合适,设太大了会浪费大量空白。

排序必须由数据源保证。分组表达式只是「遇到不同值就断组」,它不排序。SQL 里漏了ORDER BY,同一个客户的数据散落在结果集中,你会得到好几段同名分组,每段一个小计。这个现象看起来像「汇总算错了」,实际上是数据没排好。

5. 避坑与排查:五个把我卡到半夜的问题

前面几章是正常路径,这一章记录的是非正常路径。下面五条都是我或者身边同行真实卡过的,每条按现象、原因、解决写,能对号入座就别再从零猜。

5.1 中文导出 PDF 变方块、变问号、变横杠

现象:设计器里预览一切正常,导出的 PDF 里中文全是空心方块,或者是????一排问号,或者是细横线。

原因分两种。方块和问号是字体缺字:PDF 里嵌入的字体没有中文字形,阅读器只能画 notdef 方块。细横线是编码问题:字体编码用了单字节编码(比如WinAnsiEncoding),中文字符被映射到不存在的码位。

老版本引擎里有pdfFontName="STSong-Light"+pdfEncoding="UniGB-UCS2-H"这种写法,靠阅读器自带的 CJK 字体包渲染,不嵌字体,文件小但依赖阅读器。现代阅读器对这种「不嵌入」的 PDF 支持越来越差,而且移动端基本不认。现在的正确做法是字体扩展,把 ttf 嵌进去。

解决分三步。第一步,把字体文件放到项目的 classpath 下,比如src/main/resources/fonts/。第二步,写jasperreports_extension.properties:

# 注册简单字体工厂,引擎启动时扫描 net.sf.jasperreports.extension.registry.factory.simple.font.families=\ net.sf.jasperreports.engine.fonts.SimpleFontExtensionFactory # 指向字体族描述文件,路径相对 classpath net.sf.jasperreports.extension.simple.font.families.reportcjk=\ fonts/fonts.xml

第三步,写fonts.xml:

<?xml version="1.0" encoding="UTF-8"?> <fontFamilies> <fontFamily name="ReportCJK"> <normal>fonts/cjk-regular.ttf</normal> <bold>fonts/cjk-bold.ttf</bold> <italic>fonts/cjk-italic.ttf</italic> <boldItalic>fonts/cjk-bolditalic.ttf</boldItalic> <pdfEncoding>Identity-H</pdfEncoding> <pdfEmbedded>true</pdfEmbedded> </fontFamily> </fontFamilies>

Identity-H是双字节编码,中文能正确映射;pdfEmbedded=true让字体子集被嵌入 PDF,文件会大几十到几百 KB,换来的是任何设备打开都一致。fontFamily的name是你在 jrxml 里fontName要填的值,填ReportCJK而不是 ttf 文件名。

N 个字体文件对应 N 个normal/bold/italic条目,缺一个的后果是「加粗的那部分标题变方块」——这个症状我见过一次,排查了半天才发现是漏了 bold 文件。

5.2 Excel 导出后数字全变成文本,求和公式算不出来

现象:PDF 正常,导出的 xlsx 里金额列左对齐(文本的默认对齐方式),选中整列看状态栏没有求和,用户拿去做透视表报「字段不是数值」。

原因有两层。第一层是字段类型:<field name="amount" class="java.lang.String"/>,引擎拿到的是字符串,导出器只能写文本。第二层是导出器配置:即便字段是 BigDecimal,XLSX 导出器默认的detectCellType是关闭的,它会按文本处理尽量保真。

解决:先把 field 改成java.math.BigDecimal,再把导出器配置打开。

import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter; import net.sf.jasperreports.export.SimpleExporterInput; import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput; import net.sf.jasperreports.export.SimpleXlsxReportConfiguration; SimpleXlsxReportConfiguration cfg = new SimpleXlsxReportConfiguration(); // 让导出器根据字段的 Java 类型推断单元格类型,而不是一律当文本 cfg.setDetectCellType(true); // 保留 jrxml 里定义的 pattern(比如 #,##0.00) cfg.setFormatPatternsMap(java.util.Map.of("$#,##0.00", "#,##0.00")); // 不把整个报表塞进一个 sheet,按分页拆 cfg.setOnePagePerSheet(false); // 每页之间不断行,避免透视表被空行打断 cfg.setRemoveEmptySpaceBetweenRows(false); JRXlsxExporter exporter = new JRXlsxExporter(); exporter.setExporterInput(new SimpleExporterInput(jasperPrint)); exporter.setExporterOutput(new SimpleOutputStreamExporterOutput("out.xlsx")); exporter.setConfiguration(cfg); exporter.exportReport();

setDetectCellType(true)是核心,它让导出器检查字段的运行时类型,BigDecimal 就写成数值单元格。setFormatPatternsMap处理的是 pattern 映射——jrxml 里写#,##0.00,Excel 的格式串不完全一样,映射一下能保证显示效果一致。setOnePagePerSheet按需,对账单这类每客户一页的报表开着更友好,但页数上百时会生成上百个 sheet,反而卡。

还有一个隐藏坑:即使类型对了,如果 textField 上有blankWhenNull="true",null 值会写成空字符串,Excel 会把这个单元格识别成文本,整列的类型推断就废了。金额字段别开这个属性,用pattern处理 null,或者在 SQL 里COALESCE(amount, 0)。

5.3 最后一页莫名其妙多出一张空白页

现象:数据正好排满一页,导出后多出一张只有页脚、正文全空的页。

原因基本可以归到「高度超了一点」。引擎在填充 detail 带时,如果「当前页剩余高度 < 当前行高度 + pageFooter 高度」,就会换页。你肉眼看着刚好排满,实际上差几个点,于是挤出一页。另一个常见来源是columnFooter和pageFooter叠加——有的模板两个都用了,底部实际占了两块高度,但设计时只按其中一块估算。

排查顺序:先把pageFooter的 band 高度、columnFooter的 band 高度、bottomMargin三个数加起来,再跟pageHeight减topMargin比。三者之和超了,必然出空白页。

几个具体手法。<band height="20"/>里放高度 20 的 textField 是放不下的,边框会溢出,要么 band 给 21,要么 textField 给 19。detail 带里的行高用固定值比用isStretchWithOverflow更可控——自动撑高会导致行高不一致,累计误差最后挤出一页。分组带的minHeightToStartNewPage设太大会提前换页,设成 0 又会留孤行,取中间值。

还有一个不那么直观的:<groupFooter>如果开了splitType="Prevent",也就是「不允许拆分」,而它本身高度又超过当前页剩余空间,引擎会整块挪到下一页,上一页底部留下空白。看起来像空白页,其实是分组尾被推走了。

注意:调高度的时候别靠肉眼,Studio 的 Design 视图有标尺,Preview 视图有分页虚线,对着虚线调比对着成品 PDF 猜靠谱得多。

5.4 改了 jrxml,预览还是老样子

现象:改了一个 label 的文字,保存,点 Preview,出来的还是旧文字。重启 Studio 才好,过一会儿又复发。

原因:Studio 的自动构建机制在工作区里维护了一份编译过的.jasper。它判断「是否需要重新编译」靠的是文件时间戳和构建状态,在某些情况下会漏判——尤其是你从版本库拉了新代码、或者外部编辑器改了 jrxml 的时候。另外,Project > Build Automatically关掉之后,保存不会触发编译,但预览仍然会用工作区里现有的.jasper。

解决按顺序试:

第一步,Project > Clean,勾选你的报表项目,清理后会自动重建。这一步能解决八成情况。

第二步,看Build Automatically是不是被关掉了。关着的话手动Project > Build Project。

第三步,去src/reports/目录下手动删掉.jasper文件,再预览。这一步专治「Clean 也不管用」的情况——因为.jasper有时会被当成源文件参与构建,Clean 清了输出目录但它还在原位。

第四步,看看是不是有多个同名 jrxml 在 classpath 里。子报表尤其容易撞车:sub/detail.jrxml和detail.jrxml同时存在,引擎按 classpath 顺序找,你先改的那个可能压根没被加载。

预防措施是把.jasper加进.gitignore,并且养成「改完 jrxml 先 Clean 再预览」的习惯。我现在是条件反射,改完就按一下,不按心里不踏实。

5.5 十万行数据导出,堆内存直接打满

现象:小数据量一切正常,数据量上到十万行左右,导出时抛OutOfMemoryError: Java heap space,加-Xmx只能往后拖,最后还是炸。

原因:JasperReports 填充时会为每一行生成一个JRTemplatePrintText对象,十万行乘以每行几十个字段,就是几百万个对象常驻堆里。这些对象在导出之前必须全部保留,因为分页、合计、回传都依赖于完整的结果集。加堆只是把爆炸点推后,不是解决。

解决是启用虚拟化(virtualizer),把已经处理完的页序列化到磁盘,堆里只留当前页:

import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer; import net.sf.jasperreports.engine.util.JRSwapFile; // 交换文件:目录、块大小(字节)、块数量 JRSwapFile swapFile = new JRSwapFile("/tmp/jasper-swap", 1024, 50); // 第二个参数是“堆内最多保留多少页”,超出的换出到磁盘 JRSwapFileVirtualizer virtualizer = new JRSwapFileVirtualizer(2, swapFile, true); Map<String, Object> params = new HashMap<>(); params.put(JRParameter.REPORT_VIRTUALIZER, virtualizer); params.put(JRParameter.REPORT_MAX_COUNT, 200_000); // 硬性上限,防止无限填充 JasperPrint print = JasperFillManager.fillReport(report, params, dataSource); try { JasperExportManager.exportReportToPdfFile(print, "/tmp/out.pdf"); } finally { // 必须显式清理,否则临时文件会堆在磁盘上 virtualizer.cleanup(); }

JRSwapFile的三个参数分别是临时目录、单块字节数、块数量上限。块太小会频繁 IO,块太大浪费磁盘,1024×50 是常见的起步值。JRSwapFileVirtualizer的第一个参数「堆内保留页数」设 2 意味着只有当前页和下一页在内存里,剩下的都在磁盘上,堆占用能降一个数量级,代价是导出时间变长——磁盘 IO 换内存,这个账要自己算。

REPORT_MAX_COUNT是后悔药:给填充设一个硬上限,超过就停。查询写错了导致笛卡尔积,没有这个参数的话,虚拟化也救不了你,只会把磁盘写满。

cleanup()一定要放在 finally 里。虚拟化产生的临时文件不会自动删,进程不退出的话会一直堆着。我见过一次磁盘告警,查下来是某个定时任务忘了 cleanup。

6. 从预览到服务端导出:一套自检清单

设计器里跑通只是第一步,真正的交付在服务端。最小闭环不长,但每一步都有版本对齐的讲究:

import net.sf.jasperreports.engine.*; public byte[] render(byte[] jrxmlBytes, Map<String, Object> params, JRBeanCollectionDataSource ds) { // 1) 编译:也可以提前编译成 .jasper 打进 jar,省掉运行时编译开销 JasperReport report = JasperCompileManager.compileReport( new java.io.ByteArrayInputStream(jrxmlBytes)); // 2) 填充:params 里可以带 REPORT_DATA_SOURCE / REPORT_CONNECTION JasperPrint print = JasperFillManager.fillReport(report, params, ds); // 3) 导出:这里走 PDF,Excel 换 JRXlsxExporter,配置见 5.2 return JasperExportManager.exportReportToPdf(print); }

compileReport接受输入流,意味着 jrxml 可以打进 jar 从 classpath 读,不必落地成文件。服务端跑的是编译后的.jasper更稳——运行时编译依赖 JDK 而不是 JRE,很多精简过的运行环境里没有编译器,会抛JasperCompilationException,栈信息还特别长。

我交付前必走的一遍清单,六项:

检查项怎么查不过的后果
jrxml 与 jar 同版本比对 pom 里的 jasperreports 版本导出器参数不识别
字体已嵌入用 PDF 阅读器看文档属性里的字体列表换机器变方块
子报表路径在 jar 内可解析打成 jar 后在目标环境跑一次子报表空白
空数据场景已测传一个空集填充导出零字节文件
$P!{}位置有白名单全文搜$P!{注入风险
虚拟化已 cleanup跑完看临时目录磁盘慢慢涨满

最后一项是时间给的教训。早些年我交付过一套批量导出任务,功能测完就上线了,两周后运维找来说磁盘满了,查下来是临时文件没清。从那以后我每次上线导出功能,都强制走一遍「连续跑 100 次看临时目录有没有增长」这一步,哪怕只是改了一行表达式。报表这类东西,正确性靠预览能看出来,稳定性只能靠压。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表