[转载].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模式,到这一步应该化到最简了。