解析 Android XML 中的转义字符问题:从 Jsoup 到 Woodstox StAX
背景
在多语言平台(i18n)中,需要解析 Android 的 XML 配置文件(strings.xml),提取 <string> 标签中的 name 属性和文本内容,用于导入翻译词条。Android XML 文件格式如下:
1 | <resources> |
其中包含了 XML 预定义的转义实体:&、<、>、"、'。我们的需求是原样保留 XML 文件中的转义字符串,即 & 读出来就是 &,而不是被解码成 &。
问题分析
方案一:Jsoup + e.text() — 转义被自动解码
最初的实现 parseAppXmlDeprecated 使用 Jsoup 的 XML Parser 解析,通过 e.text() 获取文本内容:
1 | public Result<List<KeyContentBO>> parseAppXmlDeprecated(MultipartFile file) { |
问题:e.text() 会自动对 XML 实体进行解码:
| XML 原文 | e.text() 输出 |
期望输出 |
|---|---|---|
Dusk & Dawn |
Dusk & Dawn |
Dusk & Dawn |
< |
< |
< |
" |
" |
" |
' |
' |
' |
转义信息丢失,导致导出时无法还原原始 XML 内容。
方案二:Jsoup + e.html() — HTML 与 XML 的转义差异
既然 text() 会解码,那使用 e.html() 获取原始 HTML 呢?
问题:Jsoup 本质上是一个 HTML 解析器,即使使用了 Parser.xmlParser(),在序列化时仍然遵循 HTML 的转义规则。HTML 和 XML 的转义规则存在关键差异:
| 实体 | XML 中 | HTML 中 | 说明 |
|---|---|---|---|
" |
合法实体,代表 " |
合法实体,代表 " |
两者一致 |
' |
合法实体,代表 ' |
不合法,HTML 无此实体 | 关键差异 |
在 HTML 规范中,' 不是预定义实体(HTML4 中未定义,HTML5 中虽有但行为不一致)。Jsoup 在 html() 输出时:
&→ 保持&(不会被二次转义)<→ 保持<"→ 可能被解码为"(因为 HTML 中"在属性值外不需要转义)'→ 可能被解码为'或保持原样,行为不确定
这意味着 html() 也无法可靠地保留 XML 原始转义内容,且 HTML/XML 的语义差异会导致不可预期的结果。
核心矛盾
| 方法 | 行为 | 是否满足需求 |
|---|---|---|
e.text() |
解码所有实体 | 否,转义信息丢失 |
e.html() |
按 HTML 规则处理,与 XML 有差异 | 否,' 等处理不一致 |
根本原因: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 | <dependency> |
解决思路
核心思路:不依赖解析器的文本提取,而是利用 StAX 的位置信息,直接从原始字符串中截取内容。
- 将文件内容读取为原始字符串
- 使用 StAX 流式解析器遍历 XML 事件
- 遇到
<string>开始标签时,记录name属性值,并通过getCharacterOffset()获取起始位置,再从原始字符串中找到>的位置,确定内容起始偏移 - 遇到
</string>结束标签时,通过getCharacterOffset()获取结束位置 - 用
content.substring(contentStartOffset, endTagOffset)直接截取原始字符串
这样截取的是原始 XML 源码中的文本,所有转义实体都原样保留。
实现代码
1 | public Result<List<KeyContentBO>> parseAppXml(MultipartFile file) { |
关键代码解析
1. 获取内容起始偏移
1 | int startTagOffset = reader.getLocation().getCharacterOffset(); |
getCharacterOffset() 返回的是 START_ELEMENT 事件的位置(指向 <string 的 <),而非内容开始位置。因此需要从该偏移开始找到 >,加 1 即为内容的起始位置。
2. 获取内容结束偏移
1 | int endTagOffset = reader.getLocation().getCharacterOffset(); |
END_ELEMENT 事件的位置指向 </string> 的 <,恰好就是内容的结束位置,可以直接用于 substring。
3. 兜底降级
1 | catch (Exception e) { |
如果 StAX 解析失败(如格式错误的 XML),降级到 Jsoup 方案,保证可用性。
效果验证
对于示例 XML:
1 | <string name="snapgo_time_lapse_internal_20_s_des">Dusk & Dawn</string> |
| name | 原方案 e.text() |
新方案 substring |
|---|---|---|
snapgo_time_lapse_internal_20_s_des |
Dusk & Dawn |
Dusk & Dawn |
snapgo_time_lapse_internal_30_s_des |
Dusk '" Dawn < " ' < |
Dusk '" Dawn < " ' < |
新方案完美保留了原始 XML 中的转义实体。
总结
| 维度 | Jsoup (text()) |
Jsoup (html()) |
Woodstox StAX (substring) |
|---|---|---|---|
| 转义保留 | 全部解码 | HTML 规则,与 XML 不一致 | 原样保留 |
| 内存占用 | DOM 全量加载 | DOM 全量加载 | 流式,低内存 |
| HTML/XML 兼容 | HTML 语义 | HTML 语义 | 纯 XML 语义 |
| 位置信息 | 无 | 无 | 有 getCharacterOffset() |
核心经验:当需要保留 XML 原始文本(含转义实体)时,不应依赖解析器的文本提取 API(它们都会做解码),而应利用 StAX 的位置信息从原始字符串中直接截取。Woodstox 的 getCharacterOffset() 是实现这一方案的关键能力。