虽然写的是 PyQt5 中遇到的问题,PyQt4 中也可能会遇到类似问题。

中秋假期在家继续改进之前写的小工具,第一件事把设置数据的存取从直接读写 yaml 文件改成了使用 QSettings。什么是 QSettings?摘一段 Qt 文档的翻译。

QSettings 类提供平台无关的持久化应用程序设置。

用户通常期待应用程序在不同会话中保留其设置(窗口大小和位置、设置项等等)。这些信息在 Windows 上经常存储在注册表中,在 Mac OS X 和 iOS 上则保存在 plist 文件中。在 Unix 系统上,由于缺少标准,许多应用程序(包括 KDE 程序)使用 INI 文本文件(来存储设置)。

QSettings 是围绕这些技术提供的一个抽象层,让你使用一种可移植的方式来存储和恢复应用程序设置。它同时支持自定义存储格式。

也就是说 QSettings 是用于保存应用程序设置项(任何你希望保存的、除大文件之外的其他数据)的一个类,在不同平台上可以使用不同的存储(也可以使用统一的存储,如果可用的话),并且存储格式是可扩展的。

QSettings 的特点大概主要是这么几点:

  • 支持的数据类型很多。只要能用 QVariant 包起来的,都可以存储,例如 QImage 和 Python 的 datetime 对象。
  • 存储隔离。你可以指定使用哪种存储,但一般在使用的时候无需关心存储细节,只需要调用 QSettings.sync(),你的设置就保存好了。
  • 可以多个线程、多个进程同时使用同一个存储位置。在多线程时,每个 QSettings 对象会立即获得来自其他使用同一个存储位置的 QSettings 对象的更新;在多进程时,Qt 使用文件锁和冲突合并算法来保证存储数据的完整性,并且每次 sync 之后可以获得来自其他进程的修改。

在 Mac OS X 上,plist 文件存储在 ~/Library/Preferences/{BUNDLE_IDENTIFIER}.{APPLICATION_NAME}.plist。其中 BUNDLE_IDENTIFIER 是软件的 ID,在最终分发的 .app 目录中 Contents/Info.plist 有这个值,一般设置为开发团队域名的反写;APPLICATION_NAMEQApplication.applicationName() 的值,可以通过 QApplication.setApplicationName 来设置。对于 plist 不支持的一些 QVariant 类型,会直接存储二进制数据。注意:如果以源代码模式运行,BUNDLE_IDENTIFIER 将是 org.python.python。注意:本段所写内容只是其中常见的一种情况,实际上并不完整;关于设置数据具体存储的位置,请参考 Qt 文档的这里

在 Windows 上,默认 QSettings 使用注册表来存储数据。所有数据存储在 HKEY_CURRENT_USER\SOFTWARE\{ORGANIZATION_NAME}\{APPLICATION_NAME} 下,其中 ORGANIZATION_NAMEQApplication.organizationName() 的值,可以通过 QApplication.setOrganizationName 来设置。每个 group 是一个文件夹,以每个末端 key 为键,值有两种类型:REG_SZREG_BINARY,前者是字符串类型,后者是二进制类型。不支持直接存储的一些 QVariant 类型值会存储为二进制类型。注意:如果组织名和软件名缺少任意一个,设置将不能保存。

我已经不再继续使用桌面 Linux,因此没有 Linux 平台相关的知识可以分享。

要使用 QSettings,最简单的例子是构造一个 QSettings 对象,直接向其中写入一对键值。

1
2
3
4
5
6
7
from __future__ import print_function

import os
from PyQt5.QtCore import QSettings

settings = QSettings()
settings.setValue('cwd', os.getcwd())

要取出值,使用 .value() 方法,同时可以提供一个可选的默认值。

1
settings.value('cwd', '/')

前面说存储的时候提到了 group 的概念,什么叫做 group 呢?一个应用程序的设置通常有很多个部分,例如界面相关的设置、软件更新相关的设置、网络相关的设置等等。其实这些就是 group。大家想必都见过 INI 文件,在 INI 文件中我们可以这么写:

1
2
3
4
5
6
7
8
[UserInterface]
window-width=600
window-height=500

[SoftwareUpdate]
enabled=true
check-interval=1
development-release=false

其中每个 [] 就是开始了一个新的组。在 QSettings 中我们同样可以使用 group,并且可以嵌套。

1
2
3
4
settings.beginGroup('UserInterface')  # 开始一个新的组
settings.setValue('window-width', 600)
settings.setValue('window-height', 500)
settings.endGroup()  # 结束这个分组

上面这段代码会插入两个 key,完整的 key 是 UserInterface/window-widthUserInterface/window-height。当我们调用 .beginGroup('UserInterface'),我们实际上进入了 UserInterface 这个组中,此时设置或获取键值,拿到的是这个组中的数据。当我们调用 .endGroup() ,我们就从当前组(就是在此之前离 .endGroup() 最近的一个 .beginGroup() 调用)中回退到上一层。如果后续需要进行其他读写,在进入一个组之后,一定记得调用 .endGroup() 退出当前组。

在写入数据之后,所有的修改都处在内存中。要将修改保存到实际的存储端,调用 .sync() 方法。要删除一个键,使用 .remove()

以上就是 QSettings 的基本使用介绍。

除此之外,使用 PyQt5 时,可能会遇到与 QSettings 存储相关的一个问题。例如,延续上文中的代码示例,我们向设置中写入一个布尔值。

1
2
settings.setValue('debugMode', True)
settings.sync()

然后我们测试一下使用这个值的代码块是否可以正常工作。

1
2
if settings.value('debugMode', False):
    print('We\'re in debug mode.')

看起来是可以的。那么我们修改一下设置,把调试模式关掉看看。

1
2
3
4
5
settings.setValue('debugMode', False)
settings.sync()

if settings.value('debugMode', False):
    print('We\'re in debug mode.')

如果你是在 Mac 上测试这段代码,那么肯定没有问题。如果你是在 Windows 上测试这段代码(注意:由于大部分 Qt 功能需要预先初始化一个 QApplication 对象并进入事件循环,所有的代码基本无法在交互模式下运行),那么就会发现调试模式关不掉了!这是怎么回事?我们看看取回来的值的类型和实际的值。

1
2
3
4
5
settings.setValue('debugMode', False)
settings.sync()

debug = settings.value('debugMode', False)
print('Got data type of {}, actual value: {}'.format(type(debug), debug))

如果是在 Mac 上,这里 debug 是 bool 类型;如果在 Windows 上使用默认的注册表存储,这里返回的类型是字符串,我们设置的 False 在存储的时候被写入为了 false。为什么会这样?

前面说过,在 Mac 上 QSettings 默认存储为 plist 文件。plist 格式中支持直接存储布尔类型,因此在读写时可以保持类型不变。而注册表(以及其他可能的存储)不支持直接存储布尔类型,布尔值在存储时转换为了字符串,在读取时,由于数据本身没有类型信息,Qt 也只能以字符串类型返回,因此造成这种看似不科学的现象。更详细的讨论在这里

对于 QVariant 包裹的值,这个问题不存在。因为 QVariant 不仅包含值,也包含了值的类型信息;它在存储时,是直接以二进制方式存储到文件(或注册表)的,在读取时可以准确还原为 QVariant 类型。

那么我们如何解决这个问题呢?Qt 的文档中,QSettings.value() 方法只有两个参数,但在 PyQt5 中这个方法有额外的 type 参数,可以指定以某种类型来读取数据。参考 PyQt 的发布通知