茶器艺科智造HarmonyOS应用实战-21-缺失参数统一塞成-1,零值和非法值怎样区分:用可选字段重塑路由载荷

小艺深链只想把杯体波纹清零,于是传来 generate=1&waveLevel=0。这条请求最需要保住的是数字 0;其他高度、口径、壁厚没有传,应沿用页面当前值。可当前路由载荷要求八个杯体字段全部存在,缺失或解析失败的数字统一写成 -1。于是“没有传”“写成 abc”“主动传 -1”在 HAR 边界上变成同一个值,而合法零值又很容易在后续代码中被真假判断漏掉。

指定工程目前没有用 if (value) 丢掉 waveLevel=0,HSP 的 clampRouteNumber 会让该值正常进入 0–10 范围;这点必须如实保留。但同一函数对 heightMm=0 会夹到下限 50,对缺失或非法的 -1 则沿用旧值。最终页面行为依赖字段范围,载荷本身却无法说明输入发生过什么。本文建议把路由杯体参数改成类型化 patch:缺失字段不出现,合法零明确出现,非法字段进入独立问题列表。

用可选字段重塑路由载荷

本文解决四个边界:

  1. 还原 EMPTY_CUP_PARAMSChaqiRouteCupParams 与页面夹取的当前协作方式。
  2. 区分 missing、invalid、out-of-range 与合法 0,避免哨兵值碰撞。
  3. 比较 Partial<T> 与显式可选接口在 HAR 公共 API 中的适用位置。
  4. 给出 typed decode、patch 应用、兼容迁移和验证矩阵。

一、当前公共接口强迫“没传的字段”也带一个数字

libraryhar/src/main/ets/routes/ChaqiRouteIntent.ets:24-45 定义的 ChaqiRouteCupParams 把所有属性声明为必填,数值注释约定 -1 表示未传或失败:

export interface ChaqiRouteCupParams {
  presetKey: string;
  heightMm: number;
  diameterMm: number;
  mouthMm: number;
  bottomMm: number;
  thicknessMm: number;
  waistPct: number;
  waveLevel: number;
}

const EMPTY_CUP_PARAMS: ChaqiRouteCupParams = {
  presetKey: '', heightMm: -1, diameterMm: -1, mouthMm: -1,
  bottomMm: -1, thicknessMm: -1, waistPct: -1, waveLevel: -1
};

parseCupParams 对七个数字分别调用 parseRouteNumber;空串与无法解析的文本都得到 -1。parseWant 在 generateCup 为 false 时把同一个 EMPTY_CUP_PARAMS 放入 payload,为 true 时则构造一份字段齐全的对象。即使某次路由根本不涉及杯体,cup 仍然存在。

这种形状让消费者写起来方便,却把来源信息压扁了。看到 heightMm === -1,无法判断键缺失、值为空、编码损坏、格式错误还是调用方真的传了负一。公开类型也没有阻止调用方把任意字符串当作 presetKey。

二、页面能保住部分零值,但行为取决于字段范围

HSP 的 ChaqiExperiencePage.ets:811-815 通过下面的函数应用路由数字:

private clampRouteNumber(
  value: number,
  min: number,
  max: number,
  fallback: number
): number {
  if (value < 0 || Number.isNaN(value)) {
    return fallback;
  }
  return Math.max(min, Math.min(max, value));
}

waveLevel=0 不小于 0,会被保留为合法 0;heightMm=0 同样不小于 0,却会被夹到 50。缺失 heightMm 和非法 abc 在 HAR 中都成为 -1,于是沿用当前高度。行为表如下:

外部输入HAR 当前值页面当前结果丢失的信息
未传 heightMm-1沿用当前高度缺失
heightMm=abc-1沿用当前高度格式错误
heightMm=-1-1沿用当前高度明确负数
heightMm=00夹到 50越界
waveLevel=00应用 0合法零值

