Day 04 配置器和多语种实现

前面提到了我们在开发Qt应用时,只要有支持多语种的可能,就应当开发原生英文界面,并利用翻译机制来支持中文。今天就讨论如何实现它。在实现翻译之前,首先要知道希望使用的语言。Qt提供的默认机制是根据当前系统的位置和系统设置来决定选择哪种语言,这种做法通常不符合人们的要求。一般来说,我们更希望能够自己控制并记录这种设置,而不是让电脑自己做决定。这就涉及到系统设置的读取和保存。

配置器

Qt提供了配置支持的基础类QSettings,它支持使用注册表或ini文件的方式,支持读写操作,更重要的是它会自动检测配置源的变化,再读取QSettings对象,会得到更新后的值,而这对客户端是透明的。但是,直接使用QSettings类需要我们每次访问属性时都要提供冗长易错的配置项名称,这个太容易错了。所以我们需要封装出自己的设置类。

接下来要考虑如何提供这个接口,本质上,配置器在系统运行期间应该是唯一的,始终存在的,将其实现为Singleton是很自然的。但是实现为Singleton对单元测试不是很方便——我们还是希望在测试的时候能够有多种配置可以使用,并且最好不要互相影响。所以,最终我们决定将其实现为普通的类,显示创建它,并且将其作为参数传递给需要访问它的类。

其他语言的一些框架,比如Java,C#,往往会滥用Ioc方式,最典型的就是把配置器反向注入。对我们来说,实际上,这个软件终其一生,也没有几个需要DI的类。有兴趣的读者可以去了解一下Boost.DI。在本书中,我们虽然经常使用依赖注入的思想,但是我们始终是手工实现的。

另外我们还要考虑以后会不会使用其他的配置方式,比如流行的json方式。QSettings并不支持json格式,如果需要使用基于json的配置机制,就需要自己从头实现实现。为此,我们为配置类定义出IConfiger接口来,将基于QSettings的配置类作为它的一种实现。

首先在项目Frameworks中定义IConfiger类,我们将所有的接口类都统一放到单独的一个interface目录下面以便于管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FRAMEWORKS_EXPORT IConfiger : public QObject
{
Q_OBJECT
public:
virtual QString language() const { throw NotImplementedException(__func__);}
void language(const QString& val) { throw NotImplementedException(__func__);}

protected:
explicit IConfiger(QObject *parent = nullptr) : QObject(parent){}
~IConfiger(){}
signals:

private:
Q_DISABLE_COPY_MOVE(IConfiger);
};

这里有两点注意的,首先,我们没有将IConfiger定义为抽象类,而是为每个接口都提供了实现,只是会抛出异常。这么做的主要目的是为了让以后在单元测试时便于动态构建自己的配置类时用不着实现所有需要的方法。另外,我们将它的构造函数设置为protected,这样我们也不会错误地创建IConfiger的实例了。

当前我们仅定义了一对接口,language,用于读取/修改语言的设置。我们用”CN”表示中文,用”EN”表示英文。

接下来我们实现基于QSettings的配置类。配置类都是很简单的类,为了查阅和方便,我们都会实现为header-only的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Configer : public IConfiger
{
public:
explicit Configer(const QString& path)
: IConfiger()
, _settings{path, QSettings::IniFormat}
{}
~Configer() = default;

QString language() const { return _settings.value("language", "CH").toString(); }
void language(const QString& val) { _settings.setValue("language", val); }

private:
QSettings _settings;
};

接下来我们测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void ConfigerTester::test_configer_read_write()
{
auto file_path = QString("%1/config.ini").arg(QCoreApplication::applicationDirPath());
auto dir = QDir(QCoreApplication::applicationDirPath());
if(dir.exists("config.ini"))
{
dir.remove("config.ini");
TRACE() << "删除已经有的config.ini";
}
Configer configer(file_path);
auto v1 = configer.language();
QCOMPARE(v1, "CH");
configer.language("EN");
auto v2 = configer.language();
QCOMPARE(v2, "EN");
}

