HarmonyOS XmlPullParser:文本读到了,配置为何仍然不完整【共创稿事节】
配置解析出现问题时,日志里可能已经打印出活动名称,页面却仍然显示缺少人数。更隐蔽的情况是名称出现两次,程序悄悄采用后一份,于是配置看起来完整,却和生成方的意图不同。故障并不一定发生在字符解码,很多时候是应用只收集了文本,没有检查文本来自哪一层、出现了几次以及文档是否真正结束。

复盘从输入结构开始,而不是从页面空白开始
ConfigReader 工程读取一份很小的活动配置,根元素固定为配置容器,里面只有活动名称和人数上限两个字段。名称必须非空且不超过二十四个字符,人数是一到八十的十进制整数。它不是通用文档阅读器,也不接受任意嵌套结构;范围越明确,错误越容易给出可操作的解释。
工程使用 API 12 和 Stage 模型,从 ArkTS Kit 引入 XML 与工具模块。本文采用该版本提供的事件解析入口,官方参考列出了开始标签、文本、结束标签及文档结束事件。事件本身提供语法信息,业务还需要据此维护结构和字段约束。
一次文本回调不能被理解为“整个字段已经完整”。解析器可能按照文本、空白或文本段事件交付内容,只有遇到对应结束标签,应用才知道当前字段结束。因此修复方向是累计字段片段,在结束事件时提交字段,而不是在每次收到文本后立即覆盖页面结果。
另一个问题是把所有同名标签都视为同一字段。根容器下的名称与深层嵌套中的名称,业务意义未必相同。本例只允许两层结构,所以通过栈深度判断当前文字属于字段还是容器,并拒绝更深的元素;这种约束比仅匹配标签名更接近配置协议。
对于重复字段,本文选择直接拒绝,而不是前者优先或后者覆盖。配置生成方因此必须修复来源,使用方也不会依赖一个没有明确约定的覆盖顺序。如果将来协议允许多个同类元素,应把它建模成列表,并修改校验规则,而不是继续在单值映射中悄悄丢弃数据。
让解析器产生事件,让应用检查结构
新建 common/ReadConfig.ets。输入先限制长度并排除当前协议不支持的声明,再编码为一个准确长度的缓冲。文本长度与字节长度分别检查,因为中文字符的编码大小不同;本例缓冲很小,可以接受这次明确复制,避免把底层更大缓冲的无关区域也传给解析器。
// entry/src/main/ets/common/ReadConfig.ets
import { xml, util } from '@kit.ArkTS';
export interface WorkshopConfig { title: string; maxSeats: number; }
export function readConfig(source: string): WorkshopConfig {
if (source.length === 0 || source.length > 4096) {
throw new Error('配置长度须为 1—4096 个字符');
}
if (source.includes('<!DOCTYPE') || source.includes('<!ENTITY')) {
throw new Error('本配置不接受文档类型或实体声明');
}
const bytes = new util.TextEncoder().encodeInto(source);
if (bytes.byteLength > 8192) { throw new Error('配置字节数超出限制'); }
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const parser = new xml.XmlPullParser(buffer, 'UTF-8');
const stack: string[] = [];
const fields: Map<string, string> = new Map();
let content: string = '';
let problem: string = '';
let rootSeen: boolean = false;
let ended: boolean = false;
const onToken = (type: xml.EventType, info: xml.ParseInfo): boolean => {
if (problem.length > 0) { return false; }
if (type === xml.EventType.START_TAG) {
const name = info.getName();
if (info.getAttributeCount() > 0 || info.getNamespace().length > 0 ||
info.getPrefix().length > 0) { problem = '本配置不接受属性或带命名空间的元素'; }
if (stack.length === 0) {
if (name !== 'config' || rootSeen) { problem = '根元素必须唯一且为 config'; }
rootSeen = true;
} else if (stack.length === 1) {
if (stack[0] !== 'config' || (name !== 'title' && name !== 'maxSeats')) {
problem = '出现未知字段';
} else if (fields.has(name)) { problem = '字段重复'; }
content = '';
} else { problem = '字段中不能再嵌套元素'; }
stack.push(name);
} else if (type === xml.EventType.TEXT || type === xml.EventType.CDSECT ||
type === xml.EventType.WHITESPACE) {
const part = info.getText();
if (stack.length === 2) {
content += part;
if (content.length > 128) { problem = '字段文本过长'; }
} else if (part.trim().length > 0) {
problem = '容器外出现了非空文本';
}
} else if (type === xml.EventType.END_TAG) {
const name = info.getName();
if (stack.pop() !== name) { problem = '标签闭合不匹配'; }
if (name === 'title' || name === 'maxSeats') {
fields.set(name, content.trim());
content = '';
}
}
if (type === xml.EventType.END_DOCUMENT) { ended = true; }
if (type === xml.EventType.DOCDECL || type === xml.EventType.ENTITY_REFERENCE ||
type === xml.EventType.INSTRUCTION) { problem = '出现不支持的XML结构'; }
return problem.length === 0;
};
const options: xml.ParseOptions = {
supportDoctype: false, ignoreNameSpace: false,
tokenValueCallbackFunction: onToken
};
parser.parse(options);
if (problem.length > 0) { throw new Error(problem); }
if (!ended || !rootSeen || stack.length !== 0) { throw new Error('文档未完整结束'); }
const title = fields.get('title');
const seats = fields.get('maxSeats');
if (!title || title.length > 24 || !seats || !/^[1-9]\d{0,2}$/.test(seats)) {
throw new Error('名称或人数格式不符合规则');
}
const count = Number(seats);
if (count > 80) { throw new Error('人数须为 1—80'); }
return { title: title, maxSeats: count };
}
回调遇到业务问题时记录原因并返回停止信号,解析结束后由外层统一抛出异常。这样既保留第一处能解释的问题,也避免在回调深处直接修改页面。停止解析不能被视为成功完成,所以外层还要检查文档结束标记与元素栈是否已经清空。
文本缓存只在字段层累积。容器层允许排版空白,但拒绝无所属字段的非空文字;字段结束时才去除两端空白并存入映射。这个处理保留字段内部的普通空格,不把活动名称的所有空格一并删除,也不会让缩进换行变成一个额外字段。

