第4章 GUI编程简介
这一章,我们从回顾3段至今仍然有用的GUI程序开始。我们将利用这个机会去着重强调GUI编程中会包含的一些问题,详细的介绍会放到后面的章节。一旦我们建立起PyQt GUI编程的初步感觉后,我们就讲讨论PyQt的信号槽机制,这是一个高级的通信机制,他可以反映用户的操作并且让我们忽略无关的细节。
尽管PyQt在商业上建立的应用程序大小在几百行到十多万行都有,但是这章我们介绍的程序都在100行内,他们展示了使用很少的代码可以实现多么多的功能。
在这章,我们仅仅使用代码来构建我们的用户界面,在第7章,我们将学习如何使用可视化图形工具,Qt Designer.
Python的控制台应用程序和Python的模块文件总是使用.py后缀,不过Python GUI应用程序我们使用.pyw后缀。不无奈是.py还是.pyw,在linux都表现良好,不过在windows上.pyw确保windows使用pythonw.exe解释器而不是python.exe解释器,这确保我们运行GUI程序的时候没有不必要的控制台窗口出现。在Mac OS X,一定要使用.pyw后缀。
PyQt的文档提供了一系列的HTML文件,这些文件独立于Python文档之外。文档中最常用到的是那些转换过来的PyQt API。这些文件都是从原生的C++/Qt文档转换的,他们的索引页是classes.html;windows用户可以从他们PyQt的菜单文件里找到这些页面。大体浏览那些可用的类是很有价值的,当然深入阅读起来看起来也不错。
我们将探索的第一个应用程序是一个不同寻常的“混血”程序:虽然是一个GUI程序但是它需要从控制台载入,这是因为它需要一些参数才能运行正确。我们之所以包涵它是因为这让我们解释PyQt的事件循环机制变得容易了很多,不用多费口舌去讲解其他暂时用不到的GUI细节。第二个和第三个例子都是短小的标准GUI程序。他们都展示了如何创建和布局(标签、按钮、下拉菜单和其他的用户可以看到交互的组件)。例子也展示了我们是怎么回应用户交互的,例如,当用户进行了一个特殊操作时如何调用一个特殊的函数。
在最后一节,我们将会我们将会更加深入的讲解如何处理用户和程序的交互,在下一张我们将会更加透彻的覆盖布局和对话框的知识。这一章我们是想让你建立起GUI编程的初步感觉,体会它是怎样运作的,不需要过多的关注他的细节。之后的章节会深入讲解并且渐渐让你熟悉标准PyQt编程。
一个25行的弹出闹钟
我们的第一个GUI应用程序看上去有点儿奇怪。第一,它必须从控制台启动;第二,它们有“修饰品”——标题栏、菜单、关闭按钮。如下图:
要得到上面的展示,我们需要在命令行键入如下的命令:
C:\>cd c:\pyqt\chap04
C:\pyqt\chap04>alert.pyw 12:15 Wake Up
程序运行后,程序将在后台默默运行,仅仅是简单的记录时间,而当特殊的时间到达时,它会弹出一个信息窗口。之后大约一分钟,程序会自动终止。
这个特殊的时间必须使用24小时进制的时钟。为了测试的目的,我们使用刚刚经过的时间,例如现在已经12:30了我们使用12:15,那么窗口会理解弹出(好吧,一秒之内)。
现在我们知道了这个程序能干什么,如何去运行它,让我们回顾一下他的实现。这个文件只有几行,稍微比25行多些,因为里面会有一些注释和空白行,但是与执行相关的代码只有25行,我们从import开始:
1 import sys
2 import time
3 from PyQt4.QtCore import *
4 from PyQt4.QtGui import *
我们导入了sys模块是因为我们想要接受命令行的参数sys.argv。time模块则是因为我们需要sleep()函数,PyQt模块是因为需要使用GUI和QTime类。
1 app = QApplication(sys.argv)
开始的时候我们创建了QApplication对象。每一个PyQt GUI程序都需要有一个QApplication对象。这个对象提供了一些全局的信息借口,例如程序目录,屏幕尺寸,以及多进程系统中程序在哪个屏幕上等等。这个对象同时提供了事件循环,稍后讨论。
当我们创建QApplication对象的时候,我们传递了命令行参数,因为PyQt可以辨别一些它自己的命令行,例如-geometry 和 -style,所以我们需要给它读到这些参数的机会。如果QApplication认识他们,它将会对他们尽心一些操作,并将他们移出参数列表。至于QApplication可以认识的参数你可以在QApplication初始化文档中查询。
try:
due = QTime.currentTime()
message = "Alert!"
if len(sys.argv) < 2:
raise ValueError
hours, mins = sys.argv[1].split(":")
due = QTime(int(hours), int(mins))
if not due.isValid():
raise ValueError
if len(sys.argv) > 2:
message = " ".join(sys.argv[2:])
except ValueError:
message = "Usage: alert.pyw HH:MM [optional message]" # 24hr clock
在比较靠后的地方,这个程序需要一个时间,我们设定为现在的时间。我们提供了一个默认的时间,如果用户没有给出任何一个命令行参数,我们抛出ValueError异常。接将会显示当前时间以及包含“用法”的错误信息。
如果第一个参数没有包含冒号,那么当我们尝试调用split()将两个元素解包时将会触发ValueError异常。如果小时分钟的数字不是合法数字,那么int()将会引发ValueError异常,如果小时和分钟超过了应有的界限,那么QTime也会引发ValueError异常。虽然Python提供了自己的date和time类,但是PyQt的date和time类更加方便,所以我们使用PyQt的。
如果time通过验证,我们就讲显示信息设置为命令行里剩余的参数,如果没有其余参数的话,则显示我们开始时设置的默认信息“Alert!”。
现在我们知道了合适信息必须显示,以及显示那些信息。
while QTime.currentTime() < due:
time.sleep(20) # 20 seconds
我们连续不断的循环,同时比较当前时间和目标时间。只有当前时间超过目标时间后循环才会停止。我们可以仅仅把pass放入循环内,如果这样的话,Python会非常一遍一遍快的执行这个循环,这可不是什么好现象(资源利用过高)。所以我们使用time.sleep()来挂起这个进程20秒。这让机器上其他的程序可以得到更多的运行机会,因为我们这个程序在等待过程中并不需要做任何事情。
抛开创建QApplication对象的那部分,我们现在做的于标准控制台程序没什么不同。
label = QLabel("<font color=red size=72><b>{0}</b></font>"
.format(message))
label.setWindowFlags(Qt.SplashScreen)
label.show()
QTimer.singleShot(60000, app.quit) # 1 minute
app.exec_()
我们创建了QApplication对象,我们有了显示信息,所以现在使我们创建我们程序的时候了。一个GUI程序需要widgets,所以我们需要用label来显示我们的信息。一个QLabel可以接收HTML文本,所以我们就给了一串HTML string来显示这段信息。
在PyQt中,任何widget都可用作顶级窗口,甚至是一个button或者label。当一个widget这么做的时候,PyQt自动给他一个标题栏。在这个程序里我们不需要标题,我所以我们我们把label的标签设置为了用作窗口分割的flag,因为那些东西没有标题。当我们设置完毕后,我们调用show()方法。从此开始,label窗口显示出来了!当调用show()方法时,仅仅是计划执行“重绘事件”,也就是说,把这个产生新重绘事件的QApplication对象添加到事件队列中去。
接下来,我们设置了一下只是用一次的定时器。因为Python标准库的time.sleep()方法使用秒钟时间,而QTimer.singleShot()方法使用毫秒。我们给singleShot()方法两个参数,多长时间后触发这个定时器,以及响应这个定时器的方法。
在PyQt的术语中,一个方法或者函数我们给他一个称号“slot”(槽),尽管在PyQt的文档中,术语 “callable”, “Python slot”,和“Qt slot”这些来作为区分Python的__slots__,一个Python类的新特性。在这本书中我们仅仅使用PyQt的术语,因为我们从来没有用的__slots__。
所以现在,我们有两个时间时间表:一个paint事件我们想立即执行,还有一个timer的timeout时间我们想一分钟后执行。
调用app.exec_()开始了QApplication对象的事件循环。第一个事件是paint时间,所以label窗口带着我们设置的信息显示在了屏幕上,大约一分钟后timer触发timeout时间,调用QApplication.quit()方法。这个方法会结束这个GUI程序,他会关闭所有打开的窗口,清空申请的资源,然后退出程序。
在GUI程序中使用的是事件循环机制。用伪代码表示就像这样:
while True:
event = getNextEvent()
if event:
if event == Terminate:
break
processEvent(event)
当用户于程序交互的时候,或者有特定事件发生的时候,例如timer或者程序窗口被遮盖等等,一个PyQt事件就会发生,并且添加到事件队列中去。应用程序的事件循环持续不断的坚持是否有事件需要执行,如果有就执行。
尽管完成这个程序只用了一个简单的widget,兵器使用控制台程序看起来确实很有效,不过我们现在还没有给这个程序与用户交互的能力。这个程序的运作效果于传统的批处理程序也很相似。从他调用开始,他会处理一些进程,例如等待,显示信息,然后终止。大部分的GUI程序运行却与之不同。一旦开始运行,他们进入事件循环并且响应事件。有些事件是用户产生的,例如按下键盘、点击鼠标,有些事件则是系统产生的,例如timer定时器到达预定事件,窗口重绘等。这些GUI程序对请求作出处理,仅仅在用户要求终止时结束程序。
下一个我们要介绍的程序比我们刚刚看到的要更加符合传统,并且是一个典型的小的GUI程序。
一个30行的表达式计算器
这个程序是一个有对话框风格的用30行写成的应用程序(刨除注释和空行)。对话框风格是指这个程序没有菜单、工具条、状态栏、中央widget等等,大部分的组建是按钮。与之对应的谁主窗口风格程序,上面没有的它都有。第六章我们将研究主窗口风格的程序。
这个程序使用了两种widget:一个QTextBrowser,他是一个只读的多行文本框,可以显示普通的文本或者HTML;一个QLineEdit,,这是一个单行的输入部件,可以处理普通文本。在PyQt中所有的text都是Unicode的,当然必要的时候他们也可以转码成其他编码。
这个计算器程序就像任何GUI程序一样,双击图标运行。程序开始执行后,用户可以随意像输入框输入表达式,如果输入回车时,表达式和他的结果就会显示在QTextBrowser上。如果有什么异常的话,QTextBrowser也会显示错误信息。
像平时一样,我们先浏览下代码。这个例子展示了我们建立GUI程序的模式:使用一个form类来包含所有需要交互的方法,而程序的“主要”部分则看上去很精悍。
from __future__ import division
import sys
from math import *
from PyQt4.QtCore import *
from PyQt4.QtGui import *
因为在我们的数学计算里面并不像要截断除法计算(计算机的整数除整数的规则),我们要确定我们进行的是浮点型除法。通常,我们在import非PyQt模块是使用import 模块名字的语法;但是因为我们相用到math模块里面很多的方法,所以我们把math模块里面所有的方法导入到了我们的名字空间里面。我们导入sys模块通常是为了得到sys.argv参数列表,然后我们导入了QtCore和QtGui模块里面的所有东西。
class Form(QDialog):
def __init__(self, parent=None):
super(Form, self).__init__(parent)
self.browser = QTextBrowser()
self.lineedit = QLineEdit("Type an expression and press Enter")
self.lineedit.selectAll()
layout = QVBoxLayout()
layout.addWidget(self.browser)
layout.addWidget(self.lineedit)
self.setLayout(layout)
self.lineedit.setFocus()
self.connect(self.lineedit, SIGNAL("returnPressed()"),
self.updateUi)
self.setWindowTitle("Calculate")
我们之前已经看到,任何一个widget都可以看所是顶级窗口。但是大多数情况下,我们使用QDialog或者QMainWindow来做顶级窗口,偶而使用QWidget,不管是QDialog或者QMainWindow,以及所有PyQt里面的widget都是继承自QWidget。通过继承QDialog我们得到了一个空白的框架,他有一个灰色的矩形,以及一些方便的行为和方法。例如,如果我们单击X按钮,这个对话框就会关闭。默认情况下,当一个widget关闭的时候事实上仅仅是隐藏起来了,当然我们可以改变这些行为,下一章我们就会介绍。
我们给予我们的Form类一个__init__()方法,提供一了一个默认的parent=None,并且使用super()方法进行初始化。一个没有parent的widget就会变成顶级窗口,这正是我们所需要的。然后我们创建了我们需要的widget,并且保持了他们的引用,这样之后在__init__()之外我们就可以方便的引用到他们。以为我们没有给他们parent,看上去他们会变成顶级窗口,这不是我们所期待的。别急,在初始化的后面我们就会看到他们是怎么得到parent的了。我们给予QLineEdit一些信息来最初显示在程序上,并且将这些信息选中了。这样就保证我们的用户开始输入信息时这些显示信息会最快的被擦除掉。
我们想要一个接一个的在窗口中垂直显示我们的widget,这使得我们创建了QVBoxLayot并且在里面添加了我们的两个widget,接下来把form的布局设置为这个layout。如果我们运行我们的程序,并且改变程序大小的时候,我们会发现有一些多余的垂直空间添加到了QTextBrowser上,而所有的widget水平都会拉长。这些都会有布局管理器自动处理,并且可以很方便的通过布局策略来调整。
一个重要的边际效应是PyQt在使用layout时,会自动将布局中的widget的父母重新定位。所以尽管我们没有对我们的widget指定父母,但是当我们调用setLayout()的那个时候我们已将把这两个widget的parent设定为Form的self了。这样一来,所有的部件都是顶级窗口的一员,他们都有parent,这才是我们计划的。并且,当form删除的时候,他所有的子widget和layout都会和他一起按照正确的顺序删除。
form里面的widget可以有多种技术布局。我们可以使用resize()和move()方法去去给他们赋予绝对的尺寸与位置;我们可以重写resizeEvent()方法,动态的计算它们的尺寸和坐标,或者使用PyQt的布局管理器。使用绝对尺寸和坐标非常不方便。一方面,我们需要进行大量的计算,另一方面,如果我们改变了布局,我们还要冲进计算。动态的计算大小和位置是更好的方案,不过这依然需要我们写很多冗长无聊的代码。
使用布局管理器使这一切变得容易了很多。并且布局管理器非常聪明:他们自动的去适应resize事件,并且去满足这些改变。任何使用对话框的人相信都更喜欢大小可以改变而不是固定了使用小小的不能改变的窗口,因为这样才能适应用户的心里需求。布局管理器也让你的程序国际化时变得简单,这样当翻译一个标签的时候就不会因目标语言比源语言要啰嗦的多而被砍掉了。
PyQt提供了三种布局管理器:水平、垂直、网格。布局可以内嵌,所以你可以做出非常精致的布局。当然也有其他的布局方式,例如使用tab widget,或者splitter,这些在第九章会深入讲解。
处于礼貌,我们把focus放到QLineEdit上,我们可以调用setFocus()达到这一点。这一点必须在布局完成后才能实施。
Connect()的调用我们将在本章稍后的地方深入讲解。可以说,任何一个widget(以及某些QObject对象)都可以通过发出”信号”声明状态改变了。这些信号通常被忽略了,然而我们选择我们感兴趣的信号,我们通过QObject的声明来让我们知道这些我们感心情信号被发出了,并且这个信号发出的时候可以调用我们想用的方法。
在这个例子里,当用户在QLineEdit上按下回车键的时候,returnPress()信号将会被发射,不过因为我们的connect()方法,当这个信号发出的时候,我们调用updateUi()这个方法。马上我们就会看到发生了什么。
我在在__init__方法做的最后一件事情是设置了窗口的标题。
我们简短的看一下,我们创造了form,并且调用了上面的show()方法。一旦事件循环开始,form显示出来,好像没有其他什么发生。程序仅仅是运行事件循环,等待用户去按下鼠标或者键盘。所以当用户输入一个表达式的时候QLineEdit将会显示用户输入的表达式,当用户按下回车键时,我们的updateUi方法将会被调用。
def updateUi(self): try: text = unicode(self.lineedit.text()) self.browser.append("{0} = <b>{1}</b>".format(text, eval(text))) except: self.browser.append("<font color=red>{0} is invalid!</font>" .format(text))
当updateUi()
这个方法被调用的时候,他会检索QLineEdit
的信息,并且立刻将他转换为unicode
对象。我们使用Python
的eval()
方法来直接运算表达式的值。如果成功的话,我们将计算的结果添加到QTextBrowser
里面,这时候我们会将unicode字符转换为QString,在需要QString参数PyQt模块中,我们可以传入QString,unicode,str这些字符串,PyQt会自动进行转换工作。如果产生了异常,我们就把错误信息添加到QTextBrowser
里面,通常使用一个抓取所有异常的except
块在实际编程时并不是一个很好的用法,不过在我们这个只有30行的程序里面看上去说的过去。
不过使用eval()
方法时,我们应该自行进行语法和解析方面的检查,谁让python是个解析语言。
app = QApplication(sys.argv)
form = Form()
form.show()
app.exec_()
现在我们的Form
类已经定义完了,基本上也到了calculate.pyw文件的最后,我们创建了QApplication
对象,开始了绘制工作,启动了事件循环。
这就是全部的程序,不过这还不是故事的结局。我们还没有说用户是怎么终结这个程序的。因为我们的程序继承自QDialog
,他继承了很多有用的行为。例如,如果用户点击了X按钮,或者是按下了Esc键,那么form就会关闭。当一个form关闭的时候,他仅仅是隐藏起来了。当form隐藏起来的时候,PyQt将会探测我们的程序,看看是否还有可见的窗口或者是否还有可以交互的行为,如果所有窗口都隐藏起来了,那么PyQt就会终止程序并且delete这个form。
有些情况下,我们希望我们的程序就算在隐藏状态下依然能够运行,例如一个服务器。这种情况下,我们调用QApplication.setQuitOnLast-WindowClosed(False)
。虽然这很少见,但是他可以保证窗口关闭的时候程序可以继续运行。
在Mac OS X以及某些Windows窗口管理器中(像twm),程序是没有关闭按钮的,而在Mac上从菜单栏选择退出是没有效果的。这种情况下,我们就需要按下Esc来终止程序,在Mac上你还可以使用Command +。所以,如果这个程序有可能在Mac或者twm中使用的时候,最好在dialog中添加一个退出按钮。
现在我们已经准备好去看本章最后一个完整的小程序了,他拥有更多的用户行为,有一个更复杂的布局,以及更加复杂的处理方式,不过基本的结构域我们的计算器程序很像,不过添加了更多PyQt对话框的特性。
一个70行的货币转换工具
货币转换工具是一个试用的小工具。但是由于兑换汇率时常变换,我们不能简单的像前几章一样使用一个静态的字典来存储这些信息。通常,加拿大银行都会在网上提供这些银行汇率,并且这个文件的格式我们可以非常容易的读取更改。这些汇率与最新的数据可能有几天的时差,但是这些信息对于有国际合同需要估算预付款的时候是足够使用了。
这个程序要想使用首先下载这些汇率数据,然后他才会创建用户界面。 通常我们从import开始看代码:
import sys
import urllib2
from PyQt4.QtCore import (Qt, SIGNAL)
from PyQt4.QtGui import (QApplication, QComboBox, QDialog,
QDoubleSpinBox, QGridLayout, QLabel)
无论是python
还是PyQt
都提供了和网络有关的类。第18章,我们会使用PyQt
的类,不过在这一章我们使用Python
的urllib2
模块,因为这个模块提供了从网上抓取文件的一些非常方便的工具。
class Form(QDialog):
def __init__(self, parent=None):
super(Form, self).__init__(parent)
date = self.getdata()
rates = sorted(self.rates.keys())
dateLabel = QLabel(date)
self.fromComboBox = QComboBox()
self.fromComboBox.addItems(rates)
self.fromSpinBox = QDoubleSpinBox()
self.fromSpinBox.setRange(0.01, 10000000.00)
self.fromSpinBox.setValue(1.00)
self.toComboBox = QComboBox()
self.toComboBox.addItems(rates)
self.toLabel = QLabel("1.00")
在使用super()
初始化我们的form后,我们调用getdata()
方法。不久我们就会看到这个方法从网上下载读取汇率数据,并把它们存储到self.rates
这个字典里,并且必须返回一个有date
的字符串。字典的key是货币的币种,value
是货币转换的系数。
我们将字典中的key
排序后得到了一组copy,这样在我们的下拉列表里就以使用排序后的货种了。date
和rate
变量以及dateLabel
标签,由于只在__init__()
方法中使用,所以我们不必保留他们的引用。另一方面,我们确实需要引用到下拉列表以及toLabel
,所以我们在self
中保留了变量的引用。
我们在两个下拉列表中添加了排列后相同的列表,我们创建了一个QDoubleSpinBox
,这是一个可以处理浮点型的spinbox
。我们为它提供了最大值和最小值。spinbox
设置取值范围是一个实用的方法,如果你这么做了之后,当你设置初始值的时候,如果初始值超过了spinbox
的范围,他会自动增加或减小初始值是初始值达到范围的边界。
因为两个下拉列表开始的时候都会显示相同的币种,所以我们将value
初始设置为1.00,在toLabel
里显示的结果也是1.00。
grid = QGridLayout()
grid.addWidget(dateLabel, 0, 0)
grid.addWidget(self.fromComboBox, 1, 0)
grid.addWidget(self.fromSpinBox, 1, 1)
grid.addWidget(self.toComboBox, 2, 0)
grid.addWidget(self.toLabel, 2, 1)
self.setLayout(grid)
一个grid layout
看上去是给widget
布局的最简单的方案。当我们向grid layout
添加widget
的时候,我们需要给他一个行列的位置,这是以0作为基址的。布局如下图所示。grid layout
还可以有附加的参数,例子里面我们设置了行和列的宽度,在第九章覆盖了这些话题。
如果我们运行程序或者看截图的话,很容易看出第0列要比第1列宽,但是在代码里面却没有任何附加的说明,这是怎么发生的呢?布局管理器非常聪明,他会他会管理空白、文字、以及尺寸政策来使得布局自动适应环境。这种情况下,下拉列表水平方向会拉长的列表中最大文字宽度的值。因为下拉列表是地列队宽度元素,他的宽度就设置为这一列的最小宽度;第一列的spinBox
和它一个情况。如果我们运行程序并且向缩小窗口的话,神马都不会发生,因为窗口已经是他的最小宽度。不过我们可以使他变宽,这样两列横向都会拉长。当然你可能更希望某一列的拉伸速度更快,这也是可以实现的。
没有一个元素在初始化的时候纵向被拉伸,因为在这个例子里面是没有必要地。不过如果我们增加了窗口的高度,多余的空白都会跑到dateLabel
这里去,因为他是这个例子里唯一一个可以在所有方向上增加大小的部件。
既然我们创建了widget
,获得了数据,进行了布局,那么现在是时候设置我们form
的行为了。
self.connect(self.fromComboBox,
SIGNAL("currentIndexChanged(int)"), self.updateUi)
self.connect(self.toComboBox,
SIGNAL("currentIndexChanged(int)"), self.updateUi)
self.connect(self.fromSpinBox,
SIGNAL("valueChanged(double)"), self.updateUi)
self.setWindowTitle("Currency")
如果用户更改任意一个下拉列表的当前元素,先关下了列表的comboBox
就会发出currentIndexChanged(int)
信号,参数是当前最新元素的下标。与之类似,如果用户通过spinBox
更改了value
,那么会发出valueChanged(double)
信号。我们把这几个信号都连接在了一个Python
槽上updateUi()
。并不是必须这么做,我们下一节就会看到,不过凑巧在这个例子里是比较明智的选择。
在__init__()
方法最后,我们设置了窗口标题。
def updateUi(self):
to = unicode(self.toComboBox.currentText())
from_ = unicode(self.fromComboBox.currentText())
amount = ((self.rates[from_] / self.rates[to]) *
self.fromSpinBox.value())
self.toLabel.setText("{0:.2f}".format(amount))
这个方法被调用是为了回应下拉表的currentIndexChanged()
这个信号,以及spinbox
的valueChanged()
这个信号。所有信号调用的时候会传入一个参数。我们下一节就会看到,我们可以忽略掉信号的参数,就像我们现在做的一样。
无论哪个信号被触发了,我们会进入相同的处理环节。我们提取出to
和from
的币种,计算to
的数值,并且相应的设置toLabel
。我们给予from
文本一个名字是from_
,因为from是Python的关键字。当计算出来的数值过窄的时候,我们需要避开空白行,来适应页面;无论在任何情况下,我们都更倾向于限制行的宽度去让用户在屏幕上更方便的读取的两个文件。
def getdata(self): # Idea taken from the Python Cookbook
self.rates = {}
try:
date = "Unknown"
fh = urllib2.urlopen("http://www.bankofcanada.ca"
"/en/markets/csv/exchange_eng.csv")
for line in fh:
line = line.rstrip()
if not line or line.startswith(("#", "Closing ")):
continue
fields = line.split(",")
if line.startswith("Date "):
date = fields[-1]
else:
try:
value = float(fields[-1])
self.rates[unicode(fields[0])] = value
except ValueError:
pass
return "Exchange Rates Date: " + date
except Exception, e:
return "Failed to download:\n{0}".format(e)
在这个程序中,我们用这个方法获得数据。开始我们创建了一个新的属性self.rates
。与c++,java
以及其他相似的语言,Python
允许我们在任何需要的情况下创造属性——例如在构造时、初始化时或者在任何方法中。我们甚至可以再运行的时候在特殊的实例上添加属性。
在与网络连接时,有太多出错误的可能,例如:网络可能瘫痪,主机可能挂起,URL
可能改变,等等等等,我们需要让我们的这个程序比之前的两个更加健壮。另外可能遇到的问题是我们在得到非法法浮点数如NA(Not Availabel)
。我们有一个内部的try ... except
块,使用这个来捕获非法数值。所以如果我们转换当前币种失败的时候,我们仅仅是忽略掉这个特殊的币种,并且继续我们的程序。
我们在一个try ... except
块中处理的其他所有可能出现的突发情况。如果问题发生,我们扔出异常,并且将它当做字符串返还给掉重者,__init__()
。getdata()
方法中返回的字符串会在dataLabel
中显示,通常这个标签显示转换后的利率,不过有错误产生的时候,它会显示错误信息。
你可能注意到我们把URL分成了两行,因为它太长了,但是我们又没有escape一个新行。这么做之所以可行是因为这个字符串在圆括号内。如果没在的时候,我们就需要escape一个新行或者使用+号(并且依然要escape一个新行)。
我们初始化data
时使用了一个字符串,因为我们并不知道我们需要计算的dates
的利率。之后我们使用urllib2.urlopen()
方法使我们得到了我们想要的文件的一个句柄。通过这个句柄我们可以利用read()
方法读取整个文件,不过在这个例子里面,我们更加推荐使用readlines()
一行一行读取文件来节约内存空间。
下面是从exchange_eng.csv文件中得到的部分数据。有一些列和行被隐藏了,为了节约空间。
...
#
Date (<m>/<d>/<year>),01/05/2007,...,01/12/2007,01/15/2007
Closing Can/US Exchange Rate,1.1725,...,1.1688,1.1667
U.S. Dollar (Noon),1.1755,...,1.1702,1.1681
Argentina Peso (Floating Rate),0.3797,...,0.3773,0.3767
Australian Dollar,0.9164,...,0.9157,0.9153
...
Vietnamese Dong,0.000073,...,0.000073,0.000073
exchange_eng.csv文件中有几种不同的行格式。注释及某些空白行从“#”开始,我们将忽略这些行。交换利率是一个币种、利率的列表,使用逗号分开。那些利率是对应某种特殊币种的利率,每行的最后一个是最近的信息。我们将每行使用逗号分开,然后选取第一个元素作为币种,最后一个元素作为交换利率。也有一行是以”Date“ 开头的,这一个些列的数据是应用于各个列的。当我们计算这行的时候,我们选取最后的数据,因为这是我们需要使用的交换数据。还有一些行开始时”Closing“,我们不管这些行。
对于每一个有交换利率的行,我们在self.rates
字典中插入一项,使用当期币种作为key,交换利率作为value。我们假设这个文件的编码方式是7-bit ASCII或者Unicode,如果他不是以上两种之一,我们可能会得到编码错误。如果我们知道具体编码,我们可以在使用unicode()
方法的时候将其作为第二个参数。
app = QApplication(sys.argv)
form = Form()
form.show()
app.exec_()
信号和槽
任何一个GUI库都提供了事件处理的一些方法,例如按下鼠标、敲击键盘。例如,有一个上面写有"Click Me"的按钮,如果用户点击了之后,上面的信息就可以使用了。GUI库可以告知我们鼠标点击按钮的坐标,以及按钮的母widget
,以及关联的屏幕;它还会告诉我们Shift,Ctrl,Alt以及NumLock键在当时的状态;以及按钮精确按下的时间;等等等等。如果用户通过其他手段点击按钮,相同的信息PyQt也会通知我们。用户可能通过使用Tab键的连续变化来获得focus,之后按下空格,或者Alt+C等快捷键,通过这些方式而不是使用鼠标来访问我们的按钮;不过无论是哪一种例子都算按钮被按下,也会提供一些不同的信息。
Qt库是第一个意识到并不是在所有的情况下,程序员都需要知道这些底层的事件信息:他们并不关心按钮是如何按下的,他们关心的仅仅是按钮被按下了,然后他们就会做出适合的处理。由于这个原因,Qt以及PyQt提供了两种交流的机制:一种仍然是于其他GUI库相似的底层事件处理方案,另一种就是Trolltech(Qt的创始人)创造的“信号槽”机制。在第10章和第11章我们会学习底层的事件处理机制,这一章我们关注的是它的高级机制,也就是信号槽。
每一个QObject——包括所有的PyQt的widget(继承自QWidget,也是一个QObject)——都提供了信号槽机制。特别的是,他们可以声明状态的转换,例如当checkbox
被选中或者没有被选中的时候,过着其他重要的事件发生的时候,例如按钮按下,所有PyQt的widget提供了一系列提前定义好的信号。
无论什么时候一个信号发射后,PyQt仅仅是简单的把它扔掉!为了让我们抓到这些信号,我们必须将它链接到槽上去。在C++/Qt,槽是一个有特殊语法声明的方法,不过在PyQt中,任何一个方法都可以是槽,并不需要特殊的语法声明。
PyQt中大部分的widget也提前预置了一些槽,所以一些时候我们可以直接链接预置的信号与预置的槽,并不需要多写多少代码就能得到我们想要的行为。PyQt比C++/Qt在这方面更加的多才多艺,因为我们可以链接的不仅仅是槽,在PyQt中任意可以调用的对象都可以动态的预置到QObject中。让我们看看信号槽在下面这个例子中是怎么工作的。
无论是QDial
还是QSpinBox
都有valueChanged()
这个信号,当这个信号发出的时候,他携带的是最新时刻的信息。他俩还有setValue()
这个槽,他接受一个整数。因此我们将这两个widget的这两个信号和槽相互关联,当用户改变其中一个widget的时候,另一个widget也会做出相应的反应。
class Form(QDialog):
def __init__(self, parent=None):
super(Form, self).__init__(parent)
dial = QDial()
dial.setNotchesVisible(True)
spinbox = QSpinBox()
layout = QHBoxLayout()
layout.addWidget(dial)
layout.addWidget(spinbox)
self.setLayout(layout)
self.connect(dial, SIGNAL("valueChanged(int)"), spinbox.setValue)
self.connect(spinbox, SIGNAL("valueChanged(int)"), dial.setValue)
self.setWindowTitle("Signals and Slots")
两个widget这样连接之后,如果用户更改dial
后,例如20,dial
就会发出valueChanged(20)
这个信号,相应的spinbox
的setValue()
槽就会将20作为参数接受。不过在此之后,因为spinbox
的值改变了,他也会发出valueChanged(20)
这个信号,相应的dial
的setValue()
槽就会将20作为参数接受。这么着看起来貌似我们会陷入一个死循环,不过事实valueChanged()
信号并不会发出,因为事实上在这个方法执行之前,他会先检测目标值于当期值是否真的不同,如果不同才发出信号。
现在让我们看一下链接信号槽的标准语法。我们假设PyQt模块提供了from ... import *的语法,s和w都是QObject对象。
s.connect(w, SIGNAL("signalSignature"), functionName)
s.connect(w, SIGNAL("signalSignature"), instance.methodName)
s.connect(w, SIGNAL("signalSignature"),
instance, SLOT("slotSignature"))
signalSignature
是信号的名字,并且带着参数类型列表,使用逗号隔开。如果是Qt的信号,那么类型的名字不需是C++的类型,例如int、QString。C++类型的名字也可能带着const、*、&,不过在信号和槽里的时候我们可以省略掉这些东西。例如,基本上所有的Qt信号中使用QString参数时,参数类型都会是const QString&
不过在PyQt中,仅仅使用QString
会更加的高效。不过在另一方面,QListWidget
有一个itemActivated(QListWidgetItem*)
信号,我们必须使用这种明确的写法。
PyQt的信号在发出时可以发出任意数量、任意类型的参数,我们稍后会看到。
slotSignature
拥有相同于signalSignature
的形式。一个槽可以拥有比于他链接信号更多的参数,不过这样,多余的参数会被忽略。对应的信号和槽必许有相同的参数列表,例如,我们不能把QDial’s valueChanged(int)
信号链接到QLineEdit’s setText(QString)
槽上去。
在我们这个例子里,我们使用了instance.methodName
的语法,不过如果槽确实是Qt的槽而不是一个Python的方法的时候,使用SLOT()
语法更加高效:
self.connect(dial, SIGNAL("valueChanged(int)"),
spinbox, SLOT("setValue(int)"))
self.connect(spinbox, SIGNAL("valueChanged(int)"),
dial, SLOT("setValue(int)"))
我们早就看到了一个槽可以被多个信号连接,一个信号连接到多个槽也是可能的。虽然有种情况很罕见,我们甚至可以将一个信号连接到另一个信号上:在这种情况下,当第一个信号发出时,会导致连接的信号也发出。
我们使用QObject.connect()
来建立连接,这些连接可以被QObject.disconnect()
取消。在实际应用中,我们极少会取消连接,因为PyQt在对象被删除后会自动断开删除对象的信号槽。
至今为止,我们看到了如何建立信号槽,怎么写槽函数——就是普通的方法。我们知道当有状态转换或者某些重要事件发生的时候会发出信号。不过如果我们想要在自己建立的组件里发出我们自己的信号时应该怎么做呢?通过使用QObject.emit()
很容易实现这一点。例如,下面有一个完整的QSpinBox
子类,他会发出atzero
信号,这需要一个数字做参数:
class ZeroSpinBox(QSpinBox):
zeros = 0
def __init__(self, parent=None):
super(ZeroSpinBox, self).__init__(parent)
self.connect(self, SIGNAL("valueChanged(int)"), self.checkzero)
def checkzero(self):
if self.value() == 0:
self.zeros += 1
self.emit(SIGNAL("atzero"), self.zeros)
我们将spinbox
自己的valueChanged()
信号与我们checkzero()
槽进行了连接,如果刚好value的值为0的话,那么checkzero()
槽就会发出atzero
信号,他会计算出总共有多少次到达过0。在信号中缺少圆括号是非常重要的:这会告诉PyQt这是一个“短路”信号。
一个没有参数的信号(所以没有圆括号)是一个短路Python信号。当发出这种信号的时候,任何的附加参数都可以通过emit()
方法进行传递,他们作为Python对象来传递。这会避免在上面进行与C++类型的相互转换,这就意味着,任何的Python对象都可以当做参数进行传递,即使他不能与C++数据类型相互转换。当一个信号有至少一个参数的时候,这个信号就是一个Qt信号,也是一个非短路python信号。在这种情况下,PyQt会检查这些信号,看他是否为一个Qt信号,如果不是的话就会把他假定为一个Python信号。无论是哪种情况,参数都会被转换为C++数据类型。【此段疑似与下文某段重复】
下面是我们怎么在form的__init__()
方法中进行信号槽的连接的:
zerospinbox = ZeroSpinBox()
...
self.connect(zerospinbox, SIGNAL("atzero"), self.announce)
再提一遍,我们必须不能带圆括号,因为这是一个短路信号。为了完整期间,我们把他连接的槽贴在这里:
def announce(self, zeros):
print("ZeroSpinBox has been at zero {0} times".format(zeros))
如果我们在SIGNAL()
语法中使用了没有圆括号的方法,我们同样指明了一个短路信号。无论是发射短路信号还是连接他们,我们都可以使用这个语法。两种用法都已经出现在了例子里。
【此段内容疑似重复,略过、、、】
现在我们看另一个例子,一个小的非GUI自定义类,他是使用继承QObejct的方式来实现信号槽机制的,所以信号槽机制并不仅限于GUI类上。
class TaxRate(QObject):
def __init__(self):
super(TaxRate, self).__init__()
self.__rate = 17.5
def rate(self):
return self.__rate
def setRate(self, rate):
if rate != self.__rate:
self.__rate = rate
self.emit(SIGNAL("rateChanged"), self.__rate)
无论是rate()
还是setRate()
都可以被连接,因为任何一个Python中可以被调用的对象都可以作为一个槽。如果汇率变动,我们就会更新__rate
数据,然后发出rateChanged
信号,给予新汇率一个参数。我们也使用可快速短路语法。如果我们使用标准语法,那么唯一的区别可能就是信号会被写成SIGNAL("rateChanged(float)")
。如果我们将rateChanged
信号与setRate()
槽建立起连接,因为if语句的原因不会发成死循环。然我们看看使用中的类。首先我们定义了一个方法,这个方法将会在汇率变动的时候被调用。
def rateChanged(value):
print("TaxRate changed to {0:.2f}%".format(value))
现在我们实验一下:
vat = TaxRate()
vat.connect(vat, SIGNAL("rateChanged"), rateChanged)
vat.setRate(17.5) # No change will occur (new rate is the same)
vat.setRate(8.5) # A change will occur (new rate is different)
这会导致在命令行里输出这样一行文字"TaxRate changed to 8.50%".
在之前的例子里,我们将不同的信号连接在了一个槽上。我们并不关心谁发出了信号。不过有的时候,我们想要知道到底是哪一个信号连接在了这个槽上,并且根据不同的连接做出不同的反应。在这一节最后一个例子我们将会研究这个问题。
上图显示的Connection程序有5个按钮和一个标签,当其中一个按钮按下的时候,信号槽会更新label的文本。这里贴上__init__()
创建第一个按钮的代码:
button1 = QPushButton("One")
其他按钮除了变量的名字与文本不用之外,创建方式都相同。
我们从button1
的连接开始讲起,这是__init__()
里面的connect()
调用:
self.connect(button1, SIGNAL("clicked()"), self.one)
这个按钮我们使用了一个dedicated方法:
def one(self):
self.label.setText("You clicked button 'One'")
将按钮的clicked()
信号与一个适当的方法连接去相应一个事件是大部分连接时的方案。
不过如果大部分处理方案都相同,不同之处仅仅是依赖于按下的按钮呢?在这种情况下,通常最好把这些按钮连接到相同的槽上。有两个方法可以达到这一点。第一是使用partial function,并且使用被按下的按钮作为槽的调用参数来做修饰(partial function的作用)。另一个方案是询问PyQt看看是哪一个按钮被按下了。
返回本书的65页,我们使用Python2.5的functools.partial()
方法或者我们自己实现的简单partial()
方法:
import sys
if sys.version_info[:2] < (2, 5):
def partial(func, arg):
def callme():
return func(arg)
return callme
else:
from functools import partial
使用partial()
,我们可以包装我们的槽,并且使用一个按钮的名字。所以我们可能会这么做:
self.connect(button2, SIGNAL("clicked()"),
partial(self.anyButton, "Two")) # WRONG for PyQt 4.0-4.2
不幸的是,在PyQt 4.3之前的版本,这不会有效果。这个包装函数式在connect()
中创建的,不过当connect()
被解释执行的时候,包装函数会出界变为一个垃圾。从PyQt 4.3之后,如果在连接时使用functools.partial()
包装函数,那么这就会被特殊对待。这意味着在连接时这样被创建的方法不会被回收,那么之前显示的代码就会正确执行。
在PyQt 4.0,4.1,4.2这几个版本,我们依然可以使用partial()
:我们在连接前创建包装即可,这样只要form实例存在,就能确保包装函数不会越界成为垃圾。连接可能看起来像这样:
self.button2callback = partial(self.anyButton, "Two")
self.connect(button2, SIGNAL("clicked()"),self.button2callback)
当button2
被点击后,那么anyButton()
方法就会带着一个“Two”的字符串参数被调用。下面就是该方法的代码:
def anyButton(self, who):
self.label.setText("You clicked button '%s'" % who)
我们可以讲这个槽使用partial
的方式应用到所有的按钮上。事实上,我们可以完全不使用partial()
方法,也可以得到完全相同的结果:
self.button3callback = lambda who="Three": self.anyButton(who)
self.connect(button3, SIGNAL("clicked()"),self.button3callback)
我们在这里创建了一个lambda方法,参数是按钮的名字。这与partial()
技术是相同的,他调用了相同的anyButton()
方法,不同之处就是使用了lambda表达式。
无论是button2callback()
还是button3callback()
都调用了anyButton()
方法;唯一的区别是参数,一个是“Two”另一个是“Three”。
如果我们使用PyQt 4.1.1或者更高级的版本,我们不需要自己保留lambda回调函数的引用。因为PyQt在connection中对待lambda表达式时会做特殊处理。所以,我们可以再connect()
中直接调用lambda表达式。
self.connect(button3, SIGNAL("clicked()"),
lambda who="Three": self.anyButton(who))
包装技术工作的不错,不过这里又一个候选方法稍微有些不同,不过在某些时候可能很有用,特别是我们不想包装我们的方法的时候。则是button4和button5使用的另一种技术。这是他们的连接:
self.connect(button4, SIGNAL("clicked()"), self.clicked)
self.connect(button5, SIGNAL("clicked()"), self.clicked)
你可能发现了我们并没有包装两个按钮连接的clicked()
方法,这样我们开始的时候看上去并不能区分到底是哪个按钮按触发了clicked()
信号。然而,看下下面的实现就能清楚的明白我们想要做什么了:
def clicked(self):
button = self.sender()
if button is None or not isinstance(button, QPushButton):
return
self.label.setText("You clicked button '{0}'".format(
button.text()))
在一个槽内部,我们走时可以调用sender()
方法来发现是哪一个QObject对象发出的这个信号。当这是一个普通的方法调用这个槽时,这个方法会返回一个None。 尽管我们知道连接这个槽的仅仅是按钮,我们依然要小心检查。我们使用isinstance()
方法,不过我们可以使用hasattr(button, "text")
方法代替。如果我们在这个槽上连接所有的按钮,他们都会正确工作。
有些程序员不喜欢使用sender()
方法,因为他们感觉这不是面向对象的风格,他们更倾向使用partial function的方法。
事实上确实有其他的包装技术。这会使用QSignalMapper
类,第九章会展示这个例子。
有些情况下,一个槽运行的结果可能会发出一个信号,而这个信号可能反过来调用这个槽,无论是直接调用或者间接调用,这会导致这个槽和信号不断的重复调用,以致陷入一个死循环。这种循环圈子在实际运用中非常少见。两个因素会减少这种圈子发生的可能性。第一,有些信号只有真正发生改变时才会发出,例如:如果用户改变了QSpinBox
的值,或者程序通过调用setValue()
而改变了值,那么只有新的数值与刚刚的数值不同时,他才会发出’valueChanged()‘信号。第二,某些信号之后反应用户操作时才会发出。例如,QLineEdit
的textEdited()
信号只有是用户更改文本时才会发出,在代码中调用setText()
时是不会发出这个信号的。
如果一个信号槽形成了一个调用循环,通常情况下,我们要做的第一件事情是检查我们代码的逻辑是否正确:我们是不是像我们想象的那样进行了处理?如果逻辑正确但是循环链还在的话,我们可以通过更改发出的信号来打断循环链,例如,将信号发出的手段改为程序中发出(而不是用户发出)。如果问题依然存在,我们可以再某个特定位置用代码调用QObject.blockSignals()
,这个方法所有的QWidget
类都有继承,他传递一个布尔值——True,停止对象发出信号;False回复信号发射。
这完整的覆盖了我们的信号槽机制。我们将在剩下的整本书里见到各式各样的信号槽的练习。大部分的GUI库在不同方面上拷贝了这个基址。这是因为信号槽机制非常的实用强大,他使得程序员脱离开那些用户是如何操作的具体细节,而更加关注程序的逻辑。
总结
在这一章,我们看到了我们可以创建混血的控制台——GUI程序。我们当然可以做的更远——例如,在一个if块内导入所有的GUI代码,并执行他,只要安装了PyQt,就可以显示图形界面。如果用户没有安装PyQt的话,他就可以退化为一个控制台程序。
我们也看到了GUI程序不同于普通的批处理程序,他又一个一直运行的事件循环,检查是否有用户事件发生,例如按下鼠标,枪击键盘,或者系统事件例如timer计时,窗口重绘。程序终止只在请求他这么做的时候。
计算器程序显示了一个非常简单但是非常具有结构特点的对话框__init__()
方法。widget被创建,布局,连接,之后又一个或多个方法用于反映用户的交互。货币转换程序使用了相同的技术,仅仅是有更复杂的用户界面以及更复杂的行为处理机制。货币转换程序也显示了我们可以连接多个信号去同一个槽。
PyQt的信号槽机制允许我们在更高的级别去处理用户的交互。这让我们可以把关注点放在用户想干嘛而不是他们是怎么请求去干的。所有的PyQt widget通过发射信号的方式来传达状态发生了改变,或者是发生了其他重要事件;并且大部分事件我们可以忽略掉某些信号。不过对于那些我们感兴趣的信号,我们可以方便的使用QObject.connect()
来确保当某个信号发生时我们调用了想要进行处理的函数。不像C++/Qt,槽生命必须使用特定的语法,在PyQt中任何的callable对象,也就是说任何的方法,都可以作为一个槽。
我们也看到了如何将多个信号连接到一个槽上,以及如何使用partial function程序或者使用sender()
方法来使我们的槽来适应发出信号的不同widget。
我们也学习了我们并不一定要生命我们自己的信号:我们可以简单的使用QObject.emit()
并添加任意的参数。