HarmonyOS 字符串占位参数签名比对(连载02)【共创稿事节】
多语发布校对台已经能够找出英文目录缺少的cancel,但键齐全仍不足以保证文案能正确替换参数。中文写成“某人有几项”,英文希望先写数量再写姓名,这种换序有业务意义;若把数量参数从整数改成字符串,页面即使仍显示文字,调用契约也已经改变。资源检查需要继续深入到每个字符串内部,比较参数身份与类型。

参数签名不等于占位符出现顺序
继续沿用LocaleReview的资源扫描链路。load_strings仍负责收集默认资源和语言资源,集合差检查继续保留;本篇增加placeholder_signature,只有双方都存在的键才进入签名比较。缺键、格式问题和签名差异分别记录,避免一个问题被重复输出成多种错误。
本项目接受字符串、整数、浮点三类参数,分别使用s、d、f;相邻两个百分号表示普通百分号,不占参数位置。带位置编号的参数可以换序,未编号参数按出现次序分配位置,同一条字符串混用两种方式会被校对规则拒绝。这里定义的是项目资源合同,具体格式化接口的语法和参数要求仍应按API说明选择。
例如默认文本为“%1$s 有 %2$d 项”,英文为“%2$d items for %1$s”,两者都映射到位置一的字符串和位置二的整数。把英文第二个位置改成s,就会形成差异。检查器同时保留出现次数:若译文重复使用某个参数,本项目要求人工确认后调整规则,避免误把复制错误放行。
写一个逐字符推进的解析器
新增 placeholder_signature.py。解析从每个百分号开始,普通字符直接跳过;双百分号先于参数匹配处理。遇到未知写法直接给出位置,防止正则只挑出认识的部分,把格式问题漏掉。位置上限设为一百,是项目对资源复杂度的控制,不是系统接口的参数上限。
import re
from collections import Counter
TOKEN = re.compile(r"%(?:(?P<index>[1-9][0-9]*)\$)?(?P<kind>[sdf])")
def placeholder_signature(text: str) -> list[tuple[int, str, int]]:
counts: Counter[tuple[int, str]] = Counter()
kinds: dict[int, str] = {}
mode: str | None = None
implicit = 0
cursor = 0
while cursor < len(text):
if text[cursor] != "%":
cursor += 1
continue
if text.startswith("%%", cursor):
cursor += 2
continue
match = TOKEN.match(text, cursor)
if match is None:
raise ValueError(f"不支持的参数格式,位置{cursor}")
indexed = match.group("index")
current_mode = "explicit" if indexed else "implicit"
if mode is not None and mode != current_mode:
raise ValueError("同一文案混用编号与未编号参数")
mode = current_mode
if indexed:
position = int(indexed)
else:
implicit += 1
position = implicit
if position > 100:
raise ValueError("参数位置超出项目范围")
kind = match.group("kind")
if position in kinds and kinds[position] != kind:
raise ValueError(f"位置{position}重复声明了不同类型")
kinds[position] = kind
counts[(position, kind)] += 1
cursor = match.end()
if kinds and set(kinds) != set(range(1, max(kinds) + 1)):
raise ValueError("参数编号存在空洞")
return [(position, kind, amount)
for (position, kind), amount in sorted(counts.items())]
连续的字面百分号不产生参数,因此“完成百分之百”的资源可以写成100%%。若直接出现单个百分号,本项目要求修订为明确格式,避免不同维护者按各自理解处理。扫描器报出的是字符串中的字符位置,定位时应打开对应资源值,而不是按整个JSON文件的列号查找。
编号空洞也单独拒绝。只出现第二个位置,却没有第一个位置,虽然可以在代码里传入额外值凑齐调用,却会让文案难以维护。将这类复杂写法挡在资源层,可以让业务调用始终按紧凑的参数列表组织。
合并到原有缺键检查
下面是 locale_audit.py 的完整更新版本,和前面的解析器放在同一个目录。原有读取逻辑继续拦截重复键,新增参数检查按交集逐键处理。解析异常归入当前键的问题记录,不影响其他资源继续检查;文件无法读取或JSON损坏仍按输入失败退出。
from pathlib import Path
import argparse
import json
import sys
from placeholder_signature import placeholder_signature
def load_strings(element: Path) -> dict[str, str]:
if not element.is_dir():
raise ValueError(f"资源目录不存在: {element}")
result: dict[str, str] = {}
for source in sorted(element.glob("*.json")):
payload = json.loads(source.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"资源根节点应为对象: {source}")
rows = payload.get("string", [])
if not isinstance(rows, list):
raise ValueError(f"string应为数组: {source}")
for row in rows:
if not isinstance(row, dict):
raise ValueError(f"资源项应为对象: {source}")
name, value = row.get("name"), row.get("value")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"资源名称错误: {source}")
if name != name.strip() or not isinstance(value, str):
raise ValueError(f"资源字段错误: {source}")
if name in result:
raise ValueError(f"资源键重复: {source}: {name}")
result[name] = value
return result
def audit(resources: Path, locales: list[str]) -> dict[str, object]:
base = load_strings(resources / "base" / "element")
if not base:
raise ValueError("默认字符串集合为空")
base_signatures = {key: placeholder_signature(value)
for key, value in base.items()}
reports: list[dict[str, object]] = []
for locale in locales:
element = resources / locale / "element"
exists = element.is_dir()
target = load_strings(element) if exists else {}
issues: list[dict[str, str]] = []
for key in sorted(set(base) & set(target)):
try:
actual = placeholder_signature(target[key])
if actual != base_signatures[key]:
issues.append({"key": key, "reason": "参数签名不同"})
except ValueError as error:
issues.append({"key": key, "reason": str(error)})
reports.append({
"locale": locale,
"missing": sorted(set(base) - set(target)),
"extra": sorted(set(target) - set(base)),
"directoryMissing": not exists,
"parameterIssues": issues
})
return {"baseCount": len(base), "locales": reports}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("resources", type=Path)
parser.add_argument("--locales", nargs="+", default=["en_US", "zh_CN"])
args = parser.parse_args()
try:
report = audit(args.resources, args.locales)
except (OSError, ValueError) as error:
print(str(error), file=sys.stderr)
return 2
print(json.dumps(report, ensure_ascii=False, indent=2))
failed = any(row["missing"] or row["extra"] or row["directoryMissing"]
or row["parameterIssues"] for row in report["locales"])
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
默认资源的参数错误会在语言循环前抛出,退出码为二;目标语言的格式错误则进入parameterIssues,退出码为一。这样默认合同本身有问题时,维护者先修基线,而不是让每个目标语言都背负一条相同的差异。零、一、二三个退出码的含义与前一节保持一致。
让样例覆盖合法换序与类型变化
保留之前app_name、publish、cancel三个键,并在base增加notice,值为“%1$s 有 %2$d 项”;en_US的notice为“%2$d items for %1$s”;zh_CN与base一致。英文cancel也补成Cancel。此时默认键数为四,各语言键集合相同,notice签名一致,检查应返回零。
接着只把英文notice中的%2$d改成%2$s。键集合完全不变,参数问题列表增加notice,进程应返回一。再尝试%1$s和%d混用、编号从二开始、未知类型以及同一位置出现不同类型,检查器应分别给出格式问题,而不是把它们都简化成“翻译缺失”。

