技术5/22/2026·8 min

把 iOS i18n 硬编码 leak 清零并永久守护

用三条审计路径的 i18n-audit 工具链(静态 CSV 检查、Swift 源码扫描、伪本地化 OCR),把一个 App 的硬编码 leak 从 247 清到 0,CSV 全 locale 覆盖率保持在 95% 以上。

问题

硬编码字符串指绕过本地化系统、直接到达屏幕的字符串。仓库约定的写法是 Text(L10n.Onboarding.Welcome.titleLine1);一处 leak 则是被原样发布的 Text("看懂世界")。leak 不会让编译失败,只会对每个 locale 不是该字面量所写语言的用户静默失败。

数量并不小:MenuLens 第一遍全量扫描就在 onboarding、paywall、result 等页面暴露 247 处 leak,YoloShell 有 24 处。目标是用机器枚举 leak、把计数压到 0,再让一次回归的代价落在作者的红色流水线上,而非用户的坏页面上。

为什么单一检查不够

本地化在三个相互独立的位置坏掉,一个检测器覆盖不了:开发者直接写 Text("Start") 没走 L10n(源码可见);走了 L10n 但 CSV 在某 locale 没译文、静默回退基准语言(源码与运行时都不可见,除非切到那个 locale);走了 L10n 也译了,却被某布局、formatter 或条件分支挡住没渲染出本地化值(像素画出来前不可见)。

工具链是 DevOps/Rust/i18n-audit/ 下的 Rust crate,通过 dev i18n <子命令> CLI 暴露,用三条独立路径分别回答。

路径一:静态源码扫描

dev i18n check-source 遍历模块 Swift 文件,标记看起来像用户可见文案的字面量,分类刻意保守:含 CJK 表意文字或假名的是 ChineseLiteral;两词以上、仅 ASCII 字母标点、无格式占位符、非 camelCase 标识符的英文文案是 EnglishLiteral;其余(标识符、资源名、URL、正则、key 参数)忽略。

误报靠两层抑制:整文件跳过(Tests/Mocks/Logger.swift、生成的 L10n.swift)与行级标记(logger.NSLog(systemName:forKey:),各产品降噪旋钮(extra_skip_markers、文件前后缀排除)与各自 App 一起入库。

有一类情况扫描器不能报:故意硬编码的串。品牌行 Snap · Translate · Recommend 每种语言都一样,修法不是加 key,而是 Text(verbatim: "Snap · Translate · Recommend")——verbatim: 是作者声明"此字面量有意",MenuLens 这轮用了 9 次,意图变成代码而非一行让扫描器去猜的注释。

路径二:静态 CSV 覆盖率

dev i18n check-csv 不启动任何东西,直接校验 CSV,报告三类问题:每 locale 填充率(默认门槛 95%,最差的排前)、重复 key格式占位符一致性(每 locale 占位符必须与 en 基准一致,德语丢掉英文声明的 {pct} 会报 mismatch)。占位符检查风格感知:printf(%@%d)与花括号({name})按两个独立子集比较,译文里 % 后接字母不算 spec。

覆盖率是真实数据:MenuLens 42 locale × 381 key 全 100%;SnapSweep 20 个按页 CSV、285 条 key-row、42 locale 全 100%;YoloShell 11 locale × 164 key,最低 99.4%,全部高于门槛。

路径三:伪本地化 OCR 审计

前两条是静态的,无法证明接线正确的字符串真以本地化形态显示了出来。机制是伪 locale:csv2strings-rs --emit-pseudo 生成 xa-XA.lproj,每个本地化值被替换成带角括号的 key 路径 《Onboarding.Welcome.titleLine1》dev i18n audit 把模拟器切到该 locale、装上构建、逐页导航截图,再过 macOS Vision OCR 给每个 token 分类:点分标识符是 Sentinel(证明系统到达了那个 label);时钟、百分比、SI 单位、emoji、Apple chrome(BackCancel、星期月份名)与 OCR 噪声是 AllowedSystem;其余可读内容是 SuspectedLeak——渲染成真实词语而非哨兵,即绕过了 L10n。leak 之所以显眼,是它在一片 《...》 中唯一可读的英文或中文短语。

两个细节关键:xa-XA 只在 debug 生成,在 localization.bzlcompilation_mode 门控,release 与 App Store 构建必须不含——它是 UN M.49 区域码而非 BCP 47,发布会触发 ITMS-90176 拒收;访问页面集合是各产品一份声明式 AuditPages.json,取代了一段 148 行 bash,加页面现在是改 JSON 而非改脚本。

让它保持在零

压到 0 一次是容易的一半,难的一半是工具链可信到能拿来 gate——动不动误报的检测器一周内就被关掉。分类器带 107 个单测(仅 sentinel 文法 39 个、源码扫描器 21 个),覆盖 OCR 残渣、专有名词豁免(加密算法名、协议标识、终端主题、修饰键组合)与格式占位符边界;豁免清单精简且每条有文档,因为过宽的 allow-list 会静默吞掉真实 leak。

退出码就是契约。check-csvcheck-source 与聚合的 dev i18n status 在 MR 流水线运行,填充率低于门槛或出现新源码候选,job 即失败。leak 计数是作者合并前必须看到的数字,不是用户发布后才发现的数字。

可迁移的结论

  • 本地化在三个相互独立的位置坏掉——未翻译源码、缺失 CSV 条目、永不渲染本地化的字符串,一个检测器盖不全,三条一起跑。
  • 把意图编码进代码而非注释:Text(verbatim:) 让门槛能严而不误报;伪 locale 把"它本地化了吗"变成视觉上的是/否,但要按构建模式门控让 xa-XA 永不发布。
  • 要 gate CI 的 linter 必须用测试挣来信任,退出码才能当契约。