ENGINEERING NOTE

在云端 Mac 上为 String Catalog 建立本地化完整性门禁

在云端 Mac 上为 String Catalog 建立本地化完整性门禁

一次版本冲刺中,开发分支新增了十几条界面文案,归档任务正常结束,测试人员却在切换语言后看到英文回退和空按钮。问题不在编译器,而在流水线从未把本地化完整性当成可失败的检查项。对于长期运行的云端 Mac,最有效的做法不是等人工点遍界面,而是在每次构建前直接检查 .xcstrings

先定义门禁到底拦什么

String Catalog 是 JSON 文件,但“能被解析”不等于“可以交付”。一个实用门禁至少检查三类问题:项目要求的目标语言不存在、翻译状态不是 translated、最终字符串为空。复数、设备差异等内容还会出现在 variations 下,因此只读取第一层 stringUnit 会漏报。

先把验收范围写进仓库,而不是留在构建节点的临时配置中。假设项目源语言为英文,当前要求简体中文、日文和法文,可以把目标语言作为脚本参数传入。

检查项 失败条件 处理方式
目标语言 localizations 中没有对应键 阻止构建
翻译状态 任一叶节点不是 translated 返回具体键与语言
字符串内容 value 为空或只有空白 阻止构建
不参与翻译 shouldTranslatefalse 明确跳过

门禁只判断目录中的本地化资源是否完整,不替代界面截断、动态参数和语义准确性的人工验收。

写一个无外部依赖的检查器

把脚本保存为 Scripts/check_xcstrings.py。它只依赖 macOS 自带的 Python 运行环境;如果团队固定了独立 Python 路径,应在构建配置中显式指定,避免交互式 shell 与 CI 的 PATH 不一致。

#!/usr/bin/env python3
import argparse
import json
import pathlib
import sys

def units(node):
    found = []
    if isinstance(node, dict):
        unit = node.get("stringUnit")
        if isinstance(unit, dict):
            found.append(unit)
        for key, value in node.items():
            if key != "stringUnit":
                found.extend(units(value))
    elif isinstance(node, list):
        for value in node:
            found.extend(units(value))
    return found

parser = argparse.ArgumentParser()
parser.add_argument("root")
parser.add_argument("--locale", action="append", required=True)
args = parser.parse_args()

failures = []
files = sorted(pathlib.Path(args.root).rglob("*.xcstrings"))

if not files:
    failures.append("no .xcstrings files found")

for path in files:
    with path.open(encoding="utf-8") as handle:
        catalog = json.load(handle)

    for key, entry in catalog.get("strings", {}).items():
        if entry.get("shouldTranslate") is False:
            continue

        localizations = entry.get("localizations", {})
        for locale in args.locale:
            localized = localizations.get(locale)
            if localized is None:
                failures.append(f"{path}:{key}:{locale}:missing locale")
                continue

            leaves = units(localized)
            if not leaves:
                failures.append(f"{path}:{key}:{locale}:missing stringUnit")
                continue

            for index, unit in enumerate(leaves):
                state = unit.get("state")
                value = unit.get("value", "")
                if state != "translated":
                    failures.append(
                        f"{path}:{key}:{locale}:{index}:state={state}"
                    )
                if not value.strip():
                    failures.append(
                        f"{path}:{key}:{locale}:{index}:empty value"
                    )

for failure in failures:
    print(failure, file=sys.stderr)

sys.exit(1 if failures else 0)

在仓库根目录执行:

python3 Scripts/check_xcstrings.py . \
  --locale zh-Hans \
  --locale ja \
  --locale fr

脚本以非零状态退出后,任何常见任务编排器都能终止后续步骤。输出包含文件、字符串键、语言和叶节点序号,修复者无需先翻完整构建日志。

在编译前接入云端 Mac 流程

建议把检查放在依赖解析之后、xcodebuild buildarchive 之前。依赖准备可以确认仓库内容已经完整落盘,而提前检查又能避免为明显不合格的提交执行编译、测试和归档。

set -euo pipefail

mkdir -p build/reports
python3 Scripts/check_xcstrings.py Sources \
  --locale zh-Hans \
  --locale ja \
  --locale fr \
  2> build/reports/localization-errors.txt

xcodebuild \
  -project App.xcodeproj \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  build

set -o pipefail 很重要。后续若把输出接到 tee,没有它时流水线可能只看到最后一个命令成功,从而吞掉检查器的失败状态。报告目录也应在任务开始时重建,避免云端 Mac 上一次执行留下的文件被误认成本次结果。

用导出结果补一层人工复核

直接解析 .xcstrings 适合机器门禁,但发布前还应导出本地化包,核对开发语言、注释和上下文是否齐全。可以针对目标语言执行:

rm -rf build/xcloc
mkdir -p build/xcloc

xcodebuild -exportLocalizations \
  -project App.xcodeproj \
  -localizationPath build/xcloc \
  -exportLanguage zh-Hans

xcodebuild -exportLocalizations \
  -project App.xcodeproj \
  -localizationPath build/xcloc \
  -exportLanguage ja

导出失败时不要直接重试覆盖现场。先保留完整标准错误,确认 scheme、工程路径和目标语言标识。语言代码必须与 Catalog 中的键一致,例如 zh-Hanszh-Hant 是两个独立目标,不能用模糊前缀代替。

导出的 .xcloc 适合交给本地化负责人抽查,但不建议提交构建产物到主分支。仓库应保存源 .xcstrings、检查脚本和目标语言清单,导出目录则作为单次任务制品。

处理常见误报并完成验收

第一类误报来自“不需要翻译”的技术字符串。不要在脚本里按键名前缀随意忽略,应在 Catalog 中明确设置 shouldTranslate: false,让规则跟随资源本身。第二类来自复数分支只翻译了 one,遗漏 other;递归读取每个 stringUnit 可以把它准确定位出来。

第三类是开发者新增语言后忘记更新 CI 参数。目标语言清单最好只有一个来源,例如项目脚本中的数组或流水线变量,避免多个任务各维护一份。第四类是空格、换行或占位符被误当成有效翻译。当前脚本会拒绝纯空白;对于 %@%d 等格式参数,还可以增加源字符串与目标字符串的占位符集合对比,但应先覆盖转义和位置参数,再启用阻断。

合并前按以下顺序验收:

  1. 人为删除一个目标语言,确认任务失败。
  2. 把一条翻译改成空格,确认报告指出具体键。
  3. 制造一个未完成的复数分支,确认递归检查能发现。
  4. 恢复资源后重新运行,确认旧报告不会影响结果。
  5. 最后执行 Release 配置构建,验证门禁与正式任务使用同一份检出内容。

这样得到的不是一次性的翻译扫描,而是一条可在云端 Mac 上稳定复现的工程约束:资源不完整时尽早失败,资源修复后才进入编译、测试与归档。

常见问题

String Catalog 检查应该放在构建流程的哪个阶段?

应放在依赖准备完成之后、编译与归档之前。检查失败时立即停止,可以避免把计算时间浪费在注定不能发布的归档任务上。

只检查 state 是否为 translated 就够了吗?

不够。还要检查目标语言是否存在、翻译值是否为空,以及复数等 variations 分支中的每个 stringUnit,必要时再导出 xcloc 做人工抽查。

独享物理节点

把下一次构建放到云端 Mac 上运行。

比较三档 Apple Silicon 配置,在五个海外节点中选择适合当前工作流的区域。

选择租用方案