引入Command
回顾上一节的CounterViewController
脚本,交互逻辑只有让CounterModel.Count.Value
增加或减少的操作,非常的简单,在实际开发中交互逻辑的代码量是非常多的,除了有数据操作之外,有的时候还有查询其他Model的数据代码,有的时候还需要访问服务器等等。
用伪代码表示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| using System; using FrameworkDesign; using UnityEngine; using UnityEngine.UI;
namespace CounterApp { public class CounterViewController : MonoBehaviour { private void Start() { ... transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() => { Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { if(err){ }else{ CounterModel.Count.Value = int.Parse(response); if(CounterModel.Count.Value == 100){ } CounterModel.Gold.Value -= 10; } }); }); transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() => { Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { CounterModel.Count.Value = int.Parse(response); }); }); ... } } }
|
一个Controller如果有很多交互逻辑,会越来越臃肿,越来越难维护,而比较容易想到的做法是将网络请求的接口都封装成Service层,等等,但是这个只能缓解问题,不能根治问题
假如定义一个CounterService
,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class CounterService { public static void AddCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } public static void SubCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } }
|
在未来,如果增加相关功能,代码就会变成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public class CounterService { public static void AddCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } public static void SubCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } public static void MultiplyCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } public static void DivideCount(Action succeed){ Http.Post("https://aabbcc.com/api/sub_count",(response,err) => { }); } }
|
代码依然会越来越多,如果根据业务模块继续拆分Service,并不能解决根本问题,这里主要表达的思想是,通过给类增加方法去增加功能的这种拓展方式是有瓶颈的,很多框架的解决方式就是引入Command,即命令模式
命令模式简单概括就是,本来用一个方法来写的逻辑,改成用对象来实现,而这个对象只有一个执行方法。
我们在Framework文件夹内新建一个Command文件夹,然后新建脚本命名为ICommand
1 2 3 4 5 6 7
| namespace FrameWorkDesign { public interface ICommand { void Execute(); } }
|
在CounterApp文件夹的Scripts文件夹下新建AddCountCommand
脚本
1 2 3 4 5 6 7 8 9 10 11 12
| using FrameWorkDesign;
namespace CounterApp { public struct AddCountCommand : ICommand { public void Execute() { CounterModel.Count.Value++; } } }
|
同理,在CounterApp文件夹的Scripts文件夹下新建SubCountCommand
脚本
1 2 3 4 5 6 7 8 9 10 11 12
| using FrameWorkDesign;
namespace CounterApp { public struct SubCountCommand : ICommand { public void Execute() { CounterModel.Count.Value--; } } }
|
因为这里都是加加减减的数学操作,使用struct内存管理的效率要高很多,如果使用class,每一个都要开辟新的堆内存,还有寻址和GC的时间
修改CounterViewController
脚本
1 2 3 4 5 6 7 8
| transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() => { new AddCountCommand().Execute(); }); transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() => { new SubCountCommand().Execute(); });
|
目前结构图如下

引入Command,会让应用的描述更加清晰,从纸上设计到实际开发更加明确,比如实现一个简单的记事本APP,用图画就是如下

使用Command符合读写分离原则(Command Query Responsibility Seperation),简写CQRS,在服务端设计中是常见概念,这个概念在StrangeIOC、uFrame、PureMVC、Loxodon Framework都有实现,而在微服务领域比较火的DDD(领域驱动设计 Domain Driven Design)的实现一般也会实现CQRS。
在架构演化阶段,对Command和CQRS不做深入介绍,后续会有章节单独介绍。
这里只需要记住一点,Command模式就是逻辑的调用和执行是分离的,我们知道一个方法的调用和执行是不分离的,而Command模式能够做到调用和执行在空间和时间上是能分离的。
空间分离指的是调用的地方和方法执行的地方放在两个文件里。
时间分离指的是调用之后,Command过了一点时间才被执行(???)
而Command模式由于有了调用和执行分离这个特点,所以我们可以用不同的数据结构去组织Command调用,比如命令队列,再比如用一个命令堆栈,来实现撤销功能(Ctrl+Z),在网上有很多详细的介绍。
Command在目前的职责是分担Controller的交互逻辑的职责,让很多比较混乱的交互逻辑代码从Controller迁移到Command里。
Command是系统设计架构里非常重要的概念。
CounterApp编辑器拓展
目前为止,我们引入了Command、BindableProperty还有Event工具类,达到了Framework系统设计架构的一点雏形,在当前阶段,可以体现出这套架构的底层系统和表现层的弱耦合关系,如下图所示:

很简单,除了ViewController其他的部分都算作底层系统层,而ViewController则算作表现层。底层系统层是可以共享给别的表现层使用的。
我们目前的ViewController是由Unity的UGUI实现的(基于MonoBehaviour),当然我们也可以在其他支持C#的平台使用这套底层系统层。
Unity编辑器是完全基于C#开发的,所以我们在表现层再实现一个编辑器拓展版本的CounterApp。
在CounterApp文件夹下再新建一个Editor文件夹,在其中新建EditorCounterApp
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| using UnityEngine; using UnityEditor;
namespace CounterApp.Editor { public class EditorCounterApp : EditorWindow { [MenuItem("EditorCounterApp/Open")] static void Open() { var window = GetWindow<EditorCounterApp>(); window.position = new Rect(100, 100, 400, 600); window.titleContent = new GUIContent(nameof(EditorCounterApp)); window.Show(); } private void OnGUI() { if (GUILayout.Button("+")) new AddCountCommand().Execute();
GUILayout.Label(CounterModel.Count.Value.ToString());
if (GUILayout.Button("-")) new SubCountCommand().Execute(); } } }
|

这样很快就把表现层迁移给Unity编辑器了,这就是Framework系统设计架构第一个利好的地方,切换表现层非常方便,底层系统比较容易共享。
做到这点是因为我们引入了Command,Command把原来的Controller从负责操作数据的交互逻辑中剥离出来了(虽然当前是一个非常简单的逻辑,但大多数情况下一个Command代表的是一系列交互操作的集合),而这个交互逻辑大部分情况下和视图是没啥关系的,所以如果使用Controller剥离出来,是可以提高底层系统代码的复用率的。
如果交互逻辑直接在Controller里实现,而Controller一般都会持有View对象,或者和View有一定的耦合,这样的话想做到底层系统共享是很费劲的,引入Command恰好解决了这个问题,用户的一次操作被封装成一个Command后,可以提高代码的维护性以及可读性,换人接手时,只需要看Command列表就可以。
在项目前期,更多的是数值和玩法的验证,而数值和玩法一般情况下是和美术无关的,这个时候可以用此架构写一套系统,然后使用类似xml写界面的工具可以快速搭建一套数值和玩法的原型,等数值和玩法验证了差不多之后,就直接可以把这套视图扔掉,再用正式的UGUI或者其他制作工具去拼一套新的界面,然后接入这套系统即可。
这套系统还可以用于分工上,比如让老手写底层系统,新手写表现层,表现层需要给底层各种Command,还要监听(订阅)各种系统层的事件或委托,系统层通知的时候可以推送数据,也可以让表现层收到通知后自己去查询数据。
《点点点》使用Command
接下来我们在《点点点》项目中引入Command。

先将项目分成表现层(继承于MonoBehaviour)和系统层(下图中已经把KilledCount++
封装成KillEnemyCommand
了)

在这里,我们把GameStartEvent
和GameEndEvent
归于系统层,目前系统层的元素如下:
- Command
- Event(包括了BindableProperty)
- Model
在此例中使用的GameStartEvent
和GameEndEvent
在实际开发中通常属于游戏的状态变更事件,游戏状态一般分为 未开始状态——游戏中状态——游戏结束状态 。游戏的状态也属于游戏数据,存储在Model里面,一般用枚举表示。
伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| namespace FrameWorkDesign.Example { public enum GameStates { NotStart, Started, Over } public class GameModel { public static GameStates State = GameStates.NotStart; public static BindableProperty<int> KillCount = new BindableProperty<int>() { Value = 0 }; } }
|
当前Framework系统设计架构原则是,表现层只能往系统层发送Command或者做数据查询,不能有Event.Tirgger()
这样的代码,所以我们把GameStartEvent
和GameEndEvent
的触发部分用StartGameCommand
和EndGameCommand
包装起来。
在FrameworkDesign——Example——Scripts里面新建Command文件夹,在其中新建StartGameCommand
、EndGameCommand
和KillEnemyCommand
1 2 3 4 5 6 7 8 9 10
| namespace FrameWorkDesign.Example { public struct StartGameCommand : ICommand { public void Execute() { GameStartEvent.Trigger(); } } }
|
1 2 3 4 5 6 7 8 9 10
| namespace FrameWorkDesign.Example { public struct EndGameCommand : ICommand { public void Execute() { GameEndEvent.Trigger(); } } }
|
1 2 3 4 5 6 7 8 9 10
| namespace FrameWorkDesign.Example { public struct KillEnemyCommand : ICommand { public void Execute() { GameModel.KillCount.Value++; } } }
|
修改Enemy
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13
| using UnityEngine;
namespace FrameWorkDesign.Example { public class Enemy : MonoBehaviour { private void OnMouseDown() { new KillEnemyCommand().Execute(); Destroy(gameObject); } } }
|
修改GameStartPanel
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| using UnityEngine; using UnityEngine.UI;
namespace FrameWorkDesign.Example { public class GameStartPanel : MonoBehaviour { void Start() { transform.Find("Button_Start").GetComponent<Button>() .onClick.AddListener(() => { gameObject.SetActive(false); new StartGameCommand().Execute(); }); } } }
|
修改GameRoot
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| using UnityEngine;
namespace FrameWorkDesign.Example { public class GameRoot : MonoBehaviour { private void Awake() { GameStartEvent.Register(OnGameStart); GameModel.KillCount.OnValueChanged += OnCountChanged; }
private void OnCountChanged(int count) { if (count == 9) new EndGameCommand().Execute(); }
private void OnGameStart() { transform.Find("Enemies").gameObject.SetActive(true); }
void OnDestroy() { GameStartEvent.Unregister(OnGameStart); GameModel.KillCount.OnValueChanged -= OnCountChanged; } } }
|
我们继续优化,可以发现我们的游戏通关判断是通过在GameRoot
脚本里面监听KillCount.OnValueChanged
,而GameRoot
属于表现层,所以我们把通关逻辑迁移到系统层。
修改KillEnemyCommand
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| namespace FrameWorkDesign.Example { public struct KillEnemyCommand : ICommand { public void Execute() { GameModel.KillCount.Value++; if(GameModel.KillCount.Value == 9) { GameEndEvent.Trigger(); } } } }
|
修改GameRoot
脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| using UnityEngine;
namespace FrameWorkDesign.Example { public class GameRoot : MonoBehaviour { private void Awake() { GameStartEvent.Register(OnGameStart); }
private void OnGameStart() { transform.Find("Enemies").gameObject.SetActive(true); }
void OnDestroy() { GameStartEvent.Unregister(OnGameStart); } } }
|
这样的话EndGameCommand
就没有什么用了,直接删掉,现在我们的表现层和系统层的关系如下图所示

底层系统层里的东西越来越多,规矩也越来越多:
- 表现层订阅(监听)事件,系统层发送事件
- 表现层只能用Command改变系统层,系统层包括数据(KillCount、Gold、Point……)和状态(GameState、GameEndEvent……)等。
- 表现层可以查询数据
这三个架构使用原则是我们的Framework系统设计架构的关键