简介:这是一份面向Qt/Qml开发者的多国语言动态翻译示例资源,重点解决跨地域应用中语言切换与汉化需求,覆盖Qt Widgets与Qml两大主流界面技术,适合需要为桌面应用或嵌入式界面增加多语言支持的开发者。压缩包共16个文件,包含完整的工程配置、C++源码、翻译文件、界面布局以及Qml声明文件,整体仅10KB,结构紧凑,方便快速查阅。目前已有1616人学习,说明其参考价值得到认可。资源提供Translator_widget与Translator_qml两个子项目:前者演示基于QTranslator和lrelease生成.qm文件后在C++中动态安装与移除翻译,并支持运行时切换语言;后者展示在Qml中通过上下文属性暴露tr函数,实现界面文本随语言包即时刷新。两个工程分别对应传统C++界面与现代声明式Qml界面,可对比学习Qt Linguist工作流、.ts/.qm文件管理以及国际化项目中常见的语言包组织方式,是理解Qt/Qml动态翻译机制的简明范例。
1. Qt / QML 多国语言:不是套个翻译软件,而是把“语言”做成运行时的一等公民
做客户端开发的人对多语言这事普遍有个错觉:以为把界面文案抽成资源文件,再挂个翻译表就算完事。真到 Qt / QML 项目里做一遍汉化或者日语本地化,你会发现坑比想象的多——字符串拼接、单引号、QVariant 缓存、动态切换后界面不刷新,任何一个都能让你加班到深夜。这套资源要解决的就是从“程序跑起来”到“语言能自由切换”的完整链路:静态翻译怎么抽、.ts / .qm 怎么生成、QML 里动态切换语言时如何让已实例化的界面跟着刷新。适合正在做 Qt Widgets / Qt Quick 跨平台产品、需要在中文、英文、日文等多语言环境中发布的应用开发者,也适合想把现有单语种项目改造成国际化结构的维护者。它不是教你怎么用谷歌翻译,而是告诉你如何让语言文件成为可维护、可动态加载、不重启就能生效的正式工程模块。
2. 翻译文件的前世今生:从 ts 到 qm,再搞懂两个引擎的区别
2.1 为什么 Qt 用 ts / qm 而不是直接用键值对文件
很多第一次接触 Qt 本地化的开发者会问:我直接写一个 JSON 文件,key 是界面上的英文,value 是中文,运行时查表不就行了吗?理论上可以,但工程上你会立刻遇到三个问题:第一,语言文件和代码严重脱节,界面加了一个新按钮,忘了补翻译,运行起来就是半中半英;第二,带占位符的句子(比如“共 %1 条记录”)在 JSON 里写死格式,翻译人员很容易改坏占位符;第三,翻译人员拿到的是一堆无上下文的 key,不知道这句界面词在什么场景出现。
Qt 的 ts / qm 机制解决的就是这些问题。ts(Translation Source)是 XML 格式的翻译源文件,直接给翻译人员看,可以手工编辑也可以用 Qt Linguist 工具打开;qm(Translation Message)是编译后的二进制文件,运行时加载更快。翻译人员看到的不只是“key→value”,还能看到这句话所在的源文件、行号、上下文(Context)、占位符标记,甚至能添加翻译备注。开发流程是:代码里写好源字符串→lupdate 工具扫描生成 ts→翻译人员完成翻译→lrelease 工具编译成 qm→运行时安装 qm 文件。
# 假设工程目录下有 main.cpp、main.qml、MyComponent.qml # 先生成或更新翻译源文件,-recursive 会递归扫描子目录 lupdate . -recursive -ts app_zh_CN.ts app_en_US.ts # 打开 Qt Linguist 人工翻译(或者直接用文本编辑器改 XML) # 我用 Linguist 比较多,因为能直接看到源文件上下文 # 翻译完毕,编译成运行时加载的二进制 qm lrelease app_zh_CN.ts -qm app_zh_CN.qm这里有个参数要解释:-ts后面跟的不是一个文件而是多个,lupdate 会把所有待翻译语言写进同一次扫描;-recursive会递归搜索当前目录下的 .cpp、.h、.qml 文件。你会发现 lupdate 默认也支持 JavaScript 文件里的 qsTr / qsTranslate,所以 QML 项目里用.js文件写界面文案也能被扫到。翻译完成后,qm 文件名称里的语言代码必须符合规范(比如zh_CN、ja_JP),因为运行时加载规则严重依赖这个命名。
2.2 Widgets 的 tr() 与 QML 的 qsTr()/qsTranslate() 到底差在哪
Qt Widgets 里最经典的是QObject::tr(),它依靠 Q_OBJECT 宏里的元数据确定翻译上下文(Context),默认是类名。QML 里对应的是全局函数qsTr(),它的上下文默认是QML 文件名的去掉后缀部分。qsTranslate()则是让你显式指定上下文,通常用在不想自己文件名作为上下文的地方。
// MyComponent.qml import QtQuick 2.15 Text { // 上下文是 MyComponent,谁加载这个 qml 都会套用同一套翻译 text: qsTr("Hello") // 显式指定上下文为 "SharedContext", // 适合想让多个 qml 文件共用同一组翻译条目的场景 tooltip: qsTranslate("SharedContext", "Delete") // 带占位符:翻译人员看到的格式是 "共 %1 条记录",%1 会被 count 替换 text: qsTr("Total %1 records").arg(count) }tr()和qsTr()最大的差异在翻译时机。Widgets 里tr()本身是运行时在翻译文件里查表,每次调用都检查当前安装的 translator;QML 里qsTr()也是运行时查表,但 QML 引擎对字符串有缓存优化,同一个 Text 项的 text 属性如果绑定的是一个常量qsTr("Hello"),那么字符串只在绑定建立时解析一次。如果你要做动态语言切换,必须确保所有用qsTr()的地方都是绑定表达式(比如text: qsTr("Hello")本身就是绑定),并且切换语言后让整个 QML context 触发重新求值,否则界面不会自动刷新。
2.3 Qt Linguist 的使用习惯:备注和上下文是翻译质量的生死线
我用 Qt Linguist 的经验是:给翻译人员的备注(Translator Comments)必须写清楚使用场景。比如一句qsTr("Name"),如果不写备注,翻译成中文没问题(“名称”),但翻译成日语时可能有多种候选(“名前”“名称”),没有上下文就会翻得语义模糊。所以规范的做法是:
// 源文件里用注释形式写翻译备注 //: 用户列表页面的标题栏,显示当前登录用户的名字 //~ 长度限制:不超过12个字符,因为界面上有固定宽度 const QString label = tr("Name");lupdate 会把//:和//~行内容提取进 ts 文件的translatorcomment和extracomment字段。这个习惯非常关键,尤其是当你有第三方翻译人员而不是自己人翻译时,备注能大幅减少来回沟通成本。还有一个容易被忽略的点:ts 文件里的<location>标签记录着字符串所在文件和行号,代码改动后运行 lupdate,行号变化会导致 ts 文件 diff 巨大,翻译未完成的条目也可能因为位置变化被标记为“过时”。我在多个项目里反复踩过这个坑,后面细说。
3. 动态切换语言:从安装翻译者到让 QML 界面立刻刷新
3.1 切换语言的核心机制:translator 的安装与移除
动态语言切换的本质是卸载旧的QTranslator、加载新的.qm、然后通知所有界面刷新。对 Widgets 来说相对简单,因为 QWidget 的changeEvent会收到LanguageChange事件,重写changeEvent或者干脆在按钮点击后手动遍历重置所有控件的文本就行。QML 稍微麻烦一点,因为它没有全局广播的“语言改变”事件,需要自己建立一个信号中枢。
// LanguageManager.h class LanguageManager : public QObject { Q_OBJECT Q_PROPERTY(QString currentLanguage READ currentLanguage WRITE setCurrentLanguage NOTIFY currentLanguageChanged) public: static LanguageManager &instance(); QString currentLanguage() const; void setCurrentLanguage(const QString &lang); signals: void currentLanguageChanged(); void retranslationRequired(); private: explicit LanguageManager(QObject *parent = nullptr); QTranslator *m_nowTranslator = nullptr; QTranslator *m_prevTranslator = nullptr; QString m_currentLanguage; }; // LanguageManager.cpp void LanguageManager::setCurrentLanguage(const QString &lang) { if (m_currentLanguage == lang || lang.isEmpty()) return; QCoreApplication *app = QCoreApplication::instance(); if (m_nowTranslator) { QCoreApplication::removeTranslator(m_nowTranslator); delete m_nowTranslator; m_nowTranslator = nullptr; } m_nowTranslator = new QTranslator(app); if (m_nowTranslator->load(QString(":/translations/app_%1.qm").arg(lang))) { QCoreApplication::installTranslator(m_nowTranslator); m_currentLanguage = lang; emit currentLanguageChanged(); emit retranslationRequired(); } else { // 加载失败时回滚,保住当前语言 delete m_nowTranslator; m_nowTranslator = nullptr; } }这个实现里有两个关键决策:一是用单例模式(单身狗模式)管理,因为翻译状态是全局的,任何 QML 引擎都需要访问;二是在load失败时不是直接忽略,而是删除 translator,避免一个半加载的状态污染未来切换。这里还能看到Q_PROPERTY的用法——把当前语言暴露成属性,方便 QML 里做界面显示(比如显示“当前语言:日本語”),同时把retranslationRequired信号做成独立信号,让 QML 里一拨监听器统一响应。
3.2 让 QML 界面强制刷新:四条路线的比较
动态切换语言时,QML 界面上已实例化的文本并不会因为 installTranslator 而自动更新。常见做法有四条路线,我分别说说优缺点。
路线一:整体重建引擎。把QQmlApplicationEngine的根对象销毁再重新load。简单粗暴,但代价是应用状态全丢,切个语言等于重启程序,一般只适合开发调试时用。
路线二:通过 QML 信号中枢手动重置文本。在根 QML 对象里定义一个Connections监听LanguageManager.retranslationRequired(),然后把每个需要翻译的属性重新赋值一遍。这个做法的缺点是:你得手动记住每个 Text 的原始字符串,代码容易漏,翻译刷新像是打补丁。
// main.qml Connections { target: languageManager function onRetranslationRequired() { // 手动重新赋值,适合界面文案极少的小项目 headerText.text = qsTr("Settings") headerText.tooltip = qsTr("Open settings") } }路线三:依赖属性绑定自更新。如果每个 Text 的text都严格写成了绑定(比如text: qsTr("Settings")),那么理论上只要让引擎认为这个绑定依赖的上下文变了就会重新求值。但实际 QML 引擎不会因为 translator 变化而自动 invalidate 已有绑定,所以经常需要配合Qt.binding()或者强制改一个 dummy 属性触发。
路线四:用QQuickTextDocument级别的LayoutDirection处理 RTL,但语言刷新仍需手动触发。真正完整落地的动态切换方案,我见过最稳的是“根项替换 + 延迟加载”:切换语言时,先发信号让 QML 的顶层 Loader 的 source 置空,等一个事件循环(或者Component.onDestruction完成)再重新加载填充。这个方案牺牲一点加载时间,但保证所有文本重新走一遍绑定,不会出现漏翻译的僵尸文本。从工程上我更倾向于路线四,因为它不依赖开发者记住每一处文本,符合“语言切换是全局行为”的定位。
3.3 QML 侧语言切换的信号连接与坑点
QML 里调用 C++ 单例,需要先注册类型。常见的做法是用qmlRegisterSingletonInstance或qmlRegisterType。注意:Qt 5.15 以后推荐qmlRegisterSingletonInstance,旧版本只能用qmlRegisterType然后把单例对象作为 context property 暴露。
// main.cpp 里注册单例 #include "LanguageManager.h" #include <QQmlApplicationEngine> #include <QQmlContext> int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); QQmlApplicationEngine engine; const QUrl url(QStringLiteral("qrc:/main.qml")); QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, &app, [url](QObject *obj, const QUrl &objUrl) { if (!obj && url == objUrl) QCoreApplication::exit(-1); }, Qt::QueuedConnection); engine.load(url); // 先实例化 manager,再加入上下文属性 LanguageManager *manager = &LanguageManager::instance(); engine.rootContext()->setContextProperty("languageManager", manager); return app.exec(); }这个顺序有一个极易踩的坑:setContextProperty必须在引擎加载 QML 之前调用。上面代码虽然把 load 写在前面,但运行顺序上engine.load()是异步的?其实不是同步加载,所以有可能 QML 里已经引用 languageManager 对象时属性还没挂上。正确做法是先把setContextProperty放在engine.load(url)之前。我自己有一段血泪经验:项目里 QML 文件被加载多次时,每次加载都重新注册 context property,导致旧的 context 里残留旧 manager,切语言后 UI 状态错乱。后来改成在engine.rootContext()->setContextProperty前先判断是否已存在同名 property,没有再去挂载,才解决。
4. 动态翻译避坑手册:五个影响上线的高频故障
4.1 字符串没有出现在 ts 文件里
现象:界面上某句话永远是英文,但 Qt Linguist 里搜不到这条字符串。原因:这个案例我排查过两次,一次是因为在Component.onCompleted里使用qsTr(),lupdate 能扫到它,但如果你把字符串拆成拼接:qsTr("Total " + count + " items"),lupdate 只能识别到"Total ",后续部分全是乱码/不完整条目。另一个原因是字符串写在注释//之后而不是代码里,lupdate 不会扫描注释。解决:强制使用带占位符的写法qsTr("Total %1 items").arg(count)。如果字符串确实来自外部输入(比如服务器返回的语言包),就不要用qsTr,改用qsTranslate("ServerData", ...),这样至少能手动维护翻译表。
4.2 切换语言后,界面只刷新一半
现象:tab 页的标题刷新了,但表格列头、某些弹窗还是旧语言,甚至整个页面闪了一下颜色不对。原因:你监听的是currentLanguageChanged,但 UI 里某个控件不是通过绑定更新,而是直接在onClicked或setInterval回调里赋值文本。比如写在定时器里text: qsTr("Loading..."),其实它是绑定,但被Connections手动覆盖过。解决:从设计上约定“所有显示文案禁止手动 set,只允许绑定赋值”。如果你的业务代码不得已要动态改文本,在切换语言的槽函数里用遍历方式找到所有Text元素强制刷新:QML 里可以用objectChildren递归,但更务实的是通过QQmlContext::setContextProperty将语言包整体替换一次,让所有绑定失效。
4.3 双引号与单引号导致的翻译失败
现象:lupdate 报编译警告,或者语言文件里字符串被截断;最常见的是qsTr('Hello')(单引号)在 Qt 6 里报错。原因:QML 里单引号和双引号都表示字符串,但qsTr内部用双引号做包装,单引号字符串在扫描阶段可能不被识别。更隐蔽的是在qsTr()内嵌转义字符:qsTr("Line1\nLine2")实测 lupdate 能识别,但如果字符串里有未转义的换行符或制表符,可能被 scanner 打断。解决:项目规范统一用双引号写所有qsTr参数;包含换行时用\n而不是\。在.pro文件里加CONFIG += lrelease或者CONFIG += lupdate可以提前暴露这些解析错误。
4.4 日语/中文字体乱码或豆腐块
现象:切换语言后不再显示英文,但显示一堆方框(豆腐块),尤其 Linux 服务器环境上最容易出现。原因:系统缺少对应语言字体,Qt 默认字体解析失败;另外.qm文件的编码必须是 UTF-8,如果 ts 被其他工具保存为 ANSI 或 GBK,lrelease 能编译但运行时解析乱码。解决:发布时确保字体文件打包完整。Qt 5.9+ 可以用QFontDatabase自动 fallback,但更稳的是在 main.cpp 里预先设置字体策略:app.setFont(QFont("Noto Sans CJK SC"))(中)/Noto Sans CJK JP(日)。ts 文件用文本编辑器强制另存为 UTF-8,且不要在 ts 里嵌入 BOM。
4.5 qm 文件加载失败但没有任何提示
现象:QTranslator::load()返回 true,但界面上字符串仍然不翻译;在 Windows 上偶发,Linux 上也许正常。原因:加载路径写死相对路径,而应用当前工作目录不是预期目录;或者 qm 文件用了错误的语言代码(例如zh而不是zh_CN),Qt 的 locale 匹配规则是精确匹配优于模糊匹配,如果你加载app_zh.qm,但系统 locale 是zh_CN,它不会自动 fallback。解决:加载时用绝对路径或资源路径(:/translations/),并确认语言代码和系统 locale 编码完全一致。在代码里qDebug() << manager->filePath()打出来看一眼实际加载路径是否符合预期。这个方法能避免大多数“翻译未生效”的脑溢血排查。
5. 动态翻译再进阶:日期、复数、变量注入与上下文管理
5.1 翻译文本里的数字格式化与复数规则
中英文在复数表达上几乎没差异,但俄语、阿拉伯语有非常复杂的复数规则。Qt 的tr()自带复数支持,QML 的qsTr()也有复数重载。用法是第一个参数为单数形式,第二个参数为复数形式,第三个参数是数值:
Text { // 参数1:单数,参数2:复数,参数3:数 // 翻译时 Linguist 里会出现两种复数形式(中英文只有一种) text: qsTr("%n item(s)", "", count) }这里有个细节:%n是 Qt 特有格式化标记,它会被自动替换为 count 的值,并根据当前语言选择正确的复数形式。如果你手写qsTr("Items: " + count),那翻译文件里只能硬编码“项目:”这类不带数字的字符串,数字永远在词尾,日文/中文语序不同时会出现“Items: 3件”这种中英混合的尴尬。所以遇到数量表达,一律用%n。另外注意%n只在整数时生效,如果你要显示浮点数(比如3.5 小时),Qt 语言的文件里无法直接处理,得用qsTr("%1 hours").arg(3.5),这时候复数规则就丢了,得自己在代码里判断。
5.2 语言切换时的 QQmlEngine::retranslate 与事件过滤机制
刚才提到动态切换语言时无法自动刷新 QML 已实例化界面,其实 Qt 自己提供了一个内部机制:QEvent::LanguageChange。在 QML 中,顶层Window或Item可以收到这个事件,如果你手动给某个 Item 安装eventFilter,就能在语言切换时触发回调:
// MyPanel.qml Item { function retranslateUi() { titleText.text = qsTr("Panel Title") descText.text = qsTr("This is panel description") } Component.onCompleted: retranslateUi() Connections { target: languageManager function onRetranslationRequired() { retranslateUi() } } }这种写法虽然到处要手动绑定,但当你只有少数几个复杂面板时,它比重建 Loader 更灵活,因为你可以在 retranslateUi 里处理专业逻辑(比如同时调整布局方向和可见性)。我的做法是:把需要手动刷新的 Item 统一继承一个 BaseTranslatorItem,在该组件内自动连接 LanguageManager 信号,子类只重写retranslateUi()。这样项目里既不会出现全局重建的卡顿,也不会漏掉某个弹窗。
5.3 翻译上下文管理:当多个 QML 文件共享同一段文本时
qsTr()的上下文是 QML 文件名,如果不同 QML 文件里有相同字符串(比如“确定”“取消”),它们会各自生成一个条目,翻译时你需要在 Linguist 里翻译两次(因为两个文件是不同的 contexts)。这没错,但也意味着翻译工作量可能翻倍。解决办法是用qsTranslate()指定一个公共上下文,把常用词统一放在一个共享 ts 文件里:
// ButtonLabel.qml 或者 DialogCommon.qml Text { // 公共上下文:CommonDialog text: qsTranslate("CommonDialog", "OK") }但要注意:qsTranslate指定的上下文字符串,必须在 ts 文件里也存在对应的<context><name>CommonDialog</name></context>块。你可以提前用 lupdate 扫描生成,也可以在 Linguist 中手动添加上下文。实际项目中,我建议只对高频通用词(“确定”“取消”“保存”“删除”)使用qsTranslate,其他界面专属词用默认上下文,否则 Linguist 里会出现一堆你手动定义的 context,管理更混乱。
5.4 动态添加语言包:不重启加载新语言
常规流程是预先把所有语言 qm 打包进资源或目录,切换仅做加载。但产品上线后可能临时要求新增一种语言,不能发版。做法是在运行时从外部配置路径加载 qm 文件:
void LanguageManager::asyncLoadExternalTranslation(const QString &lang, const QString &filePath) { auto *translator = new QTranslator(this); if (translator->load(filePath)) { // filePath 必须是绝对路径 QCoreApplication::installTranslator(translator); // 如果先前的 translator 是同一个语言,需要先移除旧对象 emit retranslationRequired(); } else { delete translator; QMessageBox::warning(nullptr, "Load failed", "Cannot load " + filePath); } }这里要特别注意的是,外部加载的 qm 文件名不能和内置资源命名冲突,否则load时会优先找到内置库。“所见即所得”的测试方法是:把外部 qm 放一个带版本号的目录(如translations/v2/zh_CN.qm),加载后立刻currentLanguageChanged。这个机制能支撑“热更新翻译”的需求,但要注意安全风险——外部翻译包等同于可执行资源,必须做签名校验,且不能支持任意路径加载,否则用户或攻击者可以替换翻译包伪造界面文案。
6. 让翻译全链路可控:验证工具与终端测试习惯
6.1 用 lupdate 覆盖率检查防止漏翻译
把 lupdate 跑完后,ts 文件里的未翻译条目数量可以作为验收指标。我习惯在 CI 流程里加一步:解析 ts 文件,把<translation type="unfinished">计数打出来,超过阈值就报错。用一个小脚本即可:
# check_untranslated.sh #!/bin/bash TOTAL=0 UNFINISHED=0 for ts in translations/*.ts; do count=$(grep -c "<translation type=\"unfinished\">" "$ts" || true) total=$(grep -c "<message>" "$ts" || true) echo "$ts: $count / $total untranslated" UNFINISHED=$((UNFINISHED + count)) TOTAL=$((TOTAL + total)) done echo "Total untranslated: $UNFINISHED / $TOTAL" exit $([ "$UNFINISHED" -gt 5 ] && echo 1 || echo 0)这段脚本配合 CI 能强制团队在合并代码前补完翻译。实际操作中,我一般会把阈值设为 0 或 1,因为一旦有人习惯性提交未翻译的 ts,第二天就有几个烂尾条目混进去。
6.2 在开发环境里实时预览翻译效果
开发时手动切语言很烦,每切一次都要运行 lrelease 重新生成 qm。有一种做法是把 .ts 直接作为运行时资源加载:QTranslator 其实支持加载 .ts 文件(未编译的 XML),速度稍慢但适合调试。在main.cpp里判断一个编译期宏:
#ifdef QT_DEBUG QTranslator debugTranslator; debugTranslator.load("/home/dev/app_zh_CN.ts"); // 调试模式直接加载 ts app.installTranslator(&debugTranslator); #else trans.load(":/translations/app_zh_CN.qm"); app.installTranslator(&trans); #endif这样做的好处是:你改完 ts 存盘,让程序重走一遍加载逻辑,不需要 lrelease;缺点是不能用 qrc 那个便捷路径,必须绝对路径。在开发期天天用这个习惯能省大量时间。
6.3 我个人的终检清单
从第一个 Qt/QML 多语言项目到现在,我每次交付前都会强制走一遍这个流程:用 lupdate 生成最新 ts 并检查是否有新增未翻译项;用 Qt Linguist 打开 ts,逐条看 source 和 translation 是否匹配,重点检查带%1、%n的条目;在模拟器里把系统区域切到目标语言,跑一遍主流程,确认所有界面元素都随语言切换刷新;观察控制台有没有Cannot load translator或者qm文件缺失的警告;最后把 ts 文件 diff 下,确认提交的 ts 是更新后的版本而不是上次的缓存。从那以后我每次交付前都强制走一遍这个清单,宁可慢十分钟也不上线后再抓头发。希望帮到你。
本文还有配套的精品资源,点击获取