Qt 源码分析 --- Qt 原生多语言机制的实现原理
转载请注明原文链接: http://brabbit.xyz/blog/NoteQt/Blog/Qt源码分析_Qt原生多语言机制.html
本文通过 Qt 源码介绍 Qt 原生多语言机制的实现原理.
文章目录
- 前言
- 分析 : Qt 原生多语言机制原理与实现方式
- 总结
前言 : 多语言适配的目标与需解决的问题
应用程序多语言适配从实现思路上分两种方式 :
- “冷切换” : 用户选择语言, 待程序下一次启动初始化时读取对应的语言文件.
- “热切换” : 用户选择语言, 程序实时地切换对应的语言文件并更新至 UI.
这里第一种 “冷切换” 的方式实现起来比较简单, 不在本文的讨论范围之内. 而关于 “热切换” 的实现方式, 我把基本原理总结为下面这三个步骤, 也是我们适配多语言时需要达成的三个阶段性目标 :
多语言动态切换的步骤 / 目标 :
- 监测到切换语言的动作, 根据目标语言加载对应的语言文件.
- 根据从语言文件载入的数据, 更新应用程序内存放 UI 文本的数据结构.
- 将存放 UI 文本的数据结构内的新语言文本更新至 UI 控件.
这里强调下 2.
3.
两步骤, 加载新的语言文件后不应该直接设置到 UI 控件上, 除非你的 UI 界面永远不会发生变化. 所以必然需要一个储存语言文本的数据结构统一管理, 下文会再说到这一点.
至于实现细节部分, 还有很多需要考虑的点与很多要解决的问题. 这些问题不光涉及到软件工程, 同样也需要考虑到语言学和平面设计等方面. 我这里简单罗列了几条对于开发人员来说需要注意的点, 也是开发过程中大概率会遇到的问题:
实现多语言动态切换需要解决的问题 :
- 语法兼容 : 对于静态文本来说无所谓, 但动态拼接的文本需要考虑不同语言之间的语法差异. 如英文中的序数词和中文中的量词要如何在另一种语言中对应.
- 歧义消除 : 同一段文本在一种语言内或许可在多处复用, 但这并不代表在另一种语言中也可以在相同的地方复用. 如英文 “Start” 在中文的不同语境下可能需要分别翻译为 “启动” 或 “开始”.
- 字符串格式化 : 与第
1.
条类似, 部分需要格式化拼接的文本在不同语言下的或有格式不同. 如中文日期 “2021年 十月 2号” 与英文日期 “October 2, 2021” 对应的格式化字符串不同.
这几个问题并不是全部, 若要真正做到兼容多个地区/语言的使用习惯是门大学问, 此处不再展开讨论.
分析 : Qt 原生多语言机制原理与实现方式
Qt 原生多语言机制涉及到如下几个类 : QCoreApplication
, QObject
及其派生类, QTranslator
以及 QApplication
.
结论在前
先说结论, Qt 实现了这么一个核心功能 : 用户可以在任意时间任意位置获取到某段指定文本的最新翻译版本. 这个功能是其整个多语言机制的核心功能, 也是 Qt 想达成的目标(唯一目标).
此外, 关于在前言中提到的三个步骤 / 目标, Qt 只达成了前两个. 最后一个目标 Qt 没有实现完全自动化, 不过可以做到即时地通知用户更新 UI 控件. 而至于我提到的三个问题, Qt 考虑到了第二条, 但消除歧义的方式很原始. 下面讲思路.
基本思路
整个系统可以分为这么几个部分来看:
- 语言文件 : 也就是 Qt 规定的
.ts
文件与.qm
文件, 其实就是原始 XML 文件与二进制的 XML 文件. Qt 规定使用 XML 文件来定义语言的翻译文本. - 语言翻译器 : 语言翻译器
QTranslator
类可以载入.qm
文件的内容, 保存某一类语言的翻译文本. 其核心功能就是充当上述所谓的 存放 UI 文本的数据结构 . - 翻译器的加载 : Qt 允许用户使用
QCoreApplication
来装载不同的语言翻译器, 可以同时使用多个翻译器且越晚装载的翻译器使用优先级越高. - 获取最新的翻译文本 : Qt 让
QCoreApplication::translate
与QObject::tr
等方法始终按照使用优先级顺序读取语言翻译器内的翻译文本. - 更新 UI 控件 : Qt 将在转载一个新的语言翻译器后通过将事件
QEvent::LanguageChange
发送至全体属于top-level widgets
范畴内的控件, 提醒其用户 手动 更新 UI 上的语言文本.
其实说到这已经概括了整个系统的工作模式, 如果只想使用这个机制的话看到这其实已经足够了. 不过我建议继续看下面更细节的实现部分, 其中会包括到一些使用方式的建议.
源码分析
下面我按照 基本思路 中的几个部分分别介绍, 这里的重点在于 QCoreApplication
与 QTranslator
.
语言文件 — .ts
文件与 .qm
文件
这一部分其实没什么好介绍的, .ts
文件其实就是 XML 文件, 完全可以按照 XML 文件格式打开. 一个标准的 .ts
文件结构如下:
1 |
|
而 .qm
文件则是转为二进制后的 .ts
文件. 也是程序最终会加载的文件. .qm
文件的内容格式没法直接看到, 但在下文中我们能够从侧面推测出它的基本结构.
个人猜测实际上
.ts
到.qm
过程并不只是单纯地转二进制. 在这之前很可能还有对.ts
文件内容的删减以及标签位置的哈希计算等, 后面会再谈到这一点.
.ts
文件与 .qm
文件的生成
我们无需手动地去设置某个原文与它的上下文等这些参数, 当我们使用 Qt linguist 工具在工程目录下使用 lupdate
指令即可生成 .ts
文件, 当然也可以用 IDE 提供的快捷操作点击生成. 当生成 .ts
文件时, Qt linguist 将检查所有可被 #include
到的 .cpp
, .h
, .ui
文件 (注意, 不包括 .ui
文件生成的 ui_xxx.h
文件). 这些文件中若出现如下方法调用即被加入 .ts
文件中:
QCoreApplication::translate("上下文", "原文")
QCoreApplication::trUtf8("上下文", "原文")
QObject::tr("原文")
QObject派生类::tr("原文")
这些方法具体的实现与参数的意思在下文中会说到, 它们是用于获取翻译文本的. 当 Qt linguist 在 lupdate
操作里扫描到某处有获取译文的代码段时, 就会自动将其参数设置为对应的 .ts
文件中的标签中. 这里要注意的是, 使用这些方法时不可出现宏, 变量名, 方法调用等任何间接设置参数的方式. 这是因为 Qt linguist 只会单纯地扫描文本, 它不会去理解你的代码. 因此若其发现参数列表中出现宏或者变量名等形式时, 将判定这段代码不是用于获取译文的.
语言翻译器 QTranslator
QTranslator
核心工作就是从 .qm
文件内加载数据并保存, 以及在 QCoreApplication
需要的时候返回其所需的翻译文本. 我在这里想重点讨论其内部的数据结构, 这是整个系统里最让我耳目一新的地方.
从表面上来看, QTranslator
在头文件里的定义如下 :
1 | class Q_CORE_EXPORT QTranslator : public QObject |
清晰明了, 提供了几个重载的 load
方法用于加载文件, 提供 translate
方法用于获取译文, 还有 isEmpty
方法用于判空. 这里简单介绍下第一种 load
方法, 先来看它的具体实现:
1 | bool QTranslator::load(const QString & filename, const QString & directory, |
根据实现部分可以看出来, 具体的加载操作是调用了 d
指针的 do_load
方法. 而 load
方法则是用于确定一个可被载入的文件, 并且它不是简单地尝试载入参数所指定的文件. 实际上 load
方法将尝试检查下面这几个文件是否存在, 若有则加载:
- 目录 + 文件名 + 后缀
- 目录 + 文件名
- 目录 + 文件名 - 文件名当前最后一个分隔符及之后的内容 + 后缀
- 目录 + 文件名 - 文件名当前最后一个分隔符及之后的内容
- ……
最终只有当文件名再也无法分割还是找不到文件后, 才会最终判定加载失败.
我们发现 QTranslator
内部并没有实际加载文件的操作, 实际上也没有实际获取文本的操作, 其实 QTranslator::translate
方法内部也是调用了 d
指针的 do_translate
方法. QTranslator
甚至也没有保存数据的成员.
实际上这里的 d
指针是 QTranslator
的私有类 QTranslatorPrivate
对象的指针. 而我们所要找的这些方法与数据结构也是在私有类中.
Qt 的源码中大量使用了私有类, 以保证最大程度上只暴露仅供用户使用的方法. 当翻阅源码时若你发现了不知道哪儿来的
d
指针或者d_func
方法调用, 那就表明了接下来是要对对应的私有类对象进行操作, 你可以不用再去到处找它们的声明位置了.
语言翻译器的内部数据结构
在我的预想中私有类应该会维护一个 QMap
或者 QHash
之类的结构来保存数据, 但事实并非如此. QTranslatorPrivate
的声明部分位于 qtranslator.cpp
中, 如下:
1 | class QTranslatorPrivate : public QObjectPrivate |
注意中文注释部分, 这里直接使用了字节数组来保存所有数据, 而每个数据的位置则是通过偏移量来确定的, 最后单独记录了这些数组的总长. 那么重点就在于这个偏移量是怎么计算的. 注意这里类中定义的枚举, 唯一能与偏移量相对的就只有 Hashes
, 实际上我在 translator.cpp
中也发现了几个哈希运算相关的方法. 这里猜测这个偏移量应该是哈希结果.
上文中猜测
.ts
到.qm
的过程不只有二进制化一个步骤的理由之一就源自这里.
结合这个枚举与第二个 do_load
方法, 可以猜测这里的枚举则是文件内容的标签, 那么这里加载文件的最终操作是应该是对整个文件数据逐字节读取, 根据标签判断接下来读取的内容. 不过扯了这么多都还是猜测, 下面我们直接看 do_load
和 do_translate
验证一下.
语言翻译器的加载文件操作 — do_load
QTranslatorPrivate::do_load
有两个重载方法, 结合 QTranslator
的 load
方法不难看出, 这里的重载一个是打开文件并载入数据, 另一个是解析文件数据并初始化私有类对象成员. 事实也确实如此, 那么我们直接来看第二个重载方法 :
1 | bool QTranslatorPrivate::do_load(const uchar *data, qsizetype len, const QString &directory) |
注意中文注释部分, 看来确实如我们所猜测一般. 首先偏移量确实是通过哈希算法得来, 而且哈希运算应该是在 .ts
文件转换为 .qm
文件的过程中进行的. 其次这里最终的加载操作的确是按字节读取, 根据先读入的字节数据确定后续字节数据的数据信息. 由此我们也可以猜测出 .qm
文件的数据基本格式如下:
数据分组 | 数据长度 | 数据名称 | 数据内容 |
---|---|---|---|
第一组 | 1 字节 | tag | 数据标签 |
第一组 | 4 字节 | blockLen | 数据长度 |
第一组 | blockLen 字节 | data | 具体的数据信息 |
第二组 | … | … | … |
这个读取数据的处理方式有点像处理 TCP 流数据的所谓 “粘包” 现象, 根据约定先读几个字节来确定后面数据的信息, 再读对应长度的字节数获得具体数据. 当然事实上 “粘包” 本身就是个伪命题, 不过那就是另一个话题了.
语言翻译器的获取译文操作 — do_translate
现在我们知道了具体的数据存入的方式, 那么读取的方法 do_translate
内部的实现我们也可以猜个八九不离十了, 直接上源码:
1 | QString QTranslatorPrivate::do_translate(const char *context, const char *sourceText, |
这里最终调用 getMessage
来获取文本, getMessage
里面再没有什么秘密了, 它直接利用这里得到的参数从 messageArray
中得到我们需要的数据.
在这几个小节里, 你可能会注意到私有类中还有成员 subTranslators
, 并且 do_load
方法和 do_translate
方法里也有对其的相关的操作, 还有一些不知所云的变量如 QTranslator::translate
中的 comment
与 n
参数. 同时私有类中的 numerusRulesArray
具体用途也没有谈到. 我在源码中有给出一些注释简单带过, 下面就来细说这些东西的具体用途.
语言翻译器之间的附属关系
上文中说到 QTranslator
重载了多种 load
方法. 其中一个版本允许我们一次加载多个 .qm
文件数据, 实现如下:
1 | bool QTranslator::load(const uchar *data, int len, const QString &directory) |
可见, 该方法并不是加载 .qm
文件, 而是直接加载数据. 利用这个方法, 我们可以一次性将多个 .qm
文件数据合并再加载进同一个 QTranslator
中. 当且仅当 这种情况下, 才会使用到私有类中 subTranslators
成员及其相关的操作. 若使用另外两个 load
方法以一个 QTranslator
对象读取一个 .qm
文件的形式进行加载, 则不会出现 QTranslator
之间的附属关系.
至于这种特殊加载方式的用处, 我可以想到这么一种情况: 若一个应用程序过于庞大, 用同一个
.ts
文件难以管理同一种语言的译文. 这时便可以将同种语言的译文拆分进多个.ts
文件中管理. 同理我们最后也会得到多个.qm
文件. 此时若希望这些文件里的数据都能以最高优先级被读取, 则需要使用最后一种load
重载方法一次性将多个.qm
文件内的数据读取.
这样一来, 在 QCoreApplication
中, 我们实际上只装载了一个翻译器, 因此受此翻译器所直接或间接管理的文本都拥有最高的被读取优先级. 而实际上该翻译器内部还是遵循着一个 .qm
文件对应着用一个翻译器进行管理, 只不过最终译文都通过装载进 QCoreApplication
的那个翻译器返回到外部.
注意: 使用这种方法的时候, 需要保证所加载的 .qm
文件都处于同一目录下.
歧义消除与复数处理
歧义消除与复数处理是 Qt 多语言机制为翻译工作额外提供的功能. 关于这两个功能要注意的地方主要是使用方面, 因此这里不会说太多, 在下文有关翻译器使用的部分会详细说明.
私有类中的 numerusRulesArray
成员实际上存放着某些翻译文本的多个版本译文, 这些多个版本的译文主要用于解决前言中提到的 歧义消除 问题. 在 Qt 的设想下, 每一个应用程序应该默认提供英文的文本, 并以英文版为基础翻译出各个其他语言的版本. 此时便会遇到英文版的词语在其他语言的不同语境中出现多种不同的翻译版本的问题. 因此 Qt 允许在设置与获得译文时可额外指定一个字符串用于消除歧义(实际上就是标注一下它们是不同的字符串罢了), 而这个用于消除歧义的额外字符串正是 QTranslator::translate
中的 comment
参数.
但正如我所言, 这个解决方案是解决了在 Qt 设想下才出现的问题. 实际上若我们把应用程序的每一种语言文本都视作翻译文本, 默认文本仅仅只是文本的标识. 那么从根源上就不会出现这种问题. 下文会再提到这点.
至于 Qt 官方所谓的”复数处理”, 我们把它理解为一个基本的格式化字符串功能即可. 当我们设置原文的时候, 可以加入一个特殊的占位符 %n
, 该占位符的内容可以在获取译文时动态地插入进去. 插入的方式便是通过设置 QTranslator::translate
的 n
参数. 而 n
参数是一个 int
型变量, 也就是说这个特殊的占位符我们只能设置数字. 这就是 Qt 所谓的”复数处理”.
想必你也发现这个功能有多鸡肋了, 毕竟直接设置普通的占位符再动态地插入文本就可以完全覆盖这个功能, 并且内容也不仅限于
int
型. 我一开始还以为这个 “复数处理” 是处理复数(complex), 没想到是处理复数(plural) (瞧, 又是一个待消除的歧义). 对于这个功能我能想到潜在的用处只有两点:
- 使用这种方式可以避免字符串拼接时的拷贝构造等操作. 如果这类需要处理复数的文本将被大量使用, 这种方式或许会有效率上的优势(前提是你将大量地使用, 足够的大量).
- 或许部分工程不允许大规模使用
QString
或是std::string
等字符串数据格式(极低性能的嵌入式平台等), 也就无法使用其便捷的拼接操作, 此时只能使用这个方式勉强进行字符串拼接.
翻译器的加载与管理 — QCoreApplication
Qt 原生多语言机制的底层原理其实通过看 QTranslator
的源码实现就可以了解的差不多了. 因此从本小节开始, 我将更着眼于使用方式上的介绍, 以及一些功能重叠的方法的选择推荐.
虽然上一节花了很大篇幅讨论 QTranslator
的实现细节, 但具体到使用多语言机制时, 我们用到 QTranslator
的地方通常只有装载翻译器这个操作. 当我们装载翻译器之后, 就可以直接通过 QCoreAppliaction
获取需要的译文了. 这里我从 QCoreAppliaction
的定义中截取出与多语言机制有关的片段:
1 | class Q_CORE_EXPORT QCoreApplication |
这里的 installTranslator
与 removeTranslator
显然是用于装载和卸载翻译器的. 而 translate
用于获取译文, tr
方法内部也是通过调用 translate
实现功能. 这里的 QT_DECLARE_DEPRECATED_TR_FUNCTIONS
宏用于定义一个 trUtf8
方法, 本质上也是调用 translate
. 三种翻译方法参数列表完全相同.
在这几个对翻译器的操作之外, 我并没有在 QCoreApplication
的声明中看到保存翻译器的成员, 想必这又是被放在私有类中了. QCoreApplication
对应的私有类名为 QCoreApplicationPrivate
. 我截取出其声明中有关翻译机制的片段:
1 | typedef QList<QTranslator*> QTranslatorList; |
可见, 其内部使用了一个 QList
来保存翻译器. 至此, QCoreApplication
所有有关翻译机制的方法与成员都被找到了, 下面我们来看实现.
装载翻译器 — QCoreApplication::installTranslator
当我们要装载某个翻译器到 QCoreApplication
时, 请保证该翻译器已经正确读取了某个 .qm
文件. 详细细节来看 QCoreApplication::installTranslator
实现部分:
1 | bool QCoreApplication::installTranslator(QTranslator *translationFile) |
当我们尝试装载一个 QTranslator::isEmpty
返回 true
的翻译器, 此时将不会发出 QEvent::LanguageChange
事件. 但是 QCoreApplication
也不会主动把这个无效的翻译器移除 d->translators
中. 因此正确的使用方式是先让翻译器加载完 ,qm
文件, 再将其装载进 QCoreApplication
.
这里出现了一个
self
指针, 这是指向QCoreApplication
单例的指针. 就像d
指针往往用于指代某个类对应的私有类一样, 在 Qt 源码中往往使用self
指针来指向某个单例类的实例.
卸载翻译器 — QCoreApplication::removeTranslator
与 installTranslator
方法一样, removeTranslator
方法也需要指定目标翻译器的指针, 其实现如下:
1 | bool QCoreApplication::removeTranslator(QTranslator *translationFile) |
这里直接将翻译器从列表中移除, 唯一值得注意的地方就是当此处移除翻译器成功时还会发出一次 QEvent::LanguageChange
事件.
不得不说这个方法相当鸡肋, 既然我装载的时候已经把翻译器交给
QCoreApplication
管理了, 卸载的时候却还需要我持有这个翻译器的指针? 如果我持有这个指针又何必从你这里获取译文呢? 😂 或许是因为单例可以被全局访问到吧.
获取最新的翻译文本 — QCoreApplication::translate
与 QObject::tr
上一节里说到, QCoreApplication
中获取译文的方法有三种 : translate
, tr
与 trUtf8
. 但这三种最终都是通过 translate
来获取译文. 与此之外, QObject
类也提供了方法 tr
用于获取译文. 下面来逐个解析:
获取译文 — QCoreApplication::translate
QCoreApplication::tr
, 与 QCoreApplication::trUtf8
就不多说了, 我们直接来看 QCoreApplication::translate
. 由于这个方法将会在业务代码中大量地使用, 因此这里介绍一下它的参数:
const char *context
: 上下文, 通常对应着类名.const char *sourceText
: 源文本.const char *disambiguation
: 歧义消除文本, 当同上下文内出现一段源文对应多个译文时用到. 缺省值nullptr
.int n
: 复数值, 当源文本中出现了%n
占位符, 则再此指定具体要用到的复数. 缺省值-1
.
下面来看实现部分:
1 | QString QCoreApplication::translate(const char *context, const char *sourceText, |
看过上面的文章后, 再看这段代码就没有什么不理解的地方了, 这里直接按照优先级顺序通过调用 QTranslator::translate
来检查每个翻译器里是否有对应的译文.
QObject::tr
与 QCoreApplication::translate
的关系
上面说到了除了 QCoreApplication::translate
可以获取译文外, QObject::tr
也可以用于获取译文. 那么它们二者有什么关系呢? 我们凭直觉猜测后者应该也是最终调用了前者, 事实也确实如此. 而我之所以要特地花一小节来说这件事, 是因为 QObject::tr
的源码部分是比较容易误导人的.
如果你曾尝试过使用 QObject::tr
或者 QObject派生类::tr
进行多语言适配, 你会发现这个方法是不需要设置上下文的. 而当你打开生成的 .ts
文件时又会发现 Qt 自动帮你将这段原文的上下文设置为了 QObject
或 QObject派生类
. 对于这个问题我也会在这一小节里解答.
当我们直接打开 QObject
头文件中的声明部分可以看见这么一段代码:
1 | class Q_CORE_EXPORT QObject |
如果你尝试过通过 IDE 来查找 QObject::tr
的声明位置, 最后也会跳转到这里. 注意中文注释处, 这里的实现部分直接返回了原文的 UTF-8 编码. 但我们在实际使用中却的确可以通过这个方法获得译文, 这就是容易让人误会的地方.
你可能有注意到源码中我截取出来的两个宏 QT_NO_TRANSLATION
与 Q_CLANG_QDOC
, 如果你了解后者的意义, 那么也就明白了这里发生了什么. Q_CLANG_QDOC
宏实际上是 Qt 用于将其源代码生成文档的一个宏, 这个宏永远不会被 #define
. 所有被 #if defined(Q_CLANG_QDOC)
包裹住的代码段, 都将被 Qt 用脚本自动化地生成文档.
不得不说这个宏骗了我好久, 以至于我一直以为 Qt 有什么神奇的机制可以自动化为我的控件设置译文. 😓
OK, 那么现在我们已经知道了 QObject
源码中的 tr
方法是假的, 哪真正的 tr
方法又在哪呢? 其实就藏在我们经常用到但大多数人都不知所云的 Q_OBJECT
宏里. 这个宏被定义在 qobjectdefs.h
中, 我这里截取出相关片段:
1 | // qobjectdefs.h |
可见, 具体的 tr
方法是通过 QT_TR_FUNCTIONS
宏实现的, 而这个宏又被 Q_OBJECT
宏包含. 这里才是实现 tr
方法的地方. 但是事情还没结束, 我们可以看到实现部分是通过调用了 staticMetaObject.tr
这个方法的来的. 这个变量是 Q_OBJECT
宏所设置的一个 static const QMetaObject
成员. 想不到我们凭直觉猜测的 QObject::tr
和 QCoreApplication::translate
两个方法之间的关系, 具体实现中居然还牵扯到了 Qt 的元对象系统.
Qt 的元对象系统是一个很庞大复杂的机制, 在这里我们需要知道元对象系统可以提供类似 “反射” 的功能. 在下文中会再提到这一点.
不管怎么样, 我们还是得追查到底, 下面直接来看 QMetaObject
的源码, 我直接贴出相关的部分:
1 | // qobjectdefs.h // 声明部分还是在 qobjectdefs.h 中 |
终于水落石出了, QObject::tr
实际上是调用了 QMetaObject::tr
, 而 QMetaObject::tr
最后还是调用了 QCoreApplication::translate
. 我们的猜测最终得到了证实.
至于本小节开头的那个问题, QObject::tr
方法为什么不需要指定上下文? 而 Qt 为什么又自动帮我们在 .ts
文件中将上下文设置为 QObject
? 相信你看到这已经有了答案. 这个答案同时也解释了 QObject::tr
到 QCoreApplication::translate
的过程中为什么要牵扯到元对象系统. 这里实际上是利用了元对象系统的 “反射” 机制获得到调用方法的对象的类名. 也就是代码中的 objectClassName
方法. 所有当我们在代码中用 QObjcet::tr
的形式获取译文时, .ts
文件中将自动指定上下文为 QObject
; 而我们在 QObject
的派生类中调用 tr
方法, .ts
文件中将自动指定上下文为 QObject派生类
.
个人认为 Qt 库最精彩的部分就是它的元对象系统. 这里先挖个坑, 以后尝试分析 Qt 的元对象系统源码.
更新 UI 控件的时机 — QEvent::LanguageChange
事件
在讲解 QCoreApplication
时, 我们知道了当 QTranslator
翻译器在 QCoreApplication
中被正确地装载 / 卸载时, QCoreApplication
将利用 sendEvent
方法发出 QEvent::LanguageChange
事件. 这个操作涉及到了 Qt 的事件系统.
Qt 的事件系统简单来说就是为了跨平台而将各个操作系统自身的事件系统进行封装而得来的一个中间层. 任何一个 QObject
及其子类对象都可以通过重载 event
等方法来处理由 QCoreApplication
单例发来的事件. 不过要记得将你所有不想亲自处理的事件交由基类的同名方法进行处理.
QEvent::LanguageChange
事件发给了谁?
上文中我有提到, QEvent::LanguageChange
事件最终会发给所有属于 top-level widgets
范畴内的控件. 而这一小节的目的在于要解开一个看源码时你可能会面临的一个疑惑.
如果你仔细看了上文中的源码, 你会发现当 QCoreApplication
发出 QEvent::LanguageChange
事件的地方的代码是这样的:
1 | ...... |
可见 QCoreApplication
将 QEvent::LanguageChange
事件发送给了 self
指针, 也就是它自身的单实例. 于是我们接着来看它处理事件的地方, 也就是 QCoreApplication::event
方法:
1 | bool QCoreApplication::event(QEvent *e) |
可见它并没有处理 QEvent::LanguageChange
事件, 而是将其交给了 QObject::event
进行处理. 这里我没有贴出 QObject::event
的实现部分, 但我可以明确地告诉你这个方法里也没有对 QEvent::LanguageChange
事件进行任何处理. 那么这个事件究竟在哪里被处理了呢?
本文中多次提到 QCoreApplication
这个单例, 这个单例我们可以将其看作就是我们的 Qt 程序. 但相信你在 Qt 工程的 main
方法中还发现过 QApplication
或者 QGuiApplication
这两个类. 这三个类之间具有继承关系, 它们都可以代表着我们的 Qt 程序. 这里为了方便后文讲解简单介绍一下三者关系:
QCoreApplication
: 继承自QObjcet
, 是 Qt 程序的核心. 用于无 GUI 界面的 Qt 程序.QGuiApplication
: 继承自QCoreApplication
. 用于仅使用 QML 实现 GUI 界面的 Qt 程序.QApplication
: 继承自QGuiApplication
.用于使用了任意QWidget
相关类实现 GUI 界面的 Qt 程序.
当我们的应用程序是无 GUI 界面 QCoreApplication
时, 不需要考虑更新 UI 界面上文本的需求, 自然也就不用关心 QEvent::LanguageChange
事件了. 但若是有 GUI 界面的 QGuiApplication
程序或 QApplication
程序, 则是需要在接收到这个事件后更新 UI 界面上文本. 因此这里 QCoreApplication
将 QEvent::LanguageChange
发给 self
, 当 self
是后两种 Qt App 单例的话, 就应该会去处理这个事件了. 下面我们来逐个验证:
首先来看 QGuiApplication::event
的实现:
1 | bool QGuiApplication::event(QEvent *e) |
可见 QGuiApplication::event
事件是有被处理的.
由于
QGuiApplication
是用于纯 QML 实现 GUI 界面的 Qt 程序中, 而我对 QML 的了解程度基本为 0, 因此这里也没有办法再为你继续讲解这个setLayoutDirection
方法到底做了什么. 若以后我学了 QML 再来补充吧.
然后我们再来看 QApplication::event
的实现:
1 | bool QApplication::event(QEvent *e) |
由于 QApplication::event
处理了很多事件, 我把无关的部分省略了. 我们可以看到这里 QApplication
将这个事件转发给了所有从方法 topLevelWidgets
返回的控件列表内的控件. OK, 至此我们知道了 QCoreApplication
会将 QEvent::LanguageChange
发送给 Qt App 单例, 当这个单例是 QGuiApplication
或 QApplication
时便会处理这个事件. 而 QApplication
的处理方式便是将事件转发给所有数据 top-level widgets
范畴内的控件. 那么接下来的问题就在于, top-level widgets
的定义到底是什么? 它的范畴有多大?
top-level widgets
包括了哪些控件?
我们接着来看这个 QApplication::topLevelWidgets
方法究竟返回了什么样的列表:
1 | // qwindowdefs.h |
可见成为 top-level widgets
一份子的控件需要满足以下三个条件:
- 该控件在
QWidgetPrivate::allWidgets
之内; - 该控件的
isWindow
方法返回值为true
; - 该控件的
windowType
方法返回值不为Qt::Desktop
.
下面我们来逐一检查, 首先是 QWidgetPrivate::allWidgets
. 我们来看一下声明部分:
1 | // qwindowdefs.h |
根据官方注释, 这个 QSet
保存了所有控件的指针, 并且其存在目的是为了便于 QApplication
可以方便地删除所有控件. 我检查了一下实现部分, 发现确实如此. 当有 QWidget
被构造时便将其指针加入这个 QSet
, 而当这个 QWidget
被析构时, 也会将对应的指针移除这个 QSet
. 到这里条件一明确了, QWidgetPrivate::allWidgets
实际上就是当前存在的所有 QWidget
.
接下来我们来检查剩下的条件, 也就是 QWidget::isWindow
与 QWidget::windowType
的实现:
1 | class Q_WIDGETS_EXPORT QWidget : public QObject, public QPaintDevice |
这里 isWindow
通过 windowType
的返回值与枚举 Qt::WindowType::Window
进行按位与, 而 windowType
又是通过 data->window_flags
与与枚举 Qt::WindowType::WindowType_Mask
进行按位与. 那么我们再来看 QWidgetData *data
与 data->window_flags
以及 Qt::WindowType
是什么:
1 | // qwidget.h |
看来 data->window_flags
是 Qt::WindowFlags
, 而 Qt::WindowFlags
又是通过 Q_DECLARE_FLAGS
宏根据 Qt::WindowType
枚举生成的一个 QFlags
, QFlags
实际上与 enum class
类似, 都是通过 class
来实现枚举, 实际上二者提供的方法有所差异, 但在这里我们可以把 Qt::WindowFlags
看作是一个特殊的枚举类即可.
经过检查, 我们可以得出控件的 WindowType
等于这几个枚举时, 条件二成立:
Qt::WindowType::Window
Qt::WindowType::Dialog
Qt::WindowType::Sheet
Qt::WindowType::Drawer
Qt::WindowType::Popup
Qt::WindowType::Tool
Qt::WindowType::ToolTip
Qt::WindowType::SplashScreen
Qt::WindowType::Desktop
: 在条件三中不成立Qt::WindowType::ForeignWindow
Qt::WindowType::CoverWindow
而条件三则是在条件二的基础上排除了 Qt::Desktop
这个选项.
最终, 我们发现满足 top-level widgets
条件的控件是非常多的, 只有当一个控件的 WindowType
属于以下两类时, 才不被视为 top-level widgets
:
Qt::WindowType::SubWindow
: 当控件是子窗口时拥有此枚举.Qt::WindowType::Desktop
: 只有QDesktopWidget
拥有此枚举.
换言之, 只要我们想要接收 QEvent::LanguageChange
事件的控件的 WindowType
不是以上两种之一时, 就可以正常地接收到 QEvent::LanguageChange
事件了.
总结
在此之前我一直对 Qt 的多语言机制了解不够深入, 一直不知道 Qt 究竟什么时候才会跟新我的 UI 控件, 不知道 QObject::tr
方法究竟是怎么得到译文的. 不过现在, 我们分析完了 Qt 原生多语言机制的源码部分. 相信读到这的你已经对这个系统的实际运作了然于胸了.
最后, 当你使用 Qt 的多语言机制时, 请牢记我在最开始给出的结论. Qt 实现的核心功能是 : 用户可以在任意时间任意位置获取到某段指定文本的最新翻译版本, 其余的功能都是围绕着这个核心功能所展开. 相信你充分理解这句话的意思后可以游刃有余地运用 Qt 的原生多语言机制了.
我接下来会给出一个完全依赖于 Qt 原生多语言机制实现动态语言切换的范例. 不过篇幅有限, 写到这已经 1000 多行了, 之后再发上来吧.