Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

解析 Android XML 中的转义字符问题:从 Jsoup 到 Woodstox StAX

解析 Android XML 中的转义字符问题:从 Jsoup 到 Woodstox StAX

背景

在多语言平台(i18n)中,需要解析 Android 的 XML 配置文件(strings.xml),提取 <string> 标签中的 name 属性和文本内容,用于导入翻译词条。Android XML 文件格式如下:

1
2
3
4
<resources>
<string name="snapgo_time_lapse_internal_20_s_des">Dusk &amp; Dawn</string>
<string name="snapgo_time_lapse_internal_30_s_des">Dusk '" Dawn &lt; &quot; &apos; &lt;</string>
</resources>

其中包含了 XML 预定义的转义实体:&amp;&lt;&gt;&quot;&apos;。我们的需求是原样保留 XML 文件中的转义字符串,即 &amp; 读出来就是 &amp;,而不是被解码成 &

问题分析

方案一:Jsoup + e.text() — 转义被自动解码

最初的实现 parseAppXmlDeprecated 使用 Jsoup 的 XML Parser 解析,通过 e.text() 获取文本内容:

1
2
3
4
5
6
7
8
9
10
public Result<List<KeyContentBO>> parseAppXmlDeprecated(MultipartFile file) {
Document doc = Jsoup.parse(file.getInputStream(), "UTF-8", "",
org.jsoup.parser.Parser.xmlParser());
Elements elements = doc.select("string");
for (Element e : elements) {
String name = e.attr("name");
String value = e.text(); // 问题所在
resultList.add(new KeyContentBO(name, value));
}
}

问题e.text() 会自动对 XML 实体进行解码:

XML 原文 e.text() 输出 期望输出
Dusk &amp; Dawn Dusk & Dawn Dusk &amp; Dawn
&lt; < &lt;
&quot; " &quot;
&apos; ' &apos;

转义信息丢失,导致导出时无法还原原始 XML 内容。

方案二:Jsoup + e.html() — HTML 与 XML 的转义差异

既然 text() 会解码,那使用 e.html() 获取原始 HTML 呢?

问题:Jsoup 本质上是一个 HTML 解析器,即使使用了 Parser.xmlParser(),在序列化时仍然遵循 HTML 的转义规则。HTML 和 XML 的转义规则存在关键差异:

实体 XML 中 HTML 中 说明
&quot; 合法实体,代表 " 合法实体,代表 " 两者一致
&apos; 合法实体,代表 ' 不合法,HTML 无此实体 关键差异

在 HTML 规范中,&apos; 不是预定义实体(HTML4 中未定义,HTML5 中虽有但行为不一致)。Jsoup 在 html() 输出时:

  • &amp; → 保持 &amp;(不会被二次转义)
  • &lt; → 保持 &lt;
  • &quot; → 可能被解码为 "(因为 HTML 中 " 在属性值外不需要转义)
  • &apos; → 可能被解码为 ' 或保持原样,行为不确定

这意味着 html() 也无法可靠地保留 XML 原始转义内容,且 HTML/XML 的语义差异会导致不可预期的结果。

核心矛盾

方法 行为 是否满足需求
e.text() 解码所有实体 否,转义信息丢失
e.html() 按 HTML 规则处理,与 XML 有差异 否,&apos; 等处理不一致

根本原因:Jsoup 是为 HTML 设计的解析器,其数据模型中存储的是解码后的文本,text()html() 都是在解码后的数据上做序列化,无法获取 XML 源码中的原始文本。

解决方案:Woodstox StAX 流式解析

引入的库:Woodstox

Woodstox 是一个高性能的开源 StAX(Streaming API for XML)实现,由 FasterXML 团队维护(与 Jackson 同一团队)。其核心特点:

  • StAX 流式解析:基于拉取(pull)模型的流式解析器,不同于 DOM 的全量加载,也不同于 SAX 的推送模型,由开发者按需驱动解析进度
  • 低内存占用:不需要将整个 XML 文档构建为 DOM 树,适合处理大文件
  • 精确的位置信息XMLStreamReader2.getLocation().getCharacterOffset() 可以获取当前事件在原始文本中的字符偏移量,这是解决问题的关键能力
  • 完整支持 XML 规范:严格遵循 XML 1.0/1.1 规范,正确处理所有预定义实体,不会引入 HTML 语义
  • 高性能:作为 StAX 参考实现之一,性能优于 DOM 解析方案

Maven 依赖:

