2011/8/12

使用 Decorator 樣式實現輸入裝置的 IoC 概念

對傳統程式設計師而言, XNA 可以算是一個面臨多種適用狀況的開發環境。為什麼呢? 因為你所開發的程式, 只要經過小小的修改, 就可以移轉到 PC、XBOX 與 WP7 裝置上面。而它的輸入裝置也很多樣, 包括鍵盤、滑鼠、XBOX 搖桿、觸控輸入, 甚至其它

到目前為止, 我手頭上可以運用的輸入裝置除了鍵盤、滑鼠, 還有 XBOX 搖桿 (這東西花了我一千多塊錢), 但是事實上 XNA 可以接受更多的輸入裝置, 例如 WP7 的(多點)觸控輸入、加速感應裝置, 甚至未來的語音輸入裝置, 還有 Kinect 等等。如果我們希望在程式裡同時支援這些裝置, 無可避免的, 我們都必須為每一項撰寫對應的程式。

在我的第一個 XNA 程式中, 我刻意把裝置的輸入對應部份從其它部份中抽離出來, 也很快的把程式寫好了。但是寫好之後, 我檢視了這個程式, 我發現這個程式充滿了太多相依性; 而且這些相依性都是 tightly-coupled。

緣起

我先來解釋一下我想要寫的這個程式是用來做些什麼。這個程式的目的很簡單, 就是畫一個 3D 地形, 然後可以使用鍵盤、滑鼠和搖桿來控制整個相機視角在 X、Y、Z 三軸的旋轉、翻轉以及遠近縮放。

經由實驗, 滑鼠、鍵盤與搖桿的操控性能有或多或少的不同。其中, 搖桿的操作是最單純、最直覺的, 一個按鍵對應一個動作, 我們只需要每次去偵測它的按鈕狀況就可以了。但是對於鍵盤而言, 由於習慣上我們可能賦予鍵盤兩組操作按鍵(左邊一組、右邊一組), 因此我們就不能直接拿搖桿的程式來套用。

至於滑鼠, 我們應該採用滑鼠拖曳的方式來操控相機視角, 所以它的對應程式和鍵盤與搖桿截然不同。而且使用滑鼠滾輪來進行縮放動作時, 其刻度也不能與鍵盤或搖桿設定成一樣。

此外, 由於我想維持程式的最大彈性, 所以我允許所有按鍵或按鈕都可以重新定義; 換句話說, 使用者可以重新定義使用滑鼠左鍵來操控視角, 而不必使用滑鼠右鍵。在程式中加上這個彈性之後, 未來我們才可以額外加上一個設定視窗, 讓使用者自行定義他們偏好的操控方式。

在我的第一個程式中, 負責輸入操控的類別把所有輸入裝置的程式全部納入, 導致它快速變成一個龐大的怪物, 而且牽一髮動全身, 很難修改。

經過重構數次之後, 我把程式修改得清爽了很多, 但是相依性仍在; 如果未來要再加入其它輸入裝置的支援, 這個程式只會再變得更大、更難維護。這使得我動起套用架構的想法。

導入 IoC 的概念

所謂的 IoC (Inversion of Control 控制反轉), 具備如下的幾個基本精神:

  1. 一個特定的程序必須將實作與執行解除耦合
  2. 每個系統都可以聚焦在它所被設計來從事的目的
  3. 一個系統無需知曉或預測另一個系統應該做哪些事情
  4. 把一個系統置換掉也不會影響到其它系統

在我原來的程式中, 負責鍵盤輸入的系統必須很清楚滑鼠輸入系統的狀況, 其它系統亦然; 因此這個原始程式跟 IoC 的概念是背道而馳的, 也造成難以維護的後果。

那麼, 如果要實現 IoC 的精神, 我必須取找一種可用的樣式來使用, 或者自創樣式。而我第一個就找上了我比較熟悉的裝飾者樣式 (Decorator)。

Decorator Pattern, WHY?

在介紹 IoC 的書籍或網站中, 多半會推薦採用 Factory 或者 Observer 樣式來實現 IoC (有興趣者可以參考 Pete 的部落格文章, 裡面有很淺顯易懂的說明與範例)。

不過, 很可惜的, 不管是 Factory, Observer 或者其它近似的樣式, 它們的設計方向似乎和我的程式中所必須達成的方向相反; 所以我必須尋找更可行 (即使不一定完全符合 IoC 的定義 -- 所以本文的標題是「實現 IoC 概念」而非「實現 IoC」, 因為我在這裡要實作的東西實在跟大家普遍看得到的 IoC 有很大的差異) 的樣式。未來如果我有時間的話, 也許會試著採用 Factory 樣式再來重寫一次。

在這個程式中, 我所必須達成的目的及其原因有幾項:

  1. 不同的輸入裝置有其特殊的性能, 例如滑鼠可以拖曳, 其它裝置不行; 因此我的輸入控制程式必須完全獨立於其它系統之外而不會互相打架
  2. 這些輸入裝置必須可以以任何順序隨時加入、缷除, 而不會造成執行結果的不一致
  3. 來自不同輸入裝置的使用者指令必須同時作用, 而不是擇一而行; 例如當你持續在鍵盤上按著前後左右等等行進指令時, 還能同時使用滑鼠調整視角

如果採用 Decorator, 它除了讓我把各個輸入控制程式獨立出來之外, 最大的好處, 就是讓我可以把各個輸入控制程式串起來! 雖然聽起來好像沒什麼(因為我也可以很輕鬆的手動寫好), 但是當我把架構做出來以後, 未來我只需要替新加入的輸入裝置撰寫好, 然後再把它串在上一個輸入裝置的後面即可。

