国际化和本地化:tr()、TS 文件、QM 文件与多语言切换
引言
上一章用 QSS 把界面的颜色、边框和间距从业务逻辑中分离出来。本章解决另一件同样基础的事:用户看到的文字不能长期写死在代码里 。一个面向中文用户的 Widgets 程序,通常先以中文源文本开始;当需要英文界面时,应让翻译系统负责替换可见文字,而不是在每个槽函数里写一串 if (english)。
Qt 将这件事拆成一条清晰链路:代码和 .ui 文件中的 tr() 产生源文本,lupdate 提取为 .ts 翻译目录,Qt Linguist 负责人工翻译与校验,lrelease 将结果编译成运行时读取的 .qm 文件,QTranslator 再把它安装到应用中。
本文面向 Qt 6 Widgets,按"先会用 -> 配置构建 -> 使用 Linguist -> 运行时切换 -> 追踪源码"的顺序展开。读完后应能:
- 给一个 CMake Widgets 工程接入中文/英文翻译;
- 理解
tr()、.ts、.qm各自的职责,知道哪些文件应提交到版本库; - 用 Qt Linguist 完成翻译、标记完成并生成 QM 文件;
- 在不重启程序的前提下切换语言,并正确处理
LanguageChange; - 沿公开 API 理解一次
tr()如何查询QTranslator; - 区分国际化(i18n)与本地化(l10n),正确处理日期、数字和复数。