我们先删除config.ini文件,然后测试读取language配置项,检查它是否是默认值CH。然后再将其写为EN,再读取并检查。

写到这里,配置类就完成了。但是还有一个令人不爽的地方。我们看到Configer里面都是大量的重复代码。我们就想能不能简化它写法,而且我们不想放弃静态函数的原则。即使不能实现所有方法的简化,至少这种简单的读写方式的函数能不能简化掉?

这个工作本质上是属性。如果是简单的对私有数据的封装,我们可以很方便用模板来实现真正的属性操作的效果。但是在这里,有几个对模板很麻烦的问题。一个是配置项的名称,这是字面量,而直到C++17,模板对字面量的支持都不是很好,都必须在文件里面单独定义。另一个问题是_settings这个动态属性没法放到模板里面,除非我们每次都在模板里面创建它的实例,那就更得不偿失了。

最后我们决定用宏来凑合一下:

1
2
3
#define DEF_PROPERTY(name, label, type) \
virtual type name() const override { return _settings.value(label).value<type>(); } \
virtual void name(type val) override { _settings.setValue(label, val);}

然后,Configer可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Configer : public IConfiger
{
public:
explicit Configer(const QString& path)
: IConfiger()
, _settings{path, QSettings::IniFormat}
{}
~Configer() = default;

DEF_PROPERTY(language, "language", QString);
//QString language() const { return _settings.value("language", "CH").toString(); }
//void language(const QString& val) { _settings.setValue("language", val); }

private:
QSettings _settings;
};

同样的,我们也可以改写IConfiger的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define UNIMPL_PROPERTY(name, type) \
virtual type name() const { throw NotImplementedException(__func__); } \
virtual void name(type val) { Q_UNUSED(val); throw NotImplementedException(__func__);}

class FRAMEWORKS_EXPORT IConfiger
{
public:
UNIMPL_PROPERTY(Language, QString)
//virtual QString language() const { throw NotImplementedException(__func__);}
//void language(const QString& val) { throw NotImplementedException(__func__);}

protected:
explicit IConfiger() = default;
~IConfiger() = default;

private:
Q_DISABLE_COPY_MOVE(IConfiger);
};

这也是我们将属性访问函数的读写函数都用相同名字重载的原因。这样做有点让人看着不舒服。我们也可以换个思路,比如我们约定属性命名约定是大写字母开头的驼峰方式,我们可以这样改写这个宏:

1
2
3
4
5
6
7
8

#define UNIMPL_PROPERTY(name, type) \
virtual type name() const { throw NotImplementedException(__func__); } \
virtual void Set##name(type val) { Q_UNUSED(val); throw NotImplementedException(__func__);}

#define DEF_PROPERTY(name, label, type) \
virtual type name() const override { return _settings.value(label).value<type>(); } \
virtual void Set##name(type val) override { _settings.setValue(label, val);}

这样,利用C语言的##操作符,我们通过宏定义出两个方法:Language()SetLanguage()。这样做我个人感觉更舒服一些,将读写函数从名称上明确分开在大多数情况下更令人舒适。

我们还可以再定义一个宏让它支持默认值。

1
2
3
#define DEF_PROPERTY2(name, label, type, default_value) \
type name() const { return _settings.value(label, default_value).value<type>(); }\
void Set##name(type val) { _settings.setValue(label, val);}

这样,Language属性可以这样定义:

1
DEF_PROPERTY2(Language, "language", QString, "CH");

创建和使用配置类

接下来考虑如何使用我们创建的配置类。我们没有将其实现为单件,这就需要我们在某个地方创建它的实例,并将其传递给所有需要访问配置数据的类。

首先是创建时机。就我们的设计而言,我们要求配置文件保存到应用程序目录下面,我们会使用QCoreApplication::applicationDirPath()来获取路径,它虽然是一个static函数,但是,实际上,如果没有创建Application的实例,它返回的是空字符串。所以我们必须将其放到应用创建后,一个最好的地方就是main()函数了,我们在QApplication a(argc, argv);的后面简单地声明一个局部变量就可以了。