手机页呈现编号参数样例
以下替换 pages/Index.ets,用于查看固定的合法和错误样例。页面比较器只针对这里展示的编号参数,完整资源扫描由上面的脚本承担。核对按钮重新计算两组样例,便于将合法换序和类型错误同时放在界面上比较。
function sampleSignature(value: string): string {
const tokens: string[] = value.match(/%[1-9][0-9]*\$[sdf]/g) || [];
return tokens.sort().join('|');
}
@Entry
@Component
struct Index {
private baseText: string = '%1$s 有 %2$d 项';
private goodText: string = '%2$d items for %1$s';
private badText: string = '%2$s items for %1$s';
@State reordered: boolean = true;
@State typeChanged: boolean = true;
private checkSamples(): void {
const base: string = sampleSignature(this.baseText);
this.reordered = base === sampleSignature(this.goodText);
this.typeChanged = base !== sampleSignature(this.badText);
}
aboutToAppear(): void {
this.checkSamples();
}
build() {
Scroll() {
Column({ space: 16 }) {
Text('多语发布校对').fontSize(26).fontWeight(FontWeight.Bold)
Text('参数位置与类型').fontSize(18)
Column({ space: 12 }) {
Text('notice').fontSize(22)
Text('base').fontSize(16)
Text(this.baseText).fontSize(20)
Text('en_US').fontSize(16)
Text(this.goodText).fontSize(20)
Text(this.reordered ? '编号参数可换序' : '参数签名不同').fontSize(18)
}.width('100%').padding(20).backgroundColor('#EDF3FF').borderRadius(16)
Column({ space: 12 }) {
Text('类型检查').fontSize(22)
Text('%2$d → %2$s').fontSize(20)
Text(this.typeChanged ? '参数2类型变化' : '参数签名一致').fontSize(18)
}.width('100%').padding(20).backgroundColor('#FFF3DD').borderRadius(16)
Button('核对样例').width('100%').backgroundColor('#245AC7')
.onClick(() => { this.checkSamples(); })
}.width('100%').padding(20)
}.width('100%').height('100%').backgroundColor('#FFFFFF')
}
}

