ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt国际化翻译完整实践:从tr()到动态语言切换

Qt国际化翻译完整实践:从tr()到动态语言切换

做Qt开发这么多年,遇到最多的问题不是功能写不出来,而是功能做完以后客户说看不懂。于是QT国际化翻译就成了绕不开的环节。表面上看,这事只是把界面文字换成英文、日文,真正做起来却涉及源码扫描、翻译文件管理、运行时加载、动态切换等一系列问题。这篇文章会把一套完整、可复用的方案拆开讲清楚,从工具链的选择到工程配置,再到实际踩过的坑,适合刚接触Qt的初级开发者,也适合那些已经能用Qt写业务代码、但一直没系统做过国际化的朋友。

1. 国际化到底在做什么:先搞懂Qt的翻译机制

1.1 一切从tr()开始:字符串怎么变成可翻译资源

很多人在代码里写界面文案时,习惯直接用字符串常量,类似这样的写法:

ui->label->setText("保存");

这段代码在单语言项目里没有任何问题,但一旦要支持中英文切换,就会变成灾难。你不可能把这几十上百个“保存”、“打开”、“确定”都塞进一个字典表里手动维护,也不可能在所有用到这些文案的地方写一堆if判断。Qt给出的标准答案是:所有需要翻译的字符串,都用tr()包一层。

ui->label->setText(tr("保存"));

这看起来只是换个写法,背后发生的事情却很有意思。tr()并不是普通的成员函数,它是QObject提供的静态方法,在带有Q_OBJECT宏的类里会被特殊处理。MOC(元对象编译器)会在编译前扫描源文件,把tr()里那个字符串源文本连同上下文信息提取出来,交给lupdate工具生成.ts文件。翻译人员拿到这个文件,把每种语言的译文填进去,再用lrelease编译成.qm二进制文件。程序运行时,QTranslator对象根据当前QLocale去加载对应的.qm文件,再配合事件过滤器把界面上显示的文本替换成译文。

所以整个机制的核心链条是:源码里的tr() → lupdate提取 → ts文件翻译 → lrelease编译 → qm文件加载。这也是Qt国际化和一般用字符串替换方案最大的区别:普通方案是“运行期靠代码查表”,Qt方案是“编译前期自动扫描、运行期靠翻译文件查表”。后者不需要你手动登记每一个字符串,也不需要修改业务逻辑,只要统一用tr()包一下,剩下的交给工具链。

1.2 为什么用Linguist工具链而不是手写翻译表

之前有人问我,既然tr()最终也是要把源文和译文对应起来,那我干脆写一个QMap<QString, QString>,程序启动时加载一份JSON或者XML,不也能实现翻译吗?理论上真可以,但实际操作起来会发现几个很难受的问题。

第一是上下文管理。同一个词“Open”在“文件菜单”里翻译成“打开”,在“端口配置”里可能要根据语境翻译成“开启”。如果用手写字典表,你只能拿字符串本身当key,碰到一摸一样的源文就撞车了。Qt的ts文件会记录每个字符串所属的类名或UI对象名,不同上下文里同一个源文可以对应不同译文,这个问题天然就解决了。

第二是翻译进度的可视化。项目做到后期,几百个字符串谁翻译过、谁还没翻译,光靠脑子记不现实。Qt Linguist界面里能按上下文和翻译状态筛选,可以清楚看到未翻译、已翻译、需要确认的条目总数,还能导出统计报告。这个能力在跟翻译协作时特别有用,外包团队可以直接对着ts文件做,交付时检查一下进度就能验收。

第三是复用语料。同一个Qt项目里如果有很多重复的源文,Linguist会自动标记为“已翻译并复用”,不需要重复劳动。如果项目积累了大量ts文件,还可以用Qt的翻译记忆库功能做批量预翻译,甚至接第三方翻译服务。这套东西手写JSON是完全没有的。

2. 环境准备与工程配置:给国际化打好地基

2.1 选择Qt版本和模块时的实际考虑

做国际化这件事本身不挑Qt版本,从Qt 5.15到Qt 6.x都支持同一套Linguist工具链。真正需要花心思的是安装Qt时选了哪些模块。很多人安装Qt时图省事全选,结果磁盘占用好几个G,编译一个空窗口程序都要等半天。做界面国际化,至少要保证有这几个东西:Qt Core、Qt GUI、Qt Widgets(如果用的QWidget),然后是Qt Linguist工具、Qt Creator自带的翻译编辑器,以及对应的编译套件。