图 1:.ts 是可读、可审阅的翻译目录;.qm 是供程序快速查询的二进制结果。源码只负责声明"这段文字可翻译",不应自己保存多份语言文本。
一、先会用:一个可以即时切换语言的小窗口
下面示例有标题、说明文字、操作按钮和语言下拉框。源文本使用中文;选择 English 后,已安装的英文翻译会替换所有由 tr() 得到的文字。这个例子刻意不使用 Designer,便于先看清楚每一处可翻译文本;使用 .ui 时的处理方式会在后文说明。
1.1 工程结构
text
i18n_demo/
├── CMakeLists.txt
├── main.cpp
└── translations/
└── i18n_demo_en.ts # lupdate 生成、Linguist 编辑,提交版本库
构建后会生成 i18n_demo_en.qm。本章将它嵌入资源系统,因此不需要依赖工作目录;若改为外置部署,后文会说明应如何加载。
1.2 CMake 配置
LinguistTools 提供 lupdate 与 lrelease 的 CMake 集成。下面是 Qt 6 的推荐写法:
cmake
cmake_minimum_required(VERSION 3.21)
project(i18n_demo LANGUAGES CXX)
find_package(Qt6 REQUIRED COMPONENTS Widgets LinguistTools)
qt_standard_project_setup(
I18N_SOURCE_LANGUAGE zh
I18N_TRANSLATED_LANGUAGES en
)
qt_add_executable(i18n_demo
main.cpp
)
target_link_libraries(i18n_demo PRIVATE Qt6::Widgets)
qt_add_translations(i18n_demo
TS_FILES
translations/i18n_demo_en.ts
RESOURCE_PREFIX "/i18n"
)
set_target_properties(i18n_demo PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED ON
)
这里最容易混淆的是三个动作:qt_add_translations() 并不翻译文字,它为目标配置更新 TS、编译 QM、把 QM 加入资源 的构建规则;I18N_SOURCE_LANGUAGE 表示源文语言;I18N_TRANSLATED_LANGUAGES 声明需要维护的目标语言。将来增加日语时,在这里加入 ja,并为它增加一个 TS 文件即可。
先配置工程,再执行下面两个目标:
powershell
cmake -S . -B build
cmake --build build --target update_translations
cmake --build build --target release_translations
第一次执行 update_translations 时,lupdate 会创建或填充 translations/i18n_demo_en.ts。release_translations 调用 lrelease 生成 .qm;普通 cmake --build build 也会按依赖关系在需要时生成运行资源。不同 Qt 6 小版本的构建树中可能还会看到以目标名命名的 lupdate/lrelease 辅助目标,不必手工调用可执行文件。
.ts是源文件,应和 C++ 一起提交、参与 Code Review;.qm通常是构建产物。若发布方式是"资源内嵌",无需单独携带 QM;若采用外置翻译包,则把它作为安装产物处理,不要从开发机的 build 目录临时复制。
1.3 完整代码
cpp
#include <QApplication>
#include <QComboBox>
#include <QEvent>
#include <QLabel>
#include <QPushButton>
#include <QTranslator>
#include <QVBoxLayout>
#include <QWidget>
class TranslationDemo : public QWidget
{
Q_OBJECT
public:
TranslationDemo()
{
titleLabel = new QLabel(this);
titleLabel->setStyleSheet("font-size: 20px; font-weight: 600;");
descriptionLabel = new QLabel(this);
descriptionLabel->setWordWrap(true);
languageLabel = new QLabel(this);
languageBox = new QComboBox(this);
// 语言名称保持用户熟悉的写法,不把它当作待翻译界面文案。
languageBox->addItem(QStringLiteral("中文(简体)"), QStringLiteral("zh_CN"));
languageBox->addItem(QStringLiteral("English"), QStringLiteral("en"));
saveButton = new QPushButton(this);
statusLabel = new QLabel(this);
auto *layout = new QVBoxLayout(this);
layout->addWidget(titleLabel);
layout->addWidget(descriptionLabel);
layout->addWidget(languageLabel);
layout->addWidget(languageBox);
layout->addWidget(saveButton);
layout->addWidget(statusLabel);
connect(languageBox, &QComboBox::currentIndexChanged,
this, &TranslationDemo::switchLanguage);
connect(saveButton, &QPushButton::clicked, this,
[this] { statusLabel->setText(tr("设置已保存。")); });
retranslateUi();
}
protected:
void changeEvent(QEvent *event) override
{
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
QWidget::changeEvent(event);
}
private slots:
void switchLanguage(int index)
{
const QString language = languageBox->itemData(index).toString();
// 先移除旧翻译器。选择源语言时,tr() 会返回中文源文。
qApp->removeTranslator(&translator);
if (language == QStringLiteral("zh_CN")) {
return;
}
if (!translator.load(QStringLiteral(":/i18n/i18n_demo_en.qm"))) {
statusLabel->setText(tr("英文翻译文件加载失败。"));
return;
}
qApp->installTranslator(&translator);
}
private:
void retranslateUi()
{
setWindowTitle(tr("国际化示例"));
titleLabel->setText(tr("应用设置"));
descriptionLabel->setText(
tr("选择界面语言后,窗口中的可翻译文字会立即更新。"));
languageLabel->setText(tr("界面语言:"));
saveButton->setText(tr("保存设置"));
if (statusLabel->text().isEmpty()) {
statusLabel->setText(tr("尚未保存。"));
}
}
QTranslator translator;
QLabel *titleLabel = nullptr;
QLabel *descriptionLabel = nullptr;
QLabel *languageLabel = nullptr;
QComboBox *languageBox = nullptr;
QPushButton *saveButton = nullptr;
QLabel *statusLabel = nullptr;
};
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
TranslationDemo window;
window.resize(440, 250);
window.show();
return app.exec();
}
#include "main.moc"
示例把 QTranslator 作为窗口成员,保证它在窗口存活期间有效。不能 在 switchLanguage() 中创建一个局部 QTranslator 后安装:函数返回时对象销毁,应用保留的指针会失效。大型程序更适合让一个应用级 LanguageController 拥有翻译器,窗口只负责响应 LanguageChange。
tr() 的核心意义不是"翻译函数会猜中语言",而是为源文本提供稳定的上下文(context) 。在 TranslationDemo 成员函数中,tr("保存设置") 的 context 是类名 TranslationDemo;Linguist 正是靠 context、源文、可选注释和复数数量共同定位一条翻译。
1.4 翻译配置相关说明
tr()负责声明,TS负责保存,Linguist负责翻译,QM负责运行时查询,QTranslator负责加载,LanguageChange负责通知界面更新。
| 翻译相关组件 | 本质 | 谁产生 | 谁使用 |
|---|---|---|---|
tr() |
翻译入口 | 开发者 | C++/Qt |
.ts |
翻译源文件 | lupdate |
Linguist |
| Qt Linguist | 翻译工具 | Qt | 开发者/译者 |
.qm |
二进制翻译文件 | lrelease |
QTranslator |
QTranslator |
运行时翻译器 | 程序 | Qt |
LanguageChange |
语言变化事件 | Qt | QWidget |
QLocale |
地区格式化规则 | Qt | 程序 |
二、tr() 应该放在哪里
2.1 先包住所有给用户看的固定文案
cpp
setWindowTitle(tr("下载中心"));
startButton->setText(tr("开始下载"));
messageLabel->setText(tr("已完成 %1 / %2 个任务")
.arg(done)
.arg(total));
应翻译的是用户可见的固定模板,%1、%2 是运行时数据。不要把用户文件名、数据库内容、协议字段或稳定 ID 放进 tr():它们不是由翻译目录维护的文案。
cpp
// 正确:模板可翻译,文件名只是参数。
label->setText(tr("正在处理:%1").arg(fileName));
// 不要这样做:每个文件名都被误当作翻译键。
label->setText(tr(fileName.toUtf8().constData()));
不要把动态字符串放进 tr(),下面这个例子执行翻译错误:
cpp
QString name = "张三";
// 错误
label->setText(tr(name.toUtf8().constData()));
tr() 需要在程序构建/扫描阶段就知道 source text,lupdate扫描源码找到"正在处理:%1",然后生成.ts文件。
这里有一个重要的原则:tr() 的参数应该是稳定的源文本,动态数据通过 %1、%2、%n 等占位符传入。
2.2 自定义类、普通函数和 QObject::tr()
继承 QObject 且带 Q_OBJECT 的类,可直接使用自己的 tr();它的类名会成为 context。自由函数没有类 context 时,可显式调用 QObject::tr():
cpp
QString defaultProfileName()
{
return QObject::tr("默认配置");
}
这会把 context 记为 QObject。它适合真正跨类共享的少量通用文案;不要为了省事让整个项目都使用 QObject::tr(),否则 Linguist 中很难分辨"打开"属于哪个界面和业务含义。
2.3 .ui 文件已经为你调用了翻译入口
Designer 的 uic 生成代码会把界面文字放进 retranslateUi(),形式类似:
cpp
saveButton->setText(QCoreApplication::translate("SettingsDialog", "保存"));
因此 .ui 中设置的 text、windowTitle、toolTip 等属性同样会被 lupdate 提取。运行时切换语言时,在窗口的 changeEvent() 中调用生成类的重翻译函数:
cpp
void SettingsDialog::changeEvent(QEvent *event)
{
if (event->type() == QEvent::LanguageChange) {
ui->retranslateUi(this);
updateRuntimeTexts(); // 代码动态创建的文字在这里统一重设
}
QDialog::changeEvent(event);
}
不要把 ui->retranslateUi(this) 当作"自动翻译所有东西"。它只处理 .ui 中由 uic 生成的设置;定时器消息、动态菜单项、模型表头、错误提示等代码生成的文本,仍要在自己的 updateRuntimeTexts() 中重新赋值。
三、从 TS 到 QM:不要手写翻译流水线
.ts 是 XML 形式的翻译目录。一条记录大致如下:
xml
<context>
<name>TranslationDemo</name>
<message>
<source>保存设置</source>
<translation>Save Settings</translation>
</message>
</context>
源文变更后,先重新运行 lupdate,它会按照 context 和源文更新目录;翻译完成后再用 lrelease 编译。日常流程固定为:
改 C++/.ui -> update translations -> Linguist 翻译/复查 -> release translations -> 运行验证。
不要手工批量搜索替换 XML,也不要修改 .qm。前者容易破坏状态、复数和上下文,后者是二进制产物;真正需要审阅的是 TS 中源文、译文和备注。
3.1 Qt Linguist 的使用步骤
- 执行一次
cmake --build <构建目录> --target update_translations,确保 TS 已包含新源文。 - 在 Qt Creator 的项目树中打开
translations/i18n_demo_en.ts(示例),或从 Qt 安装目录启动 Qt Linguist 后选择"打开"。 - 在左侧 context 列表选择
TranslationDemo,在中间消息列表选择未完成项。 - 阅读源文和开发者备注,在下方 Translation 编辑区填入英文;确认无误后标记为完成。
- 使用"验证"检查占位符、加速键等问题,保存 TS。
- 回到构建执行
release_translations,随后启动程序验证加载与切换。
下面以工程为例,一个没有进行翻译的ts文件,通过qt语言家打开是这样:

