Freemarker 导出复杂 Excel
Freemarker 可作为 Excel 导出模板引擎,但复杂格式(如多行合并、跨列合并)的处理需要深入了解其 XML 配置规则。本文记录了使用 Freemarker 导出复杂 Excel 的完整流程:首先将 Excel 模板另存为 XML 格式,理解 sheet.xml 的结构(如单元格合并用 <mergeCell>、行样式用 <row>),然后编写 FTL 模板语法进行动态数据绑定。文章详细演示了多行合并、跨列合并、复杂表头、金额格式化等实际项目中常见需求的解决方案。
序言
用 Freemarker 做简单的 Excel 导出确实很容易,但是要导出带有特殊格式的复杂 Excel(例如多行合并),还是费了我一天的时间来研究排版规则。
关于Freemarker的语法, 可以参考http://freemarker.foofun.cn/index.html
一、基础步骤
首先是引入 POM 依赖,构建工具使用 Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>1. 准备模板
接下来,制作 .ftl 模板文件。
先在 Office Excel 中编辑好一个表格,调整好你想要的样式,这就是最后要生成的模板长相。

注意:必须在 Excel 中选择“另存为” -> XML 表格 (*.xml) 格式,千万不要直接去改后缀名!
将保存好的 xml 文件拷贝到项目下(比如 resources/templates 目录),然后将其后缀名修改为 .ftl。
用文本编辑器(XML 方式)打开这个 .ftl 文件,找到类似下面这段代码:
<Table ss:ExpandedColumnCount="4" ss:ExpandedRowCount="3" x:FullColumns="1"
x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="13.5">
<Row>
<Cell ss:MergeAcross="3" ss:StyleID="s66"><Data ss:Type="String">人员列表</Data></Cell>
</Row>
<Row>
<Cell ss:StyleID="s67"/>
<Cell ss:StyleID="s67"><Data ss:Type="String">name</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">age</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">address</Data></Cell>
</Row>
<Row>
<Cell ss:StyleID="s67"><Data ss:Type="Number">1</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">zhangsan</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="Number">22</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">BeiJing</Data></Cell>
</Row>
</Table>对比你的 Excel 很容易看出对应关系:每个 <Row> 就是一行,每个 <Cell> 就是一个单元格。
找到你要循环渲染的那一行,添加 Freemarker 表达式和标签(关于 Freemarker 语法可参考官方文档)。
加上遍历标签后的代码如下:
<Table ss:ExpandedColumnCount="4" ss:ExpandedRowCount="${userListSize}" x:FullColumns="1"
x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="13.5">
<Row>
<Cell ss:MergeAcross="3" ss:StyleID="s66"><Data ss:Type="String">人员列表</Data></Cell>
</Row>
<Row>
<Cell ss:StyleID="s67"/>
<Cell ss:StyleID="s67"><Data ss:Type="String">姓名</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">年龄</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">地址</Data></Cell>
</Row>
<#assign index = 0 >
<#list userList as u>
<#assign index = index + 1 >
<Row>
<Cell ss:StyleID="s67"><Data ss:Type="Number">${index}</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">${u.name}</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="Number">${u.age}</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">${u.address}</Data></Cell>
</Row>
</#list>
</Table>避坑提示:注意
<Table>标签中的ss:ExpandedRowCount属性,它的值可以通过变量${userListSize}动态传进来。如果你偷懒直接写死一个很大的固定值(比如 99999),在打开 Excel 时可能会报“文件损坏”警告,或者导致空行占用大量内存加载变慢。建议老老实实在后台计算好行数传进来。
2. 准备数据源
对于动态生成的 List,里面可以放 Map,也可以放具体的 Java 对象(POJO)。这里我先用 Map 演示。
模拟获取数据:
/**
* 构造 user 数据 List<Map<String, Object>>
*/
private static List<Map<String, Object>> getUserList() {
List<Map<String, Object>> returnList = new ArrayList<>();
Map<String, Object> map1 = new HashMap<>();
map1.put("name", "张三");
map1.put("age", "18");
map1.put("address", "广东");
Map<String, Object> map2 = new HashMap<>();
map2.put("name", "王五");
map2.put("age", "22");
map2.put("address", "北京");
returnList.add(map1);
returnList.add(map2);
return returnList;
}控制器 (Controller) 导出逻辑:
@RequestMapping(value="/exportExcel", method=RequestMethod.GET)
public void exportExcelByFreeMarker(HttpServletRequest request, HttpServletResponse response) {
try {
List<Map<String, Object>> userList = this.getUserList();
configuration.setDefaultEncoding("UTF-8");
configuration.setTemplateUpdateDelayMilliseconds(0);
configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
// 获取模板
Template template = configuration.getTemplate("userlist.ftl");
Map<String, Object> root = new HashMap<>();
root.put("userList", userList);
// 注意:计算行数时要加上表头占据的行数(这里是标题行+列名行=2行)
root.put("userListSize", String.valueOf(userList.size() + 2));
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String fileName = "人员列表_" + sdf.format(new Date());
response.setContentType("application/msexcel;charset=UTF-8");
response.setHeader("Content-disposition", "attachment;filename=\"" +
new String((fileName + ".xls").getBytes("GBK"), "ISO8859-1") + "\"");
// response 字符流转换成字节流,template 需要字节流作为输出
OutputStream outputStream = response.getOutputStream();
OutputStreamWriter outputWriter = new OutputStreamWriter(outputStream, "UTF-8");
Writer writer = new BufferedWriter(outputWriter);
template.process(root, writer);
writer.flush();
writer.close();
} catch (Exception e) {
e.printStackTrace();
}
}现在,在浏览器中请求该接口地址,就可以成功下载导出的 Excel 文件了。
二、进阶:导出带有合并单元格的复杂 Excel
生成 Excel 还有很多复杂的业务场景,比如带图片、跨行合并等。这里以多行向下合并为例,效果如下:

