技术博客

TranslationServer:CSV 变 .translation 的导入与运行时查找链

第 106 / 149 篇 · 2025-12-25 · godot · 资源管线本地化源码分析

tr("start_game") 返回"开始游戏",背后是两条链路:编辑期 CSV 被导入成 .translation 二进制资源,运行期 TranslationServer 按 locale 回退链查哈希表。把两条链都看清,才能解释几个常见困惑:为什么改了 CSV 游戏里没变、为什么切语言不用重启、key 该用什么命名。

导入期:CSV 到 .translation

多语言文本以 CSV 存在工程里,每行一个 key,每种语言一列:

keys,en,zh_CN
start_game,Start Game,开始游戏
settings,Settings,设置

导入器(importer="translation")把它编译成 .godot/imported/ 下的 .translation 资源。这一步有三个要点:

编码必须是 UTF-8 无 BOM。 带 BOM 的 CSV 会让第一列的 key 头多出 \ufeff 前缀,所有 key 悄悄失配,界面集体显示 key 原文。Windows 上 Excel 另存 CSV 默认 GBK/带 BOM,是踩坑重灾区——用 VS Code 或专门的 CSV 工具写。

产物是二进制资源。 .translation 是 binary 格式的 Translation 资源,内部就是一张 HashMap<StringName, StringName>(key → 译文),外加 locale 字段。选择二进制而非运行时解析 CSV 的理由和贴图导入同构:启动时直接内存映射建表,零文本解析成本;CSV 的转义规则(引号、逗号、换行)全部在导入期处理掉。

key 进包后就是字符串常量。 所以 key 的命名策略本质是 API 设计:用 start_game 这样的语义 key,别用英文句子当 key——改文案就断链,而且有的句子在不同语境要不同译法。

运行期:查找链

tr() 的完整路径(core/string/translation.cpp):

tr(key)
  │
  ├── 1. locale fallback 链:当前 locale → 逐级去区域 → 项目默认语言
  │        例:zh_TW 界面 → 查 zh_TW 表 → miss → 查 zh → miss → 查 en
  │
  ├── 2. 链上每个 locale 的所有 Translation 资源按加载顺序查 HashMap
  │
  └── 3. 全 miss → 原样返回 key(所以断链的表现是显示 key 本身)

两个细节。第一,fallback 链是即时解析的:locale en_US 会先查 en_US,落空再查 en,不是预先合并好的表。这意味着你只提供 en 一列就覆盖所有英语区,不用穷举 en_GB、en_AU。第二,多个 .translation 资源可以共存(按功能拆表),查表顺序就是加载顺序,后者不覆盖前者——重复 key 以先加载的为准,拆表时约定 key 前缀分域,别让两张表抢同一个 key。

切换语言是热生效的。 TranslationServer.set_locale("zh_CN") 之后,所有 Control 节点收到 translation changed 通知自动重取文本——这是 Control 在通知里对文本属性统一调了一遍 tr() 的结果。所以场景里 label 的文本写 tr("key") 还是写死译文,决定了切换语言时它动不动。.po(gettext)格式同样受支持,走同一套 importer,适合译者已经习惯 gettext 工具链的团队。

数量和占位符

两条工程约定比机制本身更影响质量。其一,带变量的句子别用字符串拼接:

# 错:语序不允许调换,俄语/日语的语序完全不同
label.text = tr("score_prefix") + str(score)

# 对:占位符留给翻译者摆位置
label.text = tr("score_is").format({"score": score})
# CSV: score_is,"Score: {score}","{score} 分"

String.format 的 {占位} 语法是翻译系统的另一半——key 只管词条,语序自由度靠占位符交给译者。其二,复数形态。中文/英文复数规则简单(one/other),俄语/波兰语有 one/few/many/other 四档。Godot 4 的 .po 导入支持 gettext 的复数条目,CSV 不直接支持——多语言认真的项目直接上 .po,把复数问题交给 gettext 的成熟方案。

性能账

查找本身是 StringName 哈希表,$O(1)$,单次纳秒级,每帧调几千次无感。成本藏在两处:key 的字面量构造(GDScript 里写字符串字面量进 tr(),编译期已是 StringName,零成本;运行时拼出来的 key 才有构造开销),以及切换语言瞬间的全场景刷新(所有 Control 重取文本,UI 节点多的场景会有一帧峰值)。两者都不需要预优化,知道存在即可。

CSV 导入与运行时查找链 strings.csv UTF-8 无 BOM 导入 .translation HashMap<key, 译文> 运行期:tr("start_game") locale 回退链逐级查:zh_TW → zh → en(项目默认),全 miss 返回 key 原文 工程约定 key 用语义命名,不用句子当 key 变量用 {占位符},不拼字符串 复数认真的项目用 .po(gettext) 热切换 set_locale() 后 Control 收通知自动重取文本 文本写了 tr() 的 label 才会跟着变 UI 节点多时切换瞬间有一帧峰值