觀察者模式 Observer
遊戲設計模式 Game Programming PatternsDesign Patterns Revisited
隨便打開電腦中的一個應用,很有可能它就使用了MVC架構,
而究其根本,是因爲觀察者模式。
觀察者模式應用廣泛,Java甚至將其放到了核心庫之中(java.util.Observer
),而C#直接將其嵌入了語法(event
關鍵字)。
觀察者模式是應用最廣泛和最廣爲人知的GoF模式,但是遊戲開發世界與世隔絕, 所以對你來說,它也許是全新的。 假設你與世隔絕,讓我給你舉個形象的例子。
成就解鎖
假設我們向遊戲中添加了成就係統。 它存儲了玩家可以完成的各種各樣的成就,比如“殺死1000只猴子惡魔”,“從橋上掉下去”,或者“一命通關”。
要實現這樣一個包含各種行爲來解鎖成就的系統是很有技巧的。
如果我們不夠小心,成就係統會纏繞在代碼庫的每個黑暗角落。
當然,“從橋上掉落”和物理引擎相關,
但我們並不想看到在處理撞擊代碼的線性代數時,
有個對unlockFallOffBridge()
的調用是不?
我們喜歡的是,照舊,讓關注遊戲一部分的所有代碼集成到一塊。 挑戰在於,成就在遊戲的不同層面被觸發。怎麼解耦成就係統和其他部分呢?
這就是觀察者模式出現的原因。 這讓代碼宣稱有趣的事情發生了,而不必關心到底是誰接受了通知。
舉個例子,有物理代碼處理重力,追蹤哪些物體待在地表,哪些墜入深淵。 爲了實現“橋上掉落”的徽章,我們可以直接把成就代碼放在那裏,但那就會一團糟。 相反,可以這樣做:
void Physics::updateEntity(Entity& entity)
{
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface())
{
notify(entity, EVENT_START_FALL);
}
}
它做的就是聲稱,“額,我不知道有誰感興趣,但是這個東西剛剛掉下去了。做你想做的事吧。”
成就係統註冊它自己爲觀察者,這樣無論何時物理代碼發送通知,成就係統都能收到。 它可以檢查掉落的物體是不是我們的失足英雄, 他之前有沒有做過這種不愉快的與橋的經典力學遭遇。 如果滿足條件,就伴着禮花和炫光解鎖合適的成就,而這些都無需牽扯到物理代碼。
事實上,我們可以改變成就的集合或者刪除整個成就係統,而不必修改物理引擎。 它仍然會發送它的通知,哪怕實際沒有東西接收。
它如何運作
如果你還不知道如何實現這個模式,你可能可以從之前的描述中猜到,但是爲了減輕你的負擔,我還是過一遍代碼吧。
觀察者
我們從那個需要知道別的對象做了什麼事的類開始。 這些好打聽的對象用如下接口定義:
class Observer
{
public:
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
任何實現了這個的具體類就成爲了觀察者。 在我們的例子中,是成就係統,所以我們可以像這樣實現:
class Achievements : public Observer
{
public:
virtual void onNotify(const Entity& entity, Event event)
{
switch (event)
{
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge_)
{
unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
}
break;
// 處理其他事件,更新heroIsOnBridge_變量……
}
}
private:
void unlock(Achievement achievement)
{
// 如果還沒有解鎖,那就解鎖成就……
}
bool heroIsOnBridge_;
};
被觀察者
被觀察的對象擁有通知的方法函數,用GoF的說法,那些對象被稱爲“主題”。 它有兩個任務。首先,它有一個列表,保存默默等它通知的觀察者:
class Subject
{
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
};
重點是被觀察者暴露了公開的API來修改這個列表:
class Subject
{
public:
void addObserver(Observer* observer)
{
// 添加到數組中……
}
void removeObserver(Observer* observer)
{
// 從數組中移除……
}
// 其他代碼……
};
這就允許了外界代碼控制誰接收通知。 被觀察者與觀察者交流,但是不與它們耦合。 在我們的例子中,沒有一行物理代碼會提及成就。 但它仍然可以與成就係統交流。這就是這個模式的聰慧之處。
被觀察者有一列表觀察者而不是單個觀察者也是很重要的。 這保證了觀察者不會相互干擾。 舉個例子,假設音頻引擎也需要觀察墜落事件來播放合適的音樂。 如果客體只支持單個觀察者,當音頻引擎註冊時,就會取消成就係統的註冊。
這意味着這兩個系統需要相互交互——而且是用一種極其糟糕的方式, 第二個註冊時會使第一個的註冊失效。 支持一列表的觀察者保證了每個觀察者都是被獨立處理的。 就它們各自的視角來看,自己是這世界上唯一看着被觀察者的。
被觀察者的剩餘任務就是發送通知:
class Subject
{
protected:
void notify(const Entity& entity, Event event)
{
for (int i = 0; i < numObservers_; i++)
{
observers_[i]->onNotify(entity, event);
}
}
// 其他代碼…………
};
可被觀察的物理系統
現在,我們只需要給物理引擎和這些掛鉤,這樣它可以發送消息,
成就係統可以和引擎連線來接受消息。
我們按照傳統的設計模式方法實現,繼承Subject
:
class Physics : public Subject
{
public:
void updateEntity(Entity& entity);
};
這讓我們將notify()
實現爲了Subject
內的保護方法。
這樣派生的物理引擎類可以調用併發送通知,但是外部的代碼不行。
同時,addObserver()
和removeObserver()
是公開的,
所以任何可以接觸物理引擎的東西都可以觀察它。
現在,當物理引擎做了些值得關注的事情,它調用notify()
,就像之前的例子。
它遍歷了觀察者列表,通知所有觀察者。
很簡單,對吧?只要一個類管理一列表指向接口實例的指針。 難以置信的是,如此直觀的東西是無數程序和應用框架交流的主心骨。
觀察者模式不是完美無缺的。當我問其他程序員怎麼看,他們提出了一些抱怨。 讓我們看看可以做些什麼來處理這些抱怨。
太慢了
我經常聽到這點,通常是從那些不知道模式具體細節的程序員那裏。 他們有一種假設,任何東西只要沾到了“設計模式”,那麼一定包含了一堆類,跳轉和浪費CPU循環其他行爲。
觀察者模式的名聲特別壞,一些壞名聲的事物與它如影隨形, 比如“事件”,“消息”,甚至“數據綁定”。 其中的一些系統確實會慢。(通常是故意的,出於好的意圖)。 他們使用隊列,或者爲每個通知動態分配內存。
現在你看到了模式是如何真正被實現的, 你知道事實並不如他們所想的這樣。 發送通知只需簡單地遍歷列表,調用一些虛方法。 是的,這比靜態調用慢一點,除非是性能攸關的代碼,否則這點消耗都是微不足道的。
我發現這個模式在代碼性能瓶頸以外的地方能有很好的應用, 那些你可以承擔動態分配消耗的地方。 除那以外,使用它幾乎毫無限制。 我們不必爲消息分配對象,也無需使用隊列。這裏只多了一個用在同步方法調用上的額外跳轉。
太快?
事實上,你得小心,觀察者模式是同步的。 被觀察者直接調用了觀察者,這意味着直到所有觀察者的通知方法返回後, 被觀察者纔會繼續自己的工作。觀察者會阻塞被觀察者的運行。
這聽起來很瘋狂,但在實踐中,這可不是世界末日。 這只是值得注意的事情。 UI程序員——那些使用基於事件的編程的程序員已經這麼幹了很多年了——有句經典名言:“遠離UI線程”。
如果要對事件同步響應,你需要完成響應,儘可能快地返回,這樣UI就不會鎖死。 當你有耗時的操作要執行時,將這些操作推到另一個線程或工作隊列中去。
你需要小心地在觀察者中混合線程和鎖。 如果觀察者試圖獲得被觀察者擁有的鎖,遊戲就進入死鎖了。 在多線程引擎中,你最好使用事件隊列來做異步通信。
“它做了太多動態分配”
整個程序員社區——包括很多遊戲開發者——轉向了擁有垃圾回收機制的語言, 動態分配今昔非比。 但在像遊戲這樣性能攸關的軟件中,哪怕是在有垃圾回收機制的語言,內存分配也依然重要。 動態分配需要時間,回收內存也需要時間,哪怕是自動運行的。
在上面的示例代碼中,我使用的是定長數組,因爲我想盡可能保證簡單。 在真實的項目中中,觀察者列表隨着觀察者的添加和刪除而動態地增長和縮短。 這種內存的分配嚇壞了一些人。
當然,第一件需要注意的事情是隻在觀察者加入時分配內存。 發送通知無需內存分配——只需一個方法調用。 如果你在遊戲一開始就加入觀察者而不亂動它們,分配的總量是很小的。
如果這仍然困擾你,我會介紹一種無需任何動態分配的方式來增加和刪除觀察者。
鏈式觀察者
我們現在看到的所有代碼中,Subject
擁有一列指針指向觀察它的Observer
。
Observer
類本身沒有對這個列表的引用。
它是純粹的虛接口。優先使用接口,而不是有狀態的具體類,這大體上是一件好事。
但是如果我們確實願意在Observer
中放一些狀態,
我們可以將觀察者的列表分佈到觀察者自己中來解決動態分配問題。
不是被觀察者保留一列表分散的指針,觀察者對象本身成爲了鏈表中的一部分:
爲了實現這一點,我們首先要擺脫Subject
中的數組,然後用鏈表頭部的指針取而代之:
class Subject
{
Subject()
: head_(NULL)
{}
// 方法……
private:
Observer* head_;
};
然後,我們在Observer
中添加指向鏈表中下一觀察者的指針。
class Observer
{
friend class Subject;
public:
Observer()
: next_(NULL)
{}
// 其他代碼……
private:
Observer* next_;
};
這裏我們也讓Subject
成爲了友類。
被觀察者擁有增刪觀察者的API,但是現在鏈表在Observer
內部管理。
最簡單的實現辦法就是讓被觀察者類成爲友類。
註冊一個新觀察者就是將其連到鏈表中。我們用更簡單的實現方法,將其插到開頭:
void Subject::addObserver(Observer* observer)
{
observer->next_ = head_;
head_ = observer;
}
另一個選項是將其添加到鏈表的末尾。這麼做增加了一定的複雜性。
Subject
要麼遍歷整個鏈表來找到尾部,要麼保留一個單獨tail_
指針指向最後一個節點。
加在在列表的頭部很簡單,但也有另一副作用。 當我們遍歷列表給每個觀察者發送一個通知, 最新註冊的觀察者最先接到通知。 所以如果以A,B,C的順序來註冊觀察者,它們會以C,B,A的順序接到通知。
理論上,這種還是那種方式沒什麼差別。 在好的觀察者設計中,觀察同一被觀察者的兩個觀察者互相之間不該有任何順序相關。 如果順序確實有影響,這意味着這兩個觀察者有一些微妙的耦合,最終會害了你。
讓我們完成刪除操作:
void Subject::removeObserver(Observer* observer)
{
if (head_ == observer)
{
head_ = observer->next_;
observer->next_ = NULL;
return;
}
Observer* current = head_;
while (current != NULL)
{
if (current->next_ == observer)
{
current->next_ = observer->next_;
observer->next_ = NULL;
return;
}
current = current->next_;
}
}
因爲使用的是鏈表,所以我們得遍歷它才能找到要刪除的觀察者。 如果我們使用普通的數組,也得做相同的事。 如果我們使用雙向鏈表,每個觀察者都有指向前面和後面的指針, 就可以用常量時間移除觀察者。在實際項目中,我會這樣做。
剩下的事情只有發送通知了,這和遍歷列表同樣簡單;
void Subject::notify(const Entity& entity, Event event)
{
Observer* observer = head_;
while (observer != NULL)
{
observer->onNotify(entity, event);
observer = observer->next_;
}
}
不差嘛,對吧?被觀察者現在想有多少觀察者就有多少觀察者,無需動態內存。 註冊和取消註冊就像使用簡單數組一樣快。 但是,我們犧牲了一些小小的功能特性。
由於我們使用觀察者對象作爲鏈表節點,這暗示它只能存在於一個觀察者鏈表中。 換言之,一個觀察者一次只能觀察一個被觀察者。 在傳統的實現中,每個被觀察者有獨立的列表,一個觀察者同時可以存在於多個列表中。
你也許可以接受這一限制。 通常是一個被觀察者有多個觀察者,反過來就很少見了。 如果這真是一個問題,這裏還有一種不必使用動態分配的解決方案。 詳細介紹的話,這章就太長了,但我會大致描述一下,其餘的你可以自行填補……
鏈表節點池
就像之前,每個被觀察者有一鏈表的觀察者。 但是,這些鏈表節點不是觀察者本身。 相反,它們是分散的小“鏈表節點”對象, 包含了指向觀察者的指針和指向鏈表下一節點的指針。
由於多個節點可以指向同一觀察者,這就意味着觀察者可以同時在超過多個被觀察者的列表中。 我們可以同時觀察多個對象了。
避免動態分配的方法很簡單:由於這些節點都是同樣大小和類型, 可以預先在對象池中分配它們。 這樣你只需處理固定大小的列表節點,可以隨你所需使用和重用, 而無需牽扯到真正的內存分配器。
剩餘的問題
我認爲該模式將人們嚇阻的三個主要問題已經被搞定了。 它簡單,快速,對內存管理友好。 但是這意味着你總該使用觀察者嗎?
現在,這是另一個的問題。 就像所有的設計模式,觀察者模式不是萬能藥。 哪怕可以正確高效地的實現,它也不一定是好的解決方案。 設計模式聲名狼藉的原因之一就是人們將好模式用在錯誤的問題上,得到了糟糕的結果。
還有兩個挑戰,一個是關於技術,另一個更偏向於可維護性。 我們先處理關於技術的挑戰,因爲關於技術的問題總是更容易處理。
銷燬被觀察者和觀察者
我們看到的樣例代碼健壯可用,但有一個嚴重的副作用:
當刪除一個被觀察者或觀察者時會發生什麼?
如果你不小心在某些觀察者上面調用了delete
,被觀察者也許仍然持有指向它的指針。
那是一個指向一片已釋放區域的懸空指針。
當被觀察者試圖發送一個通知,額……就說發生的事情會出乎你的意料之外吧。
刪除被觀察者更容易些,因爲在大多數實現中,觀察者沒有對它的引用。 但是即使這樣,將被觀察者所佔的字節直接回收可能還是會造成一些問題。 這些觀察者也許仍然期待在以後收到通知,而這是不可能的了。 它們沒法繼續觀察了,真的,它們只是認爲它們可以。
你可以用好幾種方式處理這點。
最簡單的就是像我做的那樣,以後一腳踩空。
在被刪除時取消註冊是觀察者的職責。
多數情況下,觀察者確實知道它在觀察哪個被觀察者,
所以通常需要做的只是給它的析構器添加一個removeObserver()
。
如果在刪除被觀察者時,你不想讓觀察者處理問題,這也很好解決。 只需要讓被觀察者在它被刪除前發送一個最終的“死亡通知”。 這樣,任何觀察者都可以接收到,然後做些合適的行爲。
人——哪怕是那些花費在大量時間在機器前,擁有讓我們黯然失色的才能的人——也是絕對不可靠的。 這就是爲什麼我們發明了電腦:它們不像我們那樣經常犯錯誤。
更安全的方案是在每個被觀察者銷燬時,讓觀察者自動取消註冊。 如果你在觀察者基類中實現了這個邏輯,每個人不必記住就可以使用它。 這確實增加了一定的複雜度。 這意味着每個觀察者都需要有它在觀察的被觀察者的列表。 最終維護一個雙向指針。
別擔心,我有垃圾回收器
你們那些裝備有垃圾回收系統的孩子現在一定很洋洋自得。 覺得你不必擔心這個,因爲你從來不必顯式刪除任何東西?再仔細想想!
想象一下:你有UI顯示玩家角色情況的狀態,比如健康和道具。 當玩家在屏幕上時,你爲其初始化了一個對象。 當UI退出時,你直接忘掉這個對象,交給GC清理。
每當角色臉上(或者其他什麼地方)捱了一拳,就發送一個通知。 UI觀察到了,然後更新健康槽。很好。 當玩家離開場景,但你沒有取消觀察者的註冊,會發生什麼?
UI界面不再可見,但也不會進入垃圾回收系統,因爲角色的觀察者列表還保存着對它的引用。 每一次場景加載後,我們給那個不斷增長的觀察者列表添加一個新實例。
玩家玩遊戲時,來回跑動,打架,角色的通知發送給所有的界面。 它們不在屏幕上,但它們接受通知,這樣就浪費CPU循環在不可見的UI元素上了。 如果它們會播放聲音之類的,這樣的錯誤就會被人察覺。
這在通知系統中非常常見,甚至專門有個名字:失效監聽者問題。 由於被觀察者保留了對觀察者的引用,最終有UI界面對象僵死在內存中。 這裏的教訓是要及時刪除觀察者。
然後呢?
觀察者的另一個深層次問題是它的意圖直接導致的。 我們使用它是因爲它幫助我們放鬆了兩塊代碼之間的耦合。 它讓被觀察者與沒有靜態綁定的觀察者間接交流。
當你要理解被觀察者的行爲時,這很有價值,任何不相關的事情都是在分散注意力。 如果你在處理物理引擎,你根本不想要編輯器——或者你的大腦——被一堆成就係統的東西而搞糊塗。
另一方面,如果你的程序沒能運行,漏洞散佈在多個觀察者之間,理清信息流變得更加困難。 顯式耦合中更易於查看哪一個方法被調用了。 這是因爲耦合是靜態的,IDE分析它輕而易舉。
但是如果耦合發生在觀察者列表中,想要知道哪個觀察者被通知到了,唯一的辦法是看看哪個觀察者在列表中,而且處於運行中。 你得理清它的命令式,動態行爲而非理清程序的靜態交流結構。
處理這個的指導原則很簡單。 如果爲了理解程序的一部分,兩個交流的模塊都需要考慮, 那就不要使用觀察者模式,使用其他更加顯式的東西。
當你在某些大型程序上用黑魔法時,你會感覺這樣處理很笨拙。 我們有很多術語用來描述,比如“關注點分離”,“一致性和內聚性”和“模塊化”, 總歸就是“這些東西待在一起,而不是與那些東西待在一起。”
觀察者模式是一個讓這些不相關的代碼塊互相交流,而不必打包成更大的塊的好方法。 這在專注於一個特性或層面的單一代碼塊內不會太有用。
這就是爲什麼它能很好地適應我們的例子: 成就和物理是幾乎完全不相干的領域,通常被不同的人實現。 我們想要它們之間的交流最小化, 這樣無論在哪一個上工作都不需要另一個的太多信息。
今日觀察者
設計模式源於1994。 那時候,面嚮對象語言正是熱門的編程範式。 每個程序員都想要“30天學會面向對象編程”, 中層管理員根據程序員創建類的數量爲他們支付工資。 工程師通過繼承層次的深度評價代碼質量。
觀察者模式在那個時代中很流行,所以構建它需要很多類就不奇怪了。 但是現代的主流程序員更加適應函數式語言。 實現一整套接口只是爲了接受一個通知不再符合今日的美學了。
它看上去是又沉重又死板。它確實又沉重又死板。 舉個例子,在觀察者類中,你不能爲不同的被觀察者調用不同的通知方法。
現代的解決辦法是讓“觀察者”只是對方法或者函數的引用。 在函數作爲第一公民的語言中,特別是那些有閉包的, 這種實現觀察者的方式更爲普遍。
舉個例子,C#有“事件”嵌在語言中。
通過這樣,觀察者是一個“委託”,
(“委託”是方法的引用在C#中的術語)。
在JavaScript事件系統中,觀察者可以是支持了特定EventListener
協議的類,
但是它們也可以是函數。
後者是人們常用的方式。
如果設計今日的觀察者模式,我會讓它基於函數而不是基於類。
哪怕是在C++中,我傾向於讓你註冊一個成員函數指針作爲觀察者,而不是Observer
接口的實例。
明日觀察者
事件系統和其他類似觀察者的模式如今遍地都是。 它們都是成熟的方案。 但是如果你用它們寫一個稍微大一些的應用,你會發現一件事情。 在觀察者中很多代碼最後都長得一樣。通常是這樣:
1. 獲知有狀態改變了。
2. 下命令改變一些UI來反映新的狀態。
就是這樣,“哦,英雄的健康現在是7了?讓我們把血條的寬度設爲70像素。 過上一段時間,這會變得很沉悶。 計算機科學學術界和軟件工程師已經用了很長時間嘗試結束這種狀況了。 這些方式被賦予了不同的名字:“數據流編程”,“函數反射編程”等等。
即使有所突破,一般也侷限在特定的領域中,比如音頻處理或芯片設計,我們還沒有找到萬能鑰匙。 與此同時,一個更腳踏實地的方式開始獲得成效。那就是現在的很多應用框架使用的“數據綁定”。
不像激進的方式,數據綁定不再指望完全終結命令式代碼, 也不嘗試基於巨大的聲明式數據圖表架構整個應用。 它做的只是自動改變UI元素或計算某些數值來反映一些值的變化。
就像其他聲明式系統,數據綁定也許太慢,嵌入遊戲引擎的核心也太複雜。 但是如果說它不會侵入遊戲不那麼性能攸關的部分,比如UI,那我會很驚訝。
與此同時,經典觀察者模式仍然在那裏等着我們。 是的,它不像其他的新熱門技術一樣在名字中填滿了“函數”“反射”, 但是它超簡單而且能正常工作。對我而言,這通常是解決方案最重要的條件。