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

📅 2026/8/16 22:58:54
做了海外项目才懂:国际化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(constQStringkey)const;voidregisterPluginTranslator(constQStringpluginId,constQStringqmBaseName);signals:voidlanguageChanged(Language newLang);private:Language m_language;std::unique_ptrQTranslatorm_translator;std::vectorstd::unique_ptrQTranslatorm_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(constQStringkey)const{// 遍历所有 context 查找第一个非空的翻译结果for(constQStringctx:translation_contexts::all()){constQString translatedQCoreApplication::translate(ctx.toUtf8().constData(),key.toUtf8().constData());if(!translated.isEmpty()translated!key)returntranslated;}returnkey;// 没找到就返回 key 本身}你可能会问为什么还要绕一圈 Qt 的 translate()直接用 QHashQString, 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_languagelang)return;// 相同语言不重复加载// 1. 卸载所有旧翻译器unloadAllTranslators();// 2. 加载新的 .qm 文件// app_zh_CN.qm 或 app_en_US.qmloadAppTranslator(lang);// 3. 加载各个插件注册的翻译loadPluginTranslators(lang);// 4. 更新内部状态m_languagelang;// 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 totallabel-setText(TR(records.count).arg(count));坑 2QSS 里的文字这个坑特别隐蔽。你在 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 后来换图表库时把是否支持多语言写进了评估清单。坑 4RTL 布局的镜像问题支持阿拉伯语时才发现不是把字符串从右往左写就完事了。// ❌ 中文/英语布局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. 动画方向也要反转intdirectionLM-isRTL()?1:-1;animation-setStartValue(slidePanel-x());animation-setEndValue(slidePanel-x()300*direction);坑 5翻译键与业务代码耦合很多项目用字符串原文当 keyTR(确定)// ← 用中文做 keyTR(Confirm)// ← 用英文做 key这么做第一眼看着挺方便但半年后你就会后悔——当业务方说把确定改成确认的时候你不仅要改翻译值还要改所有的 key。更糟的是不同场合的确定可能需要不同的翻译结果。正确的做法是用语义化 ID 做 key// ✅ 用语义 ID不要用原文TR(dialog.confirm)// 确定TR(dialog.cancel)// 取消TR(nav.home)// 首页TR(status.running)// 运行中// 在 .ts 文件里// message// sourcedialog.confirm/source// translation确定/translation// /message这样翻译内容和 key 完全解耦改翻译不需要改代码改代码不需要碰翻译。国际化的本质是可扩展的展示层回到开头那句话——国际化根本不是翻译问题。翻译只是最表层的工作。真正的国际化是在架构层面接受一个事实你的用户可能用任何语言、任何文字方向、任何数字格式来读你的界面。LanguageManager TR() 这套方案的底层逻辑是把语言当作一个全局状态像主题一样管理。你不应该问这段文字翻成英文是什么而应该问当语言切换时这个界面怎么完整刷新。做到位了加一个语言就是加一个 .ts 文件的事。做不到位每加一种语言就重写三分之一界面。你做过国际化项目吗遇到过什么让你头大的问题是 RTL 布局崩了还是 .ts 文件冲突合不拢评论区聊聊。觉得有用点个在看让更多人看到也鼓励我继续写下去。转给你的同事省得他踩同样的坑。下一篇从用户界面转向更底层的——序列化框架。工程文件怎么存JSONXML还是二进制我会拆解 Aether 的序列化选型过程以及为什么我们最后选了三者都用。