页面最终没有崩溃,不代表协议清晰。对于外部请求,格式错误和越界值通常需要问题回执或脱敏日志;缺失字段则是正常情况,不应记录成错误。三者先在 HAR 中区分,页面才能采用不同策略。

三、patch 语义比“全量对象加哨兵”更符合深链

深链携带的是对当前杯体的局部修改,不是完整杯体快照。适合的语义是:属性出现表示调用方提供了一个已通过类型与范围校验的值;属性缺失表示保持当前状态。

从原始参数到可选字段patch的解码流程

建议先定义领域全集,再定义可选 patch。CupPresetKey 已在 ChaqiModels.ets 中声明,可以直接复用,避免继续暴露任意 string:

export interface ChaqiCupValues {
  presetKey: CupPresetKey;
  heightMm: number;
  diameterMm: number;
  mouthMm: number;
  bottomMm: number;
  thicknessMm: number;
  waistPct: number;
  waveLevel: number;
}

export interface ChaqiRouteCupPatch {
  presetKey?: CupPresetKey;
  heightMm?: number;
  diameterMm?: number;
  mouthMm?: number;
  bottomMm?: number;
  thicknessMm?: number;
  waistPct?: number;
  waveLevel?: number;
}

这样不再需要 EMPTY_CUP_PARAMS。generateCup 为 false 时,payload 可以不带 cup;generateCup 为 true 但只传 waveLevel 时,cup 只包含 { waveLevel: 0 }。类型形状本身就表达了请求意图。

四、Partial 适合内部组装,公共边界适合显式可选字段

从语义上看,type ChaqiRouteCupPatch = Partial<ChaqiCupValues> 很简洁:它把全集字段全部变为可选。内部解码器组装临时对象时,这种写法很合适。

type MutableCupPatch = Partial<ChaqiCupValues>;

function newCupPatch(): MutableCupPatch {
  return {};
}

但 HAR 的公共 API 更推荐显式写出 ChaqiRouteCupPatch。原因不是 Partial 本身不安全,而是公共契约需要可读、可演进:某些字段未来可能变为必填、只读或不允许从深链修改;显式接口能逐字段写注释,也不会让新增到 ChaqiCupValues 的内部字段自动暴露给外部路由。

一个稳妥分工是:解码函数内部使用 Partial<ChaqiCupValues> 组装;返回前映射到明确的 ChaqiRouteCupPatchIndex.ets 只导出后者。ArkTS 在 TypeScript 风格基础上强化静态约束,实际使用 Partial 和可选属性时仍需以项目目标 SDK 编译为准,可参考华为的从 TypeScript 到 ArkTS 的适配指导

五、先把 ParseResult 定义成真正的判别联合

为了让本篇代码可以独立阅读,先在 HAR 边界定义完整的 ParseResult<T>。成功分支独占 value;MISSING、格式错误、非有限数和越界四类失败分支都不带 value。这样调用方只要还没有把 kind 收窄到 OK,编译器就不会允许它把占位值当成业务数据。

export type ParseFailure =
  | { kind: 'MISSING' }
  | { kind: 'INVALID_FORMAT'; issueCode: string }
  | { kind: 'NON_FINITE'; issueCode: string }
  | { kind: 'OUT_OF_RANGE'; issueCode: string };

export type ParseResult<T> =
  | { kind: 'OK'; value: T }
  | ParseFailure;

export type RouteField =
  | 'generate' | 'tab' | 'presetKey' | 'heightMm'
  | 'diameterMm' | 'mouthMm'
  | 'bottomMm' | 'thicknessMm' | 'waistPct' | 'waveLevel';

export interface RouteDecodeIssue {
  field: RouteField;
  code: string;
}

export interface CupPatchDecodeResult {
  patch: ChaqiRouteCupPatch;
  issues: RouteDecodeIssue[];
}

