BRabbit's Blog

This blog has super rabbit power!

转载请注明原文链接: http://brabbit.xyz/blog/NoteQt/Blog/Qt源码分析_Qt原生多语言机制.html

本文通过 Qt 源码介绍 Qt 原生多语言机制的实现原理.


文章目录


前言 : 多语言适配的目标与需解决的问题

应用程序多语言适配从实现思路上分两种方式 :

  • “冷切换” : 用户选择语言, 待程序下一次启动初始化时读取对应的语言文件.
  • “热切换” : 用户选择语言, 程序实时地切换对应的语言文件并更新至 UI.

这里第一种 “冷切换” 的方式实现起来比较简单, 不在本文的讨论范围之内. 而关于 “热切换” 的实现方式, 我把基本原理总结为下面这三个步骤, 也是我们适配多语言时需要达成的三个阶段性目标 :

<返回顶部>

多语言动态切换的步骤 / 目标 :

  1. 监测到切换语言的动作, 根据目标语言加载对应的语言文件.
  2. 根据从语言文件载入的数据, 更新应用程序内存放 UI 文本的数据结构.
  3. 将存放 UI 文本的数据结构内的新语言文本更新至 UI 控件.

这里强调下 2. 3. 两步骤, 加载新的语言文件后不应该直接设置到 UI 控件上, 除非你的 UI 界面永远不会发生变化. 所以必然需要一个储存语言文本的数据结构统一管理, 下文会再说到这一点.

至于实现细节部分, 还有很多需要考虑的点与很多要解决的问题. 这些问题不光涉及到软件工程, 同样也需要考虑到语言学和平面设计等方面. 我这里简单罗列了几条对于开发人员来说需要注意的点, 也是开发过程中大概率会遇到的问题:

<返回顶部>

实现多语言动态切换需要解决的问题 :

  1. 语法兼容 : 对于静态文本来说无所谓, 但动态拼接的文本需要考虑不同语言之间的语法差异. 如英文中的序数词和中文中的量词要如何在另一种语言中对应.
  2. 歧义消除 : 同一段文本在一种语言内或许可在多处复用, 但这并不代表在另一种语言中也可以在相同的地方复用. 如英文 “Start” 在中文的不同语境下可能需要分别翻译为 “启动” 或 “开始”.
  3. 字符串格式化 : 与第 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::translateQObject::tr 等方法始终按照使用优先级顺序读取语言翻译器内的翻译文本.
  • 更新 UI 控件 : Qt 将在转载一个新的语言翻译器后通过将事件 QEvent::LanguageChange 发送至全体属于 top-level widgets 范畴内的控件, 提醒其用户 手动 更新 UI 上的语言文本.

其实说到这已经概括了整个系统的工作模式, 如果只想使用这个机制的话看到这其实已经足够了. 不过我建议继续看下面更细节的实现部分, 其中会包括到一些使用方式的建议.

<返回顶部>

源码分析

下面我按照 基本思路 中的几个部分分别介绍, 这里的重点在于 QCoreApplicationQTranslator.

<返回顶部>

语言文件 — .ts 文件与 .qm 文件

这一部分其实没什么好介绍的, .ts 文件其实就是 XML 文件, 完全可以按照 XML 文件格式打开. 一个标准的 .ts 文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="zh_CN"> <!-- 翻译的目标语言 -->
<context> <!-- 一个上下文, 通常对应一个类 -->
<name>WindowWidgetClass</name> <!-- 上下文名, 通常与类名相对应 -->
<message> <!-- 一条文本的信息 -->
<location filename="WindowWidget.ui" line="14"/> <!-- 该文本的位置, 可以在 .ui .h .cpp 等文件中 -->
<source>WindowWidget</source> <!-- 原文内容 -->
<translatorcomment>这是窗口</translatorcomment> <!-- 提供给翻译人员的注释 -->
<translation type="unfinished">窗口</translation> <!-- 译文内容 -->
</message>
<message> <!-- 另一条文本的信息 -->
<location filename="WindowWidget.cpp" line="52"/>
<source>TextLabel</source>
<translatorcomment>这是标签</translatorcomment>
<translation type="unfinished">标签</translation>
</message>
</context>
<context> <!-- 另一个上下文 -->
<name>SubWidget</name>
<message>
<location filename="SubWidget.ui" line="14"/>
<source>SubWidget</source>
<translation type="unfinished"></translation>
</message>
</context>
</TS>

.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Q_CORE_EXPORT QTranslator : public QObject
{
Q_OBJECT
public:
explicit QTranslator(QObject *parent = nullptr);
~QTranslator();

virtual QString translate(const char *context, const char *sourceText,
const char *disambiguation = nullptr, int n = -1) const;

virtual bool isEmpty() const;

bool load(const QString & filename,
const QString & directory = QString(),
const QString & search_delimiters = QString(),
const QString & suffix = QString());
bool load(const QLocale & locale,
const QString & filename,
const QString & prefix = QString(),
const QString & directory = QString(),
const QString & suffix = QString());
bool load(const uchar *data, int len, const QString &directory = QString());

private:
Q_DISABLE_COPY(QTranslator)
Q_DECLARE_PRIVATE(QTranslator)
};

