做 Qt / QML 界面开发久了,几乎每个人都会遇到同一个“看起来很小、做起来烦”的需求:客户或者产品经理轻飘飘丢来一句——界面要支持多国语言,至少中英文切换。如果项目从一开始就把文字写死在代码里,这一句话往往意味着整个 UI 层要翻一遍;更尴尬的是,很多人认真做完之后发现翻译文件根本不起作用,或者切换语言后界面纹丝不动。这篇文章想和你认真聊的,就是 Qt / QML 里支持多国语言的标准做法,从字符串埋点、语言文件生成,到加载和运行时切换,最后把常见坑都过一遍。内容覆盖 C++ 侧与 QML 侧两套 API,适合刚接触国际化的新手建立整体认知,也适合已经踩过坑、想梳理规范流程的开发者参考。
1. 整体设计思路:为什么“翻译文件”比“运行时语言表”更靠谱
1.1 “代码即源文本”的提取逻辑
先理解 Qt 国际化的底层思路。它不做运行时查词典的事,而是把源代码里的字符串当作“源文本”,通过工具提前提取出来,由翻译人员对照翻译,生成语言文件,程序加载对应语言文件后完成显示替换。这个思路有点像电影的字幕文件:画面是代码,字幕是翻译,不同的字幕文件对应不同的语言版本,而不用为每个语言重新拍一遍电影。
这意味着你写的业务逻辑、布局结构、交互行为完全不动,只替换“对外展示的文字层”。这也是为什么 Qt 项目在后续追加语言时,通常不伤筋动骨的根本原因。只要遵循“用户可见字符串一律走翻译接口”的约定,新增一种语言的工作量就是翻译一份语言文件,而不是改一遍代码。
这个设计比在代码里维护一套Map<语言, Map<key, 文案>>的方式优雅得多。后者看似直接,实际会很狼狈:界面上的字符串可能重复出现,可能带参数拼接,可能因为上下文不同而需要不同翻译,还可能被写死在布局文件里。手动维护键值表,时间一长就会出现大量遗漏、重复、错位的文案。而 Qt 的翻译系统通过扫描工具自动收集源文本,又通过上下文区分同名文案,整个流程是可控、可审计的。
1.2 .ts 与 .qm:人类可读与机器可读的分工
在 Qt / QML 的多国语言体系里,有两类文件经常把人搞混。.ts文件是面向翻译人员的 XML 格式,人类可以直接阅读和编辑,也可以交给翻译工具处理。.qm文件则是.ts经过编译后生成的二进制文件,体积更小、加载更快,最终打包进程序或部署到文件系统的是.qm文件。
这个分工很有价值。.ts文件要进版本库、要参与团队协作、要做翻译评审,甚至要统计翻译进度,因此它必须可读、可 diff。.qm文件只是运行时产物,重新生成就行,通常不建议手工修改。很多新手直接改.qm,或者把翻译结果手工填进 XML 却忘了重新编译,这些操作都会导致预期之外的结果,后面我会具体展开。
2. 埋在代码里的细节:QML 与 C++ 的字符串提取机制
2.1 qsTr / qsTranslate / QT_TR_NOOP 到底怎么选
拿到一个 Qt / QML 程序,首先判断字符串出现在哪里。C++ 侧用tr(),QML 侧用qsTr()。这两个接口是使用频率最高的。特殊场景下,qsTranslate(context, text)可以指定自定义上下文,但日常开发中不建议滥用,它会破坏上下文管理的统一性。
有一个比较容易忽略的接口是QT_TR_NOOP。它用来“标记但不翻译”,常见场景是:你想把字符串先暂存到一个属性里,稍后在别处翻译。比如在 QML 中定义一个属性列表,让每个条目保持源语言,等到真正显示时再调用qsTr()。如果不用QT_TR_NOOP标记,扫描工具不会提取这些字符串;如果直接翻译并存入属性,又会丢失语言切换后重新计算的机会。
Item { property var labels: [ QT_TR_NOOP("Overview"), QT_TR_NOOP("Settings"), QT_TR_NOOP("Help") ] Text { text: qsTr(labels[1]) } }用QT_TR_NOOP标记之后,lupdate能识别这些字符串,运行时再通过qsTr()真正取翻译结果。这里有个关键认知:QT_TR_NOOP不是翻译动作,而是“提取标记”,翻译仍要交给qsTr()完成。
2.2 上下文与占位符:同一个单词为什么能翻出不同味道
翻译系统里有个很容易被忽略的概念:上下文(Context)。在 C++ 中,上下文通常是tr()所在类的类名;在 QML 中通常是当前 QML 文件的信息。为什么要区分上下文?因为同一个英文单词在不同界面位置可能含义不同。比如 “Run” 在菜单里是动词“运行”,在日志面板里可能是名词“一次运行”。如果都放在同一个词条里,翻译人员很难决定到底翻成哪个意思,强行给一个翻译,总有一处看起来别扭。
上下文机制让每个字符串都有自己的“归属地”,翻译人员可以针对不同上下文分别处理。在 QML 文件里,如果整个文件只有一个上下文,不同位置的同一个单词仍然可能冲突,这时候可以把字符串写得更具语境,比如qsTr("Run")和qsTr("Run count"),分别翻译成“运行”和“运行次数”。
另一个容易出错的是占位符。%1、%2这类占位符表示运行时插入的参数。它们出现在源文本中,翻译后也必须保留,占位符本身不能被翻译,但它在句中的位置可以根据目标语言习惯调整。比如中文说“共 %1 条记录”,日语句法可能把数量放在句末,翻译人员可以写成“記録は全部で %1 件です”。程序运行时,%1会被真实数据替换,从而得到完整句子。
Text { text: qsTr("%1 items").arg(itemCount) }注意 QML 里用的是.arg()链式调用,多参数时依次调用arg()。C++ 侧用QString::arg()同理。翻译时保留%1,不要改写成其他形式,也不要复制到别的位置,除非你明确了解目标语言的语序。
3. 完整实操:从工程配置到双语程序的每一步
3.1 工程文件里的准备
开始写代码前,先把工程文件配置好。在.pro文件里增加TRANSLATIONS变量,指定.ts文件的路径和命名。命名建议带上语言和地区代码,比如app_zh_CN.ts、app_en.ts,这样后续按区域加载更清晰。
TRANSLATIONS += \ i18n/app_zh_CN.ts \ i18n/app_en.ts接下来是最容易被 QML 项目坑到的地方。lupdate工具在扫描.pro工程时,默认会根据SOURCES里的文件类型决定如何处理。纯 QML 项目没有.cpp,或者 QML 文件没有列入SOURCES,很容易出现扫描不到 QML 里的字符串的情况。标准做法是在.pro中加入lupdate_only块:
lupdate_only { SOURCES += \ qml/Main.qml \ qml/Pages/SettingsPage.qml }lupdate_only的作用是只让lupdate用这些文件做翻译扫描,同时告诉工具不要把这些qml文件当作 C++ 源码编译。C++ 项目里如果同时存在.cpp和.qml,也要加上这个块并显式列出 QML 文件,别指望工具自动识别。
3.2 lupdate、linguist、lrelease 三步走
配置完成后,整个翻译流程其实是三个命令行工具的组合,熟悉之后很快。
第一步,用lupdate扫描源码中的可翻译字符串,生成或更新.ts文件:
lupdate project.pro这一步会解析 C++ 的tr()以及 QML 的qsTr()等调用,把源文本收集起来,写入.ts文件。如果.ts文件已存在,lupdate会合并新发现字符串,同时保留已有翻译。跑完之后,用文本编辑器打开.ts,你会看到类似这样的结构:
<context> <name>Main.qml</name> <message> <source>Settings</source> <translation>设置</translation> </message> </context>第二步,打开 Qt 自带的linguist工具,手动或辅助完成翻译。操作上,可以逐条审阅并填入译文,也可以借助批量翻译或者第三方翻译服务。linguist的界面并不复杂,但有一个细节值得注意:带占位符的字符串,编辑框里能看到%1的标记,不要误删;带有复数形式的字符串,翻译时需要填写单数、复数多个变体。
linguist project.pro第三步,用lrelease把.ts编译成.qm文件:
lrelease project.pro这一步会生成与.ts同目录下的.qm文件。.qm就是程序运行时真正加载的文件。有的团队喜欢把这一步放进 CI 或者构建流程里,做法是发布脚本中在构建前先执行lrelease,确保打包进去的是最新翻译。
3.3 程序加载语言文件的两种时机
生成.qm之后,就要让程序在合适的时候加载它。最简单的是启动时根据系统语言自动加载。核心流程是构造QTranslator,调用load(),再installTranslator()。注意QApplication初始化之后立刻加载,越早越好,保证所有界面创建时就已经处于目标语言环境。
int main(int argc, char *argv[]) { QApplication app(argc, argv); QTranslator translator; const QString localeName = QLocale::system().name(); const QString qmPath = QStringLiteral(":/i18n/app_%1.qm").arg(localeName); if (translator.load(qmPath)) { app.installTranslator(&translator); } QQmlApplicationEngine engine; engine.load(QUrl(QStringLiteral("qrc:/qml/Main.qml"))); return app.exec(); }这里有个容易踩的坑:translator如果是一个局部变量,且installTranslator()之后作用域结束,QTranslator被析构,翻译就不会生效,甚至可能崩溃。正确做法是让translator的生命周期覆盖整个程序,可以放在 main 函数栈上并保证存活到app.exec()结束,或者作为成员变量、静态对象保存。
接着处理运行时切换语言。用户往往希望点击一个按钮就立刻切换界面语言,不需要重启程序。实现上,要移除旧的QTranslator,加载新的,重新安装,然后通知界面重新计算所有文本。C++ 侧可以封装这样的逻辑:
class LanguageController : public QObject { Q_OBJECT public: explicit LanguageController(QObject *parent = nullptr) : QObject(parent) { m_translator = new QTranslator(this); } Q_INVOKABLE void applyLanguage(const QString &localeName) { if (m_currentLocale == localeName) { return; } QCoreApplication::removeTranslator(m_translator); const QString qmPath = QStringLiteral(":/i18n/app_%1.qm").arg(localeName); if (m_translator->load(qmPath)) { QCoreApplication::installTranslator(m_translator); m_currentLocale = localeName; emit languageChanged(); } } signals: void languageChanged(); private: QString m_currentLocale; QTranslator *m_translator = nullptr; };QML 侧调用这个接口,并在languageChanged信号触发后,让 UI 重新求值。QML 文件里的qsTr()文本,在加载新翻译器之后并不会自动全部刷新。如果界面是简单的属性绑定,需要调用QQmlEngine::retranslate()来触发所有绑定重新计算。
connect(languageController, &LanguageController::languageChanged, &engine, &QQmlEngine::retranslate);如果你用的是Loader动态加载的组件,情况会更隐蔽。Loader内部创建的项可能不会因为retranslate()重新求值,或者刷新不彻底。比较简单粗暴但稳定的做法,是让Loader先置空再恢复目标组件;如果想再细心一点,可以给Loader设置active: false再置回true,强制重建内容。这个技巧是我实际项目里用过的,虽然不算优雅,但在组件不多时非常可控。
4. 常见问题与排查技巧实录
4.1 乱码与编码不一致
很多中文程序在 Windows 上出现乱码,根本原因不是翻译系统坏了,而是源文件编码不统一。.ts文件、.cpp文件、.qml文件、甚至.ui文件的编码各行其是,lupdate读取时就会得到凌乱的字节序列。最省心的做法是:所有源代码统一用 UTF-8 保存;MSVC 构建时加上/utf-8编译选项,或者在.pro中使用QMAKE_CXXFLAGS += /utf-8。这样无论字符串出现在 C++ 还是 QML,都能被正确识别,翻译文件里也不会出现乱码。
如果你打开.ts文件发现source里的中文已经是乱码,基本不用指望后续翻译正确,先把源码编码统一,再重新执行lupdate才是根治办法。
4.2 翻译不生效的常见排查方向
程序跑起来界面仍然是英文,这是初学者问得最多的问题。我的排查顺序固定如下:
第一,确认.qm文件是否真的生成。有时只改了.ts没有重新lrelease,或者构建流程里根本没有执行lrelease,导致加载的资源是旧版本。第二,确认加载路径是否正确。用资源系统路径:/i18n/app_zh_CN.qm时,先去.qrc文件里确认文件确实挂载在这条路径下。如果用文件系统路径,需要确认工作目录和部署路径,在开发环境能加载,不代表换台机器还能加载。第三,在代码里输出返回值,别靠猜。translator.load()返回false时,打印错误信息或者至少qDebug()一下,能快速暴露资源缺失、文件名不匹配这类问题。
4.3 动态切换语言后界面纹丝不动
加载了新语言文件,界面却没有变化,通常不是翻译文件的问题,而是 UI 没有重新求值。前面给出的QQmlEngine::retranslate()是比较标准的方案,但它有一个前提:文本必须是通过qsTr()动态绑定的,而不是一次性求值后写死的。如果你在 QML 里这样写:
Component.onCompleted: { myText.text = qsTr("Settings") }那么retranslate()也救不了你,因为这里qsTr只被调用了一次,结果被塞进了普通属性,后续翻译器变化不会再重新执行这一句。正确写法是:
Text { text: qsTr("Settings") }让text成为绑定表达式,每次引擎求值时才会重新读取qsTr()的结果。这是 QML 国际化最容易忽视的一条原则:能写进绑定,就绝不写进生命周期函数。
4.4 数字、复数与语言习惯差异
多国语言不只是单词替换,数字格式和复数规则同样影响体验。比如阿拉伯语的数字写法、泰语的数字分组方式、俄语的复数规则,都不是简单替换字符串能解决的。C++ 侧可以用QLocale::toString()做数字格式化,QML 侧也建议使用Number类型相关的本地化能力,而不是硬编码千分位分隔符。
复数处理在 QML 里没有 C++ 那么直接,常见的做法是提前定义好单复数两条文案,根据数量选择:
function pluralText(count) { if (count === 1) { return qsTr("1 item") } return qsTr("%1 items").arg(count) }这不是最优雅的方案,但对绝大多数界面来说够用。更复杂的中文、日文等语言本身没有强制复数规则,处理起来就简单得多。
4.5 德语、俄语文本把界面撑爆
这是很多人做多语言时低估的一环。同样的“设置”,中文两个字符,英文 “Settings” 八个字符,芬兰语 “Asetukset” 九个字符,遇到俄语可能更长。按钮宽度、标签宽度、表格列宽都要预留足够余量。最直接的做法是布局时不要给文本控件固定宽度,使用Layout.fillWidth或者让文本支持换行;对单行文本,可以设置elide属性并用tooltip显示完整内容。实测最稳妥的办法,是在界面设计阶段就选取几种高膨胀语言(德语、俄语、芬兰语)做一遍截图检查,不要等翻译回来才发现布局全面崩溃。
5. 团队协作与产品化落地的实用经验
5.1 ts 文件必须进版本库
翻译文件不是构建产物,而是需要多人维护的“内容资产”。.ts文件要纳入版本控制,让翻译人员、开发人员、产品经理都能看到历史变更。如果只把.qm文件留在构建产物里,翻译出了问题很难追溯是谁、在哪一轮、改了哪条文案。lupdate生成的.ts文件天然适合 diff,因为它是 XML,每一行变更都清晰可见。设计合理的团队流程是:开发合并代码时更新.ts,翻译人员基于最新.ts做翻译,再通过lrelease生成.qm,最后随版本发布。
5.2 不要翻译用户生成的内容
翻译系统只服务“开发者写死的界面文案”,不负责翻译用户的输入、服务端返回的数据、动态拼接的业务文案。如果你把用户输入的内容也包一层tr(),不仅没有意义,还可能因为找不到对应词条而原样返回,甚至造成显示异常。更常见的问题是动态字符串拼接,比如把tr("Hello") + name拼在一起,不同语言的语序完全不一样,这种组合式文案在国际化里是要尽量避免的。正确做法是把整句作为模板:tr("Hello, %1").arg(name),让翻译人员能调整整句结构。
5.3 图片里的文字和 RTL 布局
多语言项目里,图片中的文字是隐藏的硬伤。一张“搜索”按钮的图片,如果文字是画进去的,换语言就无法翻译。方案有两个:一是尽量用文字控件配图标,把可翻译文字交给翻译系统;二是为不同语言准备不同资源。如果只支持两三种语言,第二种也能接受;语言多了,第一个方案是一劳永逸的。
另外,如果你要支持阿拉伯语、希伯来语这类从右到左的语言,不能只靠翻译文件。界面布局必须支持镜像翻转。Qt 提供了Qt.application.layoutDirection,当系统区域设置为 RTL 语言时,布局方向会自动改变。但这个自动行为依赖每个 Item 的布局是否使用了正确的锚点。用anchors.left、anchors.right硬编码的界面,在 RTL 下会出现左右颠倒的问题。建议工具类组件尽量复用Layout,并且充分考虑 RTL 下的锚点行为。
5.4 字体资源要跟上
多语言界面还牵涉字体问题。中文、日文、韩文、阿拉伯文、西里尔文,每类文字都需要对应字符集的字体支持。在桌面平台通常由系统字体兜底,问题不大;但在嵌入式设备、Linux 精简环境里,如果字体文件缺失,翻译得再准确,界面也可能显示成方块。嵌入字体资源时,建议挑选覆盖目标语言的字体,或者提供多个 fallback 字体。控制台fc-list可以查看系统已有的字体清单,提前在目标设备上验证比发布后发现问题要省事得多。
写在最后的一点感想
踩过几次坑之后,我现在的习惯基本固定了:开工第一天,就在工程文件里加上TRANSLATIONS,建好i18n目录;写界面时,凡是用户能看到的字符串一律走tr()/qsTr(),哪怕当时只有中文;每次提交代码前,顺手跑一遍lupdate,让新的字符串尽早进入翻译流程。这样做的最大收益是,当客户某天真提出来“增加一种语言”时,工作量就只剩翻译和打包,而不是整个 UI 层推倒重来。另外提一句,团队里如果翻译人员不熟悉linguist,建议在首次交付前陪他过一遍带占位符的词条,这一小步能避免大量来回沟通。Qt / QML 的多国语言支持本身并不复杂,复杂的是把流程定好、把习惯养成,希望这篇文章能让你少走一些弯路。