不過, 和其它套用樣式的程式一樣, 理想雖然達成了, 疊床架屋的缺點也隨之而來。如果不套用樣式的話, 直覺的程式寫法雖然不好維護(是不好維護, 而不是不能維護)、彈性小, 但是撰寫的時間反而很短。相對的, 套用樣式的程式卻有多了幾個不具實際作用的模版程式(主要是那些抽象類別)的缺點。

此外, 如果把程式交給一個不懂物件導向原理或沒用過樣式的程式設計師, 他可能甚至看不懂這個程式的目的, 甚至不知道如何 trace。

但是, 無論如何, 一旦你把樣式套好, 並且測試 OK 之後, 未來我只需要把新的輸入控制程式寫好, 然後把它串在上一個輸入控制式的後面, 就好像接力一樣, 所有的事情就會自動做好。

除此之外, 如果新的輸入裝置提供了其它裝置所沒有的新功能(例如觸控板的多點拖曳), 我們也只需把它加上去就可以了。當然, 由於我們為了符合該樣式的特性, 所以必須在其它輸入控制程式以及抽象類別裡加上一些空的對應方法; 這可以說是額外的小小代價。

運用相同的概念, 這種模式不但適用於 XNA, 也同樣適用在 Windows Form 或者 Silverlight 等等。

Decorator 淺介

如果你不了解 Decorator 樣式的話, 我再花一點篇輻把它簡略的講解一遍。

基本上, Decorator 樣式中至少包括四種類別:

  1. Abstract Component (以下簡稱 AC)
  2. 繼承 Abstract Component 的 Concrete Component (以下簡稱 CC)
  3. 繼承 Abstract Component 的 Abstract Decorator (以下簡稱 AD)
  4. 繼承 Abstract Decorator 的 Concrete Decorator (以下簡稱 CD)

你可以在 dofactory.com 網站中看到較詳細的說明與實際範例。

在上述四種類別中, 第 1 到第 3 種類別 (AC, CC & AD) 都沒有實際作用。說穿了, 它們都只是傳遞指標或提供版型以供下一層類別進行覆寫而已。倒是在第三種類別 (AD) 中有一個 SetComponent 方法, 它是特別用來把第四類別 (CD) 的不同實體串接起來的。

此外, 在四種類別中, 我們必須加上(或覆寫)我們真正要加上程式的方法(就是在 dofactory.com 網頁範例中的 Operation() 方法。真正的商業邏輯當然是寫在 CD 裡面(要記得不能把 "base.Operation();" 這行指令省掉, 否則就串不起來了); 而在其它類別中, 我們只需要照著範例寫就行了。

你也可以完全不理會以上所有饒舌的說明, 你只要照著範例的寫法, 把焦點完全放在 CD 類別即可。

如果到這裡你還看不懂的話, 那麼我把那個範例程式再度簡化一下:

abstract class Component // AC
{ public abstract void Operation(); }
 
abstract class ConcreteComponent : Component // CC
{ public override void Operation() { } }
 
abstract class Decorator : Component // AD
{
        protected Component component;
        public void SetComponent(Component component)
        { this.component = component; }
        public override void Operation()
        { if (component != null) component.Operation(); }
}
 
class ConcreteDecoratorA : Decorator
{       public override void Operation()
        {
            base.Operation();
            Console.WriteLine("ConcreteDecoratorA.Operation()");
        }
}
 
class ConcreteDecoratorB : Decorator
{       public override void Operation()
        {
            base.Operation();
            Console.WriteLine("ConcreteDecoratorB.Operation()");
        }
}

依循以上的寫法, 你已經完成一個遵照 Decorator 樣式的程式。程式中 ConcreteDecoratorA 與 ConcreteDecoratorB 才是實際上有具體意義的類別, 而其中的 Operation() 方法則是存放真正商業邏輯的地方。你只要記得在每個 Operation() 方法裡面把 "base.Operation();" 指令加上去就可以了。

那麼, 我們應該怎麼讓這個樣式作用呢?

在你的主程式中, 加上以下指令:

ConcreteComponent cc = new ConcreteComponent();
ConcreteDecoratorA cd1 = new ConcreteDecoratorA();
ConcreteDecoratorB cd2 = new ConcreteDecoratorB();
cd1.SetComponent(cc);
cd2.SetComponent(cd1);
cd2.Operation();

到這裡為止, cd1 類別實體已經把 cd2 類別實體串上來了。當程式執行到 cd2.Operation() 方法時, 它會自動把 cd1.Operation() 帶出來執行。你可以把 cd1 和 cd2 的順序對調, 對結果不應該造成任何影響。如果你的 cd1 和 cd2 的 Operation() 方法必須遵照誰先誰後的順序, 那麼你絕對不能搞錯順序。不過, 如果是在這種情況(有先後順序的問題)下, 系統間的依賴度其實並沒有被解除耦合; 我覺得或許你的程式並不十分適用 IoC 概念。

那麼, 如果你未來多了一個 Concrete Decorator 類別怎麼辦? 沒問題, 依照上面的做法, 依法炮製即可:

ConcreteComponent cc = new ConcreteComponent();
ConcreteDecoratorA cd1 = new ConcreteDecoratorA();
ConcreteDecoratorB cd2 = new ConcreteDecoratorB();
ConcreteDecoratorC cd3 = new ConcreteDecoratorC();
cd1.SetComponent(cc);
cd2.SetComponent(cd1);
cd3.SetComponent(cd2);
cd3.Operation();

如此, cd1.Operation()、cd2.Operation() 和 cd3.Operation() 將依順序被執行。

沒有留言:

張貼留言