MISSING 没有 issueCode,因为杯体 patch 中“未提供字段”是正常语义;其余失败分支携带稳定问题码,供 issues、日志或错误页使用。问题对象不保存原始值,只保存受控字段名与问题码。若需要关联一次请求,可在更外层添加随机 requestId 或调用方提供的非敏感 id,不要把完整 URI 写进公开日志。

杯体解码不能只实现高度和波纹后就声称完成。下面的字段规范表列出本篇建议契约;所有数值都要先判格式与有限性,再判闭区间,任一步失败都不得进入 patch。

原始键patch 字段OK 条件缺失时非法时
presetpresetKey六个 CupPresetKey 白名单值之一省略,不报 issue省略,PRESET_UNKNOWN
heightMmheightMm有限数,50–100省略,不报 issue省略,HEIGHT_*
diameterMmdiameterMm有限数,60–110省略,不报 issue省略,DIAMETER_*
mouthMmmouthMm有限数,55–110省略,不报 issue省略,MOUTH_*
bottomMmbottomMm有限数,25–60省略,不报 issue省略,BOTTOM_*
thicknessMmthicknessMm有限数,3–15省略,不报 issue省略,THICKNESS_*
waistPctwaistPct有限数,30–70省略,不报 issue省略,WAIST_*
waveLevelwaveLevel有限数,0–10省略,不报 issue省略,WAVE_*

表中的 * 应展开成 FORMAT_INVALIDNON_FINITEOUT_OF_RANGE,例如 HEIGHT_OUT_OF_RANGE。这些范围对应当前页面的杯体控制边界;如果领域模型以后调整范围,应先改一处字段规范,再让解码器和测试向量共同引用,避免 HAR 与页面再次分叉。

以下函数只节选高度和波纹两个差异最明显的字段,用来展示控制流,并不是完整的八字段实现。其余五个数值字段必须按表中范围走同一条分支,presetKey 则使用下一节的白名单解析器。

function decodeCupPatch(input: RouteTextMap): CupPatchDecodeResult {
  const patch: ChaqiRouteCupPatch = {};
  const issues: RouteDecodeIssue[] = [];

  const height = parseRouteNumber(input.heightMm, 50, 100, 'HEIGHT');
  if (height.kind === 'OK') {
    patch.heightMm = height.value;
  } else if (height.kind !== 'MISSING') {
    issues.push({ field: 'heightMm', code: height.issueCode });
  }

  const wave = parseRouteNumber(input.waveLevel, 0, 10, 'WAVE');
  if (wave.kind === 'OK') {
    patch.waveLevel = wave.value;
  } else if (wave.kind !== 'MISSING') {
    issues.push({ field: 'waveLevel', code: wave.issueCode });
  }
  return { patch: patch, issues: issues };
}

判断条件是 kind === 'OK',不是 if (wave.value)。因此合法 0 会明确写入 patch;失败对象压根没有 value 可读。实现其余字段时可以复用字段规范,但不要为了七个数值引入动态反射或无类型对象,否则会削弱字段名和返回值之间的静态约束。

六、presetKey 也要做类型化白名单解码

当前 presetKey 是 string,页面再遍历 CUP_PRESET_KEYS 找索引。可以把这一步前移到 HAR 的 typed decode,让 patch 中出现的键一定属于联合类型。

function parsePresetKey(raw: string | undefined): ParseResult<CupPresetKey> {
  if (raw === undefined || raw.length === 0) {
    return { kind: 'MISSING' };
  }
  if (raw === 'luohan' || raw === 'wukong' || raw === 'fang' ||
      raw === 'generic' || raw === 'spiral' || raw === 'zhujie') {
    return { kind: 'OK', value: raw };
  }
  return {
    kind: 'INVALID_FORMAT',
    issueCode: 'PRESET_UNKNOWN'
  };
}

