一起谈.NET技术,.NET控件Designer架构设计
2011-09-02 00:18 狼人:-) 阅读(249) 评论(0) 编辑 收藏 举报总体结构
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模式,到这一步应该化到最简了。