第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()
並添加任意的參數。