提审前最后一轮切换日语,支付确认页没有崩,只把“将在 3 天后续费”显示成了“将在 %d 天后续费”。更尴尬的是,安装包里的英文资源和仓库里刚修好的文件还不是同一版。

一、资源文件都能解析,不代表本地化可以交付

这次工具叫 LocaleGate Audit,审计 ID 是 locale_audit_20261001_15。工程包含 base、zh_CN、en_US、ja_JP 三套区域资源,共 312 个字符串键。此前的检查只验证 JSON 能否解析、键是否存在,构建成功后大家默认资源没有问题。

真正的问题有三类。第一类是缺键:新增支付提示只补了中文;第二类是占位符漂移:base 使用 %d,日语翻译写成普通文本;第三类是长度风险:英文按钮从 “Continue” 变成完整免责声明,窄屏上被截断。更隐蔽的是,开发机文件修完后没有触发干净打包,待提审 HAP 里仍是旧资源。

我把这次校验拆成四道关:键集合一致;占位符签名一致;关键组件文本预算不过界;源码资源与提审产物摘要一致。前面三道验证内容,最后一道验证“真正要提交的包”。

首次扫描 312 个键,发现 7 个缺失、4 个占位符不一致、9 个长度风险。修复后阻断项为 0,保留 2 个需要实机观察的非阻断提示,三套语言产物摘要全部对上,最终状态 AUDIT_PASS。

这次事故之所以直到提审前才发现,是因为日常调试一直使用中文区域,单元测试又只断言 resourceManager 能返回非空字符串。非空并不能证明取到目标语言,也不能证明格式化参数被正确消费。我们把测试断言从“有值”改成“来源区域、占位符签名和渲染预算都符合预期”,问题才真正进入自动化范围。

审计报告还记录修复前后两组数字,不覆盖原始证据。Missing 7→0、Placeholder 4→0、Overflow 9→2 warnings 能看出本轮做了什么;如果报告只剩全绿,后续评审很难判断脚本是否真的扫描过异常样本。

二、占位符比较的是签名,不是字符串里有没有百分号

简单搜索 % 不够。%d、%s 的顺序可能变化,%% 又只是字面百分号;同一文案还可能重复使用某类参数。若只比较数量,类型错位仍会漏过。

下面的扫描器解决“译文仍有占位符,却与基准参数不兼容”。它提取位置、类型和出现顺序,生成可比较签名;%% 会在解析前被移除。

export interface PlaceholderToken {
  order: number
  type: 's' | 'd' | 'f'
}

export function placeholderSignature(value: string): PlaceholderToken[] {
  const escaped = value.replace(/%%/g, '')
  const pattern = /%(?:([1-9]\d*)\$)?([sdf])/g
  const tokens: PlaceholderToken[] = []
  let match: RegExpExecArray | null
  let sequence = 1

  while ((match = pattern.exec(escaped)) !== null) {
    tokens.push({
      order: match[1] ? Number(match[1]) : sequence++,
      type: match[2] as 's' | 'd' | 'f'
    })
  }
  return tokens.sort((a, b) => a.order - b.order)
}

基准字符串 renewal_notice 的签名是 1:d,日语版本原本为空,因此产生 PLACEHOLDER_MISMATCH。修复后日语文案恢复数值占位,resourceManager 获取字符串时才能正确注入 3。工具只记录资源键、区域与签名,不把完整业务文案写进流水线日志。

占位符顺序允许通过显式位置参数调整,但参数类型不能改变。翻译语言的语序不同是正常情况,校验器不能强迫所有文本字面顺序相同。正式项目若使用 ICU MessageFormat,需要替换对应解析器,不能拿这段正则覆盖更复杂的复数和选择语法。

我们为扫描器补了六类测试:无参数、单个整数、字符串与整数混合、显式位置交换、字面百分号、缺失参数。每个测试同时跑 base 与目标区域,保证解析器升级后不会把 %% 当成新参数。格式化调用处也需要测试,因为资源签名正确,调用方传错参数顺序仍会出问题。

对开发者来说,最有用的错误不是“格式不一致”,而是明确写出 expected=[1:d]、actual=[]、locale=ja_JP、key=renewal_notice。工具在 DevEco Studio 底部日志给出短摘要,完整报告再附文件、模块和修复建议,避免流水线输出几百行重复堆栈。

三、缺键不能一律回退,否则审核页可能展示错语言

resourceManager 的回退机制能让页面继续运行,却可能把关键条款、权限说明或付费文案退回 base。功能上没有崩溃,用户看到的却是混合语言页面。我们给资源键增加等级:BLOCKING、VISIBLE、OPTIONAL。