本文不支持属性、带命名空间的元素、实体引用或处理指令。仅声明却未使用的命名空间不在这项元素检查的拒绝范围内。前置声明检查表达协议范围,事件阶段继续检查结构;它们不应被宣传成通用 XML 安全网关。若要接收第三方复杂文档,应重新评估解析能力、资源上限和信任边界,而不能简单把这些拒绝条件删除。
缓冲大小限制只是资源控制的一部分。字段文本长度、嵌套深度、重复字段和合法字段名也分别限制了处理范围。对于本例的小配置,同步解析便于保持代码清楚;如果输入扩展成大型文档,应先分析主线程占用和数据传递成本,再决定新的执行方案。
排查时还有一个容易被忽略的对象:字符编码与声明是否一致。本例固定把输入字符串重新编码为 UTF-8,再按同样的编码创建解析器,因此不接受上游传来一个二进制文件却随意声明另一种编码。如果未来入口改成文件读取,应先明确字节来源与编码契约,保留原始字节边界,不能一边猜测编码一边继续交付业务字段。
字段缓存只有一个,是因为这份协议禁止字段内部再嵌套子元素,并且同一时刻只会进入一个叶子字段。若以后允许一组并行嵌套节点,就需要按栈帧保存各自的文本和属性,单个字符串会把不同层级的内容串在一起。这里的小数据结构依赖窄格式约束,二者应一起演进。
一次解析只交付一份完整配置
页面提供正常样本与重复字段样本,方便把故障路径固定下来。重复样本仍然是可以写出来的 XML 文本,但违反本应用只允许一个名称字段的业务规则。它能帮助读者区分语法可解析与业务可接受,而不是把所有错误都归为字符编码失败。
// entry/src/main/ets/pages/Index.ets
import { readConfig, WorkshopConfig } from '../common/ReadConfig';
const GOOD: string = '<config><title>周末工坊</title><maxSeats>24</maxSeats></config>';
const DUPLICATE: string = '<config><title>甲</title><title>乙</title><maxSeats>24</maxSeats></config>';
@Entry
@Component
struct Index {
@State source: string = GOOD;
@State title: string = '尚未解析';
@State seats: string = '--';
@State status: string = '只接受本页定义的窄格式';
private parse(): void {
try {
const result: WorkshopConfig = readConfig(this.source);
this.title = result.title;
this.seats = String(result.maxSeats);
this.status = '结构与字段均通过';
} catch (error) {
this.title = '未交付配置';
this.seats = '--';
this.status = (error as Error).message;
}
}
build() {
Column({ space: 14 }) {
Text('配置读取器').fontSize(28).fontWeight(FontWeight.Bold)
Text('读完结构,再交付字段').fontSize(14).fontColor('#64748B')
TextArea({ text: this.source }).height(132).fontSize(14)
.onChange((value: string) => { this.source = value; })
Button('解析配置').width('100%').height(44).backgroundColor('#BE123C')
.onClick(() => { this.parse(); })
Button('放入重复字段样本').width('100%').height(44).backgroundColor('#64748B')
.onClick(() => { this.source = DUPLICATE; })
Column({ space: 10 }) {
Text(this.title).fontSize(23).fontWeight(FontWeight.Bold)
Text(`人数上限:${this.seats}`).fontSize(17)
}.width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(18)
Text(this.status).fontSize(15).fontColor('#BE123C')
}.width('100%').height('100%').padding(24).backgroundColor('#FFF5F7')
}
}
成功路径在解析全部结束后才更新两个字段。失败路径清除上一份展示结果,并明确写成未交付,避免名称来自新文档、人数来自旧文档的混合状态。输入框被编辑时仍展示上一次解析结果,用户必须再次点击解析;如果产品要求即时校验,就需要重新设计状态标识与调用频率。
放入重复样本按钮只替换输入,不自动执行。这个选择让验证步骤保持可见:先看准备的文本,再触发解析,再看错误。若按钮同时替换并解析,应在按钮名称和交互描述中明确,不然测试人员可能无法判断错误来自哪次输入。
页面没有把回调事件全部输出为长日志。真实排障可以临时记录事件类型、深度、行列位置和字段名,但应避免把配置中的每个值都打印出来。尤其当配置后来加入地址、令牌或用户标识,原本方便的调试输出会变成数据泄露路径。
修复要覆盖顺序、重复和结束条件
先解析正常样本,应得到活动名称和二十四的人数上限。然后放入重复样本,再点击解析,应该拒绝重复字段且不交付任何配置。交换两个合法字段的先后顺序仍应成功,因为协议要求字段各一次,并没有要求名称一定在人数之前。

