ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

做了海外项目才懂:国际化5个坑不是翻译问题

2026/8/16 23:22:24 拓冰建站 浏览量
做了海外项目才懂:国际化5个坑不是翻译问题

第一次做国际化,我以为就是搞个翻译文件而已。

那时候项目要出海,PM 扔过来一张 Excel:“把这些中文翻译成英文,塞进代码里就行。” 我心想,简单,一天搞定。结果上线第一周,bug 单飞回来三页——日期格式乱套、阿拉伯语界面崩成马赛克、有个按钮在英语下长了三倍直接把 layout 撑爆了。

后来在 Aether 框架里重新设计整套国际化方案,才彻底想明白一件事:国际化根本不是翻译问题,是架构问题。

你以为是字符串替换,其实是全维度适配

很多团队做国际化,第一反应就是"把中文替换成变量"。但真正的国际化要解决的东西,远不止这些。同一个意思,在不同 locale 下展现方式完全不同。

维度中文英文阿拉伯语
日期格式2026年7月26日07/26/202626/07/2026
数字千分位1,234.561,234.561,234.56(但数字用东阿拉伯)
文字方向左到右左到右右到左(RTL)
图标含义打勾=正确✓=正确✗=正确(文化反转)
图片文字直接嵌中文需要换图需要镜像翻转
金额格式¥1,234.00$1,234.56EGP 1,234.56
姓名顺序姓+名名+姓名+姓(但中间名复杂)

看到这张表你就明白了:国际化本质是一个多维度适配问题,字符串翻译只是冰山一角。

举个具体的例子。日期格式这件事,你在代码里写"2026-07-26",美国人看不懂,因为美国习惯"07/26/2026";欧洲人看到会困惑,因为欧洲是"26/07/2026"。就这一条,当初我们就修了 4 个 bug。

再比如图片。Aether 早期版本里有个"帮助"按钮,图标里嵌了个小写的"i"。到了阿拉伯语市场,不仅要换成阿拉伯语的图标,整个图片方向都得镜像处理——因为阿拉伯语从右往左读,居左的图标在 RTL 布局里看起来是"倒退"的。

所以第二版国际化方案,我们直接推翻了"替换字符串"的思路,改成全局语言管理器 + 资源分叉的架构。

LanguageManager + TR():把翻译藏进宏里

Aether 的方案其实不复杂:一个全局单例 LanguageManager,一个宏 TR(key),所有界面代码里不再出现任何中文字符串。

先看接口设计:

// languagemanager.hclassLanguageManager:publicQObject{Q_OBJECTpublic:enumclassLanguage{Chinese,English};staticLanguageManager*instance();voidsetLanguage(Language lang);voidtoggleLanguage();QStringt(constQString&key)const;voidregisterPluginTranslator(constQString&pluginId,constQString&qmBaseName);signals:voidlanguageChanged(Language newLang);private:Language m_language;std::unique_ptr<QTranslator>m_translator;std::vector<std::unique_ptr<QTranslator>>m_pluginTranslators;};// 暴露给所有代码的两个宏#defineLM(LanguageManager::instance())#defineTR(key)(LanguageManager::instance()->t(key))

这里有两个关键设计。

第一是 TR() 宏而不是 Qt 自带的 tr()。为什么?因为 Qt 的 tr() 依赖 Q_OBJECT 宏和 moc 元数据,静态函数和工具类里用不了。TR() 直接走 LanguageManager 实例,任何地方都能调,包括非 QObject 的纯 C++ 类。

第二是 plugin 级别的翻译隔离。每个插件可以注册自己的 .qm 文件,互不干扰。主应用翻译一个文件,插件翻译各管各的。这样第三方的插件不会污染全局命名空间。

看实现里的核心逻辑——翻译查找:

// 自动扫描所有已注册的 translation_contexts// context 列表由 CMake 构建时从 .ts 文件自动生成QStringLanguageManager::t(constQString&key)const{// 遍历所有 context 查找第一个非空的翻译结果for(constQString&ctx:translation_contexts::all()){constQString translated=QCoreApplication::translate(ctx.toUtf8().constData(),key.toUtf8().constData());if(!translated.isEmpty()&&translated!=key)returntranslated;}returnkey;// 没找到就返回 key 本身}

你可能会问:为什么还要绕一圈 Qt 的 translate()?直接用 QHash<QString, QString> 不行吗?