解析器不再为失败结果伪造 luohan。只有 OK 分支才允许把 preset.value 写入 patch;MISSING 只省略字段,其他分支把 issueCode 加入 issues。这样 preset=hacker 不会被悄悄改成罗汉杯,也不会一路进入页面才被忽略。第 23 篇还会进一步把预设键、标签和参数合并为配置表,本篇只解决路由载荷类型。

const preset = parsePresetKey(input.preset);
if (preset.kind === 'OK') {
  patch.presetKey = preset.value;
} else if (preset.kind !== 'MISSING') {
  issues.push({ field: 'presetKey', code: preset.issueCode });
}

这段接线也展示了判别联合的价值:进入最后一个分支后,类型已收窄到三类带 issueCode 的失败结果;整个失败路径都不需要、也无法访问 value

七、payload 不再携带无意义的空杯体对象

建议把公共载荷改为可选 cup 与独立 issues。tab 是否可选可以按现有路由协议另行决定,本篇保持其 number 形状以缩小改动:

export interface ChaqiRoutePayload {
  tab: number;
  generateCup: boolean;
  cup?: ChaqiRouteCupPatch;
  issues: RouteDecodeIssue[];
}

export type RoutePayloadDecodeResult =
  | { kind: 'OK'; payload: ChaqiRoutePayload }
  | { kind: 'INVALID_ROUTE'; issues: RouteDecodeIssue[] };

function generateIssue(result: ParseFailure): RouteDecodeIssue {
  if (result.kind === 'MISSING') {
    return { field: 'generate', code: 'GENERATE_MISSING' };
  }
  return { field: 'generate', code: result.issueCode };
}

function buildRoutePayload(want: Want): RoutePayloadDecodeResult {
  const generate = decodeGenerate(want);

  if (generate.kind !== 'OK') {
    return {
      kind: 'INVALID_ROUTE',
      issues: [generateIssue(generate)]
    };
  }

  if (!generate.value) {
    return {
      kind: 'OK',
      payload: { tab: decodeTab(want), generateCup: false, issues: [] }
    };
  }

  const decoded = decodeCupPatch(textMapFromWant(want));
  return {
    kind: 'OK',
    payload: {
      tab: decodeTab(want),
      generateCup: true,
      cup: decoded.patch,
      issues: decoded.issues
    }
  };
}

代码先检查 generate.kind,随后才读取 generate.value。本文明确选择:generate 缺失或格式非法时直接返回 INVALID_ROUTE 和 issues,不构造普通 payload;generate=false 是合法 OK 值,返回不带 cup 的 payload;generate=true 允许 cup 为 {},语义是“进入生成页,但沿用当前杯体参数”。如果产品希望“generate 缺失”等价于 false,应由 decodeGenerate 明确归一化为 { kind: 'OK', value: false },不能让构造器读取失败结果。

示例沿用当前返回 number 的 decodeTab 以缩小讨论范围。如果 tab 也改成 ParseResult<number>,必须先做相同的 kind 收窄,再决定默认页签或路由失败,不能无条件读取 value。

原始文本、typed decode、可选patch与页面状态的结构

八、页面应用 patch 时必须用 undefined 判定

HSP 不再需要通过负数哨兵决定是否沿用旧值,只需对出现的字段赋值。合法零必须用 !== undefined 判断:

private applyCupPatch(patch: ChaqiRouteCupPatch): void {
  if (patch.presetKey !== undefined) {
    this.applyPresetByKey(patch.presetKey);
  }
  if (patch.heightMm !== undefined) {
    this.heightMm = patch.heightMm;
  }
  if (patch.diameterMm !== undefined) {
    this.diameterMm = patch.diameterMm;
  }
  if (patch.mouthMm !== undefined) {
    this.mouthMm = patch.mouthMm;
  }
  if (patch.bottomMm !== undefined) {
    this.bottomMm = patch.bottomMm;
  }
  if (patch.thicknessMm !== undefined) {
    this.thicknessMm = patch.thicknessMm;
  }
  if (patch.waistPct !== undefined) {
    this.waistPct = patch.waistPct;
  }
  if (patch.waveLevel !== undefined) {
    this.waveLevel = patch.waveLevel;
  }
  this.syncCupLatheFromUi();
}