但是为了隔离变更,我们构造一个工厂类,将所有的这种类的实例化合并到这里来。如下:

1
2
3
4
5
6
7
8
class FRAMEWORKS_EXPORT AlgorithmFactory
{
public:
static std::unique_ptr<IConfiger> makeConfiger(const QString& path)
{
return std::make_unique<Configer>(path);
}
};

这样,main.cpp中的main()函数就可以这样写:

1
2
3
4
5
6
7
8
#include "factory.h"

int main(int argc, char *argv[])
{
QApplication a(argc, argv);
auto configer = AlgorithmFactory::makeConfiger(QString("%1/config.ini").arg(QCoreApplication::applicationDirPath()));
...

以后不管如何更改,我们只需要修改factory.h文件就可以了。我们创建了一个std::unique_ptr,这个仅仅是习惯性的保持一致性,实际上后面我们是将裸指针作为参数传给使用它的类的。

我们修改窗口类,添加配置接口IConfiger的指针。

1
2
3
4
5
6
7
8
9
10
11
#include <QMainWindow>
#include "interface/iconfiger.h"
...
class ScannerMainWindow : public QMainWindow
{
Q_OBJECT
public:
ScannerMainWindow(IConfiger* configer, QWidget *parent = nullptr);
~ScannerMainWindow();
...
};

这样,main.c中的main()函数中就修改为:

1
2
3
4
5
6
7
8
9
10
11
#include "algorighmfactory.h"
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
auto fp = QString("%1/config.ini").arg(QCoreApplication::applicationDirPath());
auto configer = AlgorighmFactory::makeConfiger(fp);
...
ScannerMainWindow w(configer.get());
w.show();
return a.exec();
}

多项目下的多语种切换

QtCreator创建的代码中在main()中已经提供了语言切换的功能。但是它有几个问题。

首先,它是根据Local信息来定义语言的,即根据你的操作系统和位置来使用本地语言。这个并不是我们想要的。我们希望能够自由切换语言。

其次,Qt提供的机制只用于app,若还有多个DLL,DLL的语言该怎么使用和切换?

我们首先看以下在main()中的实现,来了解一下Qt的多语种机制:

1
2
3
4
5
6
7
8
9
QTranslator translator;
const QStringList uiLanguages = QLocale::system().uiLanguages();
for (const QString &locale : uiLanguages) {
const QString baseName = "CellScanner_" + QLocale(locale).name();
if (translator.load(":/i18n/" + baseName)) {
a.installTranslator(&translator);
break;
}
}

函数会使用QLocale::uiLanguages()来获取系统的语言列表,然后依次获取对应的QLocal对应的“short name”,它对应于我们定义的语言文件的名称。例如,在CellScanner.pro中:

1
2
TRANSLATIONS += \
CellScanner_zh_CN.ts

其中的zh_CN就是中华人民共和国的“short name”。我们可以修改一下上面的代码,看一下结果:

1
2
3
4
5
6
7
8
9
10
11
12
...
const QStringList uiLanguages = QLocale::system().uiLanguages();

TRACE() << "系统语言列表: " << uiLanguages;
for(const auto& locale: uiLanguages)
{
TRACE() << TSHOW(locale) << TSHOW(QLocale(locale).name());
}

for (const QString &locale : uiLanguages) {
...
}

调试打印为:

1
2
3
4
5
6
7
8
12:57:21: Starting D:\CodeLib\QtInPractice\source\ScanerSuit\build\Desktop_Qt_6_7_0_MSVC2019_64bit-Debug\CellScanner\debug\CellScanner.exe...
[ main (...)] 系统语言列表: QList("zh-Hans-CN", "zh-CN", "zh", "en-US", "en-Latn-US", "en")
[ main (...)] locale:= "zh-Hans-CN" , QLocale(locale).name():= "zh_CN" ,
[ main (...)] locale:= "zh-CN" , QLocale(locale).name():= "zh_CN" ,
[ main (...)] locale:= "zh" , QLocale(locale).name():= "zh_CN" ,
[ main (...)] locale:= "en-US" , QLocale(locale).name():= "en_US" ,
[ main (...)] locale:= "en-Latn-US" , QLocale(locale).name():= "en_US" ,
[ main (...)] locale:= "en" , QLocale(locale).name():= "en_US" ,