避免把修复变成文案拼接
发现参数顺序问题后,应该在资源里使用清楚的位置编号,而不是在页面中拼接姓名、数量和单位。不同语言的语序并不相同,页面拆分得越细,翻译越难保持句子完整。调用位置仍按参数身份传值,资源决定它们在句子中的排列。
同样不要用字符串替换把所有d改成s来消除差异。类型参与调用契约,修改资源类型之后还需要核对业务传值。排错时从问题键回到默认签名,再查看目标资源和对应调用,确认修改解决的是位置、类型还是文案语义。若只看到界面显示正常就结束排查,某个极端值仍可能暴露问题。
参数检查应有稳定的失败表达
在日常维护中,可以将相同键的缺键、签名变化和解析错误归并到一个资源问题面板,但底层问题类型仍要保留。新增规则时继续向parameterIssues添加具体原因,避免改变原有缺键列表的含义,让已经接入的流水线能够稳定读取。
本次改动把发布校对从集合层推进到字符串内部。合法的语言换序保持自由,参数身份和类型保持一致,格式错误拥有独立定位。资源合同越明确,调用端越容易写清楚,后续跨模块归属和版本差异检查也就有了可靠的比较基础。
让语法范围成为团队约定
检查器当前只接受单字符类型以及可选的位置编号。宽度、精度或其他格式标记不在本项目合同内,遇到这类资源会返回格式问题。若后来确实需要更复杂的数字呈现,应先明确调用接口支持的写法,再同时更新解析规则、基准样本与资源编写约定,而不是为了让某个文件通过检查而临时放宽正则。
显式编号和未编号参数可以跨语言采用不同写法,只要归一后的签名一致。例如中文依次使用字符串和整数,英文写成带编号的反向顺序,仍可以表达相同的参数身份。真正需要拒绝的是一条文案内部混用编号方式,因为这会使维护者难以判断某个未编号参数应占哪个位置。规则应围绕可解释的合同设计。
重复参数的次数也值得保留。译文中两次出现同一个编号,可能是语言表达需要,也可能是复制时留下的错误。本项目先把次数变化视为需要处理的差异,后续可为特定资源建立显式允许规则。允许规则应绑定语言和资源键,不能把“忽略所有重复次数”变成全局开关,否则会失去发现复制错误的机会。
参数类型一致仍不能替代业务含义一致。两个字符串参数即使类型相同,也可能分别代表姓名和机构。若译文交换了它们的编号,签名集合仍相同,工具无法单靠类型理解语义。资源维护时应在翻译交付信息中说明每个位置的含义,界面校阅再检查实际填充值是否出现在正确位置。
在流水线保留可追溯输入
多语言资源变更时,检查结果应该关联当前资源修订,避免旧输出覆盖新的问题。执行命令仍从当前模块的resources开始,结果保留语言、键与原因;流水线把它关联到本次构建即可。不要把构建记录写进用户界面的翻译文本,也不要把问题键临时改名来绕过已有比较。
修复后应回到最小样本复核,而不是只重新打开完整应用。先让合法换序通过,再确认类型变化仍被发现,然后检查字面百分号和格式错误分支。这样能够验证修复没有削弱规则。默认合同发生变化时,还应同步查看代码传入的参数数量和顺序,保证资源与调用一起完成调整。
相关资料:资源分类与访问、资源管理接口、避免硬编码与直接拼接。
更多推荐

所有评论(0)