行,但没必要。Qt 的 QTranslator 底层会处理复数形式上下文消歧义这些复杂场景。你手写的 Map 翻译器遇到"file(s)"这种要根据数量切换单复数的场景就要哭了。

动态切换:从点击到界面刷新,一条链路走完

国际化的难点不只在于"怎么翻译",更在于"怎么切"——不能让用户每次切换语言都重启程序。

Aether 的动态切换链路长这样:

用户点击 "English" ↓ LanguageManager::setLanguage(English) ↓ unloadAllTranslators() ← 卸载当前 .qm ↓ loadAppTranslator(English) ← 加载 en_US.qm ↓ loadPluginTranslators(English) ← 加载各插件的 en.qm ↓ emit languageChanged(English) ← 发射信号 ↓ 各 ViewModel 收到信号 → 刷新受影响的 Property ↓ DataBinding 驱动 UI 重新渲染 ↓ 用户看到的已经是英文界面

核心代码其实只有几十行:

voidLanguageManager::setLanguage(Language lang){if(m_language==lang)return;// 相同语言不重复加载// 1. 卸载所有旧翻译器unloadAllTranslators();// 2. 加载新的 .qm 文件// app_zh_CN.qm 或 app_en_US.qmloadAppTranslator(lang);// 3. 加载各个插件注册的翻译loadPluginTranslators(lang);// 4. 更新内部状态m_language=lang;// 5. 通知全系统:语言变了emitlanguageChanged(lang);}

关键在第六步。ViewModel 如何响应语言切换?Aether 的 MVVM 框架里,ViewModel 可以监听 LanguageManager 的信号:

// BaseViewModel 里统一处理语言切换voidBaseViewModel::initLanguageAware(){connect(LM,&LanguageManager::languageChanged,this,[this](){// 刷新所有翻译相关的属性refreshLocalizedText();// 通知绑定的 UI 重新拉取值notifyAllProperties();});}

而 DataBinding 层在收到 Property 变更通知后,自动更新控件显示。整个链路对业务开发者几乎是透明的——你只要写TR("nav.home"),不用操心刷新的事。

.qm 文件的生成流水线

聊到 QTranslator,不得不提 .qm 文件的构建流程。很多 Qt 新手在这块踩坑。

.ts 文件(XML 源码翻译) ← 人工维护或使用 Qt Linguist ↓ lrelease 命令行工具 ← CMake 构建时自动调用 ↓ .qm 文件(二进制编译后翻译) ← 运行时加载 ↓ QTranslator::load(.qm) ← 分发到目标语言

Aether 的做法是:在 CMake 里加一个自定义命令,每次构建自动跑lrelease,确保 .qm 文件始终是最新的。不会出现"改了 .ts 忘了重新发布"的情况。

# CMakeLists.txt 片段:自动生成 .qm find_program(QT_LRELEASE lrelease) if(QT_LRELEASE) add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/config/i18n/app_zh_CN.qm COMMAND ${QT_LRELEASE} ${CMAKE_SOURCE_DIR}/config/app_zh_CN.ts -qm ${CMAKE_BINARY_DIR}/config/i18n/app_zh_CN.qm DEPENDS ${CMAKE_SOURCE_DIR}/config/app_zh_CN.ts ) endif()

这样开发者在 Qt Linguist 里翻译完,重新编译就完事了,不需要手动跑任何命令。

5 个隐蔽的坑,每一个都让我加过班

方案说完了,讲几个实战里真正让我头疼的问题。

坑 1:拼接字符串里的翻译

// ❌ 错误做法:运行时拼接QString msg="共 "+QString::number(count)+" 条记录";// 英语下变成:共 42 条记录 —— 不翻译?// 更隐蔽的写法label->setText(QString("文件大小:%1 MB").arg(size));// 翻译工具根本不会扫描到这行代码里的字符串

问题是:tr()只扫描字符串字面量,拼接出来的字符串不会被提取到 .ts 文件里。你翻遍了整个 .ts 也找不到"共 %1 条记录"。

解决方案:把完整模板扔进 TR() 里,不要拆开。

// ✅ 正确做法:整个字符串做 key// 中文:"共 %1 条记录"// 英文:"%1 records total"label->setText(TR("records.count").arg(count));

坑 2:QSS 里的文字

这个坑特别隐蔽。你在 QSS 文件或者 setStyleSheet() 里写了文字,Qt 的翻译系统默认不会触碰样式表。

// ❌ QSS 里的中文字符串setStyleSheet("QPushButton {"" qproperty-text: '确定';"// ← 这个不会被翻译"}");// ✅ 正确做法:别在 QSS 里写文本// 用 setText(TR("confirm")) 替代setText(TR("confirm"));setStyleSheet("QPushButton {"" min-width: 80px;""}");

看起来是常识,但在真实的项目里这玩意能藏很久——因为 QSS 经常写在单独的 .qss 文件里,review 的时候根本注意不到。

坑 3:第三方库的硬编码英文

你用的是 Qt,但你依赖的库不一定是。Aether 早期集成了一个第三方图表库,所有 tooltip 都是硬编码的英文 “Click to select”。直到给国内客户演示,PM 当场问"这个英文能不能改"才发现。

解决方案分三层:

第一层:能改源码的第三方 → Fork 一份,提取字符串走翻译 第二层:不能改源码但有 API → 用 setLocale() 或者 setText() 覆盖 第三层:啥都没有 → 在 showEvent 里暴力查找替换(下下策)

最稳妥的做法是选型时就确认第三方库的 i18n 支持情况,Aether 后来换图表库时把"是否支持多语言"写进了评估清单。

坑 4:RTL 布局的镜像问题

支持阿拉伯语时才发现,不是"把字符串从右往左写"就完事了。

// ❌ 中文/英语布局(LTR) [图标] [标题] ------------------ [展开按钮] // ✅ 阿拉伯语布局(RTL) [展开按钮] ------------------ [标题] [图标] // 注意:图标和标题的顺序反了,图片也要镜像

Qt 对 RTL 的支持其实不错,setLayoutDirection(Qt::RightToLeft)能自动翻转大部分布局。但有几个地方你需要注意:

// ✅ RTL 需要注意的点// 1. 自定义绘制的图标要镜像if(LM->currentLanguage()==Language::Arabic){painter->scale(-1,1);// 水平镜像painter->translate(-width,0);}// 2. 对齐方式要动态设置label->setAlignment(LM->isRTL()?Qt::AlignRight:Qt::AlignLeft);// 3. 动画方向也要反转intdirection=LM->isRTL()?1:-1;animation->setStartValue(slidePanel->x());animation->setEndValue(slidePanel->x()+300*direction);

坑 5:翻译键与业务代码耦合

很多项目用字符串原文当 key:

TR("确定")// ← 用中文做 keyTR("Confirm")// ← 用英文做 key

这么做第一眼看着挺方便,但半年后你就会后悔——当业务方说"把确定改成确认"的时候,你不仅要改翻译值,还要改所有的 key。更糟的是,不同场合的"确定"可能需要不同的翻译结果。

正确的做法是用语义化 ID 做 key

// ✅ 用语义 ID,不要用原文TR("dialog.confirm")// 确定TR("dialog.cancel")// 取消TR("nav.home")// 首页TR("status.running")// 运行中// 在 .ts 文件里// <message>// <source>dialog.confirm</source>// <translation>确定</translation>// </message>

这样翻译内容和 key 完全解耦,改翻译不需要改代码,改代码不需要碰翻译。

国际化的本质是"可扩展的展示层"

回到开头那句话——国际化根本不是翻译问题。

翻译只是最表层的工作。真正的国际化是在架构层面接受一个事实:你的用户可能用任何语言、任何文字方向、任何数字格式来读你的界面。

LanguageManager + TR() 这套方案的底层逻辑是:把"语言"当作一个全局状态,像主题一样管理。你不应该问"这段文字翻成英文是什么",而应该问"当语言切换时,这个界面怎么完整刷新"。

做到位了,加一个语言就是加一个 .ts 文件的事。做不到位,每加一种语言就重写三分之一界面。


你做过国际化项目吗?遇到过什么让你头大的问题?是 RTL 布局崩了,还是 .ts 文件冲突合不拢?评论区聊聊。

觉得有用?点个"在看"让更多人看到,也鼓励我继续写下去。

转给你的同事,省得他踩同样的坑。

下一篇,从用户界面转向更底层的——序列化框架。工程文件怎么存?JSON?XML?还是二进制?我会拆解 Aether 的序列化选型过程,以及为什么我们最后选了"三者都用"。