清晰明了, 提供了几个重载的 load 方法用于加载文件, 提供 translate 方法用于获取译文, 还有 isEmpty 方法用于判空. 这里简单介绍下第一种 load 方法, 先来看它的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
bool QTranslator::load(const QString & filename, const QString & directory,
const QString & search_delimiters,
const QString & suffix)
{
Q_D(QTranslator);
d->clear(); // d 是私有类对象指针
/* 确定前缀, 即目录部分 */
QString prefix;
if (QFileInfo(filename).isRelative()) {
prefix = directory;
if (prefix.length() && !prefix.endsWith(QLatin1Char('/')))
prefix += QLatin1Char('/');
}
/* 确定后缀, 分隔符等 */
const QString suffixOrDotQM = suffix.isNull() ? dotQmLiteral() : suffix;
QStringRef fname(&filename);
QString realname;
const QString delims = search_delimiters.isNull() ? QStringLiteral("_.") : search_delimiters;
/* 确定文件名, 注意这里 */
for (;;) {
QFileInfo fi;

realname = prefix + fname + suffixOrDotQM; // 检查 前缀+文件名+后缀
fi.setFile(realname);
if (fi.isReadable() && fi.isFile())
break;

realname = prefix + fname; // 检查 前缀+文件名
fi.setFile(realname);
if (fi.isReadable() && fi.isFile())
break;

int rightmost = 0;
for (int i = 0; i < (int)delims.length(); i++) {
int k = fname.lastIndexOf(delims[i]); // 定位到最后一个分隔符
if (k > rightmost)
rightmost = k;
}

// no truncations? fail
if (rightmost == 0) // 再也无法分割后判定加载失败
return false;

fname.truncate(rightmost); // 根据上文的定位分割文件名
}

// realname is now the fully qualified name of a readable file.
return d->do_load(realname, 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class QTranslatorPrivate : public QObjectPrivate
{
Q_DECLARE_PUBLIC(QTranslator)
public: /* 注意这个枚举 */
enum { Contexts = 0x2f, Hashes = 0x42, Messages = 0x69, NumerusRules = 0x88, Dependencies = 0x96 };

QTranslatorPrivate() :
#if defined(QT_USE_MMAP)
used_mmap(0),
#endif
unmapPointer(0), unmapLength(0), resource(0),
messageArray(0), offsetArray(0), contextArray(0), numerusRulesArray(0),
messageLength(0), offsetLength(0), contextLength(0), numerusRulesLength(0) {}

#if defined(QT_USE_MMAP)
bool used_mmap : 1;
#endif
char *unmapPointer; // used memory (mmap, new or resource file)
qsizetype unmapLength;

// The resource object in case we loaded the translations from a resource
QResource *resource;

// used if the translator has dependencies
QList<QTranslator*> subTranslators;

// Pointers and offsets into unmapPointer[unmapLength] array, or user
// provided data array
const uchar *messageArray; // 文本数据
const uchar *offsetArray; // 偏移量
const uchar *contextArray; // 上下文数据
const uchar *numerusRulesArray; // 歧义文本数据
uint messageLength; // 文本数据总长
uint offsetLength; // 偏移量总长
uint contextLength; // 上下文数据总长
uint numerusRulesLength; // 歧义文本数据总长
/* 上文中出现的具体 加载 / 获取译文操作 , 注意第二个 do_load */
bool do_load(const QString &filename, const QString &directory);
bool do_load(const uchar *data, qsizetype len, const QString &directory);
QString do_translate(const char *context, const char *sourceText, const char *comment,
int n) const;
void clear();
};

注意中文注释部分, 这里直接使用了字节数组来保存所有数据, 而每个数据的位置则是通过偏移量来确定的, 最后单独记录了这些数组的总长. 那么重点就在于这个偏移量是怎么计算的. 注意这里类中定义的枚举, 唯一能与偏移量相对的就只有 Hashes, 实际上我在 translator.cpp 中也发现了几个哈希运算相关的方法. 这里猜测这个偏移量应该是哈希结果.

上文中猜测 .ts.qm 的过程不只有二进制化一个步骤的理由之一就源自这里.

结合这个枚举与第二个 do_load 方法, 可以猜测这里的枚举则是文件内容的标签, 那么这里加载文件的最终操作是应该是对整个文件数据逐字节读取, 根据标签判断接下来读取的内容. 不过扯了这么多都还是猜测, 下面我们直接看 do_loaddo_translate 验证一下.

<返回顶部>

语言翻译器的加载文件操作 — do_load

QTranslatorPrivate::do_load 有两个重载方法, 结合 QTranslatorload 方法不难看出, 这里的重载一个是打开文件并载入数据, 另一个是解析文件数据并初始化私有类对象成员. 事实也确实如此, 那么我们直接来看第二个重载方法 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
bool QTranslatorPrivate::do_load(const uchar *data, qsizetype len, const QString &directory)
{
bool ok = true;
const uchar *end = data + len;

data += MagicLength;

QStringList dependencies;
while (data < end - 5) {
quint8 tag = read8(data++); // 读第 1 个字节, 是为标签
quint32 blockLen = read32(data); // 读接下来的 4 个字节, 是为数据长度
data += 4;
if (!tag || !blockLen)
break;
if (quint32(end - data) < blockLen) {
ok = false;
break;
}
/* 根据标签确定数据类型, 将本段数据开头初始化为数据数组成员, 根据长度初始化数据总长度成员 */
if (tag == QTranslatorPrivate::Contexts) { // 上下文数据组
contextArray = data;
contextLength = blockLen;
} else if (tag == QTranslatorPrivate::Hashes) { // 偏移量数据组. 哈希值对应着偏移量, 猜测正确✔
offsetArray = data;
offsetLength = blockLen;
} else if (tag == QTranslatorPrivate::Messages) { // 译文数据组
messageArray = data;
messageLength = blockLen;
} else if (tag == QTranslatorPrivate::NumerusRules) { // 歧义译文数据组
numerusRulesArray = data;
numerusRulesLength = blockLen;
} else if (tag == QTranslatorPrivate::Dependencies) { // 附属翻译文本数据组
QDataStream stream(QByteArray::fromRawData((const char*)data, blockLen));
QString dep;
while (!stream.atEnd()) {
stream >> dep;
dependencies.append(dep);
}
}

data += blockLen; // 移动指针到下一组数据的位置
}
/* 检查多套翻译数据的内容是否合法 */
if (ok && !isValidNumerusRules(numerusRulesArray, numerusRulesLength))
ok = false;
if (ok) { /* 初始化附属翻译器 (一个翻译器对应一个文本, 这里实际上是支持载入多个文本, 下文会细说) */
const int dependenciesCount = dependencies.count();
subTranslators.reserve(dependenciesCount);
for (int i = 0 ; i < dependenciesCount; ++i) {
QTranslator *translator = new QTranslator;
subTranslators.append(translator);
ok = translator->load(dependencies.at(i), directory);
if (!ok)
break;
}

// In case some dependencies fail to load, unload all the other ones too.
if (!ok) {
qDeleteAll(subTranslators);
subTranslators.clear();
}
}

if (!ok) {
messageArray = 0;
contextArray = 0;
offsetArray = 0;
numerusRulesArray = 0;
messageLength = 0;
contextLength = 0;
offsetLength = 0;
numerusRulesLength = 0;
}

return ok;
}

注意中文注释部分, 看来确实如我们所猜测一般. 首先偏移量确实是通过哈希算法得来, 而且哈希运算应该是在 .ts 文件转换为 .qm 文件的过程中进行的. 其次这里最终的加载操作的确是按字节读取, 根据先读入的字节数据确定后续字节数据的数据信息. 由此我们也可以猜测出 .qm 文件的数据基本格式如下:

数据分组 数据长度 数据名称 数据内容
第一组 1 字节 tag 数据标签
第一组 4 字节 blockLen 数据长度
第一组 blockLen 字节 data 具体的数据信息
第二组

这个读取数据的处理方式有点像处理 TCP 流数据的所谓 “粘包” 现象, 根据约定先读几个字节来确定后面数据的信息, 再读对应长度的字节数获得具体数据. 当然事实上 “粘包” 本身就是个伪命题, 不过那就是另一个话题了.

<返回顶部>

语言翻译器的获取译文操作 — do_translate

现在我们知道了具体的数据存入的方式, 那么读取的方法 do_translate 内部的实现我们也可以猜个八九不离十了, 直接上源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
QString QTranslatorPrivate::do_translate(const char *context, const char *sourceText,
const char *comment, int n) const
{
if (context == 0)
context = "";
if (sourceText == 0)
sourceText = "";
if (comment == 0)
comment = "";

uint numerus = 0;
size_t numItems = 0;

if (!offsetLength)
goto searchDependencies;
/* 这里检查该译文是否属于此翻译器, 也涉及到翻译器的附属关系, 下文会细说. */
/*
Check if the context belongs to this QTranslator. If many
translators are installed, this step is necessary.
*/
if (contextLength) {
quint16 hTableSize = read16(contextArray);
uint g = elfHash(context) % hTableSize;
const uchar *c = contextArray + 2 + (g << 1);
quint16 off = read16(c);
c += 2;
if (off == 0)
return QString();
c = contextArray + (2 + (hTableSize << 1) + (off << 1));

const uint contextLen = uint(strlen(context));
for (;;) {
quint8 len = read8(c++);
if (len == 0)
return QString();
if (match(c, len, context, contextLen))
break;
c += len;
}
}
/* 检查该翻译器有没有保存文本 */
numItems = offsetLength / (2 * sizeof(quint32));
if (!numItems)
goto searchDependencies; // to Qt : goto should goto hell (delete
/* 此处涉及到歧义消除与复数处理, 这里的 n 是文本中占位符 %n 的数值, 下文细说. */
if (n >= 0)
numerus = numerusHelper(n, numerusRulesArray, numerusRulesLength);
/* 获得译文 */
for (;;) {
quint32 h = 0; // 先计算得出哈希值
elfHash_continue(sourceText, h);
elfHash_continue(comment, h);
elfHash_finish(h);
/* 获取译文位置的偏移量 */
const uchar *start = offsetArray;
const uchar *end = start + ((numItems-1) << 3);
while (start <= end) { // 类似二分查找, 哈希值对应时得到对应的偏移量
const uchar *middle = start + (((end - start) >> 4) << 3);
uint hash = read32(middle); // 哈希值也是4字节存储
if (h == hash) { // 找到则退出循环, 此时 (start <= end) === true
start = middle;
break;
} else if (hash < h) {
start = middle + 8;
} else {
end = middle - 8;
}
}
/* 找到了就尝试获取译文文本 */
if (start <= end) {
// go back on equal key
while (start != offsetArray && read32(start) == read32(start-8))
start -= 8;

while (start < offsetArray + offsetLength) {
quint32 rh = read32(start);
start += 4;
if (rh != h) // 为啥又校验一次哈希值?
break;
quint32 ro = read32(start); // 哈希值后面四个字节是实际的偏移量
start += 4;
QString tn = getMessage(messageArray + ro, messageArray + messageLength, context,
sourceText, comment, numerus); // 这里获取文本, 到这一步该有的参数都有了
if (!tn.isNull())
return tn;
}
}
if (!comment[0])
break;
comment = "";
}
/* 想必这里就是 other translator of "many translators" witch been installed */
searchDependencies:
for (QTranslator *translator : subTranslators) {
QString tn = translator->translate(context, sourceText, comment, n);
if (!tn.isNull())
return tn;
}
return QString();
}

这里最终调用 getMessage 来获取文本, getMessage 里面再没有什么秘密了, 它直接利用这里得到的参数从 messageArray 中得到我们需要的数据.

在这几个小节里, 你可能会注意到私有类中还有成员 subTranslators, 并且 do_load 方法和 do_translate 方法里也有对其的相关的操作, 还有一些不知所云的变量如 QTranslator::translate 中的 commentn 参数. 同时私有类中的 numerusRulesArray 具体用途也没有谈到. 我在源码中有给出一些注释简单带过, 下面就来细说这些东西的具体用途.

<返回顶部>

语言翻译器之间的附属关系

上文中说到 QTranslator 重载了多种 load 方法. 其中一个版本允许我们一次加载多个 .qm 文件数据, 实现如下:

1
2
3
4
5
6
7
8
9
10
bool QTranslator::load(const uchar *data, int len, const QString &directory)
{
Q_D(QTranslator);
d->clear();

if (!data || len < MagicLength || memcmp(data, magic, MagicLength))
return false;
/* directory 是所有要加载的 .qm 文件的目录 */
return d->do_load(data, len, 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::translaten 参数. 而 n 参数是一个 int 型变量, 也就是说这个特殊的占位符我们只能设置数字. 这就是 Qt 所谓的”复数处理”.

想必你也发现这个功能有多鸡肋了, 毕竟直接设置普通的占位符再动态地插入文本就可以完全覆盖这个功能, 并且内容也不仅限于 int 型. 我一开始还以为这个 “复数处理” 是处理复数(complex), 没想到是处理复数(plural) (瞧, 又是一个待消除的歧义). 对于这个功能我能想到潜在的用处只有两点:

  • 使用这种方式可以避免字符串拼接时的拷贝构造等操作. 如果这类需要处理复数的文本将被大量使用, 这种方式或许会有效率上的优势(前提是你将大量地使用, 足够的大量).
  • 或许部分工程不允许大规模使用 QString 或是 std::string 等字符串数据格式(极低性能的嵌入式平台等), 也就无法使用其便捷的拼接操作, 此时只能使用这个方式勉强进行字符串拼接.

<返回顶部>

翻译器的加载与管理 — QCoreApplication

Qt 原生多语言机制的底层原理其实通过看 QTranslator 的源码实现就可以了解的差不多了. 因此从本小节开始, 我将更着眼于使用方式上的介绍, 以及一些功能重叠的方法的选择推荐.

虽然上一节花了很大篇幅讨论 QTranslator 的实现细节, 但具体到使用多语言机制时, 我们用到 QTranslator 的地方通常只有装载翻译器这个操作. 当我们装载翻译器之后, 就可以直接通过 QCoreAppliaction 获取需要的译文了. 这里我从 QCoreAppliaction 的定义中截取出与多语言机制有关的片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Q_CORE_EXPORT QCoreApplication
#ifndef QT_NO_QOBJECT
: public QObject
#endif
{
......
public:
......
#ifndef QT_NO_TRANSLATION
static bool installTranslator(QTranslator * messageFile);
static bool removeTranslator(QTranslator * messageFile);
#endif

static QString translate(const char * context,
const char * key,
const char * disambiguation = nullptr,
int n = -1);
......
public: \
static inline QString tr(const char *sourceText, const char *disambiguation = nullptr, int n = -1) \
{ return QCoreApplication::translate(#context, sourceText, disambiguation, n); } \
QT_DECLARE_DEPRECATED_TR_FUNCTIONS(context) \
......
};

这里的 installTranslatorremoveTranslator 显然是用于装载和卸载翻译器的. 而 translate 用于获取译文, tr 方法内部也是通过调用 translate 实现功能. 这里的 QT_DECLARE_DEPRECATED_TR_FUNCTIONS 宏用于定义一个 trUtf8 方法, 本质上也是调用 translate. 三种翻译方法参数列表完全相同.

在这几个对翻译器的操作之外, 我并没有在 QCoreApplication 的声明中看到保存翻译器的成员, 想必这又是被放在私有类中了. QCoreApplication 对应的私有类名为 QCoreApplicationPrivate. 我截取出其声明中有关翻译机制的片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef QList<QTranslator*> QTranslatorList;
......
class Q_CORE_EXPORT QCoreApplicationPrivate
#ifndef QT_NO_QOBJECT
: public QObjectPrivate
#endif
{
......
public:
......
#ifndef QT_NO_TRANSLATION
QTranslatorList translators;
QReadWriteLock translateMutex;
static bool isTranslatorInstalled(QTranslator *translator);
#endif
......
};

可见, 其内部使用了一个 QList 来保存翻译器. 至此, QCoreApplication 所有有关翻译机制的方法与成员都被找到了, 下面我们来看实现.

<返回顶部>

装载翻译器 — QCoreApplication::installTranslator

当我们要装载某个翻译器到 QCoreApplication 时, 请保证该翻译器已经正确读取了某个 .qm 文件. 详细细节来看 QCoreApplication::installTranslator 实现部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool QCoreApplication::installTranslator(QTranslator *translationFile)
{
if (!translationFile)
return false;

if (!QCoreApplicationPrivate::checkInstance("installTranslator"))
return false;
QCoreApplicationPrivate *d = self->d_func(); // self 指针是 QCoreApplication 单例类的实例
{
QWriteLocker locker(&d->translateMutex);
d->translators.prepend(translationFile); // QList::prepend 方法将 item 添加到列表的头部
}

#ifndef QT_NO_TRANSLATION_BUILDER
if (translationFile->isEmpty()) // 判空, 当翻译器没有加载数据时装载错误, 但这里没有将其卸载
return false;
#endif

#ifndef QT_NO_QOBJECT
QEvent ev(QEvent::LanguageChange); // 发送 QEvent::LanguageChange 事件
QCoreApplication::sendEvent(self, &ev);
#endif

return true;
}

当我们尝试装载一个 QTranslator::isEmpty 返回 true 的翻译器, 此时将不会发出 QEvent::LanguageChange 事件. 但是 QCoreApplication 也不会主动把这个无效的翻译器移除 d->translators 中. 因此正确的使用方式是先让翻译器加载完 ,qm 文件, 再将其装载进 QCoreApplication.

这里出现了一个 self 指针, 这是指向 QCoreApplication 单例的指针. 就像 d 指针往往用于指代某个类对应的私有类一样, 在 Qt 源码中往往使用 self 指针来指向某个单例类的实例.

<返回顶部>

卸载翻译器 — QCoreApplication::removeTranslator

installTranslator 方法一样, removeTranslator 方法也需要指定目标翻译器的指针, 其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool QCoreApplication::removeTranslator(QTranslator *translationFile)
{
if (!translationFile)
return false;
if (!QCoreApplicationPrivate::checkInstance("removeTranslator"))
return false;
QCoreApplicationPrivate *d = self->d_func();
QWriteLocker locker(&d->translateMutex);
if (d->translators.removeAll(translationFile)) { // 移除翻译器
#ifndef QT_NO_QOBJECT
locker.unlock();
if (!self->closingDown()) {
QEvent ev(QEvent::LanguageChange); // 发送 QEvent::LanguageChange 事件
QCoreApplication::sendEvent(self, &ev);
}
#endif
return true;
}
return false;
}

这里直接将翻译器从列表中移除, 唯一值得注意的地方就是当此处移除翻译器成功时还会发出一次 QEvent::LanguageChange 事件.

不得不说这个方法相当鸡肋, 既然我装载的时候已经把翻译器交给 QCoreApplication 管理了, 卸载的时候却还需要我持有这个翻译器的指针? 如果我持有这个指针又何必从你这里获取译文呢? 😂 或许是因为单例可以被全局访问到吧.

<返回顶部>

获取最新的翻译文本 — QCoreApplication::translateQObject::tr

上一节里说到, QCoreApplication 中获取译文的方法有三种 : translate, trtrUtf8. 但这三种最终都是通过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
QString QCoreApplication::translate(const char *context, const char *sourceText,
const char *disambiguation, int n)
{
QString result;

if (!sourceText)
return result;

if (self) {
QCoreApplicationPrivate *d = self->d_func();
QReadLocker locker(&d->translateMutex);
if (!d->translators.isEmpty()) {
QList<QTranslator*>::ConstIterator it;
QTranslator *translationFile;
/* 从前到后, 因此最后一个装载的翻译器有最高优先级 */
for (it = d->translators.constBegin(); it != d->translators.constEnd(); ++it) {
translationFile = *it;
/* 底层翻译直接调用 QTranslator::translate */
result = translationFile->translate(context, sourceText, disambiguation, n);
if (!result.isNull())
break;
}
}
}

if (result.isNull())
result = QString::fromUtf8(sourceText); // 若没有对应的译文则返回其 UTF-8 编码值

replacePercentN(&result, n); // 替换复数值到占位符 %n 处
return result;
}

看过上面的文章后, 再看这段代码就没有什么不理解的地方了, 这里直接按照优先级顺序通过调用 QTranslator::translate 来检查每个翻译器里是否有对应的译文.

<返回顶部>

QObject::trQCoreApplication::translate 的关系

上面说到了除了 QCoreApplication::translate 可以获取译文外, QObject::tr 也可以用于获取译文. 那么它们二者有什么关系呢? 我们凭直觉猜测后者应该也是最终调用了前者, 事实也确实如此. 而我之所以要特地花一小节来说这件事, 是因为 QObject::tr 的源码部分是比较容易误导人的.

如果你曾尝试过使用 QObject::tr 或者 QObject派生类::tr 进行多语言适配, 你会发现这个方法是不需要设置上下文的. 而当你打开生成的 .ts 文件时又会发现 Qt 自动帮你将这段原文的上下文设置为了 QObjectQObject派生类. 对于这个问题我也会在这一小节里解答.

当我们直接打开 QObject 头文件中的声明部分可以看见这么一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Q_CORE_EXPORT QObject
{
......
#if defined(QT_NO_TRANSLATION) || defined(Q_CLANG_QDOC)
static QString tr(const char *sourceText, const char * = nullptr, int = -1)
{ return QString::fromUtf8(sourceText); } // 注意这里
#if QT_DEPRECATED_SINCE(5, 0)
QT_DEPRECATED static QString trUtf8(const char *sourceText, const char * = nullptr, int = -1)
{ return QString::fromUtf8(sourceText); } // 注意这里
#endif
#endif //QT_NO_TRANSLATION
......

如果你尝试过通过 IDE 来查找 QObject::tr 的声明位置, 最后也会跳转到这里. 注意中文注释处, 这里的实现部分直接返回了原文的 UTF-8 编码. 但我们在实际使用中却的确可以通过这个方法获得译文, 这就是容易让人误会的地方.

你可能有注意到源码中我截取出来的两个宏 QT_NO_TRANSLATIONQ_CLANG_QDOC, 如果你了解后者的意义, 那么也就明白了这里发生了什么. Q_CLANG_QDOC 宏实际上是 Qt 用于将其源代码生成文档的一个宏, 这个宏永远不会被 #define. 所有被 #if defined(Q_CLANG_QDOC) 包裹住的代码段, 都将被 Qt 用脚本自动化地生成文档.

不得不说这个宏骗了我好久, 以至于我一直以为 Qt 有什么神奇的机制可以自动化为我的控件设置译文. 😓

OK, 那么现在我们已经知道了 QObject 源码中的 tr 方法是假的, 哪真正的 tr 方法又在哪呢? 其实就藏在我们经常用到但大多数人都不知所云的 Q_OBJECT 宏里. 这个宏被定义在 qobjectdefs.h 中, 我这里截取出相关片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// qobjectdefs.h
......

#ifndef QT_NO_TRANSLATION
// full set of tr functions
# define QT_TR_FUNCTIONS \ // 注意这个宏, 实现了 tr 方法
static inline QString tr(const char *s, const char *c = nullptr, int n = -1) \
{ return staticMetaObject.tr(s, c, n); } \ // 内部调用了 staticMetaObject.tr
QT_DEPRECATED static inline QString trUtf8(const char *s, const char *c = nullptr, int n = -1) \
{ return staticMetaObject.tr(s, c, n); }
#else
// inherit the ones from QObject
# define QT_TR_FUNCTIONS
#endif

......

#define Q_OBJECT \
public: \
QT_WARNING_PUSH \
Q_OBJECT_NO_OVERRIDE_WARNING \
static const QMetaObject staticMetaObject; \ // 注意这个 staticMetaObject
virtual const QMetaObject *metaObject() const; \
virtual void *qt_metacast(const char *); \
virtual int qt_metacall(QMetaObject::Call, int, void **); \
QT_TR_FUNCTIONS \ // 注意这里, Q_OBJECT 宏包含了 QT_TR_FUNCTIONS宏
private: \
Q_OBJECT_NO_ATTRIBUTES_WARNING \
Q_DECL_HIDDEN_STATIC_METACALL static void qt_static_metacall(QObject *, QMetaObject::Call, int, void **); \
QT_WARNING_POP \
struct QPrivateSignal {}; \
QT_ANNOTATE_CLASS(qt_qobject, "")

......

可见, 具体的 tr 方法是通过 QT_TR_FUNCTIONS 宏实现的, 而这个宏又被 Q_OBJECT 宏包含. 这里才是实现 tr 方法的地方. 但是事情还没结束, 我们可以看到实现部分是通过调用了 staticMetaObject.tr 这个方法的来的. 这个变量是 Q_OBJECT 宏所设置的一个 static const QMetaObject 成员. 想不到我们凭直觉猜测的 QObject::trQCoreApplication::translate 两个方法之间的关系, 具体实现中居然还牵扯到了 Qt 的元对象系统.

Qt 的元对象系统是一个很庞大复杂的机制, 在这里我们需要知道元对象系统可以提供类似 “反射” 的功能. 在下文中会再提到这一点.

不管怎么样, 我们还是得追查到底, 下面直接来看 QMetaObject 的源码, 我直接贴出相关的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// qobjectdefs.h  // 声明部分还是在 qobjectdefs.h 中
struct Q_CORE_EXPORT QMetaObject
{
......
#if !defined(QT_NO_TRANSLATION) || defined(Q_CLANG_QDOC)
QString tr(const char *s, const char *c, int n = -1) const;
#endif // QT_NO_TRANSLATION
......
};

// qmetaobject.cpp // 实现部分在 qmetaobject.cpp 中
......
#ifndef QT_NO_TRANSLATION
/*!
\internal
*/
QString QMetaObject::tr(const char *s, const char *c, int n) const
{ /* 注意此处的第一个参数 */
return QCoreApplication::translate(objectClassName(this), s, c, n);
}
#endif // QT_NO_TRANSLATION
......

终于水落石出了, QObject::tr 实际上是调用了 QMetaObject::tr , 而 QMetaObject::tr 最后还是调用了 QCoreApplication::translate. 我们的猜测最终得到了证实.

至于本小节开头的那个问题, QObject::tr 方法为什么不需要指定上下文? 而 Qt 为什么又自动帮我们在 .ts 文件中将上下文设置为 QObject? 相信你看到这已经有了答案. 这个答案同时也解释了 QObject::trQCoreApplication::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
2
3
4
......
QEvent ev(QEvent::LanguageChange);
QCoreApplication::sendEvent(self, &ev);
......

可见 QCoreApplicationQEvent::LanguageChange 事件发送给了 self 指针, 也就是它自身的单实例. 于是我们接着来看它处理事件的地方, 也就是 QCoreApplication::event 方法:

1
2
3
4
5
6
7
8
bool QCoreApplication::event(QEvent *e)
{
if (e->type() == QEvent::Quit) {
quit();
return true;
}
return QObject::event(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 界面上文本. 因此这里 QCoreApplicationQEvent::LanguageChange 发给 self , 当 self 是后两种 Qt App 单例的话, 就应该会去处理这个事件了. 下面我们来逐个验证:

首先来看 QGuiApplication::event 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool QGuiApplication::event(QEvent *e)
{
if(e->type() == QEvent::LanguageChange) { // 有处理 QEvent::LanguageChange 事件
setLayoutDirection(qt_detectRTLLanguage()?Qt::RightToLeft:Qt::LeftToRight);
} else if (e->type() == QEvent::Quit) {
// Close open windows. This is done in order to deliver de-expose
// events while the event loop is still running.
for (QWindow *topLevelWindow : QGuiApplication::topLevelWindows()) {
// Already closed windows will not have a platform window, skip those
if (!topLevelWindow->handle())
continue;
if (!topLevelWindow->close()) {
e->ignore();
return true;
}
}
}

return QCoreApplication::event(e);
}

可见 QGuiApplication::event 事件是有被处理的.

由于 QGuiApplication 是用于纯 QML 实现 GUI 界面的 Qt 程序中, 而我对 QML 的了解程度基本为 0, 因此这里也没有办法再为你继续讲解这个 setLayoutDirection 方法到底做了什么. 若以后我学了 QML 再来补充吧.

然后我们再来看 QApplication::event 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool QApplication::event(QEvent *e)
{
Q_D(QApplication);
if (e->type() == QEvent::Quit) {
......
#ifndef Q_OS_WIN
} else if (e->type() == QEvent::LocaleChange) {
......
#endif
} else if (e->type() == QEvent::Timer) {
......
#if QT_CONFIG(whatsthis)
} else if (e->type() == QEvent::EnterWhatsThisMode) {
......
#endif
}

if(e->type() == QEvent::LanguageChange) { // 有处理 QEvent::LanguageChange 事件
/* 将事件转发给了所有 topLevelWidgets() 列表中的控件 */
const QWidgetList list = topLevelWidgets();
for (auto *w : list) {
if (!(w->windowType() == Qt::Desktop))
postEvent(w, new QEvent(QEvent::LanguageChange)); // 发送事件
}
}

return QGuiApplication::event(e);
}

由于 QApplication::event 处理了很多事件, 我把无关的部分省略了. 我们可以看到这里 QApplication 将这个事件转发给了所有从方法 topLevelWidgets 返回的控件列表内的控件. OK, 至此我们知道了 QCoreApplication 会将 QEvent::LanguageChange 发送给 Qt App 单例, 当这个单例是 QGuiApplicationQApplication 时便会处理这个事件. 而 QApplication 的处理方式便是将事件转发给所有数据 top-level widgets 范畴内的控件. 那么接下来的问题就在于, top-level widgets 的定义到底是什么? 它的范畴有多大?

<返回顶部>

top-level widgets 包括了哪些控件?

我们接着来看这个 QApplication::topLevelWidgets 方法究竟返回了什么样的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// qwindowdefs.h
typedef QList<QWidget *> QWidgetList;

// qapplication.cpp
QWidgetList QApplication::topLevelWidgets()
{
QWidgetList list;
if (QWidgetPrivate::allWidgets != nullptr) { // 返回的是 QWidgetPrivate::allWidgets 的子集
const auto isTopLevelWidget = [] (const QWidget *w) {
/* 判断条件 : isWindow() == true 且 windowType() != Qt::Desktop */
return w->isWindow() && w->windowType() != Qt::Desktop;
};
std::copy_if(QWidgetPrivate::allWidgets->cbegin(), QWidgetPrivate::allWidgets->cend(),
std::back_inserter(list), isTopLevelWidget); // 符合条件就加入返回列表
}
return list;
}

可见成为 top-level widgets 一份子的控件需要满足以下三个条件:

  • 该控件在 QWidgetPrivate::allWidgets 之内;
  • 该控件的 isWindow 方法返回值为 true;
  • 该控件的 windowType 方法返回值不为 Qt::Desktop.

下面我们来逐一检查, 首先是 QWidgetPrivate::allWidgets. 我们来看一下声明部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// qwindowdefs.h
template<class K, class V> class QHash;
typedef QHash<WId, QWidget *> QWidgetMapper;

template<class V> class QSet;
typedef QSet<QWidget *> QWidgetSet;

// qwidget_p.h
class Q_WIDGETS_EXPORT QWidgetPrivate : public QObjectPrivate
{
......
public:
// All widgets are added into the allWidgets set. Once
// they receive a window id they are also added to the mapper.
// This should just ensure that all widgets are deleted by QApplication
static QWidgetMapper *mapper;
static QWidgetSet *allWidgets;
......
};

根据官方注释, 这个 QSet 保存了所有控件的指针, 并且其存在目的是为了便于 QApplication 可以方便地删除所有控件. 我检查了一下实现部分, 发现确实如此. 当有 QWidget 被构造时便将其指针加入这个 QSet, 而当这个 QWidget 被析构时, 也会将对应的指针移除这个 QSet. 到这里条件一明确了, QWidgetPrivate::allWidgets 实际上就是当前存在的所有 QWidget.

接下来我们来检查剩下的条件, 也就是 QWidget::isWindowQWidget::windowType 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Q_WIDGETS_EXPORT QWidget : public QObject, public QPaintDevice
{
public:
inline Qt::WindowType windowType() const;
bool isWindow() const;
......
QWidgetData *data;
};
inline bool QWidget::isWindow() const
{ return (windowType() & Qt::Window); }

inline Qt::WindowType QWidget::windowType() const
{ return static_cast<Qt::WindowType>(int(data->window_flags & Qt::WindowType_Mask)); }

这里 isWindow 通过 windowType 的返回值与枚举 Qt::WindowType::Window 进行按位与, 而 windowType 又是通过 data->window_flags 与与枚举 Qt::WindowType::WindowType_Mask 进行按位与. 那么我们再来看 QWidgetData *datadata->window_flags 以及 Qt::WindowType 是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// qwidget.h
class QWidgetData
{
public:
WId winid;
uint widget_attributes;
Qt::WindowFlags window_flags; // 注意这里
......
};

// qnamespace.h
enum WindowType {
Widget = 0x00000000,
Window = 0x00000001, // & Qt::Window == true
Dialog = 0x00000002 | Window, // & Qt::Window == true
Sheet = 0x00000004 | Window, // & Qt::Window == true
Drawer = Sheet | Dialog, // & Qt::Window == true
Popup = 0x00000008 | Window, // & Qt::Window == true
Tool = Popup | Dialog, // & Qt::Window == true
ToolTip = Popup | Sheet, // & Qt::Window == true
SplashScreen = ToolTip | Dialog, // & Qt::Window == true
Desktop = 0x00000010 | Window, // & Qt::Window == true, != Qt::Desktop == false
SubWindow = 0x00000012,
ForeignWindow = 0x00000020 | Window, // & Qt::Window == true
CoverWindow = 0x00000040 | Window, // & Qt::Window == true

WindowType_Mask = 0x000000ff, // 注意这里
......
};

Q_DECLARE_FLAGS(WindowFlags, WindowType) // Qt::WindowFlags 其实就是 Qt::WindowType 的 QFlags 形式

看来 data->window_flagsQt::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 多行了, 之后再发上来吧.

<返回顶部>

由于我在混用 Qt 与 STL 库时遇到了一些奇怪的错误.
经过检查实际上是因为两个库实现逻辑上有所冲突, 故写此文.
我会分点记录遇到的这些”冲突”, 后续有新发现持续更新.

1. Qt / C++ / C 三种风格字符串转换 ( QString::toStdString().data() )

案情

当一个方法需要传入一个 const char* 参数, 而此时数据保存在 QString 对象内时.
我最初的做法是使用 QString::toStdString().data() 先将 QString 转换为 const char*, 再将转换后的 C 字符串传入方法.
这么做导致参数的数据丢失了.

现场大概是这个样子:

1
2
3
4
5
6
7
8
9
10

void func(const char* cStr) {
// do something
}

QString qtStr = "confidential information";
const char* cStr = qStr.toStdString().data();

func(cStr); // cStr 的数据丢失

调查

这里检查了一下 QString::toStdString()std::string::data() 两个方法的实现机制.

QString::toStdString() 的实现机制

QString::toStdString() 内部实现中, 最终构造并返回了一个 std::string 对象. 其中携带的数据也在构造时进行了拷贝. 因此在方法执行后, 这里的 Qt 字符串与 C++ 字符串二者之间不再有任何”纠葛”.

以下内容截取自 Qt 5.14.2 官方文档:

1
2
3
4
5
std::string QString::toStdString() const
Returns a std::string object with the data contained in this QString.
The Unicode data is converted into 8-bit characters using the toUtf8() function.
This method is mostly useful to pass a QString to a function that accepts a std::string object.
See also toLatin1(), toUtf8(), toLocal8Bit(), and QByteArray::toStdString().

std::string::c_str() 的实现机制

以下代码段截取自 std::string 的 STL 源码 (GCC) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// base_string.h

template<typename _CharT, typename _Traits, typename _Alloc>
class basic_string
{
......

public:

// String operations:
/**
* @brief Return const pointer to null-terminated contents.
*
* This is a handle to internal data. Do not modify or dire things may
* happen.
*/
const _CharT*
c_str() const _GLIBCXX_NOEXCEPT
{ return _M_data(); }

......

}

注意注释部分, 此处返回的指针是指向 std::string 内部数据的指针. 实际上 _M_data() 返回的是 traits, 但最后我们还是可以得到指针. 但无论如何, 这里没有对数据进行拷贝.

PS: 顺嘴说一句, std::string::data()std::string::c_str() 两种方法至少在 C++17 之前, 都是完全一致的. 二者都是调用 std::string::_M_data().

破案

结合两个方法的实现机制可知, 当执行 QString::toStdString().data() 时, 首先构造了一个 std::string 的临时对象, 再构造一个指向该对象内部数据的 const char* 指针并返回. 这个指针最终赋值给我所声明的那个 C 字符串.

问题的关键就在于: 这个临时的 std::string 对象本身没有被任何地方引用, 因此它将在这行语句结束之后被析构. 而析构它的同时自然也释放了其内部的数据. 此时我的这个 C 字符串实际上失去了关联, 使用其传参自然是无法获得所需数据的.

总结

根据上文可知, QString::toStdString().data() 这个转换的方式本身有问题, 关键在于转换时的 std::string 临时对象没有被引用.

我遇到的问题涉及到了方法传参, 若非要用这种方式转换, 则应该直接将该转换步骤放在参数列表中, 保证临时对象在方法执行结束之前都处于被引用的状态. 或是直接用一个 std::string 变量保存数据, 在最终需要传值的时候调用 std::string::data().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

void func(const char* cStr) {
// do something
}

QString qtStr = "confidential information";

// error : 临时 std::string 对象被析构

const char* cStr = qtStr.toStdString().data();
func(cStr);

// OK

std::string stdStr = qtStr.toStdString();
const char* cStr = stdStr.data();
func(cStr);

// OK

func(qtStr.toStdString().data());

疑问

我尝试过将 QString 转为 QByteArray, 再将 QByteArray 转为 const char*. 这么做最后得到的 C 字符串却是拥有数据的, 但是 QByteArray::constData() 本身理应是不做深拷贝的, 具体原因暂时未知. 可能与 Qt 内部某些机制有关.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

void func(const char* cStr) {
// do something
}

QString qtStr = "confidential information";

// OK

const char* cStr = qtStr.toUtf8().constData(); // 此处没有进行深拷贝, 为何成功取得数据?
func(cStr);

// 为证明没有进行深拷贝, 将拷贝过程与 QByteArray 临时变量放在方法内, 此时果然cStr没有正常取得数据

void testFunc(const char* cStr) {
QString qtStr = "confidential information";
cStr = qtStr.toUtf8().constData();
}

const char* cStr = nullptr;
testFunc(cStr); // 没有正常取得数据
func(cStr); // error

Qt工程的基本配置

工程模板

使用VS2019新建一个Qt工程, 可选的项目模板有如下几项:

Qt工程项目模板

其中较为常见的有如下几项:

  • Qt Console Application
    这是一个不带GUI界面的Qt工程, 你可以如同最常见的C++程序一样让其在终端中输入/输出.
    这个工程通常用于学习与测试.

  • Qt Quick Application
    Qt Quick是Qt引入的一个概念, 其使用一种叫做QML的语言进行GUI的布局设计.
    QML使用起来有点像写前端代码一样. 日后学习到该部分再做详细介绍.

  • Qt Widgets Application
    最常见的工程模板, 通过控件进行GUI布局设计, 通过信号与槽机制建立对象的通信.
    本文与后续的大多数文章将以该类工程为例进行介绍.

此处建立一个 Qt Widgets Application 模板的工程, 工程名为 “NoteQtApp”.

Base class

当VS创建Qt工程结束后,会调用Qt VS Tools的Qt工程创建界面。其中有一个特殊的 Base class 选项,这将是该工程主类所继承的基类。Base class一共有如下几种:

  • QWidget
    QWidget类是所有用户界面对象的基类, 也是实际使用中最常见的窗口类型. 它只有一个空白的窗口界面, 并在该界面中接收系统的鼠标点击/键盘输入等事件.

  • QMainWindow
    QMainWindow顾名思义即“主窗口”,它拥有自己的布局,包括中央区域、菜单栏、工具栏等。其有点像于一个界面布局的“半成品”。

  • QDialog
    QDialog类是对话框窗口的基类, 所谓对话框窗口即用于进行短期的任务而存在的. 如”消息提示窗”, “目录选择窗口”等等.

虽然三者看似负责不同的工作, 但实际使用上你可以使用任意一种完成其余二者的工作.
具体而言就是 : 一切根据业务需求走.

此处建立一个继承 QWidget 基类的主类, 主类的名字与工程名相同即 “NoteQtApp”.

Qt程序与C++程序

Qt程序本质也是一个C++程序, 只是运用了Qt提供的相关工具. 当工程创建完成后, 打开工程的 main.cpp 文件, 可见其内容如下:

1
2
3
4
5
6
7
8
9
#include "NoteQtApp.h"
#include <QtWidgets/QApplication>

int main(int argc, char *argv[]) {
QApplication a(argc, argv);
NoteQtApp w;
w.show();
return a.exec();
}

其中可见 QApplication 对象 与 NoteQtApp 对象.

QApplication 对象是每个Qt程序必须有且仅有一个的实例, 它本身是单例的. 其主要工作是这几件事:

  1. 负责该Qt程序的初始化/收尾工作.
  2. 接受系统的各类事件, 并发送给需要的窗口.
  3. 管理窗口在系统中的各类信息, 如当前所在位置等.

总而言之, QApplication 用于辅助Qt程序的具体功能实现.

NoteQtApp 对象是通过以上几个步骤设置自动创建的. 打开 NoteQtApp.h 文件可见其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once

#include <QtWidgets/QWidget>
#include "ui_NoteQtApp.h"

class NoteQtApp : public QWidget {
Q_OBJECT

public:
NoteQtApp(QWidget *parent = Q_NULLPTR);

private:
Ui::NoteQtAppClass ui;
};

由此可见, 其与常见的C++类没有太大区别. 实际上我们也可以自己实现一个继承自 QWidget 基类的子类并创建对象, 通过调用其show()方法使这个窗口显示出来.

Qt程序基本开发步骤

若以Web开发的思维来看一个带有GUI界面的客户端程序, 则该程序需要考虑方面可以简化为两点:

  1. 实现窗口界面的布局
  2. 在此基础上实现具体的业务逻辑

具体到Qt而言就是:

  1. 设计GUI布局
  2. 建立对象间的通信

实际上Qt也有着一个复杂的架构, 是为InterView. 该架构有点类似于MVC但也有所不同. 此处只考虑简单的Qt程序, 不对其做过多介绍.

设计GUI布局

在Qt中实现GUI的设计有两种方式:

  1. 使用代码生成控件
  2. 使用Qt Designer 布局管理器 生成控件

在实际开发中两种方式都有运用, 前者适合设计一些复杂的布局, 后者在界面不太复杂的时候可以节省时间.

使用代码生成控件

Qt中每一种控件都封装为一个C++类, 其大多数继承自 QWidget 基类.
Widget之间可以设置上下级关系, 子Widget将默认显示在父Widget内.

因此, 想要生成并显示一个控件的方法就是 构造一个控件Widget的实例 并 将该实例设置为一个可显示的Widget的子Widget.

QLabel 是一个文本框控件, 其负责展示文本信息并且不可编辑. 以下代码在窗口中加入了一个
QLabel 控件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "NoteQtApp.h"

#include <QLabel>

NoteQtApp::NoteQtApp(QWidget *parent) : QWidget(parent) {
ui.setupUi(this);

this->setFixedSize(300, 150); // 设置窗口尺寸

QLabel *countLabel = new QLabel(this); // 构造QLabel对象
countLabel->setGeometry(50, 10, 100, 20); // 设置坐标
countLabel->setText("0"); // 设置文本内容

}

编译执行后显示的窗口如下:

使用代码生成控件

使用 Qt Designer 布局管理器 生成控件

Qt Designer可以用于设计布局, 其通过可视化的方式, 用户只需要使用鼠标拖拽即可将控件加入窗口中.

Qt Designer的界面如下:

QtDesigner的界面

QPushButton 是按钮控件, 其在Qt Designer中位于左侧Buttons的Push Button选项.

下面拖拽两个 Push Button 至中间的窗口, 并在右侧设置属性:

  • objectName : 控件的名字 对应 代码中实例的变量名, 设置为plusBtn与minusBtn
  • text : 按钮上显示的文本, 设置为plus与minus

保存后编译运行可见界面如下:

加入按钮后的界面

可见, 之前代码生成的控件与现在在Qt Designer中拖拽的控件都显示出来了.

Qt中的ui指针

在Qt Designer中打开 “窗体” -> “View C++ Code” 可见到当前拖拽的布局控件在底层的具体实现代码.

以下是核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Ui_NoteQtAppClass {
public:
QPushButton *plusBtn;
QPushButton *minusBtn;

void setupUi(QWidget *NoteQtAppClass) { // 注意此处
if (NoteQtAppClass->objectName().isEmpty())
NoteQtAppClass->setObjectName(QString::fromUtf8("NoteQtAppClass"));
NoteQtAppClass->resize(600, 400);
plusBtn = new QPushButton(NoteQtAppClass);
plusBtn->setObjectName(QString::fromUtf8("plusBtn"));
plusBtn->setGeometry(QRect(10, 70, 75, 23));
minusBtn = new QPushButton(NoteQtAppClass);
minusBtn->setObjectName(QString::fromUtf8("minusBtn"));
minusBtn->setGeometry(QRect(100, 70, 75, 23));

retranslateUi(NoteQtAppClass);

QMetaObject::connectSlotsByName(NoteQtAppClass);
} // setupUi

void retranslateUi(QWidget *NoteQtAppClass) {
NoteQtAppClass->setWindowTitle(QCoreApplication::translate("NoteQtAppClass", "NoteQtApp", nullptr));
plusBtn->setText(QCoreApplication::translate("NoteQtAppClass", "plus", nullptr));
minusBtn->setText(QCoreApplication::translate("NoteQtAppClass", "minus", nullptr));
} // retranslateUi

};

namespace Ui {
class NoteQtAppClass: public Ui_NoteQtAppClass {}; // 注意此处
} // namespace Ui

可见, Qt Designer的布局本质上也是通过代码实现的, 并且在具体实现细节上与我们自己用代码实现并无二至.
具体到上面的代码而言, 想要生成该界面中的布局需要一个 Ui::NoteQtAppClass 实例, 并调用它的 setupUi 方法.

查看 NoteQtApp.h 可见, 其有一个 Ui::NoteQtAppClass 的私有成员:

1
2
3
4
5
6
7
8
9
class NoteQtApp : public QWidget {
Q_OBJECT

public:
NoteQtApp(QWidget *parent = Q_NULLPTR);

private:
Ui::NoteQtAppClass ui; // 注意此处
};

再查看 NoteQtApp.cpp , 可见再 NoteQtApp 的构造函数中的确调用了这个 ui 成员的 setupUi 方法:

1
2
3
4
5
6
7
8
NoteQtApp::NoteQtApp(QWidget *parent) : QWidget(parent) {
ui.setupUi(this); // 注意此处

this->setFixedSize(300, 150);
QLabel *countLabel = new QLabel(this);
countLabel->setGeometry(50, 10, 100, 20);
countLabel->setText("0");
}

至此可知, 由Qt Designer生成的布局本质上也是由代码生成的, 并且由对应对象的ui成员初始化.

实际上, 除了VS + Qt开发模式之外. 其余开发模式中这个ui成员是以成员指针的形式出现的, 因此通常将其称为ui指针.
而由ui指针创建的各控件, 则会作为其public成员可直接调用.

建立对象间的通信(信号与槽)

在设计完布局界面之后, 本质上其只是一个静态界面, 没有特殊的功能. 因此需要实现具体的业务逻辑.
下面尝试实现这个程序的业务逻辑: 按下plus按钮则label中的数字+1, 按下minus按钮则-1.

由于各控件本质上都是对象, 而这些对象也提供了大量实用的方法. 因此 通过标准C++的实现思路 如下:

  1. 开启两个新线程分别持续检查两个按钮是否被按下
  2. 为文本框设置锁相关方法
  3. 当按钮被按下后修改文本框内的值

上述方式理论上是可行的, 但Qt提供了更好的解决方案, 即通过信号与槽机制.

信号与槽机制 (Signal & Slot)

信号与槽机制是Qt为了实现不同Qt对象之间通信而存在的.

  • 信号将在某个对象的某个特定状态下被触发, 其可以携带参数.
  • 槽是用于接收并处理特定信号的函数, 其可接收信号携带的参数为自己的参数.

每个Widget都自带有一部分信号与槽, 同时也可以自己自定义信号与槽, 并且可以自己在需要的时候发送信号/调用槽函数.

链接信号与槽 : connect函数

connect 函数用于链接信号与槽, 官方文档给出的函数原型如下:

1
2
3
4
5
6
7
// [static]
QMetaObject::Connection
QObject::connect(const QObject *sender, // 信号发送者
const char *signal, // 发送的信号
const QObject *receiver, // 信号接收者
const char *method, // 处理信号的槽函数
Qt::ConnectionType type = Qt::AutoConnection)

使用方式如下:

1
2
3
4
5
// 链接信号与槽 - 方式1
connect(sender, SIGNAL(signalFunc(bool)), recver, SLOT((bool))); // 不可指定参数名

// 链接信号与槽 - 方式2
connect(sender, &Sender::signalFunc, recver, &Recver::slotFunc);

自定义信号与槽函数

信号与槽函数的定义与成员函数的定义类似. 不同的是信号需要定义为Q_SIGNALS成员, 而槽函数需要定义为public Q_SLOTS / protected Q_SLOTS / private Q_SLOTS成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyObject : public QWidget {
Q_OBJECT // 注意, 需要添加这一句

// 自定义信号
Q_SIGNALS:
void mySignal(int);

// 自定义槽函数
public Q_SLOTS:
void mySlot(int);

public:
MyObject(QWidget *parent = Q_NULLPTR);
......
};

信号只需声明, 而槽函数除了声明也需要给出具体实现. 除了槽函数的实现部分, 信号与槽在其余地方都不应该出现参数名.

发送信号(emit关键字)与直接执行槽函数

通过 emit 关键字可以在本行代码直接发送某个具体信号. 而槽函数也可以像普通成员函数一样直接调用.

1
2
3
4
5
// 发送特定信号
emit &Sender::signalFunc; // emit关键字

// 直接执行特定槽函数
&Recver::slotFunc(true); // 直接执行, 就像普通函数一样

实现业务逻辑

在认识过Qt的信号与槽机制后, 我们可以利用其实现目标:

  1. 针对两个按钮分别自定义两个槽函数, 函数内修改文本框的内容
  2. 将两个按钮的PushButton::clicked(bool)信号与对应的槽函数链接

具体实现如下:

NoteQtApp.h :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#pragma once

#include <QtWidgets/QWidget>
#include "ui_NoteQtApp.h"

#include <QLabel>

class NoteQtApp : public QWidget {
Q_OBJECT // 注意, 需要添加这一句

// 自定义槽函数
public Q_SLOTS:
void onClickedPlusBtn(bool);
void onClickedMinusBtn(bool);

public:
NoteQtApp(QWidget *parent = Q_NULLPTR);

private:
Ui::NoteQtAppClass ui; // plusBtn minusBtn由ui创建

QLabel *countLabel; // 把countLabel作为成员
int countNum = 0; // 计数
};

NoteQtApp.cpp :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "NoteQtApp.h"

#include <QString>

NoteQtApp::NoteQtApp(QWidget *parent) : QWidget(parent) {
ui.setupUi(this);

this->setFixedSize(300, 150); // 设置窗口尺寸

countLabel = new QLabel(this); // 构造QLabel对象
countLabel->setGeometry(50, 10, 100, 20); // 设置坐标
countLabel->setText(QString::number(countNum)); // 设置文本内容

connect(ui.plusBtn, &QPushButton::clicked, this, &NoteQtApp::onClickedPlusBtn);
connect(ui.minusBtn, &QPushButton::clicked, this, &NoteQtApp::onClickedMinusBtn);
}

// 按下plusBtn数字+1
void NoteQtApp::onClickedPlusBtn(bool) {
this->countNum++;
countLabel->setText(QString::number(countNum));
}

// 按下minusBtn数字-1
void NoteQtApp::onClickedMinusBtn(bool) {
this->countNum--;
countLabel->setText(QString::number(countNum));
}

这种实现方式比只使用标准C++实现起来更简单方便.

元对象系统

所谓元对象系统就是Qt用于提供对象间通信机制(即信号与槽), 运行时类型信息和动态属性系统而提供的C++扩展. 简单来说就是引入了元对象系统才可以使用信号与槽机制.

想要在一个C++类中引入元对象系统, 需要满足以下几个条件:

  • 该类必须继承自QObject基类, 实际上所有Qt相关的类都继承自这个类.
  • 该类的private成员区必须出现Q_OBJECT宏定义, 这就是上例中提示需要加入这一句的原因.
  • 需要使用元对象编译器来提供必要的代码实现, 这一点通过配置好Qt开发环境即可满足.

前言

这是本人在Qt学习阶段所记录的笔记,留作日后自己翻阅。编写的过程也伴随着我学习的过程,因此内容中将不可避免的出现些许描述不恰当之处甚至谬误。总之可以作为参考酌情阅读。

完整文档均可下载,详见:https://github.com/BRabbitFan/NoteQt

Qt简介

Qt是一个跨平台的C++开发库,很适合用于开发GUI程序。但其除了GUI库之外,同时也为用户提供了数据库、图像处理、音视频处理、网络通信、文件操作甚至并发操作等相关API,这为客户端界面的开发提供了极大的便利。

在我开始学习Qt之前,主要的学习方向是服务器端与Web后端相关知识。相比于使用Qt进行开发工作,这二者通常都需要使用到各种各样的框架、工具进行协同开发。即便最普通的一个网游服务器端程序,也需要至少同时运用到一个框架+MySQL+Redis。
但使用Qt进行客户端程序开发时,几乎所有需要的工具其均有内置的可选项,相比之下可以说是非常方便了。

Qt与MFC

Qt最早是Linux平台的开发库,但其渐渐也支持Windows、Andriod等各个平台。至少在跨平台这方面上Qt相比MFC更强。同时在新手学习的阶段,Qt也比MFC更容易入门。在Windows平台中二者均是通过封装Windows API实现的功能,但Qt向用户屏蔽了更多底层细节,至少在初学阶段是无需考虑那些恼人的Windows API了。

开发工具

Qt本身只是一个开发库,因此所谓开发Qt程序无非是在各种IDE中调用该库。通常情况下使用最多的有这么几种方案:

  • Qt Creator + Qt
    Qt Creator是Qt官方所提供专为Qt定制的IDE。集成了Qt Designer等方便的开发工具,同时也提供了大量的案例工程与完整的帮助手册。通常情况下即便不使用QtCreator作为IDE进行开发,也会使用到其内置的Qt Designer与帮助手册进行辅助开发。
  • Visual Studio + Qt
    作为宇宙最强的IDE, VS也支持开发Qt程序,其中需要用到VS的扩展”Qt VS Tools”。得益于VS本身强大的功能,这个方案也是相当部分团队的选择。
  • VS Code + Qt
    VS Code也支持通过对扩展进行配置进行Qt程序的开发。但使用该方案自然也伴随着VS Code自身的优缺点:开发时轻便好用但总有各种各样奇怪又麻烦的配置问题。

这里我采用VS + Qt的方案,准确地说是VS2019 + Qt 5.14.2。

配置Visual Studio + Qt开发环境

安装配置Qt 5.14.2

总的来说分为下面几步:

  1. 下载安装程序
  2. 安装Qt
  3. 配置环境变量

Qt官网的下载界面选择版本5.14.2点击下载。
Qt下载

在Qt安装过程中需要登陆账号,根据提示去注册一个登录就好,后续开发不再需要注册登录。
Qt安装过程提示登录

安装过程按照需要勾选 “MSVC 2017 32-bit”“MSVC 2017 64-bit” 组件(或者直接都选)。这里选择的是VS2017,但不影响Qt与VS2019协同工作。
Qt安装过程选择组件

其他部分按照正常流程下一步即可。

安装完成后,在 “安装目录\Qt5.14.2\5.14.2” 目录下可见刚才所选组件的目录(下称组件目录),将其下的bin目录添加入环境变量中。
Qt配置环境变量

安装配置VS2019

总的来说分为下面几步:

  1. 下载安装程序
  2. 安装VS
  3. 安装Qt扩展插件
  4. 配置Qt扩展插件

首先在微软官网的VS下载界面下载VS2019。
VS下载

安装过程需要勾选 “使用C++的桌面开发” 组件。其他部分按照正常安装流程下一步即可。
VS安装

安装完成后启动VS,选择 “继续但无需代码” 。在 “扩展->管理扩展” 内搜索并下载扩展 “Qt VS Tools”
VS安装扩展

扩展安装完毕后,进入 “扩展->Qt VS Tools->Qt Versions” 添加一个Qt版本。其中Version是自定义的名字,Path选择先前安装好的Qt组件目录内bin目录下的 qmake.exe 程序。
VS配置扩展

验证测试开发环境

环境搭建完成后,启动VS。选择 “创建新项目” 新建一个 Qt Widgets Appliaction 工程。若选项里没有该工程,也可以在搜索框里搜到。
创建VS工程

创建VS工程完成后,会自动打开Qt VS Tools的创建工程界面,这里暂不做设置直接下一步直到创建完成。
创建Qt工程

创建完成后,可见资源管理器中有如下目录与文件:
工程资源管理器

此处不详细介绍个文件的作用,直接运行工程,将显示一个空白的窗口。
运行测试工程

至此,环境配置测试成功!

相关工具介绍

Qt Designer

在上面的测试工程中,双击资源管理器中的.ui文件,Qt VS Tools将自动使用Qt Designer将其打开。该工具可以通过可视化界面方便地设计Qt界面布局。大致界面如下:
工具介绍QtDesigner

有可能会出现打开.ui文件失败的情况,这是因为Qt VS Tools默认使用其自带的Qt Designer版本打开,该版本可能与当前工程的版本有冲突。解决方法就是使用创建当前工程的Qt组件所携带的Qt Designer版本。

右击.ui文件,在打开方式中添加新的打开方式。程序是Qt组件目录下bin目录中的designer.exe程序。记得将其设置为默认值。此后再双击.ui文件即可成功。

Qt Creater 中的帮助文档

安装Qt时将自动安装Qt Creater,可在Windows搜索中找到。在 “帮助->目录”“帮助->索引” 中可以查阅所安装Qt版本完整的帮助文档,这在学习与实际开发过程中很有用。
工具介绍帮助文档

Qt Creater自带的示例工程

Qt Creater自带了一些示例工程,可以直接在Qt Creater中运行,在学习过程中有一定的帮助。打开Qt Creater后即可见到。
工具介绍示例工程

文件操作

Lua中的文件I/O分为两种模式 :

  • 简单模式 : 使用io库 , 拥有一个当前输入文件和一个当前输出文件 , 并提供针对这些文件的相关操作 (和C一样) .
  • 完全模式 : 使用句柄 , 以一种面向对象的形式 , 将所有文件操作定义为文件句柄的方法 .

简单模式

Lua中的io类提供了用于文件操作的方法 .

文件的打开模式

模式 描述
r 读模式 , (读)
w 写模式 , (从头写)
a 追加模式 , (末尾写)
r+ 更新模式 , 保留之前的所有数据
w+ 更新模式 , 删除之前的所有数据
a+ 追加更新模式 , 只可在末尾写入 , 保留之前的所有数据
rb 读模式 , (读) (二进制方式)
wb 写模式 , (从头写) (二进制方式)
ab 追加模式 , (末尾写) (二进制方式)
r+b 更新模式 , 保留之前的所有数据 (二进制方式)
w+b 更新模式 , 删除之前的所有数据 (二进制方式)
a+b 追加更新模式 , 只可在末尾写入 , 保留之前的所有数据 (二进制方式)

Lua中的标准IO句柄

  • io.stdin : 标准输入
  • io.stdout : 标准输出
  • io.stderr : 标准错误

io.open(filename, mode)

打开文件 : 通过 mode 模式打开文件 filename , 返回 文件句柄 / 错误提示 .

io.close(file)

关闭文件 : 关闭文件 file , 返回 成功状态 + 退出代码 .

io.read([…])

读取内容 : 读取打开的文件 , 参数指定读取的模式 , 其模式可以是以下四种 :

  1. *n : 读取一个数字 ;
  2. *a : 从当前位置读取整个文件 ;
  3. *l缺省 : 读取下一行 , 遇到末尾返回nil ;
  4. number : 返回指定字符个数的字符串 , 遇到末尾返回nil .

io.write(…)

写入内容 : 向打开的文件中写入内容 , 参数指定了要写入的内容 .

io.input(file)

设置默认输入 : 将文件 file 设置为默认输入文件 , 无返回值 .

io.output(file)

设置默认输出 : 将文件 file 设置为默认输出文件 , 无返回值 .

io.flush()

向默认输出写入 : 将写入的数据保存到默认输出文件中 , 无返回值 .

io.lines(filename, […])

行迭代器 : 返回文件filename的迭代器 , 每次迭代返回一行的内容 , […]为打开模式.

io.popen(prog, [mode])

分离线程打开文件 : 分离一个线程开启程序prog , mode 指定了打开的模式 , 返回 文件句柄 / 错误提示 .

io.tmpfile()

临时文件句柄 : 若执行成功 , 则返回一个临时的文件句柄 .

io.type(file)

检查句柄合法性 : 返回文件file句柄的合法性 , 返回值可以是以下几个 :

  1. file : 是一个打开的文件句柄
  2. closed file : 是一个关闭的文件句柄
  3. nil : 不是文件句柄

示例 (Code/file/simple.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local file                       -- 句柄

file = io.open("test.txt", "w") -- 打开文件 (写模式)
io.output(file) -- 设置该文件为默认输出文件
io.write("test text") -- 写入数据 (写到文件里)
io.close(file) -- 关闭文件

file = io.open("test.txt", "r") -- 打开文件 (读模式)
io.input(file) -- 设置该文件为默认输入文件
print(io.read("*a")) -- 读取数据 (全部数据) --> test text
io.close(file) -- 关闭文件

io.output(io.stdout) -- 设置标准输出为默认输出文件
io.write("std out test") -- 写入数据 (写入到stdout - 打印) --> std out test

输出

6.simple.lua.png


完全模式

完全模式操作上与简单模式类似 , 用句柄:方法来代替io.方法 .

file:close()

关闭文件 : 关闭该文件 , 返回执行正确性 + 退出码 , 退出码是exit/signal二者其一 .

file:read(…)

读取内容 : 读取该文件 , 参数指定读取的模式 , 其模式可以是以下四种 :

  1. n : 读取一个数字,根据 Lua 的转换文法返回浮点数或整数 ;
  2. a : 从当前位置读取整个文件 ;
  3. l缺省 : 读取一行并忽略行结束标记 ;
  4. L : 读取一行并保留行结束标记 .

file:write(…)

写入内容 : 向该文件写入内容 , 参数指定内容 , 返回文件句柄 + [错误提示] .

file:flush()

向该文件写入 : 将写入的数据保存到该文件中 , 无返回值 .

file:lines(…)

行迭代器 : 返回该文件的迭代器 , 每次迭代返回一行的内容 , […]为打开模式.

file:seek([whence], [offset])

设置位置 : 设置基于whence处偏移量为offset的位置 , 返回偏移量[错误提示] , whence是如下几个其一 :

  1. set : 基点为 0 (文件开头);
  2. cur缺省 : 基点为当前位置 ;
  3. end : 基点为文件尾 .

file:setvbuf(mode, size)

设置缓冲模式 : 设置该文件的缓存模式为mode , 缓冲区大小为size , 缓冲模式可以是以下几种 :

  1. no : 不缓冲 , 输出操作立刻生效 ;
  2. full : 完全缓冲 , 只有在缓存满或调用 flush 时才做输出操作 ;
  3. line : 行缓冲 , 输出将缓冲到每次换行前 .

示例 (Code/file/test.lua)

1
2
3
4
5
6
7
8
9
local file                       -- 句柄

file = io.open("test.txt", "w") -- 打开文件 (写模式)
file:write("text test") -- 写入数据
file:close() -- 关闭文件

file = io.open("test.txt", "r") -- 打开文件 (读模式)
file:seek("set", 5) -- 跳转位置 (开头处5字节后)
io.stdout:write(file:read("L")) -- 读取一行 , 输出 --> test

输出

6.test.lua.png

面向对象

先前使用了 table + function 实现了 Object 类的功能 , 但是无法将其实例化 .
配合 metatable 可以解决这一问题 .

Lua官方实现(模拟)OOP的方式

  • 类定义 : 使用 metatable 描述类的属性与方法(一个new方法用于构造) .
  • 实例化 : 使用 table 设置 metatable 与 __index 元方法(用new构造 , 从元表中取得属性与方法) .
  • 继承 : 使用另一个 metatable 与原 metatable 设置元表(继承关系) , 从而实现继承 .

官方的做法是使用利用元表的特性从而模拟OOP , 实际上还有其他的实现(模拟)方式 .

实现继承的多种方式

  • 利用元表实现 (官方做法)
  • 利用复制表的方法实现
  • 利用闭合函数实现

Lua对象(table)中的 self变量 与 .:

  • . 操作符 : 通过 . 操作符可访问类(table)的成员变量 / 成员函数 .
  • : 操作符 : 通过 : 操作符可访问类(table)的成员函数 , 同时将自动将 self参数 作为第一个参数传入 .
  • self 参数 : 如同C++中对象的this指针 , 指向对象自身 .

定义成员方法时使用. , 类似于定义了一个”静态方法”(只是类似) , 其不会自动传入self参数(this指针) .


利用元表实现OOP (官方做法)

Lua官方实现OOP的方式是利用元表与表的关系模拟出继承的关系

Lua官方实现(模拟)OOP的方式 (完整示例见 Code/oop/metatable.lua)

  • 类定义 : 使用 metatable 描述类的属性与方法(一个new方法用于构造) .
  • 实例化 : 使用 table 设置 metatable 与 __index 元方法(用new构造 , 从元表中取得属性与方法) .
  • 继承 : 使用另一个 metatable 与原 metatable 设置元表(继承关系) , 从而实现继承 .

类定义

  • 使用一个table(其作为metatable)定义出类的成员属性与方法(往里面塞各种不同类型的数据)
  • 定义一个new方法 , 用于构造实例-返回table (其中要设置元表关系 , 并实现__index元方法)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    print("Person--------------------")
    -- 定义Person基类
    Person = {
    -- 成员属性
    name = "" , -- 名字
    age = 0, -- 年龄
    num = 0 -- 人数 (实例与子类实例的总数)
    }
    -- 构造方法
    function Person:new(name, age)
    local newPerson = {} -- 实例
    setmetatable(newPerson, self); -- 设置实例的元表(基类)为Person类自身
    self.__index = self; -- 设置当访问实例中不存在的属性时 , 来Person类找
    newPerson.name = name or "" -- 为成员属性赋值
    newPerson.age = age or 0
    self.num = self.num + 1 -- 静态变量人数累加
    return newPerson -- 返回构造好的实例
    end
    -- 成员方法
    function Person:speak()
    print(self.name .. " " .. self.age .. ". TotalNum:" .. self.num)
    end

实例化

  • 调用基类(metatable)的new成员方法为变量赋值 , 该变量则为一个实例(本质还是table) .
    1
    2
    3
    4
    5
    -- 实例化Person类
    local personA = Person:new("Lee", 20)
    local personB = Person:new("Van", 30)
    personA:speak()
    personB:speak()

继承

  • 遵循类定义的方式再定义一个基类(metatable) , 同时设置两个基类(metatable)之间的继承(元表)关系 .
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    print("Student-------------------")
    -- 定义Student子类继承Person基类
    Student = {
    -- 子类成员属性
    major = ""
    }
    -- 设置继承关系
    setmetatable(Student, Person)
    -- Student构造方法
    function Student:new(name, age, major)
    local newStudent = Person:new(name, age) -- 实例(调用基类构造方法)
    setmetatable(newStudent, self); -- 设置实例的元表(基类)为Person类自身
    self.__index = self; -- 设置当访问实例中不存在的属性时 , 来Person类找
    newStudent.major = major or ""
    return newStudent
    end
    -- Student子类特有成员方法
    function Student:myMajor()
    print(self.name .. " major is " .. self.major .. ". TotalNum:" .. self.num)
    end
    -- Student子类重写基类Person的成员方法
    function Student:speak()
    io.stdout:write(self.name .. " " .. self.age .. " student ; ")
    end

    -- 实例化Student类
    local studentA = Student:new("Mike", 18, "Math")
    local studentB = Student:new("Lucy", 19, "Music")
    studentA:speak()
    studentA:myMajor()
    studentB:speak()
    studentB:myMajor()

完整示例的输出

6.metatable.lua.png


利用复制表的方法实现OOP

利用一个复制表的函数 , 实现类之间的继承关系 以及 实例化的功能 .

复制表函数实现OOP的步骤 (完整示例见 Code/oop/copyTable.lua)

  • 复制table函数 : 一个用于克隆table的函数 , 将自身完整的副本返回
  • 定义类 : 用metatable定义基类 , 实现一个new方法用于构造实例 .
  • 实例化 : 在类的new方法中 , 利用复制table函数将类复制一份 , 将副本作为实例 .
  • 继承 : 利用复制table函数 , 复制一份基类 , 将副本作为子类 .

复制table函数

一个全局函数 , 作用就是返回一个参数table的副本

1
2
3
4
5
6
7
8
-- 全局函数 - 克隆表 : 返回参数表的副本
CloneTab = function(tab)
local newTab = {} -- 新表
for key, val in pairs(tab) do -- 将信标构造为参数表的副本表
newTab[key] = val
end
return newTab -- 返回副本表
end

定义类

与用元表实现中的定义类相似 , 区别在于在new方法中 , 通过克隆自身构造实例

1
2
3
4
5
6
7
8
9
10
11
-- Shape基类
Shape = {} -- 声明基类
Shape.area = 0 -- 基类成员属性
function Shape:new(area) -- Shape构造方法 : 克隆自己并返回
local newShape = CloneTab(self)
newShape.area = area or 0
return newShape
end
function Shape:printArea() -- Shape成员方法 : 输出面积
print(self.area)
end

实例化

直接调用类的new方法构造实例

1
2
3
-- 实例化 Shape
local shapeA = Shape:new(10)
shapeA:printArea()

继承

同样利用克隆基类获得副本(获得基类的属性与方法) , 并在这个副本上实现子类的完整定义 .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 子类 Square : 继承 Shape
Square = CloneTab(Shape) -- 设置继承关系并声明子类
Square.side = 0 -- 子类Square成员属性
function Square:new(side) -- 子类Square构造方法 : 克隆自己并返回
local newSquare = CloneTab(Square)
newSquare.side = side or 0
newSquare.area = (side^2) or 0
return newSquare
end
function Square:printArea() -- 子类Square重写基类Shape方法 : 输出面积
print(self.side .. " ^ 2 = " .. self.area)
end
function Square:countArea() -- 子类Square特有成员方法 : 计算面积
self.area = self.side ^ 2
end
-- 实例化子类 Square
local square = Square:new(2)
square:printArea()

完整示例的输出

6.copyTable.lua.png


利用闭合函数实现OOP

利用closure的特性 , 可以将一个”类”的成员都封装在一个function内(closure可访问非局部变量 , 类似访问类内部的成员变量) .
从而实现类定义与继承 .

利用闭合函数实现OOP的步骤 (完整示例见 Code/oop/closure.lua)

  • 定义类 : 用一个function封装类的所有成员 , 其中内部函数因为closure的特性 , 可以访问到”类”内部成员 .
  • 实例化 : 在”类”中实现构造方法 , 将构造好的新table返回 , 该table就是实例 .
  • 继承 : 在子类(function)中先获取一个基类实例 , 再定义出完整的子类 (子类的实例也是在基类实例的基础上构造而得).

定义类

用function实现类 , 需要一个self表作为实例 , 一个构造方法用于构造实例 , 其余的成员都可定义在其中(作为self表的元素) .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 基类Vehicle
function Vehicle(name, capacity)
local selfTab = {} -- 实例(私有)
selfTab.name = "" -- 成员属性 : 名称
selfTab.capacity = 0 -- 成员属性 : 载客量
local function init() -- 构造方法(私有) : 闭合函数
selfTab.name = name or ""
selfTab.capacity = capacity or 0
end
function selfTab:printMsg() -- 成员方法 : 输出自身信息
print(self.name .. " max capacity is " ..self.capacity)
end
init() -- 构造实例
return selfTab -- 返回实例
end

实例化

直接调用实现类的function , 获得实例 .

1
2
3
-- 实例化Vehicle
local vehicleA = Vehicle("boat", 10)
vehicleA:printMsg()

继承

在子类中 , 用基类的实例先赋值self表 , 再实现子类的完整定义 .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 子类Car
function Car(name, capacity, wheels)
local selfTab = Vehicle(name, capacity) -- 继承基类(构造一个基类实例)
selfTab.wheels = 0 -- 成员属性 : 轮子数量
local function init() -- 构造方法(私有) : 闭合函数
selfTab.wheels = wheels or 0
end
function selfTab:printCap() -- 重写基类成员方法 : 输出自身信息
print(self.name .. " max capacity is " ..self.capacity .. " , and wheel num is " .. self.wheels)
end
init() -- 构造实例
return selfTab -- 返回实例
end
-- 实例化Car
local carA = Car("bus", 30, 4)
carA:printMsg()

完整示例的输出

6.closure.lua.png

Coroutine 协程

协程的工作模式与进程/线程不同 , 其既不并发也不并行 , 其通过在多个任务之间跳转执行 . 正如其名”协作程序” .

协程的特点

  • 如同一个进程可以拥有多个线程 , 一个线程可以拥有多个协程 .
  • 不同于进程与线程的抢占性 , 协程是非抢占性的(协程的挂起与执行完全依靠程序逻辑) .
  • 不同于进程与线程的并发性 , 在同一时刻 , 只能有一个协程在运行 .
  • 协程不被操作系统内核管理 (用户态) , 不会像线程切换那样消耗资源 .

Lua中的协程相关函数

Lua中的coroutine包含了多个成员方法 , 其用于控制协程 .

coroutine.create(f)

创建协程 : 传入该协程的主体函数 f , 返回该协程 (一个thread类型变量) .
使用该方法创建的协程 , 需要调用resume()函数运行 .

coroutine.wrap(f)

创建协程 : 传入该协程的主体函数 f . 返回一个函数 , 每次调用其都可以延续该协程 .
使用该方法创建的协程 , 需要调用thread变量运行(以一个函数的形式调用它) .

coroutine.resume(co, [val1], …)

运行协程 : 开始或继续运行协程 co , 返回启动的结果yield给的值 . val代表主体函数的参数 .
参数 : 第一个是协程变量 , 后面的参数是主题函数的参数/上一次yield的返回值 .
返回值 : 第一个返回值是启动的结果 , 后面的返回值是主体函数的返回值/本次yield的参数 .

coroutine.yield(…)

挂起协程 : 挂起正在调用的协程的执行 , 它的参数将作为本次resume()函数的返回值 .
参数 : 下次resume()的返回值 ; 返回值 : 本次resume()的参数 .

coroutine.close(co)

关闭协程 : 关闭协程 co , 并关闭它所有等待 to-be-closed 的变量 , 并将协程状态设为 dead .

coroutine.status(co)

查看状态 : 返回协程 co 的运行状态 , 其可能是以下4种之一 :

  1. "running" : 正在运行 .
  2. "suspended" : 挂起或是还没有开始运行 .
  3. "normal" : 是活动的,但并不在运行 .
  4. "dead" : 运行完主体函数或因错误停止 .

coroutine.isyieldable(co)

检查协程是否可以挂起 : 返回协程 co 当前是否可以被挂起 .

coroutine.running()

获得当前运行的协程 : 返回当前正在运行的协程 , 同时返回一个布尔值(其表示该协程是否是主协程) .

示例 (Code/coroutine/simple.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
local co = coroutine.create(  --------- 创建协程
function(i)
print(i)
end
)
coroutine.resume(co, 1) -------------- 运行协程 -----------> 1
print(coroutine.status(co)) ---------- 查看状态 -----------> dead
print("----------")
co = coroutine.wrap( ----------------- 创建协程
function(i)
print(i)
end
)
co(1) -------------------------------- 运行协程 -----------> 1
print("----------")
co = coroutine.create(
function()
for i = 1, 10, 1 do
print(i)
if i == 3 then
print(coroutine.status(co)) -- 查看状态 -----------> running
print(coroutine.running()) --- 查看正在运行的协程 --> thread:xxx false
end
coroutine.yield() -------------- 挂起协程
end
end
)
coroutine.resume(co) ----------------- 运行协程 -----------> 1
coroutine.resume(co) ----------------- 运行协程 -----------> 2
coroutine.resume(co) ----------------- 运行协程 -----------> 3
print(coroutine.status(co)) ---------- 查看状态 -----------> suspended
print(coroutine.running()) ----------- 查看正在运行的协程 --> thread:xxx true

输出

5.simple.lua.png


进阶

协程的特性使得其在某些情况下更有优势 , 例如生产者消费者模型 .
相比于线程的实现 , 协程的实现天生不需要加锁 , 也天生负载均衡 .

使用协程实现生产者消费者模型 (Code/coroutine/productorConsumer.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
local newProductor
MaxNum = 10

Productor = function()
local num = 0
while num < MaxNum do
num = num + 1
print("send : " .. num)
Send(num)
end
end

Consumer = function()
local num = 0
while num < MaxNum do
print("recv : " .. num)
num = Recv()
end
end

Send = function(x)
coroutine.yield(x)
end

Recv = function()
local status, value = coroutine.resume(newProductor)
return value, status
end

newProductor = coroutine.create(Productor)
Consumer()

输出

5.productorConsumer.lua.png

Metatable 元表

元表是table的一种特例 , 其用于定义对table或userdata操作方式 (如两个table的加法运算等).

例如 : 现需要对表tab1, tab2执行加法运算 (tab1 + tab2) . Lua将做出如下判断 :

  1. 查看tab1是否有元表 , 若有则查看tab1的元表是否有__add元方法 , 若有则调用 .
  2. 查看tab2是否有元表 , 若有则查看tab2的元表是否有__add元方法 , 若有则调用 .
  3. 若二者都没有对应的__add元方法 , 则报错 .

因此 , 通过给table设置元表 , 并为其元定义__add元方法 , 即可实现该table的加法运算 .


设置元表 / 获取元表

setmetatable(table, metatable) - 设置元表

setmetatable(table, metatable) 设置 table 的元表为 metatable , 并返回 table .

  • 若元表中存在__metatable键值 , 则函数setmetatable失败 .

getmetatable(object) - 获取元表

getmetatable(object) 获取 object 的元表并返回 .


元表的元方法

上例中__add就是Lua中的一个原方法 , Lua中还有其他的原方法 , 可供实现并使用 .

常用元方法

元方法 描述
__add 运算符 +
__sub 运算符 -
__mul 运算符 *
__div 运算符 /
__mod 运算符 %
__unm 运算符 - (取反)
__concat 运算符 ..
__eq 运算符 ==
__it 运算符 <
__le 运算符 <=
__call 当作函数调用
__tostring 转化为字符串
__index 调用一个索引
__newindex 给一个索引赋值
__metatable 用于设置访问元表的权限

__add元方法 与 其他运算符元方法

运算符元方法的使用方式都类似 , 以__add为例 :

__add 元方法使用示例 (Code/metatable/add.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local metaTab = {}                            -- 创建元表
metaTab.__add = function (leftTab, rightTab) -- 定义元方法__add
local addTab = {}
for key, value in ipairs(leftTab) do -- 对应索引的元素相加
addTab[key] = value + rightTab[key]
end
return addTab -- 返回相加后的表
end
local tab1 = {1, 2, 3}
local tab2 = {4, 5, 6}
setmetatable(tab1, metaTab) -- 设置元表
setmetatable(tab2, metaTab)
local resTab = tab1 + tab2 -- 对表进行加法运算
for key, value in pairs(resTab) do -- 遍历输出 --> 1 5 \n 2 7 \n 3 9
print(key .. " " .. value)
end

输出

5.add.lua.png

__call 元方法的使用

__call 可以让 table 当作一个函数来使用 .
注意 : __call 的第一个参数需要设置为表自身 .

_call示例 (Code/metatable/call.lua)

1
2
3
4
5
6
7
8
9
10
local meteTab = {}                       -- 构造元表
meteTab.__call = function(myTable, ...) -- 实现方法__call , 第一个参数是自身
return {...} -- 返回以所有参数作为索引的表
end
local tab = {1, 2, 3, 4, 5}
setmetatable(tab, meteTab) -- 设置元表
local resTab = tab(2, 3, 4) -- 以函数的方式调用表
for key, value in pairs(resTab) do -- 循环输出 --> 1 2 \n 2 3 \n 3 4
print(key .. " " .. value)
end

输出

5.call.lua.png

__tostring 元方法的使用

__tostring 元方法可以修改table , 将其转化为字符串(可以像字符串一样被print()输出) .
注意 : __tostring 的参数(唯一参数)为表自身

__tostring示例 (Code/metatable/tostring.lua)

1
2
3
4
5
6
7
8
9
10
11
12
local metaTab = {}  -- 构造元表
metaTab.__tostring = function(myTable) -- 实现__tostring元方法 , 参数为表自身
local str = "{ " -- 将表中内容转为字符串格式
for key, value in pairs(myTable) do
str = str .. key .. ":" .. value .. " "
end
str = str .. "}"
return str
end
local tab = {1, 2, 3, 4, 5}
setmetatable(tab, metaTab) -- 设置元表
print(tab) -- 像string一样输出表 --> { 1:1 2:2 3:3 4:4 5:5 }

输出

5.tostring.lua.png

__index 元方法的使用

当调用table的一个不存在的索引时 , 会使用到 __index 元方法 .
注意 : __index 可以是一个函数 , 也可以是一个table .

  • 作为函数 : 将表和这个不存在的索引作为参数传入 , return 一个返回值 .
  • 作为table : 调用不存在的索引时 , 将查找这个table , 若有该索引则返回对应的值 , 否则返回nil .

__index示例 (Code/metatable/index.lua)

1
2
3
4
5
6
7
8
9
10
11
12
local metaTab = {}                        -- 构造元表
local tab = {1, 2, 3, 4, 5}
setmetatable(tab, metaTab) -- 设置元表
-- 作为function --
metaTab.__index = function(myTable, key) -- 实现__index元方法 , 第一个参数为表自身 , 第二个参数为不存在的那个索引
return "no key name : " .. key -- 提示没有这个key
end
print(tab.myKey) -- 尝试调用不存在的索引 --> no key name : myKey
-- 作为table --
metaTab.__index = {myKey="myValue"} -- 设置 __index 元方法表
print(tab.myKey) -- 尝试调用不存在的索引 , 但该索引存在元方法表中 --> myValue
print(tab.mykey) -- 尝试调用不存在的索引 , 该索引不存在元方法表中 --> nil

输出

5.index.lua.png

__newindex 元方法的使用

为table中一个不存在的索引赋值时 , 将调用元表中的__newindex元方法 .
注意 : __newindex__index 一样 , 可以作为一个function也可以作为一个table .

  • 作为function : 将赋值语句中的 , 索引 , 当作参数去调用 , 不对表进行改变 .
  • 作为table : 将该索引赋到__newindex所指向的表中 , 不对原有的表做出改变 .

__newindex示例 (Code/metatable/newindex.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
local metaTab = {}                              -- 构造元表
local tab = {k1="v1", k2="v2", k3="v3"}
setmetatable(tab, metaTab) -- 设置元表
-- 作为function --
metaTab.__newindex = function(myTab, idx, val) -- 实现__newindex元方法 , 参数 : 表自身 , 索引 , 值
print("Cannot assign value to \""..idx..":"..val.."\" (no key name: \""..idx.."\")")
end -- 输出错误提示
tab.k4 = "v4"
-- 作为table --
local errTab = {}
metaTab.__newindex = errTab --> Cannot assign value to "k4:v4" (no key name: "k4")
tab.k4 = "v4"
print(tab.k4, errTab.k4) --> nil v4

输出

5.newindex.lua.png

__metatable 的使用

__metatable 作为字符串出现 , 当一个表被设置为其他表的元表时 , 自动获得__metatable=nil .
一个拥有__metatable属性的表不可以被设置为其他表的元表 .

  • 通过设置元表的__metatable为一个字符串 , 可以禁止访问该元表中的成员或修改元表 .
  • 当出现这些操作时 , 自动返回该字符串 .

__metatable 示例 (Code/metatable/metatable.lua)

1
2
3
4
5
6
7
local metaTable = {}              -- 构造元表
local tab = {}
setmetatable(tab, metaTable) -- 设置元表
metaTable.__metatable = "locked" -- 禁止访问元表 , 设置访问失败提示词
print(getmetatable(tab)) -- 尝试访问元表失败 --> locked
metaTable.__metatable = nil -- 解锁
print(getmetatable(tab)) -- 再次访问元表成功 --> table:xxxxx

输出

5.metatable.lua.png

rawget(table, index) 与 rawset(table, index, value)

  • rawget(table, index) 可以直接获取表table中索引index的实际值(nil) , 绕过 __index .
  • rawset(table, index, value) 可以直接为表table中的索引index赋值(插入新元素) , 绕过 __newindex .

元表的使用场景

  • 作为table的元表 : 通过table设置元表 , 可以实现Lua中的面向对象编程
  • 作为userdata的元表 : 通过对userdata设置元表 , 可以实现Lua对C结果进行面向对象式的访问 .

iterator 迭代器

在Lua中也有迭代器的概念 , Lua中的迭代器与C++中的迭代器有所异同 :

  • 相同点 : Lua与C++中迭代器都是用于遍历一个容器的 ;
  • 不同点 : C++的迭代器是特殊的类 , Lua的迭代器则是由 function 或 function + table 实现 .

Lua中的迭代器的使用

  • 自定义迭代器函数并使用
  • 使用Lua提供的迭代器函数

自定义迭代器

Lua中迭代器均以 function 的形式出现 , 部分迭代器内部也配合使用了 table .

构造迭代器的基本思想 :

  • 实现一个 function , 其可以在多次调用的期间保存”状态” .
  • 利用这个”状态”使其每次每次调用都返回集合内”下一个”个元素 .

实现迭代器的常用方式 :

  1. 多状态迭代器 : 利用闭合函数实现 ;
  2. 无状态迭代器 : 借助泛型for三种状态值实现 ;

多状态迭代器 - 利用闭合函数实现

基本迭代器借助了闭合函数 , 闭合函数能够保持每次调用之间的一些状态 .

闭合函数 closure 与 非局部变量 non-local variable 与 词法域

  • 词法域 : 若一个函数定义于另一个函数内 , 则内部函数可访问外部函数的局部变量 .
  • 非局部变量 non-local variable : 既不是某个域的局部变量 , 由不是全局变量的变量 .
    对于上述内部函数而言 , 其外部函数的局部变量就是它的 非局部变量 .
  • 闭合函数 closure : closure 是由一个 function 与其所需访问的所有 非局部变量 所组成 .
    因此即便外部函数已经返回 , 其局部变量中作为 内部函数所需的非局部变量 , 将为了内部函数而保存状态 .
    (从字面来看闭合函数好像是函数的特例 , 但从技术角度上看 function 才是 closure 的特例)

示例 (Code/iterator/easyIter.lua)

1
2
3
4
5
6
7
8
9
10
11
12
local function iter(tab)   -- 实现迭代器
local x = 0 -- 闭合函数 的 非局部变量 (虽然在 function 外 , 但它是 closure 的一部分)
return function () -- 闭合函数 closure
x = x + 1
return tab[x]
end
end
local tab = {1, 2, 3, 4, 5}
local tabIter = iter(tab) -- 获取迭代器
for value in tabIter do -- 使用迭代器
print(value)
end

输出

4.easyIter.lua.png
通过例子可见 , 使用closure实现iterator , 将使得每一轮循环都要创建新的匿名函数 .
这样的开销某些情况下是无法接受的 , 无状态迭代器则没有这一问题 .

无状态迭代器 - 利用泛型for实现

所谓无状态迭代器 , 即实现迭代器的function自身不保存状态 , 状态交由泛型for的状态保存机制来保存 .

泛型for的状态保存机制

泛型for在控制循环的过程中实际上保存着3个值 : 迭代器函数 , 恒定状态 , 控制变量 .
每轮for循环将上一轮的3个值传递进函数 , 同时获得新的3个值 , 如此往复从而保存状态 .

示例 (Code/iterator/forIter.lua)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local function iter(tab, idx)    -- 迭代器定义
idx = idx + 1
local val = tab[idx]
if val then
return idx, val
end
end
local function pairs(tab) -- 包装器定义
return iter, tab, 0 -- 迭代器函数iter , 恒定状态tab , 控制变量初始值0
end
local tab = {1, 2, 3, 4, 5}
for key, value in pairs(tab) do -- 使用迭代器 --> 1 1 \n 2 2 \n 3 3 \n 4 4 \n 5 5 \n
print(key .. " " .. value)
end

输出

4.forIter.lua.png
例子中首次for将tab , 0传入iter ; tier将idx , val返回至key , value . 如此循环下去 .


Lua提供的迭代器函数

Lua提供了两个迭代器函数(实际上直接操作的是迭代器包装器) , 可用于遍历 table 和 table array .

Lua提供的iterator function

  1. pairs(table) - 通常用于遍历table , 遍历表table中的所有键值对 .
  2. ipairs(table) - 通常用于遍历array , 遍历从0开始所有数字索引的值 , 遇到首个nil停止 .

示例 (Code/iterator/iter.lua)

1
2
3
4
5
6
7
8
9
10
local tab = {1, 2, k1=3, 4, 5, 6}
tab[4] = nil -- 删除元素 4:5
print("pairs-----")
for key, value in pairs(tab) do -- 遍历所有元素, 包括 k1:3 和 5:6
print(key .. " " .. value) --> 1 1 \n 2 2 \n 3 4 \n 5 6 \n k1 3
end
print("ipairs----")
for key, value in ipairs(tab) do -- 只遍历第一个nil索引之前以数字为索引的元素
print(key .. " " .. value) --> 1 1 \n 2 2 \n 3 4
end

输出

4.iter.lua.png

0%