这里有个容易被忽略的点:如果你打算让程序在运行时动态切换语言,需要用到QEvent的事件过滤和QWidget的changeEvent重写,这些基础模块都包含在上面那几项里。如果是用QML写的界面,则要额外注意qsTrId()和QQmlEngine::retranslate()这套机制,它和QWidget的tr()实现路径不一样,配置上也要多确认Qt Declarative模块有没有装上。

安装时容易踩的坑是模块缺位。比如你后面想用WebEngine组件做嵌入式网页,结果编译时直接报错unknown module(s) in qt: webenginewidgets,这就是安装时没勾选Qt WebEngine模块。国际化项目里遇到这种报错,第一反应其实不是去补模块,而是先想想这个模块是不是必须的。如果只是为了某个功能引入了一大堆依赖,发布包体积和跨平台难度都会增加。我的习惯是:界面刷新类应用只用Widgets和Network,绝不轻易引入重量级Web组件。

2.2 qmake和CMake工程里的翻译配置

不同的工程管理方式,国际化的配置写法差别很大。先说qmake,在.pro文件里通常这样配置:

TRANSLATIONS = \ app_zh_CN.ts \ app_en_US.ts

这段配置看上去只是声明了两个文件,但lupdate工具在扫描时就是靠TRANSLATIONS变量知道该生成哪些ts文件、该往哪里放。如果你用lupdate命令手动指定文件,反而不如直接在.pro里写清楚。这里要注意命名规范,ts文件一般是应用名_语言_地区.ts,比如app_zh_CN.ts、app_en_US.ts。这样做的原因是QTranslator::load()可以自动从文件名里解析语言和地区,加载时可以直接传整个文件路径而不必手动拼locale。

如果用的是CMake,写法稍有不同:

set(TS_FILES app_zh_CN.ts app_en_US.ts ) qt5_add_translation(QM_FILES ${TS_FILES})

qt5_add_translation这个宏是Qt提供的,它会自动调用lrelease把ts编译成qm,并生成对应的构建规则。对于用Qt Creator创建的新项目,通常在“其他项目”里选择“Qt组件”时勾选“翻译支持”,会自动生成translation模板。

配置时最容易被忽略的是lupdate_only指令。如果你在.pro里写了类似这样的代码:

lupdate_only { SOURCES += \ main.cpp \ mainwindow.cpp }

意思是“只有执行lupdate时才把这些文件加入扫描列表”。这样可以避免把一些不需要翻译的生成代码或第三方库源码也扫进来,从而减少ts文件里出现无关字符串的概率。我建议所有项目都加上这个限定,不然第三方库里的英文提示也会被捞进翻译文件,后期清理特别烦。

2.3 文件目录与命名规范

翻译文件放在哪,直接决定发布阶段的加载策略。常见的布局有两种。第一种是放在源码目录下的i18n或translations子目录里,和代码一起走版本控制。第二种是把.qm文件通过Qt资源系统(qrc)嵌入到可执行程序内部。这两种方式各有利弊。

源码目录方式的好处是方便维护和更新:翻译人员可以直接从仓库里拉取最新的ts文件,翻译完成后提交回来,你只要跑一次lrelease就能重新生成qm。坏处是发布时需要额外拷贝qm文件,而且如果用户把程序装在带写权限限制的目录里(比如Windows的Program Files),运行时就不能临时生成或修改qm文件,只能依赖发布时预置的内容。

qrc嵌入方式的好处是只有一个exe,不用管外部文件路径,翻译文件不会丢。坏处是改一句翻译也要重新编译整个程序,而且程序体积会变大,对于界面文案经常改的项目不太友好。

我个人的常用方案是:ts文件放源码目录,qm文件统一复制到构建目录下的translations/文件夹,发布时把整个文件夹跟着exe一起带走。这样既方便翻译协作,又不污染源码树。如果目标平台对文件管理很敏感,再考虑把qm嵌入qrc也不迟。

3. 核心实操:从lupdate到lrelease的完整翻译链路

3.1 lupdate扫描:哪些“能翻的”会被捞出来

在源码已经全面铺上tr()之后,真正开始做翻译的第一步是执行lupdate。命令行方式很直接:

lupdate project.pro -ts app_zh_CN.ts app_en_US.ts

如果工程是CMake构建的,也可以在构建目录里执行:

lupdate . -ts app_zh_CN.ts app_en_US.ts