接着验证缺失字段、空名称、零人数、超过上限人数和额外嵌套。每个样本只改变一个条件,方便把错误归因到具体规则。不要一次同时加入未知字段、重复字段和非法数字,然后仅凭得到一条错误提示就认为所有分支都已经覆盖。
数据来源也应该出现在排障记录里。使用本文正常样本时,可以明确记录这是手工构造的测试向量;接入真实服务后,则记录配置版本与请求关联标识,避免把不同批次的输入混在同一段分析里。本文没有设备执行日志,图中事件链表达的是代码的处理规则,不能当成某台设备的解析耗时或实测回调次数。
数字字段还存在表现形式和业务数值两层要求。正则拒绝前导零、正号、小数点和科学记数法,即使这些字符串能被某些数字转换函数解释,也不会被本协议接受。先约束表示,再转换并检查上限,能让生成方和使用方看到同一套可预测规则;否则不同语言的宽松转换习惯可能造成配置分歧。
截断文档也需要独立检查。如果缺少根结束标签,既可能由解析器报告语法错误,也可能在业务完整性检查中被拒绝;无论进入哪条路径,页面都不能交付部分结果。验收重点是失败后的可观察状态,不强制所有系统版本使用完全相同的错误文本。
空元素与只有空白的字段同样应该被拒绝。它们的事件形式可能不同,但最终业务要求一致:名称必须有内容,人数必须符合十进制格式。只检查字段是否曾经出现,会让一个空标签通过“字段齐全”检查,因此字段存在与字段有效还要再分两层。
排障顺序可以从字节输入向上追:先确认传入缓冲只包含完整文档,再观察标签层级,再观察文本累计,最后检查业务规则。这样能区分源文本不完整、事件理解错误和协议约束不符,避免看到页面空白就反复修改字体、刷新状态或输入控件。
遇到新增字段需求时,不建议直接忽略所有未知字段。宽松接受可以提高向后兼容性,也可能掩盖拼写错误。当前窄协议选择拒绝未知字段,让生成方及时暴露问题;如果需要版本演进,应增加明确版本和对应允许集合,而不是让同一解析函数随着偶然输入改变含义。
这个修复的核心,是把“听见某段文本”升级为“确认它属于一个完整且符合约定的文档”。结束标签、字段唯一性和文档结束三个检查点各自承担责任,缺少其中任何一个,都可能让半份配置进入页面。事件解析器提供原料,应用仍然要负责决定什么时候可以交付结果。
参考资料
更多推荐

所有评论(0)