在TS 中存在 source,但翻译状态 是 unfinished ,lrelease 时不会把它作为有效翻译使用,运行时最终回退到 source text。
按照下图的操作顺序进行翻译并生成.qm文件,启动程序并加载qm文件就能看到翻译后的文本显示。

3.2 给译者的备注、歧义和加速键
"打开"既可以是动词,也可以是状态。不要让译者靠猜;使用 disambiguation,或在源代码旁给出 //: 开头的译者备注:
cpp
//: 菜单命令,动词"打开文件"
openAction->setText(tr("打开", "verb"));
//: 文件当前状态,形容词"已处于打开状态"
stateLabel->setText(tr("打开", "state"));
第二个参数不显示给用户,却会参与翻译键的匹配。备注会进入 TS,帮助翻译者理解领域语义。带 & 的菜单文字也是同理:tr("&文件") 中的 & 表示快捷键助记符,翻译后应按目标语言重新选择可用字母,不能机械保留原位置。
四、运行时多语言切换:安装翻译器只是第一步
QTranslator::load() 只把 QM 读入翻译器对象;QApplication::installTranslator() 才使应用的后续 tr() 查询能看到它。移除或安装翻译器后,Qt 会触发 QEvent::LanguageChange,但已经显示在屏幕上的旧 QString 不会凭空变成新语言,窗口必须重新调用 tr() 并设置控件文本。

