虽然写的是 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 对象,直接向其中写入一对键值。

from __future__ import print_function

import os
from PyQt5.QtCore import QSettings

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

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

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

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

[UserInterface]
window-width=600
window-height=500

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

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

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 存储相关的一个问题。例如,延续上文中的代码示例,我们向设置中写入一个布尔值。

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

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

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

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

settings.setValue('debugMode', False)
settings.sync()

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

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

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 的发布通知