这里列全了七个数值字段,不再用“其余字段同理”掩盖实现范围。不要写 if (patch.waveLevel),否则 0 会被当成 false 而跳过。也不要在页面再次把越界值夹到范围内并静默接受;HAR 边界已负责外部输入,页面可保留防御性断言,但遇到异常应暴露内部契约问题。

迁移时可先增加新类型与解码器,保留旧接口适配一版:把旧值 >=0 的字段转成 patch,把 -1 视为缺失;新调用链稳定后再移除哨兵。这个适配只能迁移旧载荷,无法恢复过去已经丢失的“非法还是缺失”信息。

九、验证矩阵要证明零、缺失和非法走三条路径

下面是建议加入 HAR 测试目录的用例草稿,用于说明应锁定哪些行为;本文整理时没有执行这些用例,不能把代码片段视为测试通过记录。

it('keeps explicit zero in patch', 0, () => {
  const result = decodeCupPatch(routeText({ waveLevel: '0' }));
  expect(result.patch.waveLevel).assertEqual(0);
  expect(result.issues.length).assertEqual(0);
});

it('omits missing field without issue', 0, () => {
  const result = decodeCupPatch(routeText({}));
  expect(result.patch.heightMm === undefined).assertTrue();
  expect(result.issues.length).assertEqual(0);
});

it('reports invalid field and omits it', 0, () => {
  const result = decodeCupPatch(routeText({ heightMm: 'abc' }));
  expect(result.patch.heightMm === undefined).assertTrue();
  expect(result.issues[0].code).assertEqual('HEIGHT_FORMAT_INVALID');
});

it('does not replace unknown preset with luohan', 0, () => {
  const result = decodeCupPatch(routeText({ preset: 'unknown' }));
  expect(result.patch.presetKey === undefined).assertTrue();
  expect(result.issues[0].code).assertEqual('PRESET_UNKNOWN');
});

it('returns route failure for invalid generate', 0, () => {
  const result = buildRoutePayload(routeWant({ generate: 'maybe' }));
  expect(result.kind).assertEqual('INVALID_ROUTE');
});

验证不能只覆盖一个零值和一个格式错误。下面的矩阵同时覆盖控制字段、边界值、非有限数、白名单和混合输入;“预期结果”是待验证的契约,不代表已经运行。

场景预期解码结果预期页面行为
generate 缺失INVALID_ROUTEGENERATE_MISSING不进入参数应用
generate=maybeINVALID_ROUTEGENERATE_FORMAT_INVALID不进入参数应用
generate=falseOK,payload 不带 cup不执行生成参数应用
generate=true,未传杯体字段OK,cup={},无 issue进入生成页,沿用全部当前值
waveLevel=0{ waveLevel: 0 },无 issue清除波纹
waveLevel=10{ waveLevel: 10 },无 issue应用上边界值
waveLevel=-0.1不含 waveLevel,WAVE_OUT_OF_RANGE沿用当前波纹
heightMm=50 / 100分别写入闭区间端点,无 issue高度应用 50 / 100
heightMm=0不含 heightMm,HEIGHT_OUT_OF_RANGE沿用高度,不夹到 50
heightMm=abc不含 heightMm,HEIGHT_FORMAT_INVALID沿用高度并留问题码
heightMm=NaN / Infinity不含 heightMm,HEIGHT_NON_FINITE沿用高度并留问题码
preset=luohan{ presetKey: 'luohan' },无 issue先应用罗汉杯预设
preset=unknown不含 presetKey,PRESET_UNKNOWN不切预设,也不回退罗汉杯
preset=wukong&waveLevel=0同时写入两个字段,无 issue先应用悟空杯,再把波纹覆盖为 0
heightMm=70&mouthMm=999保留 heightMm,舍弃 mouthMm 并报 issue应用合法字段,非法字段沿用当前值

