觀察者模式在 Swing 開發中常見,在 GUI 應用程式外同樣對解耦組件極有用。但監聽器註冊與調用仍存常見缺陷。本期《Java 理論與實踐》中,Java 專家 Brian Goetz 就如何做個好的監聽器、以及如何對您的監聽器友好,提供實用建議。
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 時重複調用 size()、get() 直到所有元素檢索完,如清單 1:
Vector<Listener> v;
for (int i=0; i<v.size(); i++)
v.get(i).eventHappened(event);
但若恰在最後一次調用 Vector.size() 後,有人從列表刪除一個監聽器會怎樣?現在 Vector.get() 將回傳 null(這對了,因從上次檢查 vector 狀態以來狀態已變),嘗試調用 eventHappened() 時拋出 NullPointerException。這是「檢查然後執行」序列一例——檢查是否存在更多元素,若存在取得下一元素——但在並發修改下,檢查後狀態可能已變。圖 1 演示此問題:

解決方案之一是在遍歷期間持有 Vector 鎖;另一是克隆 Vector 或調用 toArray(),在每次事件發生時檢索其內容。兩法都有效能問題:前者風險在遍歷期間將其他想訪問列表的執行緒鎖在外面;後者需建立暫存物件,且每次事件都要複製列表。
若用迭代器(Iterator)遍歷列表,同樣有此問題,只表現略異;iterator() 實作不拋 NullPointerException,但在偵測到遍歷開始後集合被修改時拋 ConcurrentModificationException。同樣可在遍歷期間鎖定集合防止。
java.util.concurrent 中的 CopyOnWriteArrayList 類能幫助防止此問題。它實作 List、執行緒安全,但其迭代器不拋 ConcurrentModificationException,遍歷期間不需額外鎖定。此特性組合通過每次列表修改時內部重新分配並複製列表內容實現,使遍歷內容的執行緒無需處理變化——從它們角度看,列表內容在遍歷期間保持不變。雖聽起來可能沒效率,但請記住:多數觀察者情況下,每個組件只有少量監聽器,遍歷次數遠超插入刪除次數。所以更快的遍歷可補償較慢的變化,提供更好並發性,因多執行緒可同時遍歷列表。
從監聽器建構函數中註冊它很誘人,但這是應避免的誘惑。它不僅造成「失效監聽器」問題(稍後討論),還造成多個執行緒安全問題。清單 2 顯示同時建構與註冊監聽器的看似無害嘗試。問題在於:導致物件的 this 參考在物件完全建構完成前逸出。雖看似無害,因註冊是建構函數做的最後一件事,但所見具有欺騙性:
public class EventListener {
public EventListener(EventSource eventSource) {
// do our initialization
...
// register ourselves with the event source
eventSource.registerListener(this);
}
public onEvent(Event e) {
// handle the event
}
}
繼承事件監聽器時會出現此法一風險:此時子類建構函數做的任何工作都在 EventListener 建構函數運行後進行,即 EventListener 發布後,造成競爭情況。某些不幸時刻,清單 3 的 onEvent 方法會在列表欄位尚未初始化前被調用,導致取消 final 欄位參考時產生令人困惑的 NullPointerException:
public class RecordingEventListener extends EventListener {
private final ArrayList<Event> list;
public RecordingEventListener(EventSource eventSource) {
super(eventSource);
list = Collections.synchronizedList(new ArrayList<Event>());
}
public onEvent(Event e) {
list.add(e);
super.onEvent(e);
}
}
即使監聽器類是 final 不可派生子類,也不應允許 this 參考在建構函數中逸出——這會危害 Java 記憶體模型某些安全保證。若程式中不出現 this 這詞,就可讓 this 參考逸出;發布非靜態內部類實例達到相同效果,因內部類持有對其包圍物件 this 參考的參考。無意允許 this 參考逸出最常見原因就是註冊監聽器,如清單 4。事件監聽器不應在建構函數中註冊!
public class EventListener2 {
public EventListener2(EventSource eventSource) {
eventSource.registerListener(
new EventListener() {
public void onEvent(Event e) {
eventReceived(e);
}
});
}
public void eventReceived(Event e) {
}
}
使用監聽器造成的第三個執行緒安全問題源於此事實:監聽器可能想訪問應用程式資料,而調用監聽器的執行緒通常不直接在應用程式控制下。若在 JButton 或其他 Swing 組件註冊監聽器,則從 EDT 調用該監聽器。監聽器代碼可從 EDT 安全調用 Swing 組件方法,但若物件本身非執行緒安全,從監聽器訪問應用程式物件會給應用程式增加新執行緒安全需求。
Swing 組件生成的事件是用戶互動結果,但 Swing 模型類在 fireXxxEvent() 方法被調用時生成事件。這些方法又會在調用它們的執行緒中調用監聽器。因 Swing 模型類非執行緒安全,且假設被限制在 EDT 內,故對 fireXxxEvent() 的任何調用也應從 EDT 執行。若想從其他執行緒觸發事件,應用 Swing 的 invokeLater() 讓方法轉而在 EDT 內調用。一般來說,要注意調用事件監聽器的執行緒,還要保證它們涉及的任何物件要么執行緒安全,要么在訪問處受適當同步(或 Swing 模型類的執行緒約束)保護。
無論何時使用觀察者模式,都耦合兩個獨立組件——觀察者與被觀察者,它們通常有不同生命週期。註冊監聽器的一個後果是:在被觀察物件與監聽器間建立強參考關係,這防止監聽器(及它參考的物件)被垃圾回收,直到監聽器取消註冊為止。許多情況下,監聽器生命週期至少與被觀察組件一樣長——許多監聽器整個應用程式期間都存在。但某些情況下,應短期存在的監聽器最後變成永久的,這種無意識拖延的證據就是應用程式效能變慢、記憶體使用高於必要。
「失效監聽器」問題可由設計層級疏忽造成:未妥善考慮包含物件的壽命,或因鬆懈編碼。監聽器註冊與取消註冊應成對進行。但即使這樣做,也必須保證在正確時間執行取消註冊。清單 5 顯示會造成失效監聽器的編碼習慣範例。它在組件註冊監聽器、執行某些動作、然後取消註冊:
public void processFile(String filename) throws IOException {
cancelButton.registerListener(this);
// open file, read it, process it
// might throw IOException
cancelButton.unregisterListener(this);
}
清單 5 問題在於:若檔案處理代碼拋出 IOException——這很有可能——監聽器永遠不會取消註冊,意味著它永遠不會被垃圾回收。取消註冊操作應在 finally 區塊中進行,這樣 processFile() 所有出口都會執行它。
有時推薦用弱參考處理失效監聽器。雖可行但實現麻煩。要讓它工作,需找到另一個物件,其生命週期恰好是監聽器的生命週期,並安排它持有對監聽器的強參考,這可不是件容易事。
另一項找到隱藏失效監聽器的技術:防止指定監聽器物件在指定事件源上註冊兩次。這通常是 bug 跡象——監聽器註冊了但未取消註冊,然後再次註冊。不用檢測問題就能緩解影響的一種方式:用 Set 代替 List 儲存監聽器;或檢測 List,註冊前檢查是否已註冊,若已註冊拋出異常(或記錄錯誤),可收集編碼錯誤證據並採取行動。
編寫監聽器時,應一直注意它們將執行的環境。不僅要注意執行緒安全,還需記住:監聽器也能用其他方式為調用者把事情搞糟。監聽器不該做的一件事:阻塞相當長一段時間(長到可感知);調用它的執行上下文很可能希望迅速返回控制。若監聽器要執行可能耗時操作(如處理大型文本)、或可能阻塞的工作(如執行 socket IO),應將這些操作安排在另一執行緒中,以便迅速返回調用者。
對不小心的事件源,監聽器造成麻煩的另一種方式:拋出未檢查異常。雖多數時候不會故意拋出未檢查異常,但確實有時會發生。若用清單 1 方式調用監聽器,列表中第二個監聽器拋出未檢查異常,不僅後續監聽器得不到調用(可能造成應用程式處於不一致狀態),還可能破壞執行它的執行緒,造成局部應用程式失敗。
調用未知代碼(監聽器就是這樣的代碼)時,謹慎方式是在 try-catch 區塊中執行,使行為有誤的監聽器不會造成更多不必要破壞。對拋出未檢查異常的監聽器,可能想自動取消註冊,畢竟拋出未檢查異常證明監聽器壞了。(可能還想記錄錯誤或提醒用戶,讓用戶知道為何程式停止如期望繼續工作。)清單 6 顯示這種方式範例,在遍歷迴圈內部嵌套 try-catch 區塊:
List<Listener> list;
for (Iterator<Listener> i=list.iterator; i.hasNext(); ) {
Listener l = i.next();
try {
l.eventHappened(event);
}
catch (RuntimeException e) {
log("Unexpected exception in listener", e);
i.remove();
}
}
觀察者模式對創建鬆散耦合組件、鼓勵組件重用非常有用,但有一些風險,監聽器編寫者與組件編寫者都應注意。註冊監聽器時,應一直注意監聽器生命週期。若監聽器壽命應比應用程式短,請確保取消其註冊,使其可被垃圾回收。編寫監聽器與組件時,請注意包含的執行緒安全問題。監聽器涉及的任何物件,應是執行緒安全的、或受執行緒約束的物件(如 Swing 模型),監聽器應確定自己在正確執行緒中執行。