Qt/QML多语言国际化实战:从翻译管线到动态切换
简介面向 Qt 开发者的多语言国际化示例资源特别适合希望为现有应用添加汉化与动态翻译支持的团队或个人覆盖传统 C 界面与 Qml 界面两种体系帮助解决跨语言版本维护和界面实时切换需求。资源内含 Translator_widget 与 Translator_qml 两个子项目分别演示了从编写 .ts 翻译源文件、使用 lrelease 编译生成 .qm 消息文件再到在应用中加载并动态切换语言的整体流程。其中 Qml 部分还展示了通过引擎上下文注入翻译对象让界面文本实时更新的写法对理解声明式界面下的翻译机制很有帮助。整套资源共 16 个文件以 pro 工程配置、cpp 逻辑代码、h 头文件、qml 界面、ui 表单以及 ts/qm 翻译文件为主压缩包仅 10KB目录层级清晰非常适合入门者快速理解 i18n 机制也可作为项目里的最小参考实现。目前已有 1616 人学习下载对需要实现多国语言无重启切换的 Qt 开发者是一份简洁实用的参考资料能直接对照工程学习语言文件的组织与加载细节。1. Qt / Qml 多国语言它不是字典表而是一套带上下文的翻译管线Qt / Qml 做多国语言绕不开这套机制源码里先写一种语言通过工具把字符串抽成翻译文件再在运行时按语言加载。很多人第一次接触时会误以为这就像在代码里维护一个HashMap语言, HashMapkey, value但真正上线后才发现翻译上下文、占位符、复数形式、热切换这些事自己造轮子只会越拖越重。Qt 自带的那套.ts/.qm翻译管线简单场景一天能跑通但要支撑产品长期迭代你得先把tr、qsTr、lupdate、lrelease的分工理解透。这篇笔记面向正在做跨平台桌面、嵌入式设备或移动端应用的开发者。我不讲概念按「为什么用自带这套 → 怎么建工程 → 怎么把第一批翻译跑起来 → 上线前查哪些坑 → 动态切语言 → 维护成本」的顺序展开直接给能抄的配置和代码。2. 先理清分工tr/qsTr 与 .ts/.qm 的关系决定你后面少踩一半坑2.1 三个文件、两个命令和一个编辑器多语言方案里你会反复接触三种后缀.ts、.qm、.qrc。.ts是翻译源文件XML 格式给人看、给人编辑里面保存源语言文本、目标语言文本、翻译上下文和翻译状态.qm是编译后的二进制文件运行时加载速度更快、体积更小.qrc是 Qt 资源定义把.qm编译进程序内部避免发布时漏掉语言包。配合这套文件的是两个命令行工具lupdate从源代码里扫描提取可翻译字符串生成或合并.ts文件lrelease把.ts编译成.qm。中间的人工翻译工作则交给 Qt Linguist 这个可视化编辑器翻译人员不需要接触代码。我一般会把语言文件放在工程里的i18n/目录命名带语言后缀例如app_zh_CN.ts、app_en.ts。这么做的好处是发布脚本里能清晰看出每个语言包对应什么区域后续做语言回退也方便。文件流的方向是源码 →lupdate→.ts→ Linguist 人工翻译 →lrelease→.qm→ QTranslator 加载。2.2 C 侧 tr() 与 QML 侧 qsTr()翻译上下文从哪来很多人分不清tr和qsTr以为只是语法不同。实际上它们的翻译上下文来源完全不一样。在 C 里QObject::tr()属于某个类Linguist 里看到的 context 就是类名例如MainWindow在 QML 里qsTr()的 context 是它所在的 QML 文件的 basename例如MainPage.qml。这意味着同一个英文单词 Open在FileMenu类和DialogButton类里可以是两个不同的翻译条目。Qt 在生成.ts时会把 context 一并写入翻译人员看到的是分组后的列表而不是一长串平铺字符串。这个特性恰恰是自建字典表很难复制的——字典表通常只有 key 和 value丢失了「这句话出现在哪个界面区域」的信息。如果你在 C 里需要定义一组常量字符串避免运行时才翻译可以这样写// 用 QT_TRANSLATE_NOOP 预定义稍后按上下文取出后再翻译 static const char *menuItems[] { QT_TRANSLATE_NOOP(MenuBar, Open), QT_TRANSLATE_NOOP(MenuBar, Save As...), QT_TRANSLATE_NOOP(MenuBar, Quit) };这段代码里两行的参数说明第一个字符串是翻译上下文MenuBar第二个是源文本。lupdate会提取QT_TRANSLATE_NOOP中的字符串但它不负责翻译只是让字符串进入.ts文件。真正翻译时你需要在代码里手动调用qApp-translate(MenuBar, menuItems[i])。这样做的价值在于同一份菜单数组可以在代码里反复使用而翻译状态始终由 Qt 统一管理。2.3 自己手写语言表的四个坑在没有 Qt 这类框架的年代很多项目确实会自建一套语言表定义一堆 ID然后每个界面控件初始化时用GetText(ID)去查。我维护过一个模拟项目 X代码里堆积了几千个ID_BUTTON_31这种命名翻译人员完全不知道这个 ID 背后对应哪个按钮。这类方案有四个绕不开的坑第一没有上下文。同一个词在菜单、提示框、标题栏里翻译可能不同纯 ID 表只能靠开发者手工区分最后往往演化成ID_OPEN_FILE_1、ID_OPEN_URL_2这种丑命名。第二合并困难。产品每加一个文案就需要同步修改所有语言文件漏改只能靠人眼检查。第三复数、占位符、数字格式这些语言特性自建方案大多只做了%s替换遇到英文单复数、俄语多复数、阿拉伯语的双数形式根本没法处理。第四漏翻译检测跑在运行时代码到处都是if (text )这种临时判断而不是在发布前静态发现。所以我的结论是既然 Qt 已经把提取、合并、编译、回退这套流程做完整了就不要再自己造一套并行的字典表。自建方案适合极少数不能引入 Qt 工具链的特殊环境普通 Qt/Qml 项目用原生的这组工具是成本最低的选择。2.4 最小工程骨架qmake 和 CMake 都要给出 TRANSLATIONS先看 qmake 写法在.pro文件里声明语言文件# 列出所有想维护的翻译文件 TRANSLATIONS \ i18n/app_zh_CN.ts \ i18n/app_en.ts \ i18n/app_ja.ts # 把 qm 编译进资源文件运行时不依赖外部文件 RESOURCES i18n/i18n.qrc这段配置说明两件事TRANSLATIONS告诉 Qt 工具链「这些是你要扫描合并的翻译目标」i18n.qrc则在构建时把.qm打进程序资源。之后你再执行lupdate时工具会根据项目中的SOURCES、HEADERS、以及QML文件自动提取字符串。对应 CMake 工程常见做法是这样set(TRANSLATIONS i18n/app_zh_CN.ts i18n/app_en.ts i18n/app_ja.ts ) qt5_create_translation(QM_FILES ${TRANSLATIONS})这里的qt5_create_translation是一个 CMake 辅助函数它会自动调用lupdate和lrelease把生成的.qm文件名收集到QM_FILES变量里。需要注意的是不同 Qt 版本里这个函数名称可能略有差异Qt 6 下通常写作qt6_add_translation。如果你不想依赖 CMake 的自动化也可以自己在构建脚本里直接调用lupdate和lrelease两个命令效果是一样的只是少了依赖追踪。3. 从源字符串到第一批翻译文件落地lupdate、Linguist、lrelease3.1 lupdate 提取只提源码不提注释和运行时拼装的文本执行lupdate之前先确认源码里所有需要翻译的文本都写在tr()、qsTr()或QT_TRANSLATE_NOOP里。这是这个方案最关键的一条纪律。lupdate不是编译器它只会提取你显式标记过的字符串普通字符串字面量、从配置文件读进来的文案、用拼装出来的句子它都不会提取。常用命令是这样# 从当前目录递归扫描生成或合并 i18n/app_zh_CN.ts lupdate . -recursive -ts i18n/app_zh_CN.ts # 指定 QML 也参与扫描避免漏掉 .qml 里的 qsTr lupdate . -recursive -extensions cpp,h,hpp,qml -ts i18n/app_zh_CN.ts参数说明.表示扫描当前目录下所有工程文件-recursive让工具递归寻找子目录里的源码-extensions明确列出参与扫描的文件后缀多平台项目里如果只写了.cpp不带.qmlQML 文案就会全部漏掉这是新手最容易翻车的地方。-ts指定目标文件它可以写多个例如-ts i18n/app_en.ts i18n/app_ja.ts一次更新多个语言。执行后打开.ts你会看到类似这样的结构TS version2.1 languagezh_CN context nameMainPage/name message sourceOpen File/source translation打开文件/translation /message /context /TS这个 XML 的 meaning 很简单context对应翻译分组message是一条待翻译记录translation是目标语言文本。lupdate的合并策略是新发现的消息追加进去已经存在的保留既有翻译源码里已经删掉的消息标记为 obsolete。这个特性后面还会讲到。3.2 用 Linguist 做一轮翻译三种状态要分清拿到.ts后打开 Qt Linguist 进行人工翻译。界面左侧是 context 分组中间是源文本下方是翻译输入框。你需要注意三种状态标记未翻译会显示一个感叹号已翻译并匹配源文本的显示绿色对号模糊翻译显示黄色问号。模糊翻译往往来自源文本被修改过。例如源代码里原来写的是 Open File后来改成 Open filelupdate会保留旧翻译但把它标为模糊提醒翻译人员检查大小写变化是否需要处理。实际项目里我会要求翻译人员重点关注模糊条目而不是一眼扫过。翻译时如果源文本里有占位符像%1或%2务必原样保留。Linguist 默认会把占位符在源文本里高亮方便对照。常见错误是翻译人员为了中文语序把%1和%2调换这在某些语言里是合法的但调换后参数错位的问题会在运行时暴露后面避坑章节会专门讲。3.3 lrelease 生成 qm编译期就暴露问题翻译完成后的命令很简单# 生成所有语言的 qm lrelease i18n/app_zh_CN.ts i18n/app_en.ts i18n/app_ja.ts # 或者一次编译目录下所有 ts lrelease i18n/*.tslrelease会统计每个文件里有多少条消息未翻译、多少条已翻译并把结果输出到终端。如果你看到某个语言的 unfinished 数值很大说明翻译没做完这个语言包发布出去会出现大量英文或源码语言残留。我在发布脚本里会加一行判断让lrelease遇到未翻译条目时返回非零状态把构建直接卡住避免漏翻译上线。3.4 启动加载 QTranslator 的最小代码程序启动时需要按当前语言加载对应的.qm。一个必须注意的细节是QTranslator的加载顺序和加载位置会直接影响生效范围。我一般把语言初始化放在QApplication创建之后、窗口对象创建之前这样所有控件一出生就拿到正确翻译。#include QApplication #include QTranslator #include QSettings int main(int argc, char *argv[]) { QApplication app(argc, argv); // 从配置读取语言默认中文 QSettings settings(MyCompany, ExampleApp); QString lang settings.value(language, zh_CN).toString(); QTranslator translator; // 从资源文件加载路径前缀由 qrc 决定 bool loaded translator.load(:/i18n/app_ lang .qm); if (loaded) { app.installTranslator(translator); } // 语言状态保存到全局方便后续动态切换 app.setProperty(currentLanguage, lang); return app.exec(); }这是 C 入口的加载方式的一个常见简化写法。translator.load()返回 bool条件不满足时不要往下执行installTranslator否则等于装了一个空翻译器。这里把翻译器声明在栈上生命周期覆盖整个main函数避免局部变量提前析构导致翻译失效。QSettings保存语言值用户切换后下次启动依然生效。需要指出的是如果你的界面完全由 QML 写成也可以在 QML 里用Qt.resolvedUrl加载.qm但统一放在 C 入口更便于管理多语言状态。3.5 手动跑一遍完整流程把前面几步串起来一套最简单的发布流程如下# 1. 更新所有语言文件 lupdate . -recursive -extensions cpp,h,hpp,qml -ts i18n/*.ts # 2. 人工翻译打开每个 ts 文件进行处理 linguist i18n/app_en.ts # 3. 编译所有语言包 lrelease i18n/*.ts # 4. 构建并运行程序切换系统语言验证这套流程第一次跑通后你就有了一个最基本的国际化基础。后面要做的两件事一是把多语言做成运行时动态切换二是把维护工作固定到自动化脚本里。接下来的避坑清单是我在多语言项目里遇到过的真实问题建议对照检查自己的代码。4. 多语言上线前必看的避坑清单现象、原因、解决4.1 qsTr 直接返回原文翻译完全不生效现象QML 文件里的qsTr(Open File)在界面上始终显示英文即使.qm文件已经加载成功。原因这个场景绝大多数情况下是因为翻译上下文没匹配上。qsTr的上下文是当前 QML 文件的 basename但lupdate扫描时用的文件名和运行时引擎记录的文件名不一致例如文件名大小写不同、路径里混入了符号链接就会导致 Linguist 里那条翻译挂在MainPage下运行时却去查mainpage的上下文。另一种常见原因是自定义 QML 组件没有继承QQmlEngine的翻译函数某些非标准组件里qsTr实际是不可解析的。解决先确认.ts里 context 名对比运行时实际发出的内容。可以临时在 QML 里写console.log(qsTr(Open File))看输出再和 Linguist 里的翻译对照。路径、大小写、文件名的任何不一致都会让翻译失效。若使用的是较老版本 Qt Quick Controls还需要检查是否缺少QT_QML_DEBUG之类的限定。4.2 源文本只改了一个字翻译全部变成未翻译现象开发者调整了源码里的英文文案比如 Open File 改成 Open filelupdate之后 Linguist 里出现一条新消息旧消息被标为 obsolete翻译人员不得不重新翻译一整条而实际上改动只是大小写。原因Qt 的翻译条目以「源文本 上下文」的组合作为唯一标识。源文本发生任何字符级变化包括空格、标点、大小写lupdate都会认为这是一条新字符串。解决改文案前先在 Linguist 里查看旧条目的翻译然后在源码里同步修改lupdate会把旧条目标记为模糊而不是新增。最好的习惯是让源文本尽量稳定把措辞修改集中到某个版本而不是频繁地小改。另外发布前用lupdate -noobsolete清理那些已经失效的旧条目避免.ts文件里堆积大量历史垃圾。4.3 热切换语言后界面仍然显示旧语言现象程序运行中调用installTranslator换了一批.qm文件窗口标题变了但 QML 界面里大量文本纹丝不动。原因QML 的翻译结果不会自动重新求值。qsTr在组件首次创建时就把结果绑定到了文本属性上之后语言变了老字符串不会自动刷新必须触发引擎重新遍历所有可翻译项。另一个常见原因是多次切换语言时只installTranslator了新翻译器没有先移除旧的导致多个翻译器叠加后面的查不到时回落到前面看起来像没切换成功。解决切换顺序应当是先removeTranslator旧实例再installTranslator新实例然后触发刷新。具体做法下一章会给出完整代码。如果界面里有从 C 传入 QML 的字符串这些字符串也要在 C 侧重新翻译一次并重新赋值。4.4 qm 文件加载失败或者中文乱码现象在开发机上一切正常打包后换一台机器运行界面变成英文或出现带问号的乱码。原因.qm文件没有随程序一起发布或者路径不对。当把.qm放进.qrc资源文件时需要用:/前缀访问如果误写了相对路径程序会到工作目录找文件而工作目录在双击启动时往往不是安装目录于是加载失败。另一个原因是部分旧版本 Qt 在读取非 UTF-8 编码的.ts时会产生乱码。解决优先把.qm打成资源文件确保安装包里一定有。加载时用QFile::exists做一次前置判断translator.load()的返回值也一定要检查。对于编码问题统一把.ts文件保存为 UTF-8不要用带 BOM 的格式否则某些老版本lrelease会把 BOM 当作源文本的一部分。4.5 翻译把 %1 和 %2 调换后运行时显示错乱现象一条消息里有两个占位符原文是%1 个文件耗时 %2 秒法语翻译成%2 secondes pour %1 fichiers结果界面上时间和数量互换了位置。原因这不是程序 bug而是翻译导致参数顺序变化。Qt 允许翻译人员调整占位符顺序翻译规范里这是合法的但代码里如果按固定的索引位置取参数就会出现语义错位。解决代码里尽量不要依赖占位符的顺序或在文档里明确要求翻译人员保持物理顺序。复杂的本地化中更好的做法是用命名占位符比如%1在翻译后仍对应同一个源参数。还有一个强制手段在lrelease之后写一个脚本扫描.ts里的translation内容统计占位符集合是否和source一致不一致直接报警。这个检查我每次发版前都会跑一遍省了不少线上问题。5. 运行时动态切换语言从重启到热更新的落地做法5.1 最低成本方案保存语言配置并重启如果产品对切换语言没有实时性要求最稳妥的做法是先保存设置再重启应用。这个方案尤其适合嵌入式设备界面资源有限动态刷新的成本高重启一次反而最简单。void saveAndRestart(const QString lang) { QSettings settings(MyCompany, ExampleApp); settings.setValue(language, lang); settings.sync(); // 重启当前进程 qApp-quit(); QProcess::startDetached(qApp-applicationFilePath(), QStringList()); }这里的settings.sync()很关键它保证配置写入磁盘避免进程退出时数据还没落盘重启后读取到旧语言。重启方案虽然粗暴但它的优势是状态干净不存在刷新不完整的问题。代价是用户切换语言时要经历一次闪退式的重启体验一般但可靠。5.2 事件驱动热切换installTranslator 加 retranslate对桌面应用来说更好的体验是热切换。核心思路只有两步更换翻译器然后让界面上所有已经显示的文本重新取一遍翻译。C 侧做起来相对简单因为控件可以遍历QML 侧则需要靠引擎层面的刷新机制。class LanguageManager : public QObject { Q_OBJECT public: Q_INVOKABLE bool switchLanguage(const QString lang) { if (lang currentLang) { return true; } // 先移除旧翻译器避免叠加 if (m_translator) { qApp-removeTranslator(m_translator); } static QTranslator newTranslator; if (!newTranslator.load(:/i18n/app_ lang .qm)) { return false; } m_translator newTranslator; qApp-installTranslator(m_translator); currentLang lang; emit languageChanged(); return true; } signals: void languageChanged(); private: QTranslator *m_translator nullptr; QString currentLang; };这个类用了static QTranslator来保证翻译器不会提前析构。每次切换都先移除旧实例再安装新实例这样查找链不会叠加。languageChanged信号发给 QMLQML 侧收到后主动刷新所有qsTr文本。注意static修饰的翻译器只能持有一个实例代码里需要用一个成员指针记录当前实例后面移除时才能找到正确的对象。QML 侧可以这样连接Connections { target: languageManager function onLanguageChanged() { // 强制引擎重新加载翻译 engine.retranslate(); // 部分场景需要重新设置布局方向 window.layoutDirection Qt.application.layoutDirection; } }这里的retranslate()是引擎提供的刷新入口它会向已注册的组件广播翻译变更信号。如果工程里的 QML 组件用了property string text: qsTr(...)那么属性会自动重新求值。若某些文本来自 C 模型需要在 C 侧收到languageChanged后更新模型数据QML 再通过 binding 自动刷新。5.3 刷新范围不止文本字体、方向、日期与数字格式切换语言不仅是换文字。阿拉伯语、希伯来语需要从左到右反转成从右到左日期格式、数字分隔符、货币符号都需要跟着区域变化。很多人只换了文本结果阿拉伯语界面排版全乱。我一般会在语言变化后同步做这几件事刷新项处理方式关联对象文本翻译retranslate()或重新赋值QML Text / Button / Label布局方向setLayoutDirection(Qt::RightToLeft)QQuickWindow / QApplication日期时间用QLocale(lang)重新格式化所有日期型展示数字与小数用QLocale的toString系列统计、金额字体族中文、日文、阿拉伯文可能需要不同的 fallback 字体全局字体设置处理日期最笨也最可靠的方法是让所有展示日期时间的地方不要直接拼字符串而是统一走一个工具函数QString formatDateTime(const QDateTime dt, const QString lang) { QLocale locale(lang); return locale.toString(dt, QLocale::ShortFormat); }这样的好处是切换语言后只要语言参数变了所有调用点自动获得新格式不需要每个界面单独改。字体方面如果你发现阿拉伯文或泰文显示成方框优先检查系统是否有对应字体并在 QML 的font.family里设置一个覆盖多种文字的字体栈。5.4 一段可复制的 LanguageManager 整合代码把上面几个部分拼起来我会这样组织主入口int main(int argc, char *argv[]) { QApplication app(argc, argv); LanguageManager langManager; langManager.switchLanguage(readSavedLanguage()); QQmlApplicationEngine engine; engine.rootContext()-setContextProperty(languageManager, langManager); engine.load(QUrl(QStringLiteral(qrc:/Main.qml))); return app.exec(); }这段代码把languageManager暴露给 QMLQML 里的按钮切换语言时直接调用languageManager.switchLanguage(ja)然后通过信号刷新界面。注意switchLanguage返回 bool如果.qm不存在会返回false这时候要弹提示而不是让界面处于半切换状态。集成这个模式后动态语言切换就能稳定工作了。还有一点小经验调试时在 QML 里打印qsTr的返回值能快速判断当前实际生效的是哪个翻译器比逐个界面排查快得多。6. 把多语言做成可维护的系统复数、回退与自动化验证6.1 复数规则与占位符翻译不只是逐字替换Qt 的翻译系统支持复数形式你可以用%n来让不同语言展示不同的单复数形态。例如qsTr(%n file(s))在英文里会生成 1 file 和 2 files在俄语里则会按 1、2-4、5 等规则生成不同条目。Linguist 的复数编辑器会列出目标语言的所有复数变体翻译人员需要把每个变体都填上漏填会导致运行时回退到源码语言。占位符数量也需要注意。不要在一个字符串里塞太多%1 %2 %3一旦超过三个翻译人员在调整语序时极易出错。更优雅的做法是拆成短句让每条翻译消息保持独立例如把 You have %1 unread messages, %2 of them are urgent 拆成两条虽然多了一次翻译但维护成本大幅下降。6.2 多级回退链防止小语种缺失直接崩到英文一个产品通常不会把每种语言都翻译到 100%。常见做法是维护回退链请求的语言找不到翻译时依次回退到备选语言最后回退到源语言。Qt 的 QTranslator 自带查找机制加载较多语言包时翻译器会按安装顺序从后往前查找所以回退链可以通过加载顺序实现// 先装回退语言再装首选语言 if (lang ! en) translatorEn.load(:/i18n/app_en.qm); if (lang ! zh_CN) translatorZh.load(:/i18n/app_zh_CN.qm); app.installTranslator(translatorEn); app.installTranslator(translatorZh);这段代码里translatorZh是后安装的首选翻译器查找时会优先命中找不到时再落到translatorEn。这个模式对小语种非常有用法语缺失的翻译会显示英文而不是显示中文。回退链设计是国际化长期运维的核心语言覆盖率永远不可能 100%有一套明确的回退策略比追求全量翻译更实际。6.3 用伪翻译自动化检查截断与漏翻译伪翻译是指把源语言自动替换成带前缀的特殊字符串例如给每个翻译文本前后加上[#]运行界面时看到哪个位置没有[#]就说明那个文本没有走翻译系统。这个技巧能一次性暴露硬编码字符串、漏提取的qsTr以及布局截断问题。自动化检查可以分成三层第一层lupdate后统计每个.ts的 unfinished 数量第二层用脚本对比.ts中 source 和 translation 的占位符集合第三层在 CI 里构建一版伪翻译语言包跑一遍截图对比。这三层做完多语言上线前的质量问题基本都能兜住。我的习惯是把第一层和第二层做成发布脚本的一部分不满足条件直接构建失败而不是靠人工在 Linguist 里肉眼检查。6.4 中文输入法与字体回退的几个附带注意如果你的源语言是中文翻译成其他语言后字体通常要按目标语言调整。中文用系统中文字体日文需要日文字体阿拉伯文需要 RTL 字体。QML 里可以通过Qt.application.font设置全局面板默认字体但单一字体无法覆盖所有文字所以字体回退顺序很重要。另一个容易被忽略的是输入法界面切换成日文后文本输入框的输入法候选项还是跟随系统输入法不建议在应用层强行绑定输入法否则会破坏用户的操作习惯。做多语言久了我的最大教训是不要把翻译当成上线前的一个周末任务而是从第一天就把所有用户可见文本收口到tr()/qsTr()并且让源语言保持稳定。语言本质上和代码一样是需要持续维护的资产。上面这套流程看着繁琐但真正坚持跑上两三个版本你的团队就会形成习惯翻译质量也只会越做越稳。希望这份笔记能帮你在 Qt/Qml 项目里少走几步弯路。本文还有配套的精品资源点击获取