执行完以后,你会发现原本空白的ts文件里多了一堆<message>节点,每个节点都包含source(源文)和translation(译文,此时为空)两个字段。这里我要强调一下,lupdate并不是把所有字符串都捞走,它只识别特定上下文里的字符串:包括所有带Q_OBJECT的类里的tr()调用、QObject::tr()调用、QCoreApplication::translate()调用,以及UI文件里那些通过setText设置的属性值。普通函数的局部字符串如果不挂在任何tr()下,lupdate是扫不到的。

实际扫描中会遇到一个比较隐蔽问题:tr()的参数必须是静态字符串或字符串字面量,不能是变量拼接。比如这样写:

QString text = prefix + "保存"; ui->button->setText(tr(text));

lupdate会直接跳过,因为工具无法在编译期知道text变量的内容。正确做法是:

ui->button->setText(tr("%1保存").arg(prefix));

这里的%1是占位符,翻译人员在Linguist里能看到完整的“%1保存”,翻译成英文时可以调整成“Save %1”,不必拘泥于中文语序。这也是Qt国际化里一个非常重要的习惯:翻译单元要尽量完整,不要在代码里拼词。

扫完以后生成的ts文件是XML格式,可以手工编辑,但我强烈不建议直接用文本编辑器改。原因很简单:XML标签里的转义、上下文和复数标记很容易弄错,Linguist又提供了快速的预翻译和检查功能,手工改效率极低。

3.2 用Qt Linguist批量翻译:把两小时的活压到半小时

Qt Linguist是Qt自带的图形化翻译工具,启动后直接打开一个ts文件就能开始工作。界面大体分四块:左侧是上下文和条目列表,中间是源文和译文输入区,下方是“模糊”、“未翻译”之类的状态标记,右侧是短语和建议。

翻译的流程并不复杂,但有几个细节能明显提升效率。第一个是善用“已完成翻译”的快捷键,Linguist里按Ctrl+Return可以快速标记当前条目并跳到下一条。第二个是开启“翻译记忆”功能,当同一源文在多个上下文中出现时,工具会自动提示“已存在的翻译”,一键复用。第三个是使用“批处理”模式里的“运行批量翻译”,如果项目里关联过机器翻译服务,可以对所有未翻译条目做一次预翻译,之后再人工校对。

我在这里特别提醒一个容易翻车的地方:不要在Linguist里直接改译文格式。如果源文里有%1、%2这种占位符,翻译时一定要保留,只是位置可以调整。如果源文里有换行符\n,译文里也要对应保留,不然界面上会出现一整块挤在一起的文字。

翻译完成后,Linguist用Ctrl+S保存ts文件,这个文件会记录翻译状态。你在Linguist底部的统计栏里能看到总共多少个词条、已翻译多少个、未翻译多少个。这里有个判断标准:未翻译条目会生成空译文,lrelease编译时不会报错,运行时显示的就是源文。所以如果交付前看到“未翻译”数字不为零,别急着发布,先补充完再说。

3.3 lrelease编译:ts到qm的转换细节

ts文件本质上是一个人类可读的XML翻译清单,运行时如果直接加载ts文件也可以,但效率很差。正式项目里一定先用lrelease把它编译成二进制.qm文件。命令很简单:

lrelease app_zh_CN.ts -qm app_zh_CN.qm

它会把ts文件里所有已翻译条目压缩成一个二进制格式,加载速度比XML快得多。这里面有一个细节:lrelease默认只把“已翻译”状态的条目编译进qm文件。“模糊”状态的条目可以通过-markuntranslated选项强制打包进去,但极不推荐,因为客户端显示一个模糊译文还带着一个小问号图标,用户体验会很奇怪。

编译完成后,建议立刻检查qm文件内容。可以用lrelease -verbose查看每个文件输出的条目数,或者用QTranslator::load()加载后打印isEmpty()判断是否加载成功。早发现问题比发布后在用户电脑上发现好一万倍。

4. 运行时加载与语言切换的工程实现

4.1 QTranslator加载路径与优先级

到了这一步,你已经有了一个或多个qm文件,剩下的问题是怎么让程序在启动时加载正确的语言。Qt提供的类是QTranslator,使用方式如下:

QTranslator translator; if (translator.load("app_zh_CN.qm", ":/translations")) { QCoreApplication::installTranslator(&translator); }

如果qm是外部文件,第二个参数可以传入路径;如果已经嵌入了qrc,第二个参数就要写成:/translations这种资源路径。这里要注意对象生命周期:QTranslator对象在installTranslator之后必须一直保持存活,不能放在if代码块里声明,否则函数结束就销毁了,翻译也就失效了。常见的做法是把translator对象声明成MainWindow的成员变量,或者堆上new一个,不管哪种方式,保证它活得比主窗口久就行。

