.NET控件Designer架構設計
總體結構
Designer總體上由三大部分組成:View,ViewModel和Model,這個結構借鑒了流行的MVVM模式。這三部分的職責分工是:
View
負責把ViewModel以圖形的方式展現出來,它主要在處理畫法。View適合用xaml來表達,對于某些復雜的layout,仍然會需要寫一些code,但這些code不涉及業務邏輯。和MVVM的區別是,我們只是在簡單輸入的情況下,采用了Behavior模式,對于復雜的輸入,由于判斷用戶的意圖需要參考許多其它信息,可能要用到很多Service,或者查閱很多的狀態信息,這些代碼寫在View端不合適,我們就直接把事件發給了ViewModel,由ViewModel去處理。View和平臺相關,不同平臺(WPF、SL,WP7)的xaml可能不同,代碼也不同。
ViewModel
主要負責邏輯的處理,接收Event和Command,判斷用戶意圖,改變數據,并反饋給View。ViewModel既有數據,又能響應事件,而且是一棵樹,所以它本質上就是一個view,只不過是一個抽象的View,它把瑣碎的畫法丟給了真正的View,只關心那些和邏輯有關的數據。ViewModel和View有一定的對應關系,但它的結點比View要少得多,因此比直接在View上進行邏輯處理要簡單得多。由于ViewModel的數據和操作都是針對抽象的概念進行的,因此它和平臺無關。為了方便對ViewModel中的邏輯操作進行管理,我們引入了Service和Feature的概念,Service是向其它模塊提供支持的內部模塊,是系統的基礎,所有的Service構成了系統的骨架。Feature是實現系統外部功能的模塊,Feature之間沒有依賴關系,它們只依賴于Service。
系統中的Service不多,而且只關注最重要的邏輯,代碼量不大,所以Service都是經過精心設計和良好的測試,具有很強的穩定性。feature是系統的皮肉,它直接暴露給用戶,關注很多細節,代碼量大,容易變化,由于feature和feature之間沒有依賴性,所以這種變化不會對其它模塊造成影響,利于漸進式的開發。
Model
是數據,按照業界大師的說法,Model是純粹的數據。但我很懷疑這個說法,如果Model是純粹的數據,那它就沒有存在的必要,因為ViewModel上也有數據,何必要把數據存兩份呢,同步起來還挺麻煩。我的理解是,Model上是有邏輯的,只是這些邏輯是屬于另一個領域的范圍了。比如Designer的Model,就是Runtime的control,這些Control是有邏輯的,但它們的邏輯已經和Designtime沒有任何關系。ViewModel和Model的關系是,ViewModel操縱Model,但同時要監測Model的變化,和Model同步。如果我們清楚除了ViewModel外,不會有其它的模塊去修改Model(這種情況對于一些簡單的Designer是正常的),那么ViewModel和Model的關系可以更簡單一些,只有ViewModel改變Model,上圖中ViewModel和Model之間的箭頭就只需要保留左邊那一個(圖中的箭頭表示數據傳遞)。
從上面的介紹,我們可以看出,View和Model在DesignTime下都是比較簡單的,復雜度主要在ViewModel,我們需要進一步對它闡述。
ViewModel層的結構
我們前面提到,Designer主體結構分成三大部分:View,ViewModel,Model,這里的概念是一個宏觀概念,代表它所在的那一層里的所有結構。我們現在討論ViewModel這一層,它里面除了一個ViewModel樹,還有一些Service和許多的Feature。我們知道,圖形軟件的功能,不外乎就是處理用戶的鼠標鍵盤輸入,然后改變數據,最后以可視化的方式反饋給用戶,因此,我們只要分析清楚我們的軟件是如何來應對這樣一個輸入輸出過程就可以了。
我們看上圖,紅色虛線內的結構都是在處理輸入,紅色虛線外的部分在處理輸出(展現),可見對于Designer,輸入非常復雜,輸出比較簡單。我們先分析簡單的輸出:ViewModel時刻監視著Model的變化,一旦發現Model發生了變化,就改變自己的Property以同步,注意這里的Model不一定實現了INotifyPropertyChanged接口,因此這種同步可能不能借用綁定。但ViewModel一定是DependencyObject,或者實現了INotifyPropertyChanged接口,所以當ViewModel的屬性變化后,View通過綁定會讓展現和數據保持一致,輸出過程就完成了。
對于輸入,我們需要針對不同的情況進行考慮,基本上,我們可以把輸入分成兩大類:簡單輸入和復雜輸入。
什么是簡單輸入?
就是整個輸入處理過程很簡單,牽涉的模塊很少,Command有明確的接受對象。常見的Adorner上的行為,大部分都是如此。舉一個具體的例子,有一個Button,當它被選中的時候,會出現一個Adorner,上面有一個Slider,調整這個Slider,Button的透明度會隨著變化。要處理這個Slider對Model的改變,最簡單的做法就是把Slider雙向綁定到對應的Adorner ViewModel的某個屬性,即使不能用雙向綁定,也可以通過Behavior模式調用對應ViewModel的Command。整個過程只涉及到一個Adorner View,一個Adorner ViewModel和一個Button Control,和系統的其它部分沒有什么關系,這類輸入行為用雙向綁定或者Behavior模式處理最合適。
什么是復雜輸入呢?
就是整個輸入處理過程涉及到的模塊比較多,受很多系統狀態的影響,輸入沒有明確的接收對象,充滿了變化。這類行為在Designer中也很多。舉一個Multirow Template Designer的例子,一個CellView上收到一個MouseLeftButtonDown事件,View應該怎么處理呢?它會調用ViewModel的什么Command呢?CellView需要先判斷用戶的意圖,但這個判斷比較有難度。用戶有可能是想選中這個Cell,如果是這樣需要執行Selection Command,但是如果這個時候Designer處于Tab Order模式,那就不允許選擇,可能是用戶想改變Tab order的值。也有可能是用戶剛才選擇了一個ToolboxItem,現在是想創建一個Cell,還有可能是用戶想移動Cell,要進行這些判斷,必須要借助其它Service和查詢系統中某些狀態,如果判斷出來是選擇,還得檢查這個時候的鍵盤狀態,檢查目前是否支持擴展選擇,在擴展選擇模式下,按Control鍵和Shift鍵的行為不一樣。我們還得檢查當前Cell是否已經被選中,如果已經被選中,就需要反選,這需要我們查詢Selection Service。如果我們發現Cell是可以移動的,那么MouseLeftButtonDown的處理又得注意了,如果Cell沒有被選擇,要先選中Cell,如果Cell已經被選中了,不能立即反選,要看用戶是否后續有移動的操作,反選必須放到MouseLeftButtonUp中進行。
還要考慮到,今后可能需要增加新的Feature,比如增加一個移動畫布的功能,用戶先在Toolbar上單擊了一個手型Icon的Command,然后再在CellView上單擊了一下,這個時候以前的判斷都無效,因為用戶現在是要移動整個畫布,那么我們很可能得去修改以前的CellView的Code。總之,View在處理某些事件的時候,需要知道的東西太多,只靠ViewModel提供的Property遠遠不夠,ViewModel層必須把整個結構(所有的Service和各種狀態)完全暴露給View層,這樣顯然不符合我們模塊劃分的思路。因此,對于這類復雜輸入,我們讓View什么都不處理,而是把事件轉發給ViewModel層去處理。
除了某些事件處理很復雜,某些Command的處理也比較麻煩,比如菜單上的cut,copy,paste,delete等,這些Command沒有明確的接收對象,最終由誰來處理需要根據系統當時的各種狀態決定。為了解決這類Command,我們必須設計一個Command的路由機制,讓那些關心這個Command的feature能夠按照一定的優先級來處理這個Command.
為了處理上述的復雜輸入,我們學習wpf designer,設計了一個比較復雜的機制。我們設計了一個叫Tool的類,它有一個Task集合,按照一定的優先級把Command交給每個Task處理。Task從哪兒來呢?Task屬于Feature,當一個Feature認為它需要監聽某些Command時,它會把自己的Task添加到Tool的Tasks中。事實上Task并沒有直接處理Command,Task內部有一個CommandBinding集合,它負責處理Command。Task的Commandbinding在執行代碼時,修改ViewModel的屬性,或者執行一個ViewModel的Command。
對于View層轉發給ViewModel層的Event,在處理中會被先翻譯成Command,然后按照前面的Command流程處理。在這個過程中,需要經歷下面兩個步驟:
第一步
View接到鼠標鍵盤事件后,會調用InputService的一個函數PerformInput,把事件轉發給InputService。InputService會對這個事件進行預處理,然后再轉發出去。預處理解決兩個問題:1.把針對View的事件,轉換成針對ViewModel的事件。因為ViewModel就是一個抽象的View,如果把事件轉換成了針對ViewModel的事件(就是把事件的參數Sender轉換成對應的ViewModel,EventArgs轉變成適合于ViewModel的EventArgs),我們就可以按照以前熟悉的windows事件處理思路來處理ViewModel的事件,把View徹底屏蔽掉。2.添加或改變一些事件,以方便后續的處理。Designer有一些頻率特別高的操作,比如Drag,系統的默認事件比較弱,或者沒有對應的事件,如果我們在這兒進行一些強化,后面的處理就會減少很多麻煩。
第二步
InputService對事件進行完預處理后,會把事件交給Tool。Tool不但可以對Command派發,還能對Event進行派發,因為Task中除了有CommandBinding,還有InputBinding,InputBinding用于處理事件。事件被處理完后,會生成一個Command,這個過程就是把事件翻譯成Command的過程。翻譯成的Command,會發給Tool處理,繞這個圈是為了和前面的Command處理流程保持一致。
在和大家的討論中,覺得輸入處理的流程太復雜,尤其是我開始的時候,為了減少ViewModel層的信息入口,不建議View去直接改變ViewModel,所有事件都轉發給ViewModel層來處理。大家發現,如果那樣做,即使做一個很簡單的輸入,都要繞很大一個圈子,非常麻煩。因此,對于簡單的輸入處理,我們認為應該用雙向綁定或者Behavior模式,直接修改ViewModel,只對于那種比較復雜的輸入,才把事件轉發給ViewModel。這樣一來,這個圖變得似乎更復雜了,但我經過仔細考慮,覺得不能刪減,因為Designer有些輸入處理的流程確實非常復雜,過于簡單的結構會導致后面寫feature的時候需要考慮更多的問題。
另外說一下Tool,大家不大適應這個結構。因為按照我們以前的思路,即使事件交給ViewModel層處理,經過預處理后,InputService也應該直接把事件派發給對應的ViewModel,即使要路由,也可以學Wpf的路由機制,那樣大家都比較熟悉。但我認為,那樣的設計會讓大量的邏輯寫到ViewModel中,和ViewModel綁得比較死,這樣會有兩個大的缺點:
復雜輸入處理
邏輯往往跨越多個ViewModel,本來是一個完整的邏輯,不得不分片寫在不同的ViewModel中,依靠全局變量或者Service來協調。比如我們在Winform Designer中,就設計了一個DragService,用得非常頻繁,原因就是在Drag中,不同的View需要協作來完成一些任務,它們只能通過DragService來協調。但現在這種機制下,就不需要DragService了。
對原有的行為進行修改很困難
一個典型場景就是,在某種狀態下,需要禁止掉某些原有的行為。在Winform Designer下,我們只能有兩種處理方式:一,修改原來的Code,增加判斷條件,這種方式很容易搞出來新的Bug。二,在原來的View上蓋上一個透明的View,把事件劫持掉,這種方式屬于比較變態的方式,系統中如果用多了,會讓后面的人很難理解原有的設計。微軟的Winform Designer在處于這種情況時有一個經典的變態處理,它需要放一個Runtime的Control在Designer上,但不想讓它的行為在Designer中起作用,或者在某些情況下有選擇的讓它起作用,它用了hook技術,劫持windows消息,如果有需要,可以選擇性的放過去一些消息。Visual Studio中這類東西用得比較多,導致即使你按正常的方式放一個Control在Visual sdudio中,它有時工作也不正常,因為它的某些消息被hook劫持掉了。wpf中提供了Preview message,在某些情況下能夠簡化這類問題的處理,但我相信它的靈活性還是遠遠不如Tool這種把消息集中起來處理的方式,因為這種機制把邏輯徹底從ViewModel中剝離出來了,談不上需要改變哪一個ViewModel的行為,因為ViewModel沒有控制行為的代碼,所有行為都屬于外面的Feature,只要Feature發生變化,對應的ViewModel的“行為”自然就發生變化。
當然,Tool這種把所有消息集中處理的方式也有缺點,就是模塊間的干擾非常嚴重,就相當于編程語言中的全局變量,方便了使用,但帶來了干擾。因此我們推薦復雜的輸入用這種方式,簡單的輸入用Behavior模式,直接修改ViewModel,或者通過DelegateCommand,把View的事件直接轉發給ViewModel處理,繞過這個機制。但是,我們一定要意識到,繞過這個機制會帶來的問題,就是后面要改變原有的行為是不行的,因為消息的傳輸過程中沒有留下改變的控制點,只能去修改原有的View和ViewModel的代碼。在designer中,這類簡單輸入方式主要應該用于Adorner,因為Adorner一般都是臨時使用一下,輸入簡單,即使后面發現需要改變它的行為,不得已可以換一個AdornerModel和AdornerView,也不會對系統造成多大影響。但如果你要把Multirow Template designer中SectionViewModel和SectionView,或者CellViewModel和CellView換了,那影響就大了去了。所以我們今后在選擇哪種輸入處理方式時,一定要充分考慮到后面變化的需要。
View和ViewModel的對應關系
討論中大家覺得View和ViewModel的對應關系比較復雜,所以這兒單獨花一節來談談它們的關系。舉一個大家熟悉的MultiRow的例子,現在假設有一個SectionViewModel,它的Chilren中有兩個Cell,分別是CellViewModel1和CellViewModel2,現在我們看這個ViewModel Tree如何展現,事件如何傳遞,HitTest是如何實現的。
先看一下我們會怎樣來設計View,為了便于用Xaml表達,我們一般會用UserControl來表達View,雖然CustomControl也能用Xaml,但它的xaml一般要寫到Resource中,所以我們一般不用。我們現在有兩個類:SectionViewModel和CellViewModel,因此,對應的我們會設計兩個UserControl,分別叫做SectionView和CellView。這兒我要說明的是,由于CellView很簡單,做產品的時候也許不會單獨為它用一個UserControl,而是在Section的Xaml里直接表達了,甚至MultiRow的整個Template都用一個UserControl描述。在這里為了方便闡述概念,我們把兩個View看成是兩個獨立的UserControl。
怎樣來設計SectionView呢?我們會在UserControl中放一個ItemsControl,把它的ItemsSource邦定到datacontext的Chilren屬性上,然后把ItemsPanel設置成Canvas,在ItemTemplate中指定用CellView來展現CellViewModel,當然,我們也可以用隱式DataTemplate來表達。
CellView呢?我們就在UserControl中放一個Border,把Border的Background綁定到DataContext的Background就可以了。
當外部某個對象把SectionView加載到VisualTree上時,它會負責把SectionView的DataContext指向SectionViewModel(這個對象很可能也是一個DataTemplate),通過綁定,所有的CellViewModel都會有對應的CellView,最后的VisualTree會如圖中所示。
我們看到,VisualTree的Visual結點明顯多于ViewModel的結點,那么它們的對應關系是如何的呢?有兩條原則:
1.一個ViewModel有且只有一個Visual和它對應,我們可以把這個Visual叫做這個ViewModel的View。一個Visual對應一個或零個ViewModel。
2.如果ViewModel A是ViewModel B的祖先,那么對應的Visual A也應該是Visual B的祖先,如果ViewModel A不是ViewModel B的祖先,那么對應的Visual A也不應該是Visual B的祖先。
按照我們的設計,ViewModel和Visual對應關系如上圖,紅色結點的Visual就是ViewModel對應的View。那么這個對應關系是怎么記錄的,因為今后的很多邏輯會依賴這個數據。
首先,我們會在設計的時候認定ViewModel和Visual的對應關系,如上圖,我們認為SectionViewModel對應SectionView(UsrControl),CellViewModel對應CellView(UserControl),所以我們會在這兩個UserControl的Xaml中設置一個附加屬性ViewProperties.ViewModel,把它綁定到DataContext上,這樣就讓View指向了ViewModel,在附加屬性ViewProperties.ViewModel的PropertyChanged事件里,我們會創建一個IViewModel對象賦給對應的ViewModel的View屬性,IViewModel會抓著真正的Visual。這樣ViewModel和View的雙向對應關系就建立起來了。
如何解決HitTest?
View層會實現一個IViewService,里面有一個函數:IEnumerable<IView> FindViews(Point p),其它對象可以調用這個函數來拿到HitTest的IView,再通過IView拿到ViewModel(我想這一步可以簡化到直接返回ViewModel,目前是返回的IView)。View層是如何查找View的呢?它會調用VisualTreeHelper的HitTest,找到Hit的Visual,然后遍歷父Visual,找到某個有對應ViewModel的Visual,那么這個Visual就是Hit的View了。
解決了HitTest,InputService對事件的預處理就簡單了,它拿到一個Mouse事件參數后,會得到Mouse的坐標,然后調用IViewService的FindView函數,就可以知道這個Mouse事件是針對哪一個ViewModel,然后把Sender和EventArgs都轉換成適合于ViewModel的,再轉發出去就可以了。
與PropertyGrid交互
會有一個專門的Service來負責與PropertyGrid交互,展現在PropertyGrid上的對象是ViewModel創建的一個對象,因此受ViewModel控制,ViewModel可以決定是把自己交給PropertyGrid,或者設計另一個類型,融合Model的Property和Design Time的Property。
序列化
序列化由專門的Service來完成,Service中登記有不同類型的ViewModel的Serializer,默認的Serializer會調用Runtime的序列化方法直接把RuntimeControl序列化成文本,這個序列化設計學習Winform designer的序列化架構。
Undo/Redo
從我們上面的設計看,所有的輸入都要經過ViewModel,所以在ViewModel上做Undo/Redo。系統中有一個UndoService,當一個ViewModel的Property被改變時,會通知UndoService,UndoService會把改變前的值記錄下來。值得注意的是,不是所有的ViewModel的屬性都需要Undo,這點具體設計時根據需要判斷,一般來說,Runtime的Property都需要,非Runtime的Property可能有部分需要。我考慮過根據Undo和序列化的要求,在ViewModel和runtime control之間再隔離出來一個層次,比如叫ModelItem,這樣結構上更清楚一些。但多一個層次開發的時候會多不少工作,覺得不劃算,目前暫定由ViewModel兼任這個職責。
架構如何應對未來的變化
目前的架構是針對復雜Designer設計的架構,如果未來的Designer比較簡單,這個架構是不是有點高射炮打蚊子呢?我的想法萬一未來的Designer比較簡單,這個架構可以從下面三個地方去簡化:
1.砍掉輸入的無關事件和無關Feature.目前的架構添加了一些事件,如Drag,實現了一些和這些事件有關的核心Feature,如果未來不需要,可以砍掉。因為按現在的架構,Feature是獨立的,彼此互不影響,把這類Feature刪掉即可。事件也一樣,刪減事件不影響整體流程。
2.如果仍然覺得復雜,可以把Tool,Task等概念刪掉,增加Tool,task的概念,是為了讓輸入集中處理,擁有更強的靈活性。如果把這些概念刪除掉,InputService直接把事件派發給對應的ViewModel就可以了,這就相當于winform的事件機制,由ViewModel直接處理事件。這一步把輸入大大的簡化了。
3.如果上面簡化還不夠,把InputService也干掉,由View利用Behavior處理輸入,然后調用ViewModel的Command,這就變成了經典的MVVM模式,到這一步應該化到最簡了。