博客文章
被老板一句话逼出来的(纯野路子)硬编码文本多语言方案:怎么在基本不影响日常开发的前提下,把翻译的路提前铺好
运营两年的游戏突然被一句'发海外',硬编码中文全部返工。于是我琢磨出一套野路子:两层key——短词走常量表、一次性长句用中文原文当key;简中零成本;禁止拼接统一占位符;AST扫描工具兜底。不影响日常开发,提前铺好翻译的路,不出海代价近乎为零。
被老板一句话逼出来的(纯野路子)硬编码文本多语言方案:怎么在基本不影响日常开发的前提下,把翻译的路提前铺好
之前做的一款游戏,上线运营都两年了,然后老板一句话:这游戏要发海外。
当时直接懵逼了。
那个项目里不止有超级多的硬编码中文,还有各种拼接出来的中文字符串,外加超级多带文字的图。界面设计的时候压根没考虑过多语言,文本框就那么点大,英文塞进去直接溢出。这种情况没有任何取巧的办法,只能把所有文本全部翻一遍、逐个硬找。幸亏现在有 AI,让 AI 先快速过一遍把文本全扒出来,再人工一条条处理,不然纯靠人肉翻代码,工期根本不敢想。
所以,以后设计游戏架构,得把多语言提前考虑进去。不一定用得上,但是万一哪天老板又来一句”发海外”,至少不用再经历一遍这种痛苦。
所以就有了下面这套东西。先说明,这不是什么业界标准方案,纯野路子——我自己琢磨出来的一种”既方便后期转多语言,又基本不影响日常开发”的适配方式。
先说明一下范围:下面主要讲的是代码里硬编码文本怎么处理,带文字的图和界面布局这两个坑不好用同一套办法解决,放到最后简单聊聊。
有一个前提:硬编码文本还是尽量写中文。毕竟是中国人,显示界面中的文本写个中文看起来多顺眼。如果为了多语言,日常开发全写 I18n.get("ui_btn_confirm") 这种 key,对于我这种英语不太好的人来说,看不懂啊,难受啊。所以我的目标是:平时写代码就当没有多语言这回事,只在需要的时候能够快速支持其他语言。
下面就讲讲具体是怎么做
核心思路:两层 key
多语言最常见的做法是”文案表”:代码里只写 key,运行时拿 key 查表得到当前语言的文案。问题在于 key 是要人来起的,一句”确定”你得先想好它叫 common_ok 还是 dialog_confirm,再去表里登记,写代码的节奏就被打断了。
我的做法是把 key 分成两层。
分之前先说一个中文特有的坑:同一个中文词,在不同语境下翻译可能完全不同。比如”装备”,作名词是背包里的装备,英文是 equipment;作动词是把装备穿上,英文是 equip。如果两处都直接写中文,就只能共用一条译文,必有一处译错。这种词必须起独立的 key、分开翻译——这就是常量层存在的核心原因之一。
第一层:常量 key。短词、会被多处复用的文案 或者 相同的中文但是需要不同的翻译文本的,在 TextKey.ts 里集中定义:
/**
* 简中文案表 属性名即key 值即中文 新增文案在这里加一行
* 本表同时就是简中语言的文案表 所以简中不需要加载任何资源
*/
export const ZH_TEXTS = {
/********** 通用 **********/
common_ok: "确定",
common_cancel: "取消",
common_close: "关闭"
} as const;
用的时候 I18n.get(TK.common_ok),有 IDE 补全,key 拼错了 TS 编译直接报错。
第二层:中文原文本身就是 key。只出现一次的长句,根本不起 key,直接把中文传进去:
ui.WindowManager.showWindow(Alert, {
title: I18n.get("更新提示"),
content: I18n.get("检测到{mb}MB的更新资源, 是否更新?", { mb: result.size / 1024 }),
okTitle: I18n.get("更新"),
cancelTitle: I18n.get("退出游戏")
});
写起来跟硬编码几乎没有区别,只是外面套了一层 I18n.get()。查表的时候 key 就是这句中文本身,查不到就直接返回 key——反正 key 就是原文。
这一层,图的就是省事:写这句话的时候完全不用想”这个 key 该叫什么、该去哪个文件加一行”,写完中文往外面套一层函数就完事,不用跳出去改配置文件。
两层的分工很简单:短词、复用的走常量;一次性的长句直接用中文。什么时候该把中文提成常量,交给检查工具去提醒(后面讲),开发的时候不用纠结。
简中零成本:不加载任何资源
这套设计里我最满意的一点是:简体中文不需要任何额外资源。
ZH_TEXTS 这张表本身就是简中的文案表,初始化时直接灌进内存;中文原文 key 更省事,查不到表就返回 key 本身,天然就是正确文案:
public static init(lang: Language): void {
this._lang = lang;
this._texts.clear();
if (lang === Language.ZH) {
// 简中直接拿 ZH_TEXTS 当文案表 不读任何资源
const texts: Readonly<Record<string, string>> = ZH_TEXTS;
for (const key of Object.keys(texts)) {
this._texts.set(key, texts[key]);
}
} else {
this.loadTexts(lang);
}
}
只有非中文语言,才去加载对应语言的文案表 json(按语言分 bundle,language-en/hardcode.json 这种):
const language = LanguageHelper.language;
if (language !== Language.ZH) {
// 加载对应语言的文件, 方便之后使用
}
也就是说,只要这个项目不出海,多语言这一整套东西对包体、对加载流程、对运行时性能、对平常开发的便捷程度的影响几乎都是零。这就是我说的”不影响日常开发”。
禁止拼接:老项目里最大的坑
回到开头那个老项目,最要命的其实不是硬编码多——硬编码再多吃苦也能翻完。真正折磨人的是拼接字符串:
// 反面教材
text = "恭喜你获得了" + count + "个" + itemName;
这种句子没法整体进文案表,因为变量部分的取值无法穷举。更要命的是语言之间的语序不一样,中文”获得了3个金币”,英文是 “Obtained 3 gold coins”,”个”这种量词在英文里根本不存在,按词拆开翻译再拼接,拼出来全是病句。
所以这套方案里,动态内容统一用 {name} 占位符:
I18n.get("检测到{mb}MB的更新资源, 是否更新?", { mb: size });
整句进文案表,翻译可以看到完整语境,变量位置随便挪。
那有人非要写拼接怎么办?靠自觉是不行的,得靠工具拦。
工具链:交给 AI 写就行
光靠自觉是不够的,得有工具拦一下。我写了几个脚本放在 tools/i18n/ 下,核心是用 TypeScript Compiler API 做 AST 扫描——比正则靠谱,能准确分清参数到底是字符串字面量、TK.xxx 常量引用,还是拼出来的模板串。
脚本主要干两件事:
- 导出待翻译文案:把
ZH_TEXTS的全部条目、加上代码里所有I18n.get("中文…")的字面量,合并成一份 json,丢给翻译或者 AI 去翻,翻完回填到对应语言的文案表; - 一致性检查:拦掉模板串/
+拼接的写法,拦掉”某句中文已经在ZH_TEXTS里定义过又被人以原文重复写了一遍”的情况,顺带提醒哪些中文重复出现太多次该提成常量了。
导出的 json 具体怎么用,再说细一点: 翻译同学或者策划不一定爱看 json,这块可以再写个工具把这份数据转成 Excel、CSV 或者任何他们习惯用的格式——具体导出成什么样、字段怎么排,看自己团队的需求,这种转换脚本 AI 写起来也很顺手。
这几个脚本具体怎么写我就不展开了——这种活儿现在直接丢给 AI 写就行,把规则讲清楚,AST 怎么解析、判断逻辑怎么写,AI 都能给你搞出来。写完之后接进 pre-commit,提交前跑一下增量检查,写错了当场打回;CI 上再跑一次全量扫描保底就好了。
这套方案没解决什么
说实话,它只覆盖了”代码里的文本”,老项目里另外两个坑它管不了:
- 带文字的图。我的做法是:把这类图按语言分开放进不同的 bundle,路径和文件名保持一致。非中文语言加载图的时候,先去对应语言的 bundle 里找同路径同名的图,找到了就用,找不到就 fallback 回中文那张。这样哪个语言的图还没配齐,也不会直接崩掉,慢慢补就行;
- 界面布局。这个真没有野路子,英文平均比中文长 30% 以上,文本框不预留空间必溢出,只能在设计阶段就把这事考虑进去——文本框给够、用自适应布局,别用固定宽度写死。
另外这套扫描只覆盖代码,FGUI 编辑器里直接填的静态文本也在射程之外,项目里的约定是:界面文本统一走代码赋值,FGUI 里只放占位——这条约定本身也是为了方便扫描。
最后总结
整套东西说白了就是想解决一个问题:开发的时候压根不想惦记多语言这回事,能不能等真要出海了再一次性搞定。
- 两层 key:短词复用、语境不同翻译不同的走常量表;一次性长句直接写中文,中文本身就是 key,不用跳出去加配置;
- 简中零成本:不加载任何资源,不出海就当这套东西不存在;
- 禁止拼接:动态内容一律
{name}占位符,让翻译看到整句; - 工具兜底:检测和导出脚本让 AI 写,pre-commit/CI 拦一下违规写法就行。
日常开发的体验几乎没有变化——写中文、正常提交,唯一的额外动作是偶尔多套一层 I18n.get()。但如果哪天真要出海,跑一次导出脚本,所有需要翻译的文案就整整齐齐躺在一份 json 里了。
这只是我自己琢磨出来的一个野路子,肯定不是什么标准答案,带文字的图和界面布局那两块也还有很多细节没讲透,有更好的方式欢迎交流讨论。核心思路就一条:日常开发的时候,脑子里只装中文就够了。