语言的选择逻辑一般是:启动时先读配置文件,如果没有配置,就用系统默认的QLocale::system()去匹配。匹配规则是先用完整名称(如app_zh_CN.qm),找不到就尝试只用语言代码(如app_zh.qm),再找不到就尝试英文或源文。我习惯写一个辅助函数做这件事,把可能的路径都试一遍:

bool loadLanguage(QTranslator *translator, const QString &lang) { QString path = QString("translations/app_%1.qm").arg(lang); if (translator->load(path)) { QCoreApplication::installTranslator(translator); return true; } return false; }

这个函数简化了很多,但核心思路是对的:尽量让加载逻辑有回退,不要因为一个qm文件缺失就让整个程序不能启动。

4.2 动态切换语言:界面刷新与信号处理

启动时加载语言只是第一步,很多产品经理还会要求“不重启程序就能切换语言”。这个需求实现起来比启动加载要复杂一点,主要难点在于界面上所有控件文本都要重新设置。

Qt官方推荐的做法是重写QWidget::changeEvent,监听QEvent::LanguageChange事件:

void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { retranslateUi(); } QMainWindow::changeEvent(event); } void MainWindow::retranslateUi() { ui->saveButton->setText(tr("保存")); ui->openButton->setText(tr("打开")); statusBar()->showMessage(tr("就绪")); }

这里的retranslateUi就是最初用Qt Designer生成界面时那个函数,它把界面所有可翻译文本重新设置了一遍。触发这套逻辑的方法是:

void changeLanguage(const QString &lang) { QTranslator *translator = new QTranslator(qApp); loadLanguage(translator, lang); QEvent event(QEvent::LanguageChange); QCoreApplication::sendEvent(qApp, &event); }

sendEvent发出去的LanguageChange事件会层层传递到所有顶层窗口,每个窗口在changeEvent里重新设置界面文字。这个机制有一个坑:如果你在changeEvent里只是重新retranslateUi,那些用代码动态创建、但没在这个函数里设置的控件,就不会被翻译。比如你在某个逻辑分支里临时new了一个QLabel并setText("加载中"),翻译切换时就漏掉了。我的建议是:所有动态控件初始化的文本也尽量调用retranslateUi统一管理,不要散落在各个业务逻辑里。

4.3 翻译之外的区域格式化问题

国际化不等于翻译,这一点经常被忽略。同一个日期,中国习惯“2025年3月15日”,美国习惯“03/15/2025”,法国习惯“15/03/2025”。同一个数字,有的地区用小数点,有的地区用逗号。如果程序里手动拼接日期或数字字符串,翻译做得再好,用户的体验依然很糟糕。所以从做国际化的第一天起,就应避免用这种写法:

QString dateStr = QString("%1-%2-%3").arg(year).arg(month).arg(day);

而应该用QLocale:

QLocale locale = QLocale::system(); QString dateStr = locale.toString(QDate(2025, 3, 15));

同样,货币、百分比、排序规则都要交给QLocale处理。这套机制和qm文件是独立的,语言切换时也记得同步更新QLocale设置,否则会出现界面文字变了,日期格式还是旧格式的怪异状态。

5. 发布、部署与调试中的高频坑

5.1 编译环境问题:版本混用与缺失模块

做Qt项目的人多多少少都见过这类报错:cannot mix incompatible Qt library (version 0x50601) with this library。这句话翻译成白话就是:你编译时链接的Qt动态库和你Qt安装目录里的头文件版本对不上。我遇到过最离谱的一种情况是:电脑上装了多个Qt版本,又设置了混乱的PATH环境变量,结果编译时用了Qt 5.15的头文件,运行时却加载了Qt 5.12的动态库。

解决这种问题,第一步是检查qmake或cmake指向的Qt路径,确保和运行时LD_LIBRARY_PATH(Linux)或PATH(Windows)里能找到的Qt一致。第二步是彻底清理构建目录重新编译,别只做增量编译,版本混用产生的缓存很可能残留旧库的引用。第三步是把环境中不需要的Qt路径撤掉,只保留当前项目用的那一个。

另外记得,Qt 6已经把widgets模块拆得比较细,如果你是老项目从5迁移到6,还按原来的模块依赖去编译,会碰到很多unknown module报错。这不是国际化本身的坑,但确实会挡住后续的翻译工作流,遇上了也习惯先从模块配置入手排查。

5.2 运行时报“找不到平台插件”怎么办

运行Qt程序时,另一个特别常见的报错是:

qt.qpa.plugin: could not find the Qt platform plugin "linuxfb" in ""

这个报错往往不是翻译代码引起的,而是发布环境缺少平台插件。Qt通过插件体系加载图形平台,常见的有windows、xcb、linuxfb、eglfs等。如果你在打包时只拷贝了exe和qm文件,没把platforms目录带过去,程序一启动就崩,翻译根本来不及生效。

解决方法是:Windows下用windeployqt工具扫描依赖并自动拷贝插件目录。Linux下手动把plugins/platforms目录拷贝到安装目录,或者通过环境变量QT_QPA_PLATFORM_PLUGIN_PATH指定插件路径。交叉编译或嵌入式场景更要提前确认编译时是否生成了对应的linuxfb插件,否则就算指定路径也找不到。

发布前一个很有效的自检动作是:把translations、platforms、styles等关键目录列出来,对照源Qt安装目录里的插件结构,逐项核对。少了任何一个插件目录,轻则界面异常,重则程序直接闪退。

5.3 翻译不生效的排查清单

如果程序能跑,界面也正常,但切换语言后毫无反应,这是国际化项目里最常见的“疑难杂症”。我总结了一个排查顺序,照着做基本能定位:

  • 确认.qm文件是lrelease生成的,不是改了扩展名的ts文件(别笑,真有人这么干)。
  • 确认QTranslator::load()返回值为true,并检查实际加载的路径,看是不是加载了空文件。很多人会把translator.load("app_zh_CN.qm")写在成员变量初始化里,此时资源系统还没初始化,加载必然失败。
  • 确认installTranslator只调用了正确的translator,而不是同一个对象被重复安装多次。连续installTranslator同一个对象,第二次操作其实是替换,可能导致目标语言翻译没生效。
  • 确认信号已经触发retranslateUi。动态切换时如果忘了发LanguageChange事件,界面文本就不会刷新。
  • 确认tr()的调用者是在QObject子类里。如果你在普通函数(不是成员函数)里直接写tr("保存"),编译可能能过(某些编译器会误解析)但lupdate扫不到。

我遇到最多的情况是translate文件加载的路径不对。开发时用的是相对路径,发布后exe所在目录变了,qm响应路径找不到,程序抢不到翻译文件,只好显示源文。这种事只有把出问题的用户环境日志拉回来,才能发现原来是没文件。

5.4 经验补充:把翻译交给第三方时的交接习惯

到了项目后期,国际化的翻译工作可能交给专业翻译团队,或者用机器翻译API批量处理。这时候开发者的主要戏份就不是写翻译了,而是保证交付的ts文件“干净”。

什么叫干净?第一,不要把一些日志信息、调试提示加进可翻译字符串,更不要让翻译看到“Error code 0x80070005”之类的内容。这些通常不适合翻译,应该用代码处理而不是靠翻译。第二,给翻译团队提供一份“术语对照表”,把产品里的核心名词列出来,比如“设备ID”“通道号”“固件版本”,避免整个团队翻译时前后不一致。第三,聊好占位符规则,让他们知道%1、%2必须保留,而且别理会在界面上出现的位置,位置顺序可以调整。

技术层面的导出也很简单:直接用ts文件交付,翻译完成后回收ts。这个过程中不需要翻译人员接触代码,也不需要他们装任何编译器,只要装一个免费的Qt Linguist或者任何一个能编辑XML的工具即可。

我在实际项目中还养成了一个习惯:每次发布前跑一遍lrelease -verbose,把输出的“未翻译条目”数记录下来,形成一份简单的翻译覆盖率报告。这样产品经理、翻译负责人、测试人员都能清楚知道当前版本到底还有哪些语言没做完,而不是等到用户打开程序看到中英混杂的界面才匆忙发补丁。

做QT国际化翻译这件事,技术难度其实不高,真正考验人的是流程和习惯。tr()包得干不干净、ts文件管理得规不规范、加载路径设计得稳不稳定,这些细节决定了后期维护的工作量。如果你现在正准备给老项目加国际化,不要急着写代码,先把所有界面文字用一个函数汇总起来,用一套统一的命名规则管理翻译文件,再动手改源码,后面会顺利很多。每个项目第一次做国际化都会乱一阵,但只要把第一版跑通,后续加语言版本基本就是照着这个流水线再走一遍。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进