1. 为什么纯代码添加的部件会让翻译文件“失灵”
很多做桌面端开发的朋友第一次接触国际化时,都会经历一个相当迷惑的阶段:界面上的文字明明已经用tr()包起来了,.ts文件也翻译好了,lrelease也跑过了,可程序一启动,菜单栏和按钮还是英文,只有那些用设计器拖出来的控件老老实实跟着语言切换。这个现象背后其实不是 Qt 的 bug,而是对tr()的上下文(context)机制理解不到位导致的。
1.1 翻译文件到底在匹配什么
Qt 的翻译系统本质上是一张查找表,查找的键不是单纯的字符串,而是“上下文 + 源文本”这个组合。上下文默认取的是调用tr()的那个类的类名。也就是说,QObject::tr("Open")和MainWindow::tr("Open")在翻译文件里是两条完全不同的记录,哪怕源文本一模一样。
用设计器生成的.ui文件在编译时会被uic处理,自动生成形如QApplication::translate("MainWindow", "Open", nullptr)的调用,上下文被明确写死成MainWindow。而纯代码里如果你随手写了个tr("Open"),编译器会把它解析成当前所在类的tr(),上下文就变成了那个类。两边对不上,翻译自然查不到。
1.2 一个真实踩坑场景
我之前维护过一个工具类项目,主窗口是设计器做的,但工具栏上有一排动态生成的按钮,是在一个独立的ToolBarManager类里用代码 new 出来的。当时所有按钮文字都用了tr(),结果切换语言时主窗口菜单正常,工具栏纹丝不动。用 Qt Linguist 打开.ts文件一看,里面确实有ToolBarManager这个上下文,翻译也填了,但运行时就是不生效。
后来才定位到问题:ToolBarManager这个类没有加Q_OBJECT宏。没有这个宏,tr()根本不会走元对象系统,而是退化成了一个静态函数,上下文信息丢失,翻译查找直接失败。加上Q_OBJECT并重新qmake之后,问题立刻解决。这个坑非常隐蔽,因为代码能编译能运行,只是翻译静默失效。
1.3 本文要解决的问题边界
这篇内容聚焦的是“纯代码添加部件”这一特定场景下的中英文切换,不涉及设计器部分(那部分 Qt 已经处理得很好)。具体覆盖:动态创建的菜单项、工具栏按钮、对话框、状态栏提示、以及运行时动态变更的文字。同时会讲清楚.ts文件的生成、翻译、发布全流程,以及语言切换后如何让已经显示的界面立即刷新,而不是重启才生效。
适合已经能跑通基本 Qt 程序、但对国际化机制还停留在“加个 tr 就行”阶段的开发者。如果你正在被“翻译文件里有词但界面不切换”折磨,这篇应该能帮你把整条链路理顺。
2. 从零搭建一套可切换的中英文框架
在动手改代码之前,得先把工程层面的基础设施搭好。很多人一上来就急着写tr(),结果.pro文件里连TRANSLATIONS都没配,后面全是无用功。这一章按顺序把工程配置、翻译文件生成、加载逻辑讲透。
2.1 .pro 文件里必须写对的三行配置
Qt 的翻译流程依赖.pro文件里的几个关键配置,缺一个都会导致后续步骤断链。
TRANSLATIONS += lang/zh_CN.ts \ lang/en_US.ts CODECFORTR = UTF-8TRANSLATIONS告诉lupdate工具要去哪些文件里提取待翻译字符串。这里有个细节:路径里的目录必须提前存在,lupdate不会自动创建文件夹,如果lang/目录不存在,命令会直接报错退出。我习惯在项目根目录手动建好lang文件夹再跑命令。
CODECFORTR指定源文件编码,现在基本都是 UTF-8,写上可以避免中文乱码。另外如果你的项目用了CONFIG += console,注意翻译输出不要和标准输出混在一起,调试时容易看花眼。
还有一个容易被忽略的点:如果你用的是 CMake 而不是 qmake,配置方式完全不同,需要在CMakeLists.txt里用qt_add_translations或者手动调用lupdate/lrelease。本文以 qmake 为主,CMake 的思路类似,只是命令入口不同。
2.2 用 lupdate 生成 .ts 文件的正确姿势
配置好.pro之后,在项目根目录执行:
lupdate project.pro -ts lang/zh_CN.ts lang/en_US.ts这条命令会扫描所有源文件,把tr()包裹的字符串提取出来,按上下文归类写进.ts文件。第一次执行会生成全新的文件,之后每次执行是增量更新——已有的翻译不会丢,新加的字符串会追加进去。
这里有个实操经验:lupdate默认只扫描.cpp、.h、.ui等文件,如果你把界面文字放在.qml或者自定义后缀的文件里,需要在.pro里额外声明。另外,如果某个字符串你暂时不想翻译,可以用QT_TR_NOOP()包一层,它只标记不翻译,等需要时再处理。
生成后的.ts文件是 XML 格式,可以直接用文本编辑器打开,但强烈建议用 Qt Linguist 图形工具,因为手动改 XML 很容易破坏结构,而且 Linguist 有“未完成翻译”的过滤视图,效率高很多。
2.3 翻译文件里上下文对不上的典型表现
打开.ts文件,你会看到这样的结构:
<context> <name>MainWindow</name> <message> <source>Open</source> <translation>打开</translation> </message> </context><name>就是上下文。如果纯代码里的tr()所在类没有Q_OBJECT,lupdate提取时上下文会变成空或者错误的类名,翻译填了也白填。判断方法很简单:在 Linguist 里看上下文列表,如果出现一个你不认识的类名,或者本该有的类没出现,基本就是这个问题。
另一个常见情况是命名空间。如果你的类放在namespace app里,上下文会变成app::MainWindow,翻译文件里也得对应上。这个在跨模块项目里特别容易踩,因为不同模块可能定义了同名类,上下文冲突会导致翻译串台。
3. 纯代码部件的翻译上下文绑定实战
基础设施搭好之后,进入核心环节:怎么让代码里 new 出来的部件正确参与翻译。这一章按部件类型拆开讲,每种都有各自的坑。
3.1 动态菜单项和工具栏按钮的 tr 写法
假设你在MainWindow的构造函数里动态创建菜单:
QMenu *fileMenu = menuBar()->addMenu(tr("File")); QAction *openAction = fileMenu->addAction(tr("Open")); QAction *saveAction = fileMenu->addAction(tr("Save"));这里的tr()解析成MainWindow::tr(),上下文是MainWindow,和设计器生成的菜单上下文一致,所以能正常翻译。关键在于:动态创建的部件,其 tr 调用必须和它所属窗口的类保持一致。
如果你把创建逻辑抽到了一个辅助函数里,比如void createMenus(),只要这个函数是MainWindow的成员函数,tr()依然解析成MainWindow::tr(),没问题。但如果抽到了一个独立的工具类MenuBuilder里,上下文就变成了MenuBuilder,这时候要么给MenuBuilder加Q_OBJECT并单独翻译,要么在MenuBuilder里显式调用QCoreApplication::translate("MainWindow", "Open")来强制指定上下文。
我个人的习惯是:能用成员函数就用成员函数,实在要抽独立类,就在那个类里统一用QCoreApplication::translate()显式指定上下文,避免上下文碎片化。上下文太多会导致.ts文件臃肿,翻译时也容易漏。
3.2 对话框和自定义部件的上下文处理
自定义对话框是重灾区。假设你写了一个SettingsDialog继承自QDialog,里面所有标签、按钮都是代码创建的:
class SettingsDialog : public QDialog { Q_OBJECT public: SettingsDialog(QWidget *parent = nullptr) : QDialog(parent) { QLabel *nameLabel = new QLabel(tr("Name:")); QLineEdit *nameEdit = new QLineEdit; QPushButton *okBtn = new QPushButton(tr("OK")); // ... } };只要Q_OBJECT在,tr()上下文就是SettingsDialog,翻译文件里对应填好即可。但如果你忘了加Q_OBJECT,编译器不会报错,运行时翻译静默失效。这是最坑的地方,因为没有任何提示。
判断一个类有没有正确参与翻译,有个简单方法:在 Linguist 里看上下文列表里有没有这个类名。没有就说明Q_OBJECT缺失或者lupdate没扫描到。
还有一种情况是自定义部件被提升(promote)到设计器里使用。这时候部件本身的tr()上下文是它自己的类名,和主窗口无关,翻译文件里会多出一个上下文。这是正常的,只要翻译填了就能工作。
3.3 运行时动态变更文字的刷新机制
语言切换后,已经显示的界面不会自动刷新,因为tr()只在调用时求值一次。要让界面立即更新,需要手动处理。
标准做法是重写changeEvent():
void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { retranslateUi(); } QMainWindow::changeEvent(event); }retranslateUi()里把所有需要更新的文字重新设置一遍:
void MainWindow::retranslateUi() { fileMenu->setTitle(tr("File")); openAction->setText(tr("Open")); saveAction->setText(tr("Save")); statusBar()->showMessage(tr("Ready")); }注意这里有个顺序问题:retranslateUi()必须在QMainWindow::changeEvent()之前调用,否则某些基类行为可能覆盖你的设置。另外,如果部件是在运行时动态增删的,retranslateUi()里要能覆盖到所有当前存在的部件,不能只处理初始化时创建的那些。
对于设计器生成的界面,uic会自动生成一个retranslateUi()函数,你只需要在changeEvent里调用它。但纯代码部件没有这个自动生成,得自己写。我通常会把所有动态部件的指针存成成员变量,然后在retranslateUi()里统一刷新,这样逻辑清晰,也不容易漏。
4. 语言切换的加载与即时生效链路
翻译文件准备好了,界面刷新逻辑也写了,接下来是把它们串起来:程序启动时加载默认语言,用户切换时重新加载并触发刷新。这一章讲清楚QTranslator的加载时机和常见陷阱。
4.1 QTranslator 的加载顺序与覆盖规则
QTranslator可以加载多个,后加载的会覆盖先加载的同名条目。利用这个特性,可以实现“默认语言 + 用户覆盖”的层级结构。
QTranslator *appTranslator = new QTranslator; QTranslator *qtTranslator = new QTranslator; // 先加载 Qt 内置翻译(比如标准对话框的按钮) qtTranslator->load("qt_zh_CN", QLibraryInfo::location(QLibraryInfo::TranslationsPath)); qApp->installTranslator(qtTranslator); // 再加载应用自己的翻译 appTranslator->load("zh_CN", "lang"); qApp->installTranslator(appTranslator);顺序很重要:Qt 内置翻译先装,应用翻译后装。这样如果应用翻译里也定义了OK、Cancel这类词,会覆盖 Qt 内置的,保证风格统一。反过来装的话,应用翻译会被 Qt 内置的覆盖,出现“一半中文一半英文”的诡异现象。
QLibraryInfo::location(QLibraryInfo::TranslationsPath)返回 Qt 安装目录下的翻译文件夹路径,里面自带了几十种语言的标准对话框翻译。这个路径在不同平台上不一样,用这个 API 获取最稳妥,不要硬编码。
4.2 切换语言时先移除旧翻译再装新的
用户点击“切换英文”时,不能直接装一个新的QTranslator就完事,得先把旧的移除:
void MainWindow::switchLanguage(const QString &lang) { qApp->removeTranslator(appTranslator); qApp->removeTranslator(qtTranslator); if (lang == "en") { appTranslator->load("en_US", "lang"); qtTranslator->load("qt_en", QLibraryInfo::location(QLibraryInfo::TranslationsPath)); } else { appTranslator->load("zh_CN", "lang"); qtTranslator->load("qt_zh_CN", QLibraryInfo::location(QLibraryInfo::TranslationsPath)); } qApp->installTranslator(qtTranslator); qApp->installTranslator(appTranslator); }移除和安装的顺序要对称:先移除后安装,且安装顺序和初始化时一致。如果只装不移,多个翻译叠加会导致查找混乱,出现“切了英文但部分文字还是中文”的情况。
另外,load()的返回值要检查。如果文件路径不对或者文件名拼错,load()返回 false,但程序不会崩溃,只是翻译不生效。我习惯在load()后面加个断言或者日志,方便定位问题。
4.3 让已打开的所有窗口都收到 LanguageChange 事件
installTranslator()之后,Qt 会向所有顶层窗口发送QEvent::LanguageChange事件。但这里有个前提:窗口必须已经显示,且事件循环正在运行。如果窗口是在切换语言之后才创建的,它自然用的是新语言,不需要额外处理。
对于已经打开的多个窗口,每个窗口的changeEvent()都会被触发,各自刷新自己的文字。这就是为什么前面强调每个自定义部件都要正确实现changeEvent()——漏一个,那个窗口就不会更新。
有个特殊情况:模态对话框。如果切换语言时正好有个模态对话框开着,它也会收到事件并刷新。但如果对话框是在exec()阻塞状态下,事件循环是嵌套的,刷新逻辑依然能工作,只是要注意不要在retranslateUi()里做耗时操作,否则界面会卡顿。
5. 那些让翻译“看起来生效了其实没有”的隐蔽问题
前面讲的都是正常流程,但实际项目里总有一些诡异现象,代码看起来没问题,翻译就是不工作。这一章列几个我踩过的坑,以及排查思路。
5.1 字符串拼接导致的翻译断裂
这是新手最容易犯的错误:
// 错误写法 label->setText(tr("Total: ") + QString::number(count) + tr(" items"));这种写法在翻译文件里会变成三条独立的记录:"Total: "、" items",中间的数字是运行时拼进去的。翻译时你只能分别翻译这两段,但语序在不同语言里可能完全不同。比如英文是“Total: 5 items”,中文是“共 5 项”,语序对不上,翻译出来会很别扭。
正确做法是用占位符:
label->setText(tr("Total: %1 items").arg(count));这样整句作为一个翻译单元,翻译者可以根据目标语言调整语序。%1、%2这些占位符在翻译时保留原样,运行时被arg()替换。
同理,tr()里不要用变量拼接,所有可变部分都用占位符。这是国际化开发的基本原则,但很多人图省事直接拼,后面改起来很痛苦。
5.2 编码问题导致中文翻译显示为乱码
.ts文件是 XML,默认编码是 UTF-8。如果你的源文件是 GBK 编码,lupdate提取出来的中文会乱码,翻译文件里也是一堆问号。解决办法是在.pro里加CODECFORTR = UTF-8,并确保所有源文件保存为 UTF-8 无 BOM 格式。
另一个坑是lrelease生成的.qm文件。.qm是二进制格式,理论上没有编码问题,但如果.ts本身就是乱码,.qm里也是乱码。所以根源还是在.ts的生成环节。
排查方法:用 Linguist 打开.ts,如果源文本显示正常但翻译文本乱码,说明是翻译时输入的问题;如果源文本就乱码,说明是lupdate提取时的编码问题。两种情况处理方式不同。
5.3 翻译文件没重新编译导致的“改了没效果”
.ts文件改完之后,必须重新执行lrelease生成.qm,程序加载的是.qm而不是.ts。很多人改完.ts直接运行程序,发现没变化,就是因为忘了这一步。
lrelease project.pro这条命令会根据.pro里的TRANSLATIONS配置,把所有.ts编译成对应的.qm。生成的.qm默认和.ts同目录,加载时路径要对应上。
我习惯在.pro里加一个自定义目标,把lrelease挂到构建流程里:
lrelease.target = lrelease lrelease.commands = lrelease $$PWD/project.pro QMAKE_EXTRA_TARGETS += lrelease这样每次构建时自动跑一遍,避免忘记。不过要注意,如果翻译文件很大,每次构建都跑会拖慢速度,可以只在发布版本里启用。
6. 一套可复用的语言管理封装思路
项目大了之后,语言切换逻辑散落在各个窗口里会很乱。这一章分享一个轻量级的封装思路,把翻译加载、切换、刷新统一管理起来。
6.1 用单例管理翻译器的生命周期
核心思路是搞一个LanguageManager类,持有QTranslator指针,对外暴露switchTo(lang)接口:
class LanguageManager : public QObject { Q_OBJECT public: static LanguageManager &instance(); bool switchTo(const QString &lang); QString currentLang() const; signals: void languageChanged(); private: LanguageManager(); QTranslator *m_appTranslator; QTranslator *m_qtTranslator; QString m_currentLang; };switchTo()里做移除旧翻译、加载新翻译、安装新翻译三件事,然后发一个languageChanged()信号。各个窗口连接这个信号,在槽里调用自己的retranslateUi()。这样就不依赖changeEvent()了,控制更显式。
两种方式各有优劣:changeEvent()是 Qt 原生机制,自动触发,不容易漏;信号槽方式更灵活,可以控制刷新时机,但需要手动连接。我一般两者结合:changeEvent()里做基础刷新,信号槽里做业务相关的文字更新。
6.2 语言配置的持久化
用户选了英文,下次启动应该还是英文。用QSettings存一下:
QSettings settings("MyCompany", "MyApp"); settings.setValue("language", lang);启动时读取:
QString lang = settings.value("language", "zh_CN").toString(); LanguageManager::instance().switchTo(lang);注意默认值给zh_CN,保证首次启动是中文。如果给空字符串,switchTo()里要处理这种情况,回退到系统语言或者默认语言。
QSettings在不同平台上的存储位置不同,Windows 在注册表,Linux 在~/.config,macOS 在 plist。这些细节不用关心,API 是统一的。但要注意组织名和应用名不能为空,否则会写到默认位置,可能和其他程序冲突。
6.3 新增语言时的扩展点
如果以后要加日文、韩文,只需要在.pro的TRANSLATIONS里加一行,在switchTo()里加一个分支,其他代码不用动。这就是封装的价值。
更进一步,可以把支持的语言列表做成配置:
struct LangInfo { QString code; QString displayName; QString qtFile; }; QList<LangInfo> supportedLangs = { {"zh_CN", "简体中文", "qt_zh_CN"}, {"en_US", "English", "qt_en"}, {"ja_JP", "日本語", "qt_ja"} };菜单里根据这个列表动态生成语言选项,新增语言时只改这一处。这种数据驱动的做法在长期维护的项目里能省很多事。
7. 实测中几个值得记录的细节
最后分享几个在实际项目中验证过的细节,都是文档里不太会提但确实会遇到的问题。
第一个是关于tr()在静态函数里的行为。静态函数里不能用tr(),因为tr()是QObject的成员函数,依赖this指针获取上下文。如果非要在静态函数里翻译,得用QCoreApplication::translate("Context", "Text")显式指定上下文。这个上下文要和翻译文件里的<name>完全一致,包括大小写。
第二个是关于翻译文件的版本控制。.ts是文本文件,适合纳入版本控制;.qm是二进制,每次lrelease都会变,不建议提交。在.gitignore里加上*.qm,让构建流程自动生成。但要注意,如果发布包里需要预置.qm,得在打包脚本里确保lrelease跑过。
第三个是关于QEvent::LanguageChange的触发时机。这个事件是在installTranslator()调用后由 Qt 异步发送的,不是立即同步触发。如果你在installTranslator()之后马上检查界面文字,可能还是旧的。要验证是否刷新,得等事件循环跑一轮,或者手动调用QCoreApplication::processEvents()。
第四个是关于富文本的翻译。如果QLabel里用了 HTML,tr()要包住整个 HTML 字符串,而不是只包文字部分。因为 HTML 标签也是字符串的一部分,翻译时需要保留标签结构。比如tr("<b>Name:</b> %1").arg(name),翻译文件里<b>标签要原样保留。
这些细节单独看都不复杂,但组合在一起,就是“为什么我的翻译不生效”的答案。把上下文、编码、编译、刷新这四个环节都检查一遍,基本能覆盖 95% 的翻译问题。剩下的 5%,多半是拼写错误或者路径问题,用 Linguist 的“未完成翻译”视图过一遍就能发现。