MeteoHealth "工作原理"页面的六个段落,在六个语言版本里全都以俄语上线——而项目里明明有 7784 个键 × 6 种语言(en、ru、es、zh-Hans、ja、ar),翻译在形式上"是存在的"。罪魁祸首是一行看起来完全无辜的 SwiftUI 代码:
Text(LocalizedStringKey("today.howitworks.\(topic).title"))LocalizedStringKey 内部的插值不会按 topic 的枚举情况编译成六个具体的键,而是编译成一个模板键 today.howitworks.%@.title。任何一种语言里都没有这个键——也不可能有。查找失败,SwiftUI 不给出任何警告就把插值后的字符串原样打印出来,用户看到的不是文案,而是裸露的键名。
编译器在这里无能为力:在它看来一切都正确。测试也保持沉默——毕竟"某个"字符串确实渲染出来了。答案是我写的 scripts/check_localization_coverage_impl.py——1788 行 Python 的 Swift 源码静态分析器,常驻在 Xcode build phase 和 CI 中,只要代码里存在未被六种语言全部覆盖的键,就不允许项目构建通过。
为什么 grep 行不通#
第一反应是"这不就是对字面量做 grep 吗"。不是。grep 不知道哪个字面量是本地化键、哪个是 UserDefaults 里的条目名;不知道注释里的字符串不算字符串;也不知道 "a.\(x).title" 不是一个键,而是一族键。
所以门禁自带一个 Swift 注释剥离器,而且有两种模式:strip 和 blank。后者把注释替换成空格,保持文件长度不变——为的是报告里的行号准确。docstring 里把原因写得很直白:
def blank_swift_comments(src: str) -> str:
"""与 strip_swift_comments 相同,但长度保持不变:注释 → 空格。
这是为了输出中的行号。删除注释会使位置偏移,门禁就会指向文件里
并不存在的行——而一个把人指向错误位置的门禁,浪费掉的正是它
节省下来的那些时间。
"""接下来是调用语义。门禁理解 24 个 SwiftUI 初始化器(Text、Label、Button、Toggle、TextField、Picker、Section、NavigationLink、Link、Menu、ProgressView、ContentUnavailableView……)、十个修饰符(navigationTitle、alert、confirmationDialog、accessibilityLabel、searchable)的第一个位置参数,以及具名参数 prompt:/placeholder:。
仅这一项扩展就把 +876 个键纳入保护——并立刻发现了一个真实的遗漏:PregnancyDetailView 里的 Text("common.more"),任何语言里都没有对应的字符串。
在运行时拼装的键#
显式调用并不是全部。最有意思的是形如 "a.\(x).title" 的插值键。门禁会按插值背后 enum 的真实枚举情况展开它们:包括 Int 底层的枚举、嵌套的三元表达式(递归处理),以及在 switch 分支中赋值的变量。如果 topic 是一个有六个 case 的 enum,模板就展开成六个具体的键,每一个都必须存在于六种语言中。
而当插值背后不是 enum,而是一份取值目录——"戒烟的十二个里程碑""孕周"——门禁会按地址(文件 + 常量名)从生产代码里读取列表,而不是自己保存一份副本:
INTERPOLATED_TEMPLATE_CATALOGS = {
"smoking.recovery.*.title": [
{"file": "SmokingDashboardModels.swift", "symbol": "all", "pick": "strings"}
],
}脚本里的注释解释了为什么非要这样:"否则门禁就会开始检验昨天的真相——加了一个里程碑,忘了加字符串,而门禁保持沉默,因为它对照的是自己内部那份列表的副本"。按地址取到空结果同样会导致构建失败:说明地址已经过时了。
"不是键"同样是一种断言#
门禁无权默默认定"这不是键"。如果某个模板被拒绝,但 .strings 里确实存在它名下的条目,构建就会失败并要求给出判定:要么在 NON_CONTEXT_ALLOW 里登记一条附带可核查理由的记录,要么把这些字符串当作死代码删掉。如今这份清单里唯一的条目是 daily_snapshot.*:它是由日期拼出来的 UserDefaults 条目名,与卡片字符串前缀的重合纯属巧合——理由栏里正是这样解释的。
反向的约定同样生效——命名即声明:赋值给任何以 …Key/…Keys 结尾的东西(属性、函数返回值、detailKeys: […] 的元素)的字符串字面量,默认被视为本地化键。有据可查的例外恰好只有一个——DailySnapshotService 里的 storageKey,这个名字诚实地说的是存储,而不是翻译。
盲区,以及它们是怎么被找到的#
即便如此较真的系统也抓不到一切。最有教益的两个 bug,是我在门禁自己身上找到的。
复合键。 let base = "a.\(x)" → "\(base).title" 这种代入模式门禁本来是会处理的。但键形状的校验在代入 base 之前就执行了,于是以 *.title 这类占位点开头的键悄无声息地漏出了检查范围。结果:约 200 条活着的字符串——cycle.superpower_detailed.*(120 条)、cycle.insight.intimacy.*、onboarding.v3.*——在门禁一片绿灯的情况下,完全没有任何保护。已在提交 f165ef1 中修复。
枚举同名。 GoalType 在项目里存在两次——分别在 Goal.swift 和 SmokingEntry.swift 里,它们是不同的 enum。扁平的类型索引让一个覆盖了另一个:门禁一边索要不存在的键,一边放过了 7 个真实的键。现在类型寻址时必须带上文件名——Goal.swift:GoalType。
两个 bug 有一个共同点:门禁当时是绿的。带着窟窿的绿色门禁比没有门禁更糟——它制造出一种被保护的错觉。
一个无法过期的门禁#
任何 allowlist 都会随时间变成垃圾场。所以在这里,一条不再覆盖任何东西的 ALLOW 记录会让构建失败,并要求删除它自己——"否则'清单只减不增'就只能靠君子协定来维持"。技术债直接编码在脚本里:DEBT_MISSING_KEYS 和 DEBT_UNRESOLVED,每一行都附带理由,已还清的债务也必须删除——门禁会检查这一点。
有效性的证明是双向的。在旧提交 eba1008 上,门禁恰好在 today.howitworks.* 的 12 个键 × 6 种语言上失败——正是那次事故。在 master 上它是绿的。而如果从 ar.lproj 里删掉 pregnancy.detail.baby_size,它会带着键名和语言名失败。
扩展分析的代价:运行时间从 3 秒涨到 7.6 秒。对 build phase 来说可以接受。
还有对边界的诚实:重构计划许诺"约 2400 个死键",核查只确认了 62 个。门禁的未使用键清单扫描的是源码,而运行时拼装的键不会以完整形态出现在代码里——删掉这些"死"字符串,用户就会在屏幕上看到裸露的键名(9bfad9b)。
同一个厨房里的三个 bug#
这个项目里本地化坏掉的不止是键——而我剖析过的每一个案例,都印证了同一个想法。
stringsdict 让应用崩溃。 上一次的崩溃修复因错误的诊断被回滚了。真正的原因:规则的键被写成了 NSStringFormatSpecType/NSStringFormatValueType——少了 Key 后缀。Foundation 期望的是 NSStringFormatSpecTypeKey,规则不被识别,%#@value@ 未经展开就进入格式化——进程崩溃。坏掉的是 54 条规则 × 6 种语言(d841ef9)。
格式说明符的顺序。 气温骤变的通知:代码按 (Double, String) 传参,而六种语言里的模板全都期望相反的顺序——视语言不同,要么打印乱码,要么直接崩溃。药方是位置化说明符 %2$@ / %1$.1f(c0ba693)。
两套字符串解析器。 应用同时通过 Bundle.main 和自己搭在 UserDefaults 之上的机制解析字符串——在设置里切换语言后,同一个页面会同时显示两种语言(ee29de2)。
本地化到底坏在哪里#
本地化不是坏在 .strings 文件里——那里通常什么都不缺。它坏在键被拼装的地方:插值、复合前缀、类型同名、格式规则的后缀。这意味着要检查的是代码,而不只是翻译文件。
第二点:一个诚实的门禁必须会说"我不确定",并要求附带理由的明确判定——而不是默默放行。我们找到的所有窟窿,都不在门禁误报的地方,而在它自信地保持沉默的地方。