图 2:语言切换不需要销毁窗口。翻译器列表变化后,窗口收到 LanguageChange;retranslateUi() 再从新的翻译器中取回文本。业务数据、选择状态和输入内容不应因语言变化丢失。
4.1 安装顺序会影响覆盖关系
应用可安装多个 QTranslator,并按照后安装者优先的顺序查询。这正适合"先安装 Qt 自带翻译,再安装产品自己的翻译",让产品同名文案能够覆盖默认文本。
cpp
QTranslator qtBaseTranslator;
qtBaseTranslator.load(QLocale("zh_CN"), "qtbase", "_",
QLibraryInfo::path(QLibraryInfo::TranslationsPath));
qApp->installTranslator(&qtBaseTranslator);
QTranslator appTranslator;
appTranslator.load(":/i18n/i18n_demo_zh_CN.qm");
qApp->installTranslator(&appTranslator); // 后安装,优先查询
Qt 模块翻译文件名、安装位置会随 Qt 发行版变化,应使用 QLibraryInfo::TranslationsPath 找到 Qt 的翻译目录;自己的翻译文件则建议使用资源路径或明确的安装目录。两类翻译器都必须拥有足够长的生命周期。
4.2 外置 QM 与资源内嵌怎么选
| 方式 | 加载路径 | 适合场景 | 注意点 |
|---|---|---|---|
| 资源内嵌 | :/i18n/app_en.qm |
固定语言包、小型桌面应用 | 语言包随可执行文件发布,更新语言需更新程序 |
| 外置文件 | applicationDirPath()/translations |
可独立更新语言包、插件化产品 | 安装规则、签名、缺失提示与路径测试要完整 |
外置文件不能写成相对路径 "translations/app_en.qm" 并假定总能工作。用户可能从快捷方式、命令行或 IDE 用不同工作目录启动程序;应以 QCoreApplication::applicationDirPath() 或明确的配置目录为基准,并在 load() 失败时记录实际路径。
五、本地化不只等于翻译文字
国际化是为多地区使用预留结构,tr()、TS、QM 属于这一层;本地化是针对目标地区给出正确内容和格式。切换英文 UI 不意味着数字、日期、货币和排序会自动符合英文习惯,格式化时应使用同一个目标 QLocale:
cpp
const QLocale locale("en_US");
priceLabel->setText(locale.toCurrencyString(123456.78, "USD"));
timeLabel->setText(locale.toString(QDateTime::currentDateTime(),
QLocale::ShortFormat));
不要用字符串拼接实现"¥%1"或"%1 年 %2 月 %3 日"。货币符号位置、小数点、分组符、日期顺序和月名都随地区变化。语言代码和地区代码也不是一回事:en_US、en_GB 同为英语,却可能使用不同日期、货币和计量习惯。
5.1 复数必须交给 tr()
cpp
fileCountLabel->setText(tr("已选择 %n 个文件", nullptr, count));
第三个数量参数会让 lupdate 创建 numerus 翻译项。英语需要区分 1 file 与 2 files,其他语言的复数规则还可能更多;不要先把 count 转为字符串再拼接单复数。TS 中的每种复数形式都应由 Linguist 完成。
5.2 数据与界面文案的边界
数据库的"状态码 -> 本地化名称"应由领域映射表或资源维护,而不是让 UI 直接翻译任意外部字符串。一个稳定的枚举值可以映射为固定 tr() 模板:
cpp
QString statusText(TaskStatus status)
{
switch (status) {
case TaskStatus::Queued: return tr("等待中");
case TaskStatus::Running: return tr("运行中");
case TaskStatus::Done: return tr("已完成");
}
return {};
}
这样数据层依然只保存 TaskStatus,界面层才负责本地化显示;切换语言后可重新渲染模型或刷新视图,而不用修改业务数据。
六、从 tr() 到 QM:源码调用链怎么看
下面的私有实现名称来自 Qt 6 Core 源码,用于建立心智模型,不是应用程序应链接或包含的 private API。公开入口保持稳定:业务代码只使用 tr()、QCoreApplication::translate()、QTranslator 与事件处理。