纯函数用例只能证明 decode 语义;页面集成还要观察 patch 应用顺序、3D 同步与持久化。两类证据不能互相替代。

待执行的验收清单

以下项目特意保持未勾选,只有在目标工程中拿到对应证据后才能逐项更新:

  • ParseResult<T>、字段规范和解码器接入 HAR,并通过目标 SDK 的 ArkTS 编译。
  • 为七个数值字段分别覆盖缺失、格式错误、NaN、Infinity、上下边界和越界输入。
  • 验证所有 ParseResult 失败对象都不含 value,业务代码只在 kind === 'OK' 后读取它。
  • 验证未知 preset 不会生成 luohan,也不会改变页面当前预设。
  • 验证 generate 缺失或非法返回路由失败,generate=false 返回不带 cup 的合法 payload。
  • 验证 preset 与显式数值同时出现时,页面先应用预设,再应用字段覆盖,包括 waveLevel=0
  • 运行 hvigorw assembleHap --no-daemon,保存完整构建结果并处理编译错误。
  • 在模拟器或真机从小艺入口验证冷启动与 onNewWant,观察页面、3D 同步、持久化和脱敏日志。
  • 发布前在 CSDN 编辑器核对三张图片、代码块、表格和标题显示,再单独确认保存或发布状态。

十、故障排查

现象首查根因修正
waveLevel=0 没生效页面是否用真假判断0 被当 false改用 !== undefined
非法值没有 issuedecode 是否仍返回 -1旧哨兵链未移除返回 ParseResult 并汇总
缺失字段产生大量错误是否把 MISSING 加入 issues正常省略被当异常missing 只是不写 patch
新领域字段意外可被深链修改公共类型是否直接用 Partial全集新增自动暴露公共接口显式列可选字段
未知 preset 变成罗汉杯失败对象是否还有 luohan 占位失败分支被误读删除失败 value,只在 OK 后赋值
generate 非法却按 false 处理是否先读取 generate.value控制字段失败被吞掉先收窄 kind,失败返回 INVALID_ROUTE
页面仍把越界值夹到下限是否复用旧 clamp输入错误被改写成合法值越界字段不进入 patch

十一、工程总结

路由参数的关键不是找一个更隐蔽的哨兵,而是让类型形状保留输入事实。ParseResult<T> 负责区分成功、缺失与三类非法输入;成功分支才拥有 value。ChaqiRouteCupPatch 负责表达“只修改出现过且已验证的字段”,所以 0 与 undefined 各有明确含义。

控制字段和业务字段还要分层处理。generate 决定整条路由是否成立,解析失败应在 payload 构造前结束;单个杯体字段非法则进入 issues,其他合法字段仍可组成 patch。页面只消费验证后的 patch,并按“先预设、后显式字段”的顺序应用,避免预设覆盖调用方明确给出的零值。

迁移可以保留一版旧载荷适配器,但它只能把 >=0 转成 patch、把 -1 当缺失,无法恢复已经被哨兵压扁的历史信息。稳定后应删除旧全量对象和页面 clamp 路由链,让 HAR 成为外部输入的唯一校验边界。

十二、证据边界

当前源码事实包括:ChaqiRouteCupParams 所有字段必填;EMPTY_CUP_PARAMS 用空字符串与 -1 填满;缺失和解析失败数字都成为 -1;generate=false 仍携带 cup;HSP 对负数沿用旧值,对非负越界值做夹取,且合法 waveLevel=0 当前能够生效。本文的可选 patch、Partial 分工、typed decode、issues 与迁移方案均为建议,没有修改只读项目。本文整理未运行单测、HAP 构建、模拟器、真机、小艺入口或 CSDN 发布,因此只能确认源码现状与静态设计,不能声称新载荷已经在运行环境生效。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