手动在 Excel 里合并好样式并另存为 XML 后,观察生成的关键代码:
<Table ss:ExpandedColumnCount="5" ss:ExpandedRowCount="99999" x:FullColumns="1"
x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="13.5">
<Row>
<Cell ss:MergeAcross="3" ss:StyleID="s66"><Data ss:Type="String">人员列表</Data></Cell>
<Cell ss:StyleID="s62"/>
</Row>
<Row>
<Cell ss:StyleID="s67"/>
<Cell ss:StyleID="s67"><Data ss:Type="String">姓名</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">年龄</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">孩子</Data></Cell>
<Cell ss:StyleID="s69"><Data ss:Type="String">地址</Data></Cell>
</Row>
<Row>
<Cell ss:MergeDown="1" ss:StyleID="m87387632"><Data ss:Type="Number">1</Data></Cell>
<Cell ss:MergeDown="1" ss:StyleID="m87387612"><Data ss:Type="String">张三</Data></Cell>
<Cell ss:MergeDown="1" ss:StyleID="m87387592"><Data ss:Type="Number">22</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">小张</Data></Cell>
<Cell ss:MergeDown="1" ss:StyleID="m87387572"><Data ss:Type="String">北京</Data></Cell>
</Row>
<Row>
<Cell ss:Index="4" ss:StyleID="s67"><Data ss:Type="String">二张</Data></Cell>
</Row>
</Table>仔细观察并分析上述代码,与普通 XML(不带合并) 的区别在于:
- 每个需要向下合并的
<Cell>都带有一个ss:MergeDown属性,它的值为 (实际需要合并的格数 - 1)。 - 合并的格数,通常需要通过子对象的集合大小来动态计算(比如这里的“孩子”集合长度)。
- 被合并挤占后的扩展行(下一行),其有效单元格会带有一个
ss:Index属性。这个值代表它在当前行属于第几列(例如上面的“二张”在第 4 列,所以是Index="4")。
分析好逻辑后,我们开始在 .ftl 模板中加入判断标签:
<Table ss:ExpandedColumnCount="5" ss:ExpandedRowCount="4" x:FullColumns="1"
x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="13.5">
<Row>
<Cell ss:MergeAcross="3" ss:StyleID="s66"><Data ss:Type="String">人员列表</Data></Cell>
<Cell ss:StyleID="s62"/>
</Row>
<Row>
<Cell ss:StyleID="s67"/>
<Cell ss:StyleID="s67"><Data ss:Type="String">姓名</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">年龄</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="String">孩子</Data></Cell>
<Cell ss:StyleID="s69"><Data ss:Type="String">地址</Data></Cell>
</Row>
<#assign index = 0 >
<#list userBoList as u>
<#assign index = index + 1 >
<#assign num = u.children?size-1>
<#if num lt 0>
<#assign num = 0>
</#if>
<Row>
<Cell ss:MergeDown="${num}" ss:StyleID="m87387632"><Data ss:Type="Number">${index}</Data></Cell>
<Cell ss:MergeDown="${num}" ss:StyleID="m87387612"><Data ss:Type="String">${u.name}</Data></Cell>
<Cell ss:MergeDown="${num}" ss:StyleID="m87387592"><Data ss:Type="Number">${u.age}</Data></Cell>
<!-- 在children的List中取出第一个 -->
<#list u.children as firstChildren>
<#if firstChildren_index == 0>
<Cell ss:StyleID="s67"><Data ss:Type="String">${firstChildren.name}</Data></Cell>
</#if>
</#list>
<Cell ss:MergeDown="${num}" ss:StyleID="m87387572"><Data ss:Type="String">${u.address}</Data></Cell>
</Row>
<!-- 从第一个之后开始取 -->
<#list u.children as c>
<#if (u.children?size > 1 && c_index > 0 )>
<Row>
<Cell ss:Index="4" ss:StyleID="s67"><Data ss:Type="String">${c.name}</Data></Cell>
</Row>
</#if>
</#list>
</#list>
</Table>关键总结
- 防止负数报错:
MergeDown属性中一定要判断<#if num lt 0>。亲测在 Microsoft Office 中,如果该值为负数会导致 Excel 直接报错打不开(WPS 容错强不会报错),所以最好做兜底判断。 - 数据结构设计:在这个复杂 Excel 场景中,后台组装的数据结构需要是包含一对多嵌套关系的实体。例如:
- 主对象:
List<UserBo> - 子对象:在
UserBo内部包含List<ChildrenBo> - 两者关系:
User -> Children : 1 -> N - 当然,使用嵌套的
List<Map<String, Object>>也是完全可以的。
- 主对象: