制作WPF联机飞行棋的失败体验
飞行棋作为幼时的娱乐项目在我的记忆里印象是相当深刻的,用编码实现它也一直是我自己的目标。WPF有着图像编码的舒适体验,自然成为我的首选;伴随着WPF的Binding,一种新的模式也应运而生——MVVM(Model-View-ViewModel),使得页面和逻辑更好的分离。可这次的体验对我而言不管从技术到思想都深深的受到了打击。
起因:MVP模式的讨论
当然让我冲动的在没有什么准备的前提下做这款游戏还是由于以下对话:
某人:你们现在还在用MVP模式么?
我:我们现在用MVVM。
某人:MVVM?
我:WPF绑定功能很强大,可以实现View 和 Model的完全分离。
某人:用MVP也可以分离,Presenter作为两者的媒介就可以了。
我:你们现在的做法是不是从Service 上获取一些DTO,然后通过Presenter为控件赋值,在Presenter注册IView上的控件事件(下面会说到只放方法、属性和事件),在事件里回调Service。
某人:没错,有什么问题么?
我:假设说对象是Employee,上面有一些常用的属性Name,Id,Address外,或许需要还有个DoWork方法,而且Employee可能是个抽象类,下面还有各种分工,每个子类的DoWork方法是不同的。按照面向对象的思想是不是DoWork是Employee对象上的。而你们现在的做法是Service 上有个DoWork方法然后把Emplyee对象传进去,这样你们传递的对象没有行为只有属性,好听点叫做贫血模式,实际换个C语言时候的结构体也一样;对象上的函数才叫方法,而Service只是函数的集合而已,现在的说法是面向服务编程,可骨子里却是面向过程。
某人:是的,对象上应该有行为才是面向对象编程。可主流的方法都是这样,WebSerivce,WCF;即使你直接连接的是数据库也需要一层转换你总不会在方法里直接下SQL吧,即使下了SQL也是面向过程了,因为数据库不是面向对象的。
我:嗯.. 我们也会用一些面向过程的操作,可我们尽量应该避免面向过程的行为。
某人:那么就实现对象行为而言,把从Service获取的DTO在客户端转换成一个对象,在对象的方法中封装调用Service的服务,在控件的事件里不直接调用Service而是调用那个对象的方法不就可以了,我们现在就是这么做的。
我:这样确实可以,实际上即便是WPF也需要注册一些事件来满足需求。但假如界面上需要显示一个人员的姓名,当你修改姓名的时候是不是要在IView上把控件列出,在Presenter注册一个类似TextChanged事件,一个还可以,假如还有电话、住址等杂七杂八的一些东西,是不是Presenter单单注册事件就变得臃肿了,而且你IView上需要曝露具体控件,如果IView上不是曝露具体控件,而是些属性和方法时,随着控件操作的繁多IView会慢慢变的混乱不堪,毕竟赋值,值变化,操作变化都要对应的加上方法或属性,那时你就会想从View上直接调用Presenter的方法,可MVP的思想是View 不知道Presenter,Presenter 可以操纵View,要不然就变成了MVC了。
某人:这对于IView的维护确实不容易,假如去掉IView直接让Presenter操纵具体的View呢?
我:定义IView还有个意图,也就刚才说的只在上面添加属性和方法,这样做的目的是为了界面的变化,WEB能用,WINFROM也能用。一般常用的方法是View放在一个项目,Presenter放在一个项目,IView在另外一个项目,View中通过IOC容器注册IView,当Presenter启动IView时,IOC容器自动的会去找到对应的View来做实例,这样Presenter和View的引用是分离的。也就是说IView变成View的话Presenter和View就要放在一起,最起码也是要Presenter引用View的项目,虽然也是MVP,不过感觉弱些。
某人:我们现在就是把Presenter和View放在一起的,甚至是放在一个文件夹中,Presenter负责的是View上数据的呈现变换,就是把View上的对应数据变化通过Presenter赋值到客户端的对象中——也就是DTO转出的对象。我们把这些对象放在另一个项目中,你该不会认为我们会把一些逻辑放在Presenter中处理,毕竟界面的变换只是数据的表现,换个UI而已对象还是在的,对象也不知道具体的Presenter,这样也算作一种分离吧,再者你刚才说的IView上放属性和方法,如Button的Click是不是又要加个事件,事件本身就是和UI有联系了,除非在IView层上再添加些对象来传递信息,可这样感觉是为了分层而分层了。
我:那你们的Presenter可以从多个对象上获取数据么?比如View上的数据有一部份来自A对象,一部分来自B对象,而A和B属于并列关系。
某人:目前不能,我们一个Presenter只对应一个对象,如果发生这种情况,我们会把Presenter切的更细。
我:也就是说A和B对象各对应一个View,那么A View 和B View 的容器C View,对应的也是A对象和B对象的容器C对象,也可以说C对象上有A和B属性?
某人:A和B对象是各对应一个View,你说的这种C View的是一种做法,可有时候A对象和B对象作为C 对象的属性会使对象臃肿;假设A对象的产生必定会产生一个View,那么界面设计者是应该知道这个View停靠在哪的,比如该View同你所说是停在C View 容器中那么在C容器中会有地方留给它,我们的做法是写个CustomerPanel类似SCSF中的容器,在上面写上需要放的View的RegionName,通过一些容器再做个桥接应该就可以达到这个效果了。
我:你们能达到一个View多个地方么,比如我修改的信息的View是在一个地方,新增信息又是在弹出的窗口中。
某人:可以,因为数据不一样,创建对象首先创建出对应的Presenter,Presenter根据对象的一些信息来决定View的RegionName,一个View有多个RegionName,当然一个RegionName只能出现一个地方。
我:那对象中数据的变化怎么让Presenter知道呢?比如你改变了A属性但B属性在A变化的逻辑中也发生了变化,而B也是在View上要显示的一个栏位,你怎么侦测B属性的变化,写个事件给Presenter注册?
某人:这样做会有什么问题么?
我:如果是普通事件的话,就会有强引用问题,当你View关闭了,可对象还不需要销毁,这样对象上还是保存着Presenter的引用,这会造成内存泄露。
某人:实际上我们的对象会继承自一个基类,基类上做了个弱事件机制,就是弱引用住委托,把弱引用放在一个List里面,如果弱引用不为空也就是还存在则引发其中的委托,Presenter会注册这个弱事件,得到他的通知,很像Winform中的INotifypropertychanged机制。
我:WPF也是靠这个机制做的Binding,不过比Winform强些,另外他是通过WeakEventManager来管理注册的事件,我感觉你们View和Presenter的关系只是一个不知道业务对象,一个知道。
某人:这样不是可以更好的让前端设计师发挥么,业务对象毕竟涉及到业务逻辑,前端应该主要侧重是UI,UE的设计。
我:你说的有道理,你们的设计确实可以满足大部分需求,可前端设计师也是需要知道一些业务逻辑的,要不然如何设计出符合用户操作的界面。你的一些类似姓名,地址之类的文本赋值其实直接知道客户端对象也未尝不可,这样可以减少Presenter的工作量,毕竟对于文本赋值,文本改变这样的简单操作Presenter的设计显的太庸俗化,而且对于行为的操作,比如点击Button大体会产生什么效果,界面设计师应该也是清楚的。
某人:我们的原则是有关界面变化的操作尽量在View层操作,如Button点击会产生提示框,这个提示框的样式、位置是View层控制的,内容是Presenter赋予的。不过就同你所说Presenter的工作量大、繁杂,需要完全了解View和客户端逻辑层,但是Presenter不就是做两者的承接么。MVVM有更好的体验?
我:MVVM 是由WPF的控件机制决定的,它能够比较轻易的实现Bindng,和Winform的那个Binding差不多,前端的TextBox里的值改变了,后端的类中对应的属性也跟着变化,这样就省去了Presenter的麻烦赋值,对于控件的事件也就是行为WPF提供了Command机制,这样你每个View真的只对应一个对象,有行为有属性,有血有肉。不过缺点是界面需要客户端对象,我们把这个客户端对象称为ViewModel,为了更好的实现分离和交互,可以把ViewModel做个接口,View只知道接口,这样View要呈现哪些更清晰些。
某人:好像有点抽象,WPF特有的?有什么例子么?
我:我正好准备写个简单的游戏,可以到时候让你开开眼界,实现的代码绝对优雅。
经过:错误的设计
会出现错误设计的大体原因一般有这么几个:技术难点没有抓住,业务需求没有搞清.当然对我而言还有很多原因,如9月开学了,还有一堆考试和重考,白天又要上班,精力不够,所以想把它快点解决。
错误一:
由于没有做过网络互动程序的经历,我开始是这么想的,通讯方式使用WCF,可糟糕的是我没有具体的学过WCF,仗着自己对网络通信的一知半解,认为可以怎么简单怎么来,用TCP,UDP无所谓,能达到效果就成,以致于到本程序完成差不多前一周才开始了解学习WCF。
WCF可以使用svcutil 工具生成代理类,也可以把服务端的契约自动转成客户端类,只要在svcutil 的连接后加上/edb 便可以生成的客户端类带有INotifiyPropertyChanged接口,并自动实现一个RaisePropertyChanged方法,在属性的赋值中调用RaisePropertyChanged。这其实应该是方便开发人员的,一般尽量不要去修改这个生成的类,因为当服务器端改变时可以更方便的自动生成,要不然常常修改这个"大块头"也是件麻烦的事;但坚持这个做法也让我比较累,比如我本来想Room中放个Dictionary来当座位然后在其中放Player(玩家类),由于不考虑参观人数,所以我就可以把Dictionary设成4个,然后用个for循环便可以判断座位上有没有人(不用列表List的原因是线程间安全),可如果Seats[0] =Player UI是不会刷新的,因为没有引发PropertyChangedEventHandler,Seats是属性除非为Seats = Seats 的动作,不过即使这样,自动生成代码是这样的:
public System.Collections.Generic.Dictionary<int, ModelModule.Player> Seats { get { return this.SeatsField; } set { if ((object.ReferenceEquals(this.SeatsField, value) != true)) { this.SeatsField = value; this.RaisePropertyChanged("Seats"); } } }
大家看出来,自己把自己赋值给自己的这条路也行不通,然后的我不得不在partial class中写
/// <summary> /// player不为空把座位上指定玩家否则移除玩家 /// </summary> /// <param name="seatNo">座位号</param> /// <param name="player">玩家</param> public void SetSeat(int seatNo, IPlayer player) { Seats[seatNo] = (Player)player; if (player == default(Player)) { RemovePlane(seatNo); } RaisePropertyChanged("Seats"); Application.Current.Dispatcher.BeginInvoke((Action)CommandManager.InvalidateRequerySuggested); }
如果再设计,我一定要加个Seat的概念,Player中对应的属性不是Room而是Seat,Seat知道房间号和坐席号。我现在是Player有个属性是Room还有个属性是SeatId。这个设计是我现在最耿耿于怀的。
错误二:
对WPF信心太高,认为WPF可以实现更快速的快发,实际上套用MVVM需要自定义大批量的控件,需要大量的AttachBehaiver类,需要更高设计思想来分离ViewModel。其中设计最烂一个完全可以让大家鄙视我的做法是,居然不是在让服务端判断战斗的胜负,而是让客户端告知的,客户端自己得知输赢。为什么会这样的设计的一个原因是,我最初死都不想让客户端和服务器端各用一遍飞行逻辑,要不让服务端产生传给客户端需要走的格子,客户端完全全只是UI的呈现,当时劈头劈脑的也做好了,无非是把坐标转成路径嘛,用MatrixAnimationUsingPath跑下就好了。可问题又来了,一架飞机飞越另一架飞机是要爆炸的,这个时机点要准确,只告诉坐标无疑太没用,也就是说还要传递在哪个格子上消灭了哪几个飞机,用关键帧的方式实现了下,感觉太蠢,搞了好久,最后因为时间花费的是在太久,就用了现在的方法,客户端接受服务器的色子数跑,由于在类中跑一个格子太快,我只好用了AutoResetEvent来等,这应该是我程序生涯中最不爽的一件事。重新再来的话,我一定让服务端执行飞行逻辑,客户端也执行飞行逻辑(只接受服务端所给的色子数,节省传输资源),但客户端的飞行逻辑只为生成飞行路线,在另一个逻辑中判断飞机是否碰撞到另外的飞机,飞行动画效果也不用什么内置的什么Storyboard在CompositionTarget.Rendering中用RenderTransform属性就好了。因为用AnimationTimeline类,你的一些呈现便会捕捉不到,注册他的CurrentTimeInvalidated事件又太费,我们不能控制AnimationTimeline的时钟,本来打算飞机飞过的时候有个尾气效果,可花了很大的功还是不能达到理想的效果。
游戏整体设计思路及做法
大厅的设计并没像市面上的游戏大厅一样有多个区,如新手区、高手区。因为本意只是做个Demo,能在局域网环境下跑就可以了,所以玩家打开画面只会看到一个大厅。我把大厅取名为Area,由于Area只需一个实例,在服务端以Sington方式运行。
Area中有Room(房间),默认数目为50,房间数目应该是Area创建后便不会改变,我就把它放在了Area的构造函数中初始化,Player(玩家)在客户端实例时自动创建一个Id(Guid),调用服务端的Enter方法进入大厅,并得到服务器上的房间,房间的结构中便包含了在房间中的玩家。
为什么不返回进入大厅的全部的人员,而只返回房间?
当时的考虑点是让玩家看到进入大厅的人意义不大,这个简单的游戏并不包括厅内聊天功能。只需让玩家知道哪几个房间内坐了人,有几个人举了手,该房间是否开始游戏就足够了。
服务端的Enter方法,不仅会保存玩家的基本信息,还会保存玩家的通信管道(OperationContext.Current.GetCallbackChannel<IPlaneChessCallback>()),我想对于安全的话,每次客户端放送请求都应该检查该通信管道是否一致。
客户端调用服务器端方法EnterRoom进入房间,这时服务端应该判别该座位是否已有人,如果有人则抛错(可怜的我还不懂如何搞WCF的异常信息),进入房间应该通知进入大厅的所有人,通知所有人的操作还包括举手,退出房间,房间开始游戏,房间结束游戏。
当进入房间的玩家都举了手(房间中的玩家大于一),游戏便开始。游戏开始后的轮圈,系统随即骰出的色子数只需要通知房间内的人。
客户端选中飞机给服务端骰色子,服务端骰出色子通知房间内的人,如果在一定时间内(默认20秒)没有动作,客户端会自己重选中一架飞机提交服务端。
当客户端发现游戏完成,则通知服务端,服务端通知进入大厅的全部人该房间游戏已结束。
当服务端发送给客户端信息失败是,先判断该玩家是否在房间中,如果有房间,则先退出房间,然后再从系统的人员列表中删除。
项目结构设计
服务端:Host为启动程序,Service为实体类和服务类
客户端:WPFClient为启动程序
ControlLibrary为WPF控件库
Common为IOC和读取App.cofig的东西
ViewModule为界面
ModelModule为ViewModel
值得注意的是在WPFClient中我并没有添加ViewModule和ModelModule的引用
在配置文件中
<modules> <module assemblyFile="ViewModule.dll" moduleType="ViewModule.Module, ViewModule" moduleName="ViewModule"> </module> <module assemblyFile="ModelModule.dll" moduleType="ModelModule.Module, ModelModule" moduleName="ModelModule"> </module> </modules>
这是学习Prism框架的模块做法,我针对项目自己做了些改进,读出Assembly后,看这个程序集中是否有继承于IModule的类,有的话实例那个类并调用IModule接口中的Register方法来注册相应的类到IOC容器中。
可是ModelModule和ViewModule没有引用到WPFClient中,如果要读取它们的Assembly还是要把dll还是拷贝到WPFClient对应的程序目录下。需要加以下命令:
xcopy "$(TargetDir)*.*" "$(SolutionDir)WPFClient\bin\$(ConfigurationName)\" /Y
为何需要劳师动众的这么搞?意义何在?
这是为了模块组建化,我原本想把房间内的聊天做成另一个项目,然后可以作为插件构建进去,也就是说在原来的View上留好一个地方,我的聊天View就可以通过一定的容器放进去,在Prism中的做法便是留一个Region,然后在View上写上RegionName,其实放的RegionName的容器不一定是单容器,可以是Menu,Listbox,Canvas,StackPanel这样的多容器,自然地你要为这样的多容器写Adapter。
结果:反思
首先在没有十足的把握下少说大话,即使有一定把握也要低调
没有按照软件工程的思想去设计,整个流程应该是先设计类,再设计界面,然后修正类, 并且每一步要列出详细的时间表,允许用最滥的方法先完成,时间允许再修改。在某一点花费的时间太久往往会造成以后的设计都没有经过更好的思考,所以除非已经完成或是错误实在太大才允许修正以前的不足之处,最起码先拿一个版本出来,这样做的时候心里有底,考虑问题也更全面些。当然在类设计完毕后测试也要跟上。记得刚学编程的时候有本网络上流传的C++ 箴言,现在越来越感觉他说的是真理。C 不是C ++ ,C++ 也不是C#,可以说有机结合,也可以说完全不同——语言的特性使得设计思路完全不同。
有的地方太技术而技术了,比如Storyboard完全可以舍弃的。今天做公交的时候看到一个动画大片的广告,其中有一句话印象深刻:动画片是以故事为主的,用3D技术表现只是为了让故事更生动。对程序而言用内置控件只是为了更加简单而不是为了耍酷。另外关于性能方式的选择请看:http://msdn.microsoft.com/en-us/magazine/dd483292.aspx,即使有时候明知是不好的还是得用,但可以尽量的用更好的。
关于模式,至少目前为止MVVM从界面脱离程度来讲还不如MVP,因为Command的绑定等都需要和WPF相绑定,MVVM模式使得界面也局限于WPF一种。即使你用了类似Caliburn这样可以不用Command的框架来部分解耦,还是很难和WPF完全分离。
另外因为时间拖的太久,这个游戏不得不中止,所以很多地方的做法都表现的很仓促,这个游戏可以说是只完成了一小部分,甚至可以说只是个开头的尝试,或许以后有时间的话再完工了。当然继续的话估计也是推倒重来了,不管是类设计还是XMAL,还是页面的美感,它都已经让我到了忍无可忍的地步。
PS:不单单这件作品,近来对原来设计的东西是越来越看不顺眼,哪都想改,哪都想推倒重做。
最后感谢吾爱孟夫子对色子的提供。
还是给出源码,希望对有些人用。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· [AI/GPT/综述] AI Agent的设计模式综述