写Qt国际化这几年,我碰到过不少次"其它翻译都好好的,就某个类的翻译死活不生效"的情况,每次排查到最后,原因都藏在一些极其隐蔽的位置。这个标题本身就是个典型的坑,单看报错现象,可能觉得是Qt翻译机制的bug,但实际查下来,十有八九是我们在某个环节上疏忽了。今天我把这类问题彻底拆开讲一讲,包括一条完整的排查链路、每个环节的原理和实操验证方法,顺便把那些常规文档里不会写的坑也一并列出来。
1. 先搞明白Qt翻译的完整链路,才知道问题可能出在哪
要排查"某个class翻译不生效",第一步不是盯着代码看,而是先从头到尾梳理一遍Qt翻译是怎么生效的。很多人一上来就怀疑tr()写错了,其实问题的根源往往在更早的环节。
Qt的翻译机制本质上是一个"三段链路":
- 源码扫描与翻译文件生成:工具
lupdate扫描源代码里所有的tr()调用,把可翻译字符串提取到.ts文件中。这个.ts文件是XML格式,里面会按类名(也就是context上下文)对字符串分组。 - 翻译文件编译:人工或者借助工具在
.ts文件里填写各语言的译文后,用lrelease把.ts编译成二进制的.qm文件。运行时真正加载的是.qm,不是.ts。 - 运行时加载与文本替换:代码中通过
QTranslator加载.qm文件,Qt在遇到tr("文本")时,会根据当前QLocale去翻译器里查找对应的译文并替换显示。
打个生活化的比方:lupdate像是一个编辑,把所有需要翻译的句子摘抄到笔记本上,记的时候按"说话的人"分了章节;翻译人员就是照着笔记本逐句翻译;lrelease把笔记本压缩成一张速查卡;程序运行时,每个"说话的人"(也就是class)通过自己的名字(context)去速查卡上找对应的翻译内容。
"某个class翻译不生效",说明整条链路中至少有一个环节出了问题。可能是笔记本上根本没记这个类的内容,可能是速查卡上的内容过期了,可能是加载时把速查卡放错了位置,也可能是代码里的"说话人名字"和笔记本上的章节名对不上。接下来我们就按这个链路逐段排查。
2. 第一段排查:编译链路,这个类的词条到底进没进ts文件
2.1 先打开ts文件,直接搜索类名
这是我最先做的动作,也是最容易被忽略的。很多人项目出问题时,第一时间去改代码、改加载逻辑,其实应该先把.ts文件打开,直接搜一下不生效的那个类名。如果搜不到对应条目,后面的一切排查都是浪费时间。
用文本编辑器打开.ts文件,你会看到类似这样的结构:
<context> <name>MainWindow</name> <message> <location filename="mainwindow.cpp" line="42"/> <source>Open File</source> <translation>打开文件</translation> </message> </context>如果某个class的翻译不生效,首先搜索<name>你的类名</name>。如果压根没有这个context节点,说明lupdate没能提取到这个类里的tr()字符串,问题就出在提取环节。这种情况通常有几种原因:
- 类定义在
.cpp文件里,但.pro/CMakeLists.txt中没有把该文件纳入扫描范围。 .h文件里没有包含Q_OBJECT宏,导致元对象编译器没有生成tr()的静态上下文。lupdate跑完后没有保存ts文件,或者代码是之后新加的,忘了重新跑lupdate。- 项目用了复杂的文件目录结构,lupdate的include路径没有覆盖到位。
实操验证方法:手动跑一次lupdate,看看输出内容中是否提示新增了多少条翻译。
# qmake工程 lupdate project.pro -ts translations/zh_CN.ts # CMake工程,假设target名叫MyApp lupdate . -ts translations/zh_CN.ts -source-language en_US -target-language zh_CN跑完之后再看ts文件,如果还是没有这个类,那基本可以确认是这个类所在的源文件根本没有被扫描到。
2.2 搜到了条目,但source文本和代码里的对不上
有时候ts里明明有<name>MyDialog</name>节点,但打开一看,里面的<source>文本和代码中tr()里写的英文原文不一样。最常见的坑就是复制粘贴时多打了一个空格,或者大小写不同。
比如代码里写的是:
ui->label->setText(tr("Name:"));而ts里存的却是:
<source>Name :</source>这种情况下,运行时Qt拿"Name:"去查找,根本匹配不上,翻译自然就不生效。排查方法很简单:在ts文件里Ctrl+F搜索Name:和Name :,对比一下就知道。
还有一种比较隐蔽的情况:源代码里使用了tr("Start", "verb")这种带歧义消除字段(disambiguation)的写法,ts文件里会多出一行<extracomment>或<numerusform>,但更关键的是,如果翻译时给不同歧义的同一单词填了不同的译文,而代码里调用时漏了第二个参数,那也会出现匹配不上的问题。平时检查时容易被忽略。
2.3 lupdate之后再没跑过lrelease,qm文件是旧的
这个坑我踩过太多次。ts文件里已经填好了译文,代码也没问题,但运行时就是不生效。原因往往是改了ts之后,忘了执行lrelease重新编译qm文件。
Qt运行时根本不读ts文件,它只认qm。如果你只改了ts没重新生成qm,那程序加载的还是旧版翻译数据,等于你辛苦翻译的内容根本没进入最终可执行的环境。
手动编译也很简单:
lrelease translations/zh_CN.ts -qm translations/zh_CN.qm如果是在Qt Creator中,构建时通常会自动执行lrelease,但前提是.pro文件或CMake配置正确。.pro里一般这样写:
TRANSLATIONS += translations/zh_CN.tsCMake里可以用qt5_add_translation或qt6_add_translations等命令完成。
经验之谈:排查到这一步时,可以顺手把项目目录下的.qm文件的时间戳和.ts文件对比一下。如果qm明显比ts旧,别想了,先重新生成再说。
2.4 多个同名类导致context冲突
项目大了以后,很容易出现两个类都叫LoginDialog,只是放在不同的命名空间或不同的模块里。lupdate在提取字符串时,如果两个类名相同,它们会被合并到同一个<name>LoginDialog</name>节点下,最终谁翻译了、谁没翻译,全看运气。
这种情况在大型工程里非常典型,模块A的登录框翻译生效,模块B的登录框翻译失效。查起来还特别费劲,因为ts文件里只有一个LoginDialog节点。
解决办法有两个:
- 彻底贯彻类名唯一性,即使在不同模块,也加上模块前缀,比如
Auth::LoginDialog。 - 在ts文件里手动把两个context拆开,但这种手工维护方式只适合临时救急,不建议长期依赖。
3. 第二段排查:加载链路,qm文件到底有没有被正确加载
编译链路查完之后,接下来就要看运行时。加载环节出问题,同样是"部分类生效、部分类不生效"的经典场景。
3.1 QTranslator的加载顺序:后加载的会覆盖先加载的吗
很多人不知道,Qt允许同时安装多个QTranslator。程序启动时,一般这样写:
QTranslator translator; if (translator.load(":/translations/zh_CN.qm")) { QCoreApplication::installTranslator(&translator); }这里面隐藏着一个关键点:多个translator并存时,Qt按安装顺序依次查找翻译。先安装的先查,如果查到了,后面再安装的就没机会生效。这会导致一个神奇的现象:A类的翻译生效,B类的不生效,原因可能不在B,而在于先安装的那个translator里恰好包含了B的某些字符串,但这个字符串翻译成的是另一种语言或干脆是空串。
我遇到过一例,程序先加载了一个"通用术语包"的qm,这个包覆盖了全局很多公共字符串,其中正好包含某个类的源文本,但译文是英文原文(等于没翻译)。于是这个类的所有翻译都"失效"了,而其它类因为源文本不在通用包中,反而能正常翻译。排查方法很直接:启用代码里逐个注释掉QTranslator的加载,看哪个类翻译恢复正常。
3.2 load成功了没,路径对不对
QTranslator::load是个静默型函数,加载失败不抛异常、不打印明显错误,只是返回false。很多代码写成:
translator.load(":/translations/zh_CN.qm");根本没检查返回值。造成的结果就是:你感觉加载了翻译,实际上根本没加载成功。
排查时建议临时加上检查:
if (!translator.load(":/translations/zh_CN.qm")) qWarning() << "Failed to load translator!";如果打印出加载失败,那就要检查资源和路径了。资源系统里常见错误是路径写错,比如实际资源路径是:/translations/zh_CN.qm,写成了:/translations/zh_CN,或者少了前缀。非资源模式下更典型,往往会带着相对路径去找,但程序的工作目录不对,qm文件根本不在那里。
想要更稳妥,建议用绝对路径定位:
QDir dir; QString qmPath = dir.absoluteFilePath("translations/zh_CN.qm"); bool ok = translator.load(qmPath);注意:Qt 6里QTranslator::load对locale参数更敏感,建议明确指定locale,不要依赖默认构造,否则可能在某些系统环境上选错翻译文件。这个也是我后来反复踩到的一个暗坑。
3.3 切换语言时,UI没有refresh
很多"翻译不生效"其实发生在动态切语言的过程中。程序启动时默认语言没问题,切到英文再切回中文,某个类的界面文字没变。这种情况不是翻译文件的问题,而是界面没有重新翻译。
Qt的惯用做法是在顶层窗口里重写changeEvent,检测LanguageChange事件后,调用retranslateUi():
void MainWidget::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { ui->retranslateUi(this); // 手动更新部分文本的setText } QWidget::changeEvent(event); }单窗口还好说,如果是多个子窗口、多个dialog,只要有任何一个窗口没有正确重写changeEvent,或者漏调了retranslateUi,那么这个窗口里涉及到的class就会表现为"翻译不生效"。这种情况和标题描述的现象很像——A窗口的翻译正常,B窗口的不正常。
4. 第三段排查:代码调用,tr()的上下文到底挂在哪
编译链路、加载链路都查完没问题,剩下的重心就在代码本身了。这里面的水最深,因为很多坑编译不会报错,运行也不会崩,纯粹是"逻辑正确但行为诡异"。
4.1 tr()必须在能识别QObject的类里调用
tr()并不是一个普通函数,它是在QObject类中定义的静态成员函数,并且和类的元对象系统绑定。如果你在普通类、命名空间函数、或者静态工具函数里调用tr(),虽然代码能编译(因为QObject::tr是公开静态函数),但真正生效时,上下文取决于代码所在的位置。
下面这段代码,就是不生效的典型:
void Utility::formatText(const QString &name) { qDebug() << QObject::tr("Hello %1").arg(name); }这里用的是QObject::tr,它的context就是QObject本身,而不是Utility类。如果你在ts文件里给Utility类填了翻译,那肯定匹配不上。正确做法是在Utility里也加上Q_OBJECT宏并继承,或者用QCoreApplication::translate("Utility", "Hello %1")显式指定上下文。
判断标准很简单:检查target翻译不生效的那个class,是否真的声明了:
class MyClass : public QObject { Q_OBJECT ... };如果类没有Q_OBJECT宏,那么它内部的tr()调用不能直接使用,要改用QCoreApplication::translate("MyClass", "...")。
4.2 context标记被手写成了别的名字
这个坑经常藏在"手动维护ts文件"的项目里。某位同事图省事,在ts文件里新建了一个context节点,名字不是类名,而是随便起的,比如<name>CommonDialog</name>,代码里类名却是SettingDialog。于是这个类的翻译全都不生效。
解决起来也很机械:要么重命名context节点为类名,要么代码里统一改用QCoreApplication::translate("CommonDialog", ...)。但后者会丢失一些自动化工具的支持,不推荐。
4.3 lambda、内部类、模板类让lupdate识别错乱
c++11之后,大量代码在lambda里写tr调用,这个lupdate本身是能处理的,但有几个边缘情况:
- lambda作为某个类的成员变量被捕获,调用tr时上下文指向lambda所在函数的外围类(因为tr是QObject的静态函数,通过类名限定访问,lambda没有自己的类上下文。其实在类成员函数中,lambda默认不捕获this,但可以用
[this]捕获)。
拿[this]捕获后调用tr(),context仍然属于外围类。如果ts文件里没有这个外围类的context,也会出现翻译失效。
模板类更麻烦。比如:
template<typename T> class BaseView : public QWidget { Q_OBJECT public: void showText() { label.setText(tr("Save")); } };lupdate对模板类的支持经常不理想,即便你扫描了源码,它也可能提不出来或提错了上下文。这种场景下,最稳妥的做法是改用QCoreApplication::translate("BaseView", "Save"),强制指定上下文,然后手工在ts里维护这个节点。
4.4 注意动态拼接:tr("%1").arg(...)没问题,但千万不要动态分段
这算是另一个典型的"class翻译不生效"来源。很多人在代码里这样拼接:
label->setText(tr("Today is ") + dayName + tr(", please check"));手动拼一个完整的句子,看着没什么问题,实际上在中文、日文等语言里,整个句子的语序会变,翻译效果极差。但这不是今天的重点。更严重的坑是lupdate根本提取不了动态拼接的字符串:
QString msg = QString("Error code: %1").arg(code); label->setText(tr(msg.toUtf8()));tr(msg.toUtf8())这种写法,lupdate无法识别其中的字面量,也就不会在ts里生成任何词条。就算你手工在ts里加上了条目,运行时也匹配不上,因为Qt的翻译查找是基于源字符串的精确匹配,代码里的源字符串是运行时的动态值。
解决方案很成熟:把整句作为tr的参数,用占位符代替变量:
label->setText(tr("Error code: %1").arg(code));这样lupdate就能提取到完整句子。
4.5 同名字符串,不同context,翻译效果可能互相影响
一个容易被忽略的细节:Qt在查找翻译时,是先在所有已安装的translator中查找匹配的context + source文本。如果两个类都出现了tr("Save"),那么你会看到ts文件里如下结构:
<context> <name>DialogA</name> <message> <source>Save</source> <translation>保存</translation> </message> </context> <context> <name>DialogB</name> <message> <source>Save</source> <translation>保存</translation> </message> </context>正常情况下没问题,但假如你只给DialogA填了翻译,DialogB忘了填,而DialogB又恰好是程序里真正显示的对话框,那表现出来就是"DialogB的翻译不生效"。这种问题在ts文件里非常难肉眼发现,因为关键词一搜,两个context都有,但翻译内容分别是"有和空"。
排查办法是直接对比同一字符串在所有context下是否有译文。
5. 第四段排查:运行环境与疑难杂症,很多情况比想象的更刁钻
前三段链路都排查完仍然没解决的,多半就掉进一些更"脏"的环境问题里了。这类问题不像代码和配置那么好复现,但一旦碰上也是同样的表现——个别class的翻译时灵时不灵。
5.1 源文件编码问题
Qt 5以后,源码文件推荐UTF-8编码。如果一个.cpp文件是GBK或其它编码保存的,而里面又写了中文字符串字面量,lupdate扫描时会对非ASCII字符产生歧义,导致生成的ts文件里出现乱码,或者扫描出来但翻译匹配不上。
比如源码是GBK编码,Qt按UTF-8读取,tr("打开文件")里的中文字符串已经被解析成了乱码。lupdate提取进去的,和运行时Qt拿到的,自然不是一串字节。你会看到这类类的翻译全部失效。
解决办法很简单:把所有源文件统一转成UTF-8,最好在文件头部不写BOM,让Qt用QString::fromUtf8去处理。
5.2 嵌入式/移动平台上,qm文件没随安装包走
在没有文件系统的嵌入式环境,或者移动端打包时,qm文件可能压根就没有被打进资源或者安装包里。表现就是模拟器上一切正常,真机上部分翻译失效。
这种场景下,推荐把qm文件编译进qrc资源系统,确保它一定会随程序走。加载时用:/前缀路径。如果资源体积有要求,也可以选择按语言分开打包,启动时动态下载,但这就已经超出简单排错的范畴了。
5.3 程序运行很久,翻译文件热更新后没重新加载
很多桌面应用支持在线更新语言包。更新qm文件后,如果没有卸载旧translator再安装新的,程序还会继续用旧翻译。由于缓存机制,通常是崩溃过后再启动才能看到新翻译,给用户的感受就是"不生效"。
我见过一个更隐蔽的情况:应用在启动时加载了qm翻译,但之后某个模块用QResource::registerResource动态加载了新的qm路径,这个新资源里的context恰好覆盖了旧资源里的某个context。由于resource注册顺序的原因,Qt查找时可能先找到旧的,于是新翻译不生效。排查这类问题,可以短暂地在event loop中打印每次查找时的翻译结果。
5.4 快捷键、复数形式、零参数匹配的坑
如果某个class中使用了tr("1 item", "number of items", count)这种复数形式,那在ts文件里对应的message会带<numerusform>,翻译时要正确填写单复数形式。如果你只填了单数,没有填复数,那么在某些语言环境下,count不等于1时直接不翻译,表现也是"不生效"。
快速检查方法:看ts文件里对应message节点是否存在<numerusform>,以及译文是否完整。
5.5 QML与C++混编时,C++的context和QML的context混淆
QML项目中常遇到:某个C++类的翻译生效,但同一个类在QML里调用时翻译失效。这是因为QML中如果直接绑定C++对象的属性,文本的翻译查找走的是QML引擎的翻译机制,而不是C++类的context。QML的qsTr()绑定的是QML文件自身的context(通常是QML文件名),和C++类名没有关系。
如果你在C++类里用Q_PROPERTY暴露了一个字符串,QML中直接显示这个字符串,那么无论C++类里tr怎么写,QML都不会自动为这个字符串翻译。正确做法是把字符串翻译放在QML侧,或者让C++类暴露一个翻译函数,在QML文本变化时调用。
6. 实操复盘:一个最小复现项目的完整排查思路
理论讲完了,我最后带着你完整复盘一次排查过程。假设现在有个场景:主窗口的翻译正常,但某个弹出对话框叫ReportDialog,这个类的翻译怎么都不生效。我会按下面的顺序操作。
6.1 第一步:确认ts文件里有这个类的词条
打开zh_CN.ts,搜索ReportDialog。
如果搜不到,就手动跑一遍lupdate,然后用版本管理工具看diff,确认代码文件是否被纳入了扫描。如果文件确实没有纳入扫描,就需要修改.pro或CMakeLists.txt:
SOURCES += \ dialogs/reportdialog.cpp \ dialogs/reportdialog.hCMake中使用qt6_add_translations或者qt6_add_lrelease时,要确认传给它的源文件列表包含了所有相关文件。
6.2 第二步:确认qm文件是最新的
看qm和ts的时间戳。如果ts修改时间明显晚于qm,说明ts改完没重新生成。用lrelease重新编译一次,看要不要加-qm参数指定输出路径。
6.3 第三步:确认加载路径和返回状态
在代码里临时加日志:
bool loaded = translator.load(":/translations/zh_CN.qm"); qDebug() << "translator loaded:" << loaded;如果load返回false,检查资源文件路径、文件是否存在于qrc中。qrc里路径和实际路径不一致是常见问题,尤其当qrc文件放在子目录时,:/前缀后面的路径要仔细对。
6.4 第四步:用QCoreApplication::translate直接测试
在不改源码的情况下,最快验证翻译数据是否完整的方法是直接在代码里临时调用:
QString tr = QCoreApplication::translate("ReportDialog", "Report Title"); qDebug() << tr;如果这里返回的是中文,说明翻译数据没问题,问题出在代码调用上;如果返回的还是英文原文,说明翻译数据压根没有这个匹配项。
这个测试能帮我们把"翻译数据有没有"和"代码调用对不对"这两件事彻底分开。
6.5 第五步:跨上下文检查
当translate测试也搜不到时,在ts文件中搜索源文本中出现的关键词,看它到底被放到了哪个context下。可能它被错误地归到了QObject或者某个基类名下。这时就需要手工调整context节点,或者修改代码。
6.6 第六步:清理构建产物后重新构建
最后还有一个土办法经常奏效:完全清理构建目录后重新构建。原因在于Qt的moc生成文件有时会因为增量编译而滞后,特别是改了头文件、加了Q_OBJECT宏之后,moc文件没有完整重新生成,tr上下文的元数据没更新。
# 以qmake工程为例 make clean rm -rf .qmake.stash Makefile qmake make -j8这一步很多时候能解决"代码改了但行为没变"的诡异现象。
7. 排查经验速查表
| 排查点 | 检查方法 | 常见结果 |
|---|---|---|
| ts文件中是否存在该class的context | 搜索类名和source字符串 | 无词条 / 有词条但source不匹配 / 多个同名context |
| qm文件是否最新 | 对比ts与qm时间戳,重新lrelease | qm过期 / 被旧文件覆盖 |
| translator是否成功加载 | 检查load返回值、资源路径 | load失败 / 加载顺序导致被覆盖 |
| tr调用位置 | 检查类是否继承QObject、是否带Q_OBJECT | 在普通类或lambda中调用导致上下文漂移 |
| 源字符串是否精确对应 | 搜索ts与代码中的source文本 | 空格、大小写、disambiguation不一致 |
| 是否是动态拼接文本 | 查看tr参数是否为变量或拼接表达式 | lupdate无法提取,运行时也匹配不上 |
| 语言切换时是否更新界面 | 检查changeEvent和retranslateUi | 界面不刷新 |
| 源码编码 | 查看源码文件编码,统一为UTF-8 | lupdate提取乱码或提取失败 |
| 复数形式/上下文歧义 | 检查ts中的numerusform和extracomment | 单复数字段缺失导致找不到翻译 |
| 项目增量编译问题 | 清理后重新构建 | moc元数据滞后导致上下文失效 |
8. 我自己常用的几条预防措施
越到后面越会发现,这类问题的根源大多不是Qt的翻译机制复杂,而是工程管理上的失控。我把这几年沉淀下来的预防习惯也分享出来,能帮你省掉大部分排查时间。
第一,代码提交前自动跑lupdate和lrelease。现在我习惯在CI脚本里加一步,每次提交代码后自动重新提取ts,并且检查是否存在未翻译的message。这样ts文件和代码永远保持同步,不会出现"代码改了、ts没更新"的脱节状态。
第二,给每个类保持唯一且明确的context。同类名冲突不可怕,可怕的是两个同名类在不同命名空间下,ts里合并到一处。平时写类的时候,尽量带着模块前缀,或者至少保证项目内类名全局唯一。
第三,写tr时严格遵循"整句、占位符"原则。凡是需要拼接变量的文本,统一用tr("%1")占位。这既是为了翻译质量,也是为了让lupdate稳定提取。
第四,不要手动维护ts文件。除非你对XML格式非常熟,否则手工修改ts经常会导致格式错误或上下文漂移。所有改动都通过Qt Linguist或lupdate来完成,保持机器可解析。
第五,界面上所有动态文本的更新,尽量集中在retranslateUi这类统一入口里。这样切语言时,只要调用一次,所有class的文本都会刷新。我见过不少项目在不同窗口里各自写setText,翻来翻去反而漏了某一个窗口。
第六,设计一个"翻译自检"界面。在开发阶段,我习惯在设置页放一个按钮,点击后遍历某个类里主要文本的翻译结果,显示英文原文、中文译文、以及是否匹配成功。这个工具在排查问题时价值极大,可以瞬间定位到是哪个class的哪个字符串出了问题。
单看"某个class翻译不生效"这个现象,能牵扯出的原因确实不少,但只要按着编译链路、加载链路、调用链路、运行时环境逐段扫一遍,绝大多数问题都能在十分钟内定位。我个人体会最深的一条经验是:遇到翻译问题别急着改代码,先看数据,再看加载,最后才动调用方式——数据不对,改上天也没用。希望这篇文章能把你在Qt国际化上的弯路给抹掉一部分。