图 3:翻译键不是只有一段可见文本。context、source text、disambiguation 和复数数量共同决定能否命中 QM 中的翻译;未命中时 Qt 用源文兜底。
6.1 tr() 先把类名变成 context
在带 Q_OBJECT 的类中,tr("保存设置") 是 QObject::tr() 的类级便利入口。它通过该类的静态元对象取得类名,并概念上转交为:
cpp
QCoreApplication::translate("TranslationDemo", "保存设置", nullptr, -1);
这里的 TranslationDemo 是翻译 context,不是窗口标题。改类名会改变翻译键并使既有 TS 条目失配,因此不要把类重命名当作无影响的纯重构;重命名后必须更新 TS,并让译者确认上下文是否仍正确。
6.2 QCoreApplication 逆序询问已安装翻译器
QCoreApplication::translate() 的实现位于 Qt Core 的应用程序逻辑中。它读取当前已安装的翻译器列表,按后安装优先的顺序调用每个 QTranslator::translate()。第一个返回非空译文的翻译器决定结果;全部未命中时,tr() 回退到原始 source text。
这解释了两个常见现象:没有加载 QM 时,中文源文仍能正常显示;同一 source text 在不同 context 下可以得到不同译文。也解释了为什么将"临时补丁翻译器"最后安装可覆盖旧译文,但这种覆盖应有清楚的所有权和卸载顺序。
6.3 QTranslator 用复合键检索 QM
QTranslator::load() 会读取 QM 的二进制翻译数据;QTranslator::translate() 使用 context、sourceText、disambiguation 和 n 查询其中的消息。QM 的设计目标是运行时高效查询,不是供人工编辑,所以源码分析时应把它理解为"已编译索引",而不是另一个 XML 文件。
翻译缺失的诊断顺序也因此固定:先确认 load() 返回 true,再确认已调用 installTranslator(),再检查类 context、源文、备注和复数参数是否与 TS 条目一致,最后检查当前显示文字是否在 LanguageChange 后重新计算。不要先在字符串外面补一个英文 if,那会掩盖真正的翻译键问题。
6.4 安装/卸载为何触发 LanguageChange
翻译器列表改变后,Qt 的应用层会向顶层 Widgets 发出语言变更通知,并将事件传播到控件树。QWidget::event() 最终使重写的 changeEvent() 获得 QEvent::LanguageChange;你的重翻译函数在这里重新设置文字。这个过程是事件驱动的,不应在每个控件上手工遍历并调用 repaint(),文本更新后控件会在正常事件循环中重绘。
这条路径只改变显示层。当前编辑框的用户输入、表格选择、任务进度和业务对象都不应被"重新初始化";因此 retranslateUi() 应只做文字、菜单、表头、提示等显示赋值,避免顺手重建模型或清空控件状态。
七、常见问题与排查顺序
7.1 TS 中没有新文字
确认文案被 tr()、QCoreApplication::translate() 或 .ui 的可翻译属性包住,然后执行 update_translations。仅运行普通构建不会替代源文扫描;此外,改了源码却没有重新配置或使用了错误 build 目录,也会看到旧 TS。
7.2 Linguist 已翻译,运行时仍显示源文
依次检查:TS 是否已保存,release_translations 是否重新生成 QM,QTranslator::load() 是否返回 true,资源路径是否为 :/i18n/...,翻译器是否仍存活并被安装。最后检查 context 是否因类名或 QObject::tr() 的使用方式而不同。
7.3 切换语言后只有部分控件改变
.ui 控件需调用 ui->retranslateUi(this);代码创建的控件需在 changeEvent() 中重新 setText(tr(...));模型表头、菜单、状态栏和延迟创建的对话框也必须纳入同一个重翻译入口。不要在语言切换槽中只改当前点击的按钮。
7.4 翻译器装上后程序异常或切回语言失效
检查 QTranslator 生命周期和移除顺序。翻译器必须在 removeTranslator() 后才允许析构;切换时先移除旧翻译器、成功加载新 QM 后再安装。对于失败路径,要让界面保留可读的源文并记录错误,而不是留下半安装状态。
7.5 %1、%n 或 & 被翻坏了
这些是格式和交互协议的一部分。Linguist 验证能发现一部分占位符不一致;发布前还应实际点击菜单、触发复数、传入多个参数。不要依赖肉眼检查一条英文样例覆盖所有复数形式。
八、入门阶段 API 速查表
| API / 工具 | 职责 | 入门阶段的正确使用 |
|---|---|---|
tr("...") |
声明类上下文中的可翻译源文 | 所有需要随界面语言变化的固定用户可见文案,都应该进入翻译系统;运行数据用 .arg() 传入 |
QObject::tr("...") |
自由函数或公共文案的翻译入口 | 少量跨类文案可用,不要让它吞掉业务 context |
QCoreApplication::translate() |
显式指定 context 的底层公开入口 | 通常由 tr() 或 uic 生成代码调用 |
lupdate / update_translations |
从 C++、.ui 提取和更新 TS |
每次源文修改后执行 |
| Qt Linguist | 编辑、校验、完成 TS 翻译 | 不手改 QM;发布前处理 unfinished |
lrelease / release_translations |
将 TS 编译成 QM | 翻译确认后、打包前执行 |
QTranslator::load() |
读取 QM | 检查返回值,使用资源绝对路径或稳定安装路径 |
installTranslator() |
让后续查询使用翻译器 | 保证翻译器生命周期;后安装者优先 |
QEvent::LanguageChange |
通知窗口重新取文本 | 在 changeEvent() 中调用 retranslateUi() |
QLocale |
本地化数字、日期、货币、排序 | 与 UI 目标地区一致,避免手工字符串拼接 |
总结
Qt 国际化的上手点很小:把界面固定文字写成 tr("..."),用 qt_add_translations() 配置 TS 与 QM,然后在 Linguist 中完成翻译。真正可靠的工程实践是把它当成一条构建流水线:源文更新由 lupdate 提取,TS 是协作资产,QM 是运行产物,QTranslator 负责查询,LanguageChange 负责让已显示的 Widgets 重新取文案。
从源码角度看,tr() 并不是全局字符串替换。它携带类 context,将查询交给 QCoreApplication 已安装的翻译器列表;QTranslator 再用 context、源文、备注和复数数量命中 QM 索引。理解这条链路后,翻译不生效、切换不刷新、同词不同义和临时覆盖等问题都有了可验证的排查入口。
最后记住边界:翻译文字是 i18n 的一部分,日期、货币、数字和复数规则同样需要本地化;显示层语言切换不能污染业务数据,也不应该丢失用户正在编辑或选择的状态。
第一阶段至此完成。下一阶段将从信号与槽进入 Qt 的核心机制:先理解它为什么能替代许多手写回调,再逐步走进连接、分发与队列投递的源码路径。