支付、隐私、权限与审核必经页属于 BLOCKING,目标区域缺键就阻止产物进入提审目录;普通业务文案属于 VISIBLE,可以在开发包回退但必须出报告;埋点描述等不展示文本属于 OPTIONAL。

下面的 compareLocale 解决“所有缺键都被当成同一种问题”。它对每个目标区域生成可解释结果,并保留多余键,防止已废弃资源在某个语言包里长期滞留。

export interface LocaleIssue {
  locale: string
  key: string
  kind: 'MISSING' | 'EXTRA' | 'PLACEHOLDER_MISMATCH'
  severity: 'BLOCKING' | 'WARNING'
}

export function compareLocale(base: ResourceMap, target: ResourceMap,
  locale: string, policy: KeyPolicy): LocaleIssue[] {
  const issues: LocaleIssue[] = []
  Object.keys(base).forEach(key => {
    if (target[key] === undefined) {
      issues.push({
        locale, key, kind: 'MISSING',
        severity: policy.level(key) === 'BLOCKING' ? 'BLOCKING' : 'WARNING'
      })
      return
    }
    if (JSON.stringify(placeholderSignature(base[key])) !==
      JSON.stringify(placeholderSignature(target[key]))) {
      issues.push({ locale, key, kind: 'PLACEHOLDER_MISMATCH', severity: 'BLOCKING' })
    }
  })
  Object.keys(target).filter(key => base[key] === undefined)
    .forEach(key => issues.push({ locale, key, kind: 'EXTRA', severity: 'WARNING' }))
  return issues
}

policy 由仓库配置维护,不能让脚本根据键名猜测风险。新增关键页面时,代码评审要同时更新策略。LocaleGate Audit 把 7 个缺键中的 3 个判定为阻断,其他 4 个进入修复列表;修复完成后才继续做产物对账。

生命周期上,这个工具在 Hvigor 的提审构建任务前运行,而不是页面启动时扫描全部资源。运行时只保留一个轻量诊断页展示报告,不让普通用户承担文件遍历成本。

多模块工程还要处理覆盖顺序。entry 与 feature 模块可能定义同名键,最终生效值由资源合并结果决定。源码阶段会先分别检查每个模块,再按构建图生成 merged manifest;若两个模块都声明 BLOCKING 键且值不一致,报告标记 RESOURCE_SHADOWING,要求开发者明确保留哪一项。

删除资源同样需要谨慎。目标区域存在 base 已删除的 EXTRA 键,表面上没有运行风险,却可能说明旧页面或旧合并产物仍在使用。LocaleGate 把它列为 WARNING,并在连续两个版本未引用后才允许清理,避免脚本自动删除翻译资产。

四、超长文案需要组件预算,而不是按字符数拍脑袋

英文字符多不一定更宽,日语字符少也不一定更窄;字号、字重、组件宽度和系统字体缩放都会改变结果。脚本阶段无法完全替代实机排版,但可以用“组件预算”筛出高风险文本。

我们为关键控件记录宽度、最大行数、字号和是否允许省略。按钮默认不允许截断,协议说明允许多行滚动,标题可在两行内换行。工具在 zh_CN、en_US、ja_JP 与 1.0/1.3 字体缩放下计算占用,超过预算的条目进入风险列表。

首次扫描发现 9 个风险,其中 7 个通过缩短文案或调整组件约束消除;剩余 2 个只在 1.3 字体缩放下接近边界,保留 WARNING 并进入实机清单。我们没有为了让报告归零而强制压小字体,因为可访问性比漂亮数字更重要。

长度检查容易误用。它不应该自动把文本截成省略号,也不应该替翻译做决定。工具只给出 locale、key、组件、预计行数和预算差值,让开发与本地化人员选择改文案、改布局,还是改交互。

字体缩放测试不止改变字号。部分组件固定高度、图标间距和左右 padding 也会共同挤压文本,所以预算描述的是最终可用宽度。按钮在 1.3 倍下若必须换行,设计可以增加高度;若交互规范要求单行,就需要缩短文案或改成页面说明,不能通过负 letterSpacing 硬塞进去。

日期、数字和货币也会影响长度。测试数据覆盖 3 天、128 天、短价格和长价格,避免只用最短样例通过。日语与英文的换行机会不同,估算器会保守处理不可断开的 token。仍然无法确定的条目保留 WARNING,由实机截图验证。

五、仓库已修复,不等于提审 HAP 已包含修复

