Java导出Excel出现emoji乱码

前言

做后端开发经常需要导出Excel, 一般有两种选择POIJXLSeasyexcel.
其实底层都是使用POI来做的.

三种框架分析

  1. Apache名下的POI
    • 优点: 微软全家桶基本都可以做, 什么WordExcelPPT.
    • 缺点: 所有格式都要通过代码完成, 代码又长又臭.
  2. JXLS专门用来操作Excel, 使用JXLS模板语法可以书写Excel模板, 底层使用org.jxls.transform.poi.PoiTransformerPOI做对接.
    • 优点: 使用JXLS模板语法, 可以将格式和代码解耦, 快速开发, 代码精简.
    • 缺点: 需要额外的学习成本学习JXLS模板语法(学什么不是学
  3. easyexcel是阿里基于POI开发的Excel解析工具.
    • 优点: 据README说, 解决POI内存溢出问题.
    • 缺点: 没用过不评价

emoji乱码代码单元测试

复现下测试环境, 依赖pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.17</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.17</version>
</dependency>

<!-- poi-ooxml 依赖 poi-ooxml-schemas, 依赖 xmlbeans 2.6.0 -->
<!-- https://mvnrepository.com/artifact/org.apache.xmlbeans/xmlbeans -->
<!-- <dependency>
<groupId>org.apache.xmlbeans</groupId>
<artifactId>xmlbeans</artifactId>
<version>2.6.0</version>
</dependency> -->
</dependencies>

生成Excel的代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Main {
// 𤆕🔝biu~ better me人🌝
public static final String UNICODE = "\uD850\uDD95\uD83D\uDD1Dbiu~\t\uE110better me\uE110人\uD83C\uDF1D";

public static void main(String[] args) {
createXLSX("C:/test.xlsx");
createXLS("C:/test.xls");
}

public static void createXLSX(String filename) {
try(XSSFWorkbook workbook = new XSSFWorkbook();
FileOutputStream fos =new FileOutputStream(filename)) {

XSSFSheet sheet = workbook.createSheet("TestSheet");
XSSFRow row = sheet.createRow(0);
XSSFCell cell1 = row.createCell(0);
cell1.setCellValue(UNICODE);

workbook.write(fos);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void createXLS(String filename) {
try(HSSFWorkbook workbook = new HSSFWorkbook();
FileOutputStream fos =new FileOutputStream(filename)) {

HSSFSheet sheet = workbook.createSheet("TestSheet");
HSSFRow row = sheet.createRow(0);
HSSFCell cell1 = row.createCell(0);
cell1.setCellValue(UNICODE);

workbook.write(fos);
} catch (IOException e) {
e.printStackTrace();
}
}
}

运行完毕, 可以看到xls能正常输出𤆕🔝biu~ better me人🌝, 而xlsx只能输出????biu~ better me人??.
emoji表情不能输出.

解决方案(简单版)

解决方案有两种.

  1. 升级POI4.0.0以上.
  2. 替换xmlbeans3.0.0以上.

本质都是替换xmlbeans.

问题原因

为什么xls没问题, xlsx有问题?
因为xlsHSSFRichTextString使用了UnicodeString进行编码, 内联字符串, 没有重用字符串.
xlsx为了解决内存问题, 将相同字符串先写入sharedStrings.xml, 重用字符串.

我们可以将一个xlsx改为zip解压, 在里面可以找到一个sharedStrings.xml文件, 里面就存储着我们的字符串.

1
2
3
4
<?xml version="1.0" encoding="UTF-8"?>
<sst count="1" uniqueCount="1" xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<si><t>????biu~ better me人??</t></si>
</sst>

那很明显就是写入xml的时候出了问题.

Write 16 bits character to .xlsx file using Apache POI in Java提到了org.apache.xmlbeans.impl.store.SaverisBadChar方法.
点击查看源码L1559

1
2
3
4
5
6
7
8
9
10
abstract class Saver {
private boolean isBadChar(char ch) {
return ! (
(ch >= 0x20 && ch <= 0xD7FF ) ||
(ch >= 0xE000 && ch <= 0xFFFD) ||
(ch >= 0x10000 && ch <= 0x10FFFF) ||
(ch == 0x9) || (ch == 0xA) || (ch == 0xD)
);
}
}

🌝这个字符为例, 它的Unicode编码为\uD83C\uDF1D, 一个char是存不下的, 要用两个char.
那么第一个char传入isBadChar, 得到true. 第二个char传入也得到true.
那么我们看下哪里有调用到这个isBadChar方法.
点击查看源码L2310

1
2
3
4
5
6
7
8
9
10
11
abstract class Saver {
private void entitizeAndWriteCommentText(int bufLimit) {
// 省略部分代码
for (int i = 0; i < bufLimit; i++) {
char ch = _buf[ i ];
if (isBadChar(ch))
_buf[i] = '?';
// 省略部分代码
}
}
}

有很多地方都有调用, 但是基本都是一个逻辑, 如果是true, 则替换为?, 所以我们才在sharedStrings.xml看到一堆??.
那这就是xmlbeans这个库的问题了, 不是我们代码的锅(甩锅大成功!

xmlbeans 3.0.0 解决方案

再看看xmlbeans 3.0.0怎么解决的.
点击查看源码L286

1
2
3
4
5
6
7
8
9
10
11
12
abstract class Saver {
private boolean isBadChar(char ch) {
return ! (
Character.isHighSurrogate(ch) ||
Character.isLowSurrogate(ch) ||
(ch >= 0x20 && ch <= 0xD7FF ) ||
(ch >= 0xE000 && ch <= 0xFFFD) ||
(ch >= 0x10000 && ch <= 0x10FFFF) ||
(ch == 0x9) || (ch == 0xA) || (ch == 0xD)
);
}
}

🌝这个字符为例, 它的Unicode编码为\uD83C\uDF1D, 一个char是存不下的, 要用两个char.
那么第一个char传入isBadChar, 得到false. 第二个char传入也得到false.
所以也就不会被替换成?, bug 解决.

参考资料