而在QLocale::uiLanguages()中得到的列表的顺序是和我们的电脑的语言顺序有关的。上面的结果来自于如下的设置:

如果我们调整一下次序,将英语拖到中文的前面:

重新运行程序,可以看到输出变成了下面的样子:

1
2
3
4
5
6
7
8
13:06:14: Starting D:\CodeLib\QtInPractice\source\ScanerSuit\build\Desktop_Qt_6_7_0_MSVC2019_64bit-Debug\CellScanner\debug\CellScanner.exe...
[ main (...)] 系统语言列表: QList("en-US", "en-Latn-US", "en", "zh-Hans-CN", "zh-CN", "zh")
[ main (...)] locale:= "en-US" , QLocale(locale).name():= "en_US" ,
[ main (...)] locale:= "en-Latn-US" , QLocale(locale).name():= "en_US" ,
[ main (...)] locale:= "en" , QLocale(locale).name():= "en_US" ,
[ main (...)] locale:= "zh-Hans-CN" , QLocale(locale).name():= "zh_CN" ,
[ main (...)] locale:= "zh-CN" , QLocale(locale).name():= "zh_CN" ,
[ main (...)] locale:= "zh" , QLocale(locale).name():= "zh_CN" ,

可以看到,英文跑到了前面去了。

知道这个机制就可以了,这就是我们实现语言切换的机制。

主程序CellScanner的资源文件已经可以访问了,但是其他DLL的资源文件如何访问?我们采用一种最简单的做法:把qm文件作为资源文件管理。

我们就直接使用之前创建的resources.qrc,利用右键菜单项添加现有文件...,将几个.qm文件都添加进来。完成之后如下图所示:

它们的路径太复杂了,我们为它们定义一下别名。在Creator中打开并编辑resources.qrc文件:

对每个qm资源,选中它,并在别名一栏中,设置为它的文件名:

Qt的资源文件其实就是一个xml文件,我们可以选择使用文本编辑器打开它,提供了别名后的内容如下。熟悉了也可以直接手工编辑修改:

1
2
3
4
5
6
7
8
<RCC>
<qresource prefix="/">
...
<file alias="CellScanner_zh_CN.qm">../CellScanner_zh_CN.qm</file>
<file alias="Adaptors_zh_CN.qm">../../Adaptors/Adaptors_zh_CN.qm</file>
<file alias="Frameworks_zh_CN.qm">../../Frameworks/Frameworks_zh_CN.qm</file>
</qresource>
</RCC>

接下来修改main()函数,注释掉原有的加载语言资源部分,改为如下的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
auto fp = QString("%1/config.ini").arg(QCoreApplication::applicationDirPath());
auto configer = AlgorighmFactory::makeConfiger(fp);

QTranslator gui_translator(&a), frame_translator(&a), adaptor_translator(&a);
TRACE() << "当前语言:" << configer->Language();
if(configer->Language()=="CH")
{
adaptor_translator.load(":/Adaptors_zh_CN.qm");
frame_translator.load(":/Frameworks_zh_CN.qm");
gui_translator.load(":/CellScanner_zh_CN.qm");
a.installTranslator(&adaptor_translator);
a.installTranslator(&frame_translator);
a.installTranslator(&gui_translator);
}

ScannerMainWindow w(configer.get());
w.show();
return a.exec();
}

运行程序,我们发现界面变成中文了,因为Configer::Language()的默认值是“CH”。我们在执行程序目录下创建一个config.ini文件,并编写内容如下:

1
2
[General]
language=EN

再次运行程序,可以看到界面又变成英文了。

这里给出的方法适用于静态修改语言的情况,每次启动程序时加载资源。如果想要实现动态,要复杂一些。其实动态修改界面最麻烦的是对于哪种不是使用设计器生成的界面的情况。实际上动态切换语言的需求大都是采购时供应商互相卷出来的东西,这里我们就不展开讨论了。