这次最值得保留的教训,是开发目录和最终产物必须各算一次摘要。缓存构建、错误的输入目录或多模块资源覆盖,都可能让包内文件落后于源码。只跑源码扫描,结论仍不完整。

下面的 ManifestVerifier 解决“修复文件与待提交产物不是同一版”。构建前生成每个 locale 的规范化摘要,构建后从待审计目录读取打包资源摘要,逐项比较。

export interface LocaleDigest {
  locale: string
  keyCount: number
  sha256: string
}

export class ManifestVerifier {
  verify(source: LocaleDigest[], artifact: LocaleDigest[]): string[] {
    const errors: string[] = []
    source.forEach(item => {
      const packed = artifact.find(value => value.locale === item.locale)
      if (!packed) {
        errors.push([item.locale, 'MISSING_IN_ARTIFACT'].join(':'))
        return
      }
      if (packed.keyCount !== item.keyCount || packed.sha256 !== item.sha256) {
        errors.push([item.locale, 'DIGEST_MISMATCH'].join(':'))
      }
    })
    return errors
  }
}

摘要前会按资源键排序并规范换行,避免文件顺序差异制造误报。哈希用于一致性对账,不用于隐藏或加密内容。若模块覆盖是设计行为,最终合并后的资源必须作为一份独立清单参与比较,不能简单忽略冲突。

本轮 base、en_US、ja_JP 三项都显示 312 keys / MATCH。若有一项不一致,状态保持 ARTIFACT_MISMATCH,Hvigor 任务失败,不把包复制到 release-candidate。重复执行使用审计 ID 与产物哈希去重,相同包不会产生多份报告。

产物对账必须发生在签名前的稳定内容阶段,签名之后再修改任何资源都会破坏包完整性。流水线顺序固定为 audit source、build、audit artifact、sign、publish candidate。签名信息本身另由发布检查负责,LocaleGate 不把职责扩展成又一个全能审核器。

我们还模拟了缓存污染:先构建旧日语资源,再只修改源码而不清理构建目录。源码扫描全绿,artifact digest 立即报错,证明最后一道门确实能抓到“仓库正确、包里错误”。清理缓存重建后,三项摘要恢复 MATCH。

六、报告要能指导修复,而不只是报一串红字

手机诊断页时间为 15:46,审计 ID locale_audit_20261001_15。页面显示 Locales=3、Keys=312、Missing 7→0、Placeholder 4→0、Overflow 9→2 warnings、Artifact match 3/3,以及最终状态 AUDIT_PASS。

两条剩余 WARNING 分别对应订阅说明和恢复购买提示,页面给出 1.3 字体缩放下的预计行数,测试人员能直接跳到对应路由。它们不是被忽略,而是从自动阻断转入明确的实机观察。

七、把本地化检查放进真正的发布路径

LocaleGate Audit 最终接入 releaseAudit 任务:先收集资源,再做键、占位符和预算检查,阻断项为零后构建 HAP,最后验证产物摘要。任何步骤失败都不会生成“可提审”标记。开发包可以保留 WARNING,提审包必须附带报告。

缓存策略也需要克制。脚本可以按资源文件摘要跳过未变化区域,但策略文件、字体预算或构建配置变化时必须重新扫描。缓存键包含工具版本、KeyPolicy 版本、组件预算版本和三个区域资源摘要,不能只看文件修改时间。

工具运行结束后清理临时解包目录和中间清单,只保留结构化报告与最终摘要。异常退出时通过 finally 删除临时文件,避免下一次把残留目录当成新产物。报告不包含完整敏感文案,只记录键与规则结果。

CI 中的报告按审计 ID 存档,并与 bundleName、versionName、versionCode 和产物摘要绑定。测试人员打开诊断页看到 locale_audit_20261001_15,就能确认它对应哪一个候选包,而不是拿另一条分支的绿色报告为当前产物背书。

分支合并时,若两个改动分别新增 base 键与翻译键,单独分支都可能失败,这是有意设计。开发包允许通过临时策略把新键标为 VISIBLE,提审分支则不接受临时豁免。豁免必须有到期版本与负责人,过期后自动恢复阻断,避免 warning 永久留在配置里。

这套流程没有试图代替翻译评审,也没有承诺静态测量能发现全部截断。它解决的是另一类更基础的问题:参数不能丢、关键键不能靠回退、明显超预算要提前暴露、真正提交的包必须包含已经确认的资源。

最终的 AUDIT_PASS 因此不是“JSON 全部能打开”,而是源码、布局预算和提审产物三边对上。对本地化来说,这比临上线前手工切换几次语言更可靠,也更容易在下一次新增资源时复用。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