
去年接了一个桌面端工具的外贸版改造客户要求界面同时支持中文、英语、日语三种语言还得做到运行中切换不用重启。这个需求放到Qt/C侧其实好说但一到QML层翻译不生效切换完界面一半中文一半英文复制过来的翻译文件打开就报错这类问题就接连冒出来前后折腾了两周才理顺。后来我把这套流程整理成了固定的内部规范陆续用到两个新项目上基本没再翻过车。这篇东西就专门聊Qt/QML里多国语言的完整实现从工具链配置、源字符串提取、翻译发布到运行时的动态切换把C和QML两侧都讲透顺带把文档里不会写的坑全列出来。如果你正在做带界面的Qt应用或者打算把一个已有项目改造成多语言版本这篇应该能帮你省下不少时间。1. 先搞懂Qt国际化的底层分工C靠trQML靠qsTr1.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. 动态切换语言不只是调一下installTranslator3.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的变体。英语规则是n1时用单数其余用复数俄语的规则是一套复杂的多类复形式日语甚至没有复数区分任何数量都用同一个形式。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文件里是否存在typeunfinished的条目。如果存在就中断构建并提示缺少翻译。没有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的国际化学起来不算难但真正做到干净利落靠的还是这些细节上的较真。