1
2
3
4
5
<dependency>
<groupId>com.fasterxml.woodstox</groupId>
<artifactId>woodstox-core</artifactId>
<version>6.6.2</version>
</dependency>

解决思路

核心思路:不依赖解析器的文本提取,而是利用 StAX 的位置信息,直接从原始字符串中截取内容

  1. 将文件内容读取为原始字符串
  2. 使用 StAX 流式解析器遍历 XML 事件
  3. 遇到 <string> 开始标签时,记录 name 属性值,并通过 getCharacterOffset() 获取起始位置,再从原始字符串中找到 > 的位置,确定内容起始偏移
  4. 遇到 </string> 结束标签时,通过 getCharacterOffset() 获取结束位置
  5. content.substring(contentStartOffset, endTagOffset) 直接截取原始字符串

这样截取的是原始 XML 源码中的文本,所有转义实体都原样保留。

实现代码

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
39
40
41
42
43
public Result<List<KeyContentBO>> parseAppXml(MultipartFile file) {
try {
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
WstxInputFactory factory = new WstxInputFactory();
XMLStreamReader2 reader = (XMLStreamReader2) factory
.createXMLStreamReader(new StringReader(content));
try {
String currentName = null;
int contentStartOffset = -1;
List<KeyContentBO> resultList = new ArrayList<>();

while (reader.hasNext()) {
int event = reader.next();

if (event == XMLStreamConstants.START_ELEMENT
&& "string".equals(reader.getLocalName())) {
currentName = reader.getAttributeValue(null, "name");
int startTagOffset = reader.getLocation().getCharacterOffset();
contentStartOffset = content.indexOf('>', startTagOffset) + 1;
}

if (event == XMLStreamConstants.END_ELEMENT
&& "string".equals(reader.getLocalName())) {
if (currentName != null && contentStartOffset >= 0) {
int endTagOffset = reader.getLocation().getCharacterOffset();
String value = content.substring(contentStartOffset, endTagOffset);
if (StringUtils.isNotBlank(currentName)) {
resultList.add(new KeyContentBO(currentName, value));
}
}
currentName = null;
contentStartOffset = -1;
}
}
return Result.newInstance(resultList);
} finally {
reader.close();
}
} catch (Exception e) {
log.error("parseAppXml error, fallback to parseAppXmlDeprecated:", e);
return parseAppXmlDeprecated(file);
}
}

关键代码解析

1. 获取内容起始偏移

1
2
int startTagOffset = reader.getLocation().getCharacterOffset();
contentStartOffset = content.indexOf('>', startTagOffset) + 1;

getCharacterOffset() 返回的是 START_ELEMENT 事件的位置(指向 <string<),而非内容开始位置。因此需要从该偏移开始找到 >,加 1 即为内容的起始位置。

2. 获取内容结束偏移

1
2
int endTagOffset = reader.getLocation().getCharacterOffset();
String value = content.substring(contentStartOffset, endTagOffset);

END_ELEMENT 事件的位置指向 </string><,恰好就是内容的结束位置,可以直接用于 substring

3. 兜底降级

1
2
3
4
catch (Exception e) {
log.error("parseAppXml error, fallback to parseAppXmlDeprecated:", e);
return parseAppXmlDeprecated(file);
}

如果 StAX 解析失败(如格式错误的 XML),降级到 Jsoup 方案,保证可用性。

效果验证

对于示例 XML:

1
2
<string name="snapgo_time_lapse_internal_20_s_des">Dusk &amp; Dawn</string>
<string name="snapgo_time_lapse_internal_30_s_des">Dusk '" Dawn &lt; &quot; &apos; &lt;</string>
name 原方案 e.text() 新方案 substring
snapgo_time_lapse_internal_20_s_des Dusk & Dawn Dusk &amp; Dawn
snapgo_time_lapse_internal_30_s_des Dusk '" Dawn < " ' < Dusk '" Dawn &lt; &quot; &apos; &lt;

新方案完美保留了原始 XML 中的转义实体。

总结

维度 Jsoup (text()) Jsoup (html()) Woodstox StAX (substring)
转义保留 全部解码 HTML 规则,与 XML 不一致 原样保留
内存占用 DOM 全量加载 DOM 全量加载 流式,低内存
HTML/XML 兼容 HTML 语义 HTML 语义 纯 XML 语义
位置信息 getCharacterOffset()

核心经验:当需要保留 XML 原始文本(含转义实体)时,不应依赖解析器的文本提取 API(它们都会做解码),而应利用 StAX 的位置信息从原始字符串中直接截取。Woodstox 的 getCharacterOffset() 是实现这一方案的关键能力。