去年接了一个桌面端工具的外贸版改造,客户要求界面同时支持中文、英语、日语三种语言,还得做到运行中切换不用重启。这个需求放到Qt/C++侧其实好说,但一到QML层,"翻译不生效""切换完界面一半中文一半英文""复制过来的翻译文件打开就报错"这类问题就接连冒出来,前后折腾了两周才理顺。后来我把这套流程整理成了固定的内部规范,陆续用到两个新项目上,基本没再翻过车。这篇东西就专门聊Qt/QML里多国语言的完整实现,从工具链配置、源字符串提取、翻译发布到运行时的动态切换,把C++和QML两侧都讲透,顺带把文档里不会写的坑全列出来。如果你正在做带界面的Qt应用,或者打算把一个已有项目改造成多语言版本,这篇应该能帮你省下不少时间。
1. 先搞懂Qt国际化的底层分工:C++靠tr,QML靠qsTr
1.1 为什么同一套界面要两套翻译入口
Qt对国际化的支持和大多数框架一样,核心思路是"源码不写死文案,运行时查翻译表"。在C++的QObject体系里,这个入口是tr()函数;QML则使用qsTr()。很多初学者误以为这两种函数是等价的,直到翻了车才发现它们背后的机制差别很大。
C++中tr()是QObject的静态成员函数,以类的翻译上下文(context)为单位。翻译字符串会带着上下文标识一起匹配,比如MainWindow、Dialog这些类名就是天然上下文。QML的qsTr()则不同,翻译上下文默认是QML文件自身的路径和组件上下文。
这个差异会引出一个非常典型的坑:你在C++里用tr("Open")翻译好了,QML里的qsTr("Open")不会自动复用同一个翻译条目。两个"Open"虽然长一样,但在翻译文件中属于两个不同上下文下的独立条目,必须分开翻译。我在项目里见过不少新人卡在这一步,明明C++侧翻译都齐了,界面里还有英文,查了半天才发现是QML的翻译文件没做。
1.2 tr()翻译查找机制的细节
tr()实际调用的是QCoreApplication::translate(context, sourceText, disambiguation, n),翻译查找顺序如下:
- 当前安装的所有
QTranslator按安装顺序依次尝试; - 每个翻译器内部通过
findMessage方法,按照上下文、源文本、消歧义注释、复数数量四个维度匹配; - 所有翻译器都找不到时,返回源字符串本身。
这个机制说明了两个关键点。第一,翻译器加载顺序很重要,后安装的翻译器可以覆盖先安装翻译器里的同一条目,这在做"基础语言包 + 覆盖语言包"时是个可以利用的特性。第二,如果翻译文件匹配规则不对,比如上下文写错,Qt并不会报错,只会静默返回源字符串,表现出来就是"改了半天翻译没生效"。所以排查翻译问题时,第一步永远先确认上下文名称和源字符串完全一致。
1.3 qsTr()的绑定特性与间接绑定陷阱
QML里的qsTr()有一层特殊性:当翻译数据变化时,绑定了qsTr()的属性会被自动重新求值。也就是说,只要你在Text的text属性里直接写qsTr("Hello"),当translator切换后,QML引擎会自动重算这个绑定来更新界面。这一点是QML最省心的地方。
但省心的前提是别引入中间层。最常见的错误写法是这样的:
property string greeting: qsTr("Hello") Text { text: greeting }这里的greeting属性只在组件创建时求值一次,翻译切换后它不会自动更新,界面自然也就纹丝不动。这种"间接绑定导致不刷新"的问题,我在实际项目里至少见过三次。解决办法很简单:让所有文本直接绑定qsTr()调用,不要把翻译结果存到中间变量里。如果确实需要中间层,就得在语言切换信号里手动重新赋值。
另外提醒一点,qsTr()还有第二个参数用于指定复数上下文或消除歧义,比如:
qsTr("file", "A file on disk")和
qsTr("file", "A file in a queue")这两个"file"内容相同但含义不同,通过第二参数区分上下文,翻译时才能给出各自准确的译文。
2. 工具链实操:从工程配置到可用的语言包
2.1 TRANSLATIONS配置与lupdate提取
翻译工作的起点是让工具从源码里自动提取所有需要翻译的字符串。Qt提供了三个命令行工具来完成这件事:
| 工具 | 作用 |
|---|---|
| lupdate | 扫描源代码,将tr()/qsTr()里的字符串提取到.ts文件 |
| Qt Linguist | 图形化编辑工具,用于人工翻译和审校.ts文件 |
| lrelease | 将.ts文件编译为二进制的.qm文件,供运行时加载 |
使用qmake时,工程文件(.pro)里这样配置语言包列表:
TRANSLATIONS = app_zh_CN.ts \ app_ja_JP.ts \ app_en_US.ts使用CMake时,可以借助Qt的翻译辅助模块:
qt_add_translations(myapp TS_FILE_BASE myapp TS_FILES app_zh_CN.ts app_ja_JP.ts app_en_US.ts )配置完成后执行提取命令:
lupdate myapp.pro -ts app_zh_CN.ts app_ja_JP.ts app_en_US.ts这里有个容易被忽略的点:lupdate默认能识别tr()和qsTr(),但如果你在QML里把翻译字符串存进//%注释,或者使用了自定义的属性包装函数,就需要额外配置才能正确提取。例如QML里常见的两种写法:
//% "Backup directory" property string backupDir: qsTr("%n files will be copied", "", fileCount)//%注释可以直接给翻译者提供上下文提示,lupdate会自动把它作为注释提取到ts文件里,这一点对翻译质量帮助很大。
2.2 Qt Linguist里的操作习惯
用Linguist打开.ts文件后,可以逐条查看和编辑翻译。日常使用中有几个细节值得注意:
- 未翻译条目会显示为黄色/灰色状态,发布前可以按"未翻译"过滤逐一处理;
- 占位符高亮:源字符串里的
%1、%2、%n在Linguist里会被高亮,翻译时绝不能漏掉,否则运行时会出现参数缺失; - 完成标记:翻译完成后需要点击"完成"标记,否则
lrelease不会把这条翻译编进.qm文件。
如果在团队协作,.ts文件可以直接提交到版本库,翻译人员用Linguist编辑提交即可。但要统一文件编码为UTF-8,避免Windows环境下默认的本地编码导致中文字符乱码。这个坑我在跨平台项目里踩过两次,团队成员用不同操作系统编辑同一个ts文件,最后合并时出现大量"锟斤拷"这类乱码字符,非常糟心。
2.3 将.qm文件打包进资源
生成.qm文件后,最常见的方式是通过qrc资源文件嵌入到程序里:
QTranslator translator; if (translator.load(":/i18n/app_zh_CN.qm")) { QCoreApplication::installTranslator(&translator); }如果希望语言包外置,方便运营人员直接替换而不重新打包程序,也可以从文件路径加载:
QTranslator translator; if (translator.load(QApplication::applicationDirPath() + "/i18n/app_ja_JP.qm")) { QCoreApplication::installTranslator(&translator); }这里有一个非常重要的C++细节:QTranslator对象的生命周期必须覆盖整个使用周期。如果你在函数里写成局部变量,安装完translator后函数返回,对象立刻析构,轻则翻译完全不生效,重则程序崩溃。正确的做法是把translator作为成员变量、单例持有,或者用std::shared_ptr托管。我在初次接触Qt国际化时在这里栽过一次,后来每次看到"加载了但没反应"的问题,第一反应就是先查translator是否还活着。
3. 动态切换语言:不只是调一下installTranslator
3.1 C++侧:changeEvent与retranslate机制
运行中切换语言,C++侧的经典方案是:
- 移除旧的
QTranslator,安装新的QTranslator; - 向顶层窗口发送
QEvent::LanguageChange事件; - 每个窗口在事件处理器里重新执行
retranslateUi()或手动重设所有文本。
MainWindow里的标准写法如下:
void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { ui->retranslateUi(this); m_statusLabel->setText(tr("Ready")); m_openButton->setText(tr("Open File")); } QMainWindow::changeEvent(event); }很多人以为这个事件会自动传播到所有子组件,其实不然。Qt官方文档建议,所有包含翻译字符串的类都应该显式处理语言切换事件并重新设置文本。如果你用Qt Designer生成的界面,ui->retranslateUi(this)会重新翻译设计器里的所有文本,但代码里手动setText的字符串,必须在事件处理器里手动再设置一遍。这也是"切换后部分翻译没更新"的最常见原因。
3.2 QML侧:绑定失效的三种典型场景
QML层面,qsTr()的绑定理论上会在新翻译器安装后自动重算,但实际项目里"切换不刷新"的问题集中在三种场景:
第一种是前面提到的间接绑定。把qsTr()结果存进普通属性再绑定,绑定的是快照值,永远等不到更新。
第二种是Loader加载的子组件。父组件的语言切换信号并不会自动传递到Loader加载出来的子QML对象,子组件里的文本不会重算。解决方法是监听语言变化后,手动重新设置Loader的source,或者给子组件暴露一个刷新方法。
第三种是C++注册的QML类型。如果你在C++类里对外暴露了属性字符串,这些字符串在构造时已经通过tr()翻译好了,QML侧只是读取。语言切换后需要C++侧主动emit属性变化通知,QML才能重新取值。
3.3 一套能用的完整切换方案
我自己的项目是这样设计的,整体分成三层,既覆盖了大多数场景,又不会把整个组件树全部销毁重建:
第一层,定义一个LanguageManager单例(C++实现),持有当前QTranslator和语言代码。切换语言时,先移除旧translator,再加载新的.qm文件并安装,最后emit一个languageChanged信号。
第二层,C++侧所有窗口在changeEvent里重新执行翻译;QML侧所有文本直接绑定qsTr(),并把当前语言代码暴露为ContextProperty。部分无法自动更新的对象,通过Connections类型监听languageChanged信号做手动刷新。
第三层,为了兜底,我把少量"无论如何都无法自动刷新"的静态属性缓存在一个专门的对象里,语言切换时手动调用reset()方法重新赋值。
这套方案跑下来,三语言切换稳定在几百毫秒内完成,没有重建界面,也没有卡顿感。最重要的是,它把"自动刷新"和"手动刷新"的边界划清楚了,出了问题好排查。
4. 那些容易翻车的细节:占位符、复数、日期与字体
4.1 占位符顺序不能想当然
多语言翻译最经典的翻车点就是占位符顺序。下面这句话在英语里很自然:
tr("%1 has %2 files", "", count)翻译成"«%2» 个文件属于 «%1»"这样的语序(在某些语言里)需要把%2放到%1前面。Qt的翻译机制完全允许这种做法,翻译时把占位符挪到目标语言习惯的位置即可,运行时参数会跟着占位符走。
但这引出一个工程原则:同一个字符串的key(即源字符串本身)要保持稳定,不要在一个版本里写成tr("%1 has %2 files"),下个版本改成tr("%1 has %2 file(s)"),那样旧翻译全部失效,语言包膨胀,翻译人员也会很痛苦。所有界面文案的改动都要经过评审,哪怕只是小小的措辞变化,也会影响所有语言包的维护成本。
4.2 复数形式:%n不只是数字占位符
Qt的复数处理比普通占位符复杂得多。先看正确写法:
tr("%n file(s)", "", fileCount)这个字符串的翻译条目会携带复数元数据。Qt会在运行时根据当前语言的复数规则自动选择"file"的变体。英语规则是n==1时用单数,其余用复数;俄语的规则是一套复杂的多类复形式;日语甚至没有复数区分,任何数量都用同一个形式。Qt的语言环境数据会自动匹配这些规则,翻译者在Linguist里能看到多个复数形式的输入框。
如果只是把%n当普通数字占位符来写,比如tr("Files: %n"),那么在俄语等语言里就不会触发真正的复数形态变化,显示出来会很别扭。QML侧同样支持复数:
Text { text: qsTr("%n item(s)", "", itemCount) }在翻译文件里,这个条目需要翻译成各种复数形式。语言包做得好不好,看复数处理就知道。
4.3 日期、数字与货币也得跟着语言变
字符串翻译只是国际化的一部分。日期时间、数字格式、货币符号这些"非文本内容",照样需要本地化。例如:
- 中文习惯用"2025年4月1日",英语用"April 1, 2025",日语可能是"2025年4月1日"但年号规则不同;
- 数字千分位分隔符,中文和英文都是逗号,但德语里是点;
- 货币符号位置,
$100和100¥的习惯完全不同。
Qt里最顺手的方案是QLocale类:
QLocale locale(QLocale::Chinese, QLocale::China); QString date = locale.toString(QDate::currentDate(), QLocale::LongFormat);QML里可以用Qt.formatDate、Qt.formatDateTime,但这些函数依赖系统区域设置。如果想让界面语言跟随应用的语言包而不是系统设置,最好自己封装一个本地化管理器,在语言切换时统一更新Locale对象和格式化函数。我的经验是:日期时间格式这件事,越早统一封装越好,否则业务代码里散落几十处toString("yyyy-MM-dd"),改起来非常痛苦。
4.4 字体与字符集:翻译出来了但显示是方框
东亚文字(中日韩)和西里尔字母在默认字体下经常显示为方框或乱码。桌面Windows和macOS上,系统字体通常能自动fallback,但Linux和嵌入式平台上,默认字体往往完全不含东亚字形,翻译一切换,满屏方框。
处理方式有这么几种:
- 在界面根节点设置候选字体列表,把目标语言的字体加入候选,比如中文字体、日文字体;
- 在语言切换时动态调整整个界面的
FontFamily; - 嵌入式平台需要把字体文件嵌入到qrc资源里,运行时通过
QFontDatabase::addApplicationFont()注册; - 源文件和
.ts文件统一使用UTF-8编码,避免中文显示成乱码。
项目里最省心的做法是:准备一张字体优先级表,每种语言对应一组候选字体,语言切换时一并切换字体优先级。这样能同时覆盖中文、日文、韩文、西里尔字母等常见场景。
5. 从实际项目中沉淀的避坑清单
5.1 翻译遗漏:用自动化卡住"漏翻"
新加界面文案后经常忘记跑lupdate,导致发布出来的版本里有空白或英文夹杂。这是多语言项目里最常见、也最容易被忽视的问题。
我的做法是在CI脚本里加一步检查:执行lupdate后,扫描生成的.ts文件里是否存在type="unfinished"的条目。如果存在就中断构建并提示缺少翻译。没有CI条件的项目,至少养成每次提交新文案后手动跑一遍lupdate的习惯。还有个技巧:利用.ts文件里的obsolete标记追踪源码中被删除或改写的字符串,用lupdate -no-obsolete清理过期条目,避免语言包越来越臃肿。
5.2 布局被长文本撑爆
界面文案翻译之后,控件因为内容长度变化导致布局错乱的问题很常见。英文文本普遍比中文短,但德语某些复合词非常长,俄语也不短。处理策略我总结为四条:
Text元素设置elide属性,文本过长时省略号展示;- 按钮和标签设置合理的
minimumWidth和maximumWidth范围; - 在布局里组合使用
Layout.fillWidth、Layout.preferredWidth,让长文案自动换行而不是挤爆窗口; - 在
.ts文件的注释里标注界面空间限制,提醒翻译者使用简短译法。
第四条看似和代码无关,但实际很有效。翻译者如果知道某个按钮只能放下大约8个字符,就不会给出一个长长的译文。
5.3 团队协作时翻译文件冲突的化解
多人维护同一个.ts文件时,很容易出现上下文冲突或者合并冲突。例如同一个窗体类被复制改名后,新类名生成的上下文和旧类名不一样,翻译文件里就多出一套几乎相同的条目。这时要排查哪些上下文还在被使用,哪些已经过期,及时清理。
还有一个协作细节:.ts和.qm文件的生成顺序必须保持一致。不要出现代码里更新了字符串,翻译文件还是旧版,结果发布时lrelease把旧的翻译编进程序里。我建议把"lupdate -> 翻译 -> lrelease -> 打包"做成一条固定的发布流水线,每步都自动化,人只负责审核翻译内容本身。
5.4 一个绕不开的建议:从第一天就用tr和qsTr
如果你是在新项目里,从一开始就要把所有用户可见字符串用tr()/qsTr()包起来,不要等翻译需求来了再改。临时改造老项目时,最耗费时间的不是翻译本身,而是到处找硬编码字符串。我在处理旧代码时见过各种写法:拼接出来的字符串、拆成两半的"Hello " + name、藏在配置文件里的界面文本……这些都要先重构,才能真正用上Qt的翻译体系,成本比一开始就规范写高出一大截。
还有,尽量为每个字符串提供清晰的上下文注释,这对翻译质量影响非常大。一段没有上下文的"Open",和一段注释了"打开文件对话框的按钮"的"Open",翻译者给出的译文质量完全不一样。Qt支持tr("...", "context hint")的第二参数,QML支持//%注释,这些设计就是给翻译者用的,别浪费。
最后再分享一个我在两个项目里都用过的习惯:语言切换之后,不要只检查界面文本,要去实际点一遍主要操作路径。有些错误提示、右键菜单、会话框标题是延迟创建的,语言切换信号根本没触发到它们。把"切换语言后完整跑一遍核心流程"写进测试用例,能拦下八成以上的漏网之鱼。Qt的国际化学起来不算难,但真正做到干净利落,靠的还是这些细节上的较真。