引入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);

//可能有成就判断
//(这种代码其实不合理,一般是由成就系统去监听Count的变更事件)
if(CounterModel.Count.Value == 100){
//达成100次点击成就提示,等等
}
//10金币点一次(直接减去10,或者再请求一次服务器
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的交互逻辑

引入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系统设计架构的一点雏形,在当前阶段,可以体现出这套架构的底层系统和表现层的弱耦合关系,如下图所示:

CounterApp双层关系

很简单,除了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();
}
}
}

EditorCounterApp

这样很快就把表现层迁移给Unity编辑器了,这就是Framework系统设计架构第一个利好的地方,切换表现层非常方便,底层系统比较容易共享。

做到这点是因为我们引入了Command,Command把原来的Controller从负责操作数据的交互逻辑中剥离出来了(虽然当前是一个非常简单的逻辑,但大多数情况下一个Command代表的是一系列交互操作的集合),而这个交互逻辑大部分情况下和视图是没啥关系的,所以如果使用Controller剥离出来,是可以提高底层系统代码的复用率的。

如果交互逻辑直接在Controller里实现,而Controller一般都会持有View对象,或者和View有一定的耦合,这样的话想做到底层系统共享是很费劲的,引入Command恰好解决了这个问题,用户的一次操作被封装成一个Command后,可以提高代码的维护性以及可读性,换人接手时,只需要看Command列表就可以。

在项目前期,更多的是数值和玩法的验证,而数值和玩法一般情况下是和美术无关的,这个时候可以用此架构写一套系统,然后使用类似xml写界面的工具可以快速搭建一套数值和玩法的原型,等数值和玩法验证了差不多之后,就直接可以把这套视图扔掉,再用正式的UGUI或者其他制作工具去拼一套新的界面,然后接入这套系统即可。

这套系统还可以用于分工上,比如让老手写底层系统,新手写表现层,表现层需要给底层各种Command,还要监听(订阅)各种系统层的事件或委托,系统层通知的时候可以推送数据,也可以让表现层收到通知后自己去查询数据。

《点点点》使用Command

接下来我们在《点点点》项目中引入Command。

点点点项目引用关系图

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

点点点表现层和系统层

在这里,我们把GameStartEventGameEndEvent归于系统层,目前系统层的元素如下:

  • Command
  • Event(包括了BindableProperty)
  • Model

在此例中使用的GameStartEventGameEndEvent在实际开发中通常属于游戏的状态变更事件,游戏状态一般分为 未开始状态——游戏中状态——游戏结束状态 。游戏的状态也属于游戏数据,存储在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()这样的代码,所以我们把GameStartEventGameEndEvent的触发部分用StartGameCommandEndGameCommand包装起来。

在FrameworkDesign——Example——Scripts里面新建Command文件夹,在其中新建StartGameCommandEndGameCommandKillEnemyCommand

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系统设计架构的关键