Swing 架構以事件偵聽器的形式廣泛利用了觀察者模式(也稱為釋出-訂閱模式)。Swing 元件作為使用者互動的目标,在使用者與它們互動的時候觸發事件;資料模型類在資料發生變化時觸發事件。用這種方式使用觀察者,可以讓控制器與模型分離,讓模型 與視圖分離,進而簡化 GUI 應用程式的開發。
“四人幫”的 設計模式 一書(參閱 參考資料)把觀察者模式描述為:定義對象之間的“一對多”關系,這樣一個對象改變狀态時,所有它的依賴項都會被通知,并自動更新。觀察者模式支援元件之間的松散耦合;元件可以保持它們的狀态同步,卻不需要直接知道彼此的辨別或内部情況,進而促進了元件的重用。
AWT 和 Swing 元件(例如
JButton
或
JTable
)使用觀察者模式消除了 GUI 事件生成與它們在指定應用程式中的語義之間的耦合。類似地,Swing 的模型類,例如
TableModel
和
TreeModel
,也使用觀察者消除資料模型表示 與視圖生成之間的耦合,進而支援相同資料的多個獨立的視圖。Swing 定義了
Event
和
EventListener
對象層次結構;可以生成事件的元件,例如
JButton
(可視元件) 或
TableModel
(資料模型),提供了
addXxxListener()
和
removeXxxListener()
方法,用于偵聽器的登記和取消登記。這些類負責決定什麼時候它們需要觸發事件,什麼時候确實觸發事件,以及什麼時候調用所有登記的偵聽器。
為了支援偵聽器,對象需要維護一個已登記的偵聽器清單,提供偵聽器登記和取消登記的手段,并在适當的事件發生時調用每個偵聽器。使用和支援偵聽器很容易 (不僅僅在 GUI 應用程式中),但是在登記接口的兩邊(它們是支援偵聽器的元件和登記偵聽器的元件)都應當避免一些缺陷。
線程安全問題
通常,調用偵聽器的線程與登記偵聽器的線程不同。要支援從不同線程登記偵聽器,那麼不管用什麼機制存儲和管理活動偵聽器清單,這個機制都必須是線程安全的。Sun 的文檔中的許多示例使用
Vector
儲存偵聽器清單,它解決了部分問題,但是沒有解決全部問題。在事件觸發時,觸發它的元件會考慮疊代偵聽器清單,并調用每個偵聽器,這就帶來了并發修改的風險,比如在偵聽器清單疊代期間,某個線程偶然想添加或删除一個偵聽器。
管理偵聽器清單
假設您使用
Vector<Listener>
儲存偵聽器清單。雖然 Vector 類是線程安全的(意味着不需要進行額外的同步就可調用它的方法,沒有破壞
Vector
資料結構的風險),但是集合的疊代中包含“檢測然後執行”序列,如果在疊代期間集合被修改,就有了失敗的風險。假設疊代開始時清單中有三個偵聽器。在疊代
Vector
時,重複調用
size()
和
get()
方法,直到所有元素都檢索完,如清單 1 所示:
清單 1. Vector 的不安全疊代
|
但是,如果恰好就在最後一次調用
Vector.size()
之後,有人從清單中删除了一個偵聽器,會發生什麼呢?現在,
Vector.get()
将傳回
null
(這是對的,因為從上次檢測 vector 的狀态以來,它的狀态已經變了),而在試圖調用
eventHappened()
時,會抛出
NullPointerException
。這是“檢測然後執行”序列的一個示例 —— 檢測是否存在更多元素,如果存在,就取得下一進制素 —— 但是在存在并發修改的情況下,檢測之後狀态可能已經變化。圖 1 示範了這個問題:
圖 1. 并發疊代和修改,造成意料之外的失敗

這個問題的一個解決方案是在疊代期間持有對 Vector 的鎖;另一個方案是克隆 Vector 或調用它的
toArray()
方法,在每次發生事件時檢索它的内容。所有這兩個方法都有性能上的問題:第一個的風險是在疊代期間,會把其他想通路偵聽器清單的線程鎖在外面;第二個則要建立臨時對象,而且每次事件發生時都要拷貝清單。
如果用疊代器(Iterator)去周遊偵聽器清單,也會有同樣的問題,隻是表現略有不同;
iterator()
實作不抛出
NullPointerException
,它在探測到疊代開始之後集合發生修改時,會抛出
ConcurrentModificationException
。同樣,也可以通過在疊代期間鎖定集合防止這個問題。
java.util.concurrent
中的
CopyOnWriteArrayList
類,能夠幫助防止這個問題。它實作了
List
,而且是線程安全的,但是它的疊代器不會抛出
ConcurrentModificationException
, 周遊期間也不要求額外的鎖定。這種特性組合是通過在每次清單修改時,在内部重新配置設定并拷貝清單内容而實作的,這樣,周遊内容的線程不需要處理變化 —— 從它們的角度來說,清單的内容在周遊期間保持不變。雖然這聽起來可能沒效率,但是請記住,在多數觀察者情況下,每個元件隻有少量偵聽器,周遊的數量遠遠超 過插入和删除的數量。是以更快的疊代可以補償較慢的變化過程,并提供更好的并發性,因為多個線程可以同時疊代清單。
初始化的安全風險
從偵聽器的構造函數中登記它很誘惑人,但是這是一個應當避免的誘惑。它僅會造成“失效偵聽器(lapsed listener)的問題(我稍後讨論它),而且還會造成多個線程安全問題。清單 2 顯示了一個看起來沒什麼害處的同時構造和登記偵聽器的企圖。問題是:它造成到對象的“this”引用在對象完全構造完成之前轉義。雖然看起來沒什麼害處, 因為登記是構造函數做的最後一件事,但是看到的東西是有欺騙性的:
清單 2. 事件偵聽器允許“this”引用轉義,造成問題
|
在繼承事件偵聽器的時候,會出現這種方法的一個風險:這時,子類構造函數做的任何工作都是在
EventListener
構造函數運作之後進行的,也就是在
EventListener
釋出之後,是以會造成争用情況。在某些不幸的時候,清單 3 中的 onEvent 方法會在清單字段還沒初始化之前就被調用,進而在取消 final 字段的引用時,會生成非常讓人困惑的
NullPointerException
異常:
清單 3. 繼承清單 2 的 EventListener 類造成的問題
|
即使偵聽器類是 final 的,不能派生子類,也不應當允許“this”引用在構造函數中轉義 —— 這樣做會危害 Java 記憶體模型的某些安全保證。如果“this”這個詞不會出現在程式中,就可讓“this”引用轉義;釋出一個非靜态内部類執行個體可以達到相同的效果,因為内部 類持有對它包圍的對象的“this”引用的引用。偶然地允許“this”引用轉義的最常見原因,就是登記偵聽器,如清單 4 所示。事件偵聽器不應當在構造函數中登記!
清單 4. 通過釋出内部類執行個體,顯式地允許“this”引用轉義
|
偵聽器線程安全
使用偵聽器造成的第三個線程安全問題來自這個事實:偵聽器可能想通路應用程式資料,而調用偵聽器的線程通常不直接在應用程式的控制之下。如果在 JButton 或其他 Swing 元件上登記偵聽器,那麼會從 EDT 調用該偵聽器。偵聽器的代碼可以從 EDT 安全地調用 Swing 元件上的方法,但是如果對象本身不是線程安全的,那麼從偵聽器通路應用程式對象會給應用程式增加新的線程安全需求。
Swing 元件生成的事件是使用者互動的結果,但是 Swing 模型類是在 fireXxxEvent() 方法被調用的時候生成事件。這些方法又會在調用它們的線程中調用偵聽器。因為 Swing 模型類不是線程安全的,而且假設被限制在 EDT 内,是以對 fireXxxEvent() 的任何調用也都應當從 EDT 執行。如果想從另外的線程觸發事件,那麼應當用 Swing 的 invokeLater() 功能讓方法轉而在 EDT 内調用。一般來說,要注意調用事件偵聽器的線程,還要保證它們涉及的任何對象或者是線程安全的,或者在通路它們的地方,受到适當的同步(或者是 Swing 模型類的線程限制)的保護。
|
|
失效偵聽器
不管什麼時候使用觀察者模式,都耦合着兩個獨立元件 —— 觀察者和被觀察者,它們通常有不同的生命周期。登記偵聽器的後果之一就是:它在被觀察對象和偵聽器之間建立起很強的引用關系,這種關系防止偵聽器(以及它 引用的對象)被垃圾收集,直到偵聽器取消登記為止。在許多情況下,偵聽器的生命周期至少要和被觀察的元件一樣長 —— 許多偵聽器會在整個應用程式期間都存在。但是在某些情況下,應當短期存在的偵聽器最後變成了永久的,它們這種無意識的拖延的證據就是應用程式性能變慢、高 于必需的記憶體使用。
“失效偵聽器”的問題可以由設計級别上的不小心造成:沒有恰當地考慮包含的對象的壽命,或者由于松懈的編碼。偵聽器登記和取消登記應當結對進行。但 是即使這麼做,也必須保證是在正确的時間執行取消登記。清單 5 顯示了會造成失效偵聽器的編碼習慣的示例。它在元件上登記偵聽器,執行某些動作,然後取消登記偵聽器:
清單 5. 有造成失效偵聽器風險的代碼
|
清單 5 的問題是:如果檔案處理代碼抛出了 IOException —— 這是很有可能的 —— 那麼偵聽器就永遠不會取消登記,這就意味着它永遠不會被垃圾收集。取消登記的操作應當在
finally
塊中進行,這樣,
processFile()
方法的所有出口都會執行它。
有時推薦的一個處理失效偵聽器的方法是使用弱引用。雖然這種方法可行,但是實作起來很麻煩。要讓它工作,需要找到另外一個對象,它的生命周期恰好是偵聽器的生命周期,并安排它持有對偵聽器的強引用,這可不是件容易的事。
另外一項可以用來找到隐藏失效偵聽器的技術是:防止指定偵聽器對象在指定事件源上登記兩次。這種情況通常是 bug 的迹象 —— 偵聽器登記了,但是沒有取消登記,然後再次登記。不用檢測問題,就能緩解這個問題的影響的一種方式是:使用
Set
代替
List
來存儲偵聽器;或者也可以檢測
List
,在登記偵聽器之前檢查是否已經登記了,如果已經登記,就抛出異常(或記錄錯誤),這樣就可以搜集編碼錯誤的證據,并采取行動。
|
|
其他偵聽器問題
在編寫偵聽器時,應當一直注意它們将要執行的環境。不僅要注意線程安全問題,還需要記住:偵聽器也可以用其他方式為它的調用者把事情搞糟。偵聽器 不該 做的一件事是:阻塞相當長一段時間(長得可以感覺得到);調用它的執行上下文很可能希望迅速傳回控制。如果偵聽器要執行一個可能比較費時的操作,例如處理 大型文本,或者要做的工作可能阻塞,例如執行 socket IO,那麼偵聽器應當把這些操作安排在另一個線程中進行,這樣它就可以迅速傳回它的調用者。
對于不小心的事件源,偵聽器會造成麻煩的另一個方式是:抛出未檢測的異常。雖然大多數時候,我們不會故意抛出未檢測異常,但是确實有些時候會發生這 種情況。如果使用清單 1 的方式調用偵聽器,清單中的第二個偵聽器就會抛出未檢測異常,那麼不僅後續的偵聽器得不到調用(可能造成應用程式處在不一緻的狀态),而且有可能把執行它 的線程破壞掉,進而造成局部應用程式失敗。
在調用未知代碼(偵聽器就是這樣的代碼)時,謹慎的方式是在
try-catch
塊中執行它,這樣,行為有誤的偵聽器不會造成更多不必要的破壞。對于抛出未檢測異常的偵聽器,您可能想自動對它取消登記,畢竟,抛出未檢測異常就證明偵聽 器壞掉了。(您可能還想記錄這個錯誤或者提醒使用者注意,好讓使用者能夠知道為什麼程式停止像期望的那樣繼續工作。)清單 6 顯示了這種方式的一個示例,它在疊代循環内部嵌套了 try-catch 塊:
清單 6. 健壯的偵聽器調用
|
|
|
結束語
觀察者模式對于建立松散耦合的元件、鼓勵元件重用非常有用,但是它有一些風險,偵聽器的編寫者群組件的編寫者都應當注意。在登記偵聽器時,應當一直 注意偵聽器的生命周期。如果偵聽器的壽命應當比應用程式的短,那麼請確定取消它的登記,這樣它就可以被垃圾收集。在編寫偵聽器群組件時,請注意它包含的線 程安全性問題。偵聽器涉及的任何對象,都應當是線程安全的,或者是受線程限制的對象(例如 Swing 模型),偵聽器應當确定自己正在正确的線程中執行。
轉自http://www.ibm.com/developerworks/cn/java/j-jtp07265/