交互逻辑和表现逻辑

上一节已经引入了Model模块,这里多介绍一下MVC架构模式。

代码应该写在View、Controller还是Model里?区分交互逻辑和表现逻辑,就能更好判断

假设有一个计数器应用

计数器应用

它的交互逻辑和表现逻辑如下所示

交互逻辑和表现逻辑

用伪代码表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Controller
{
void Start
{
//表现逻辑
View.CountText.text = Model.Count.ToString();
View.BtnAdd.onClick.AddListener(()=>{
//交互逻辑
Model.Count++;
//表现逻辑
View.CountText.text = Model.Count.ToString();
});
View.BtnSub.onClick.AddListener(()=>{
//交互逻辑
Model.Count--;
//表现逻辑
View.CountText.text = Model.Count.ToString();
});
}
}

简单总结就是:

  • 交互逻辑:View->Model
  • 表现逻辑:Model->View

用户的输入和操作等等,叫做交互逻辑,一个App的展现数据的逻辑叫做表现逻辑。

在很多时候,并不会真的去用MVC架构,而是使用表现(View)和数据(Model)分离这样的思想,只要知道View和Model之间的表现逻辑和交互逻辑,就不用管中间到底是Controller、还是ViewModel、还是Presenter。

仅仅把逻辑分出来还不够,在中间层里写的表现逻辑和交互逻辑越来越多,那么这个层就会越来越臃肿,解决这个问题比较好的方法,就是引入Command这个概念,这个和设计模式中的“命令模式”是相同的含义,在深入了解之前,关于交互逻辑和表现逻辑还有一些事情需要学习

首先我们在Unity里面实现计数器,因为是多余的小项目,所以在Project中新建一个CounterApp文件夹

整体结构如下

计数器

CounterViewController脚本挂载在Panel上

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
using UnityEngine;
using UnityEngine.UI;

namespace CounterApp
{
public class CounterViewController : MonoBehaviour
{
private void Start()
{
UpdateView();
transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.count++;
UpdateView();
});
transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.count--;
UpdateView();
});
}

void UpdateView()
{
transform.Find("Text_Count").GetComponent<Text>().text = CounterModel.count.ToString();
}
}

public class CounterModel
{
public static int count = 0;
}
}

为什么起名叫“ViewController”呢,在Unity中,View和Controller是比较难区分的,在别的开发领域里,View类引用了各种UI,但是Unity的UI是基于Component的,只要是继承了MonoBehaviour,都可以轻易获取到,单独创建一个View类会比较浪费(比如挂载两个脚本,一个View,一个Controller)。一个基于MonoBehaviour的脚本由于是挂载在GameObject上,所以既有View职责,又有Controller职责

引入BindableProperty

上面的计时器应用,调用表现逻辑是方法直接调用CounterModel.count,这里我们使用对象之间的第二种交互方式来实现它,即使用委托

修改CounterViewController脚本

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
41
42
43
44
45
46
47
48
49
50
51
using UnityEngine;
using UnityEngine.UI;
using System;

namespace CounterApp
{
public class CounterViewController : MonoBehaviour
{
private void Start()
{
CounterModel.OnCountChanged += UpdateView;
UpdateView(CounterModel.Count);//第一次运行时主动调用一次
transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count++;
});
transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count--;
});
}

void UpdateView(int value)
{
transform.Find("Text_Count").GetComponent<Text>().text =value.ToString();
}
private void OnDestroy()
{
CounterModel.OnCountChanged -= UpdateView;
}
}

public class CounterModel
{
private static int m_Count = 0;
public static Action<int> OnCountChanged;

public static int Count
{
get => m_Count;
set
{
if(value != m_Count)
{
m_Count = value;
OnCountChanged?.Invoke(value);
}
}
}
}
}

这样逻辑就改成了:

View——> 增删改数据 ——>Model:交互逻辑

View<—— View注册Model的OnCountChanged委托 <——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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using UnityEngine;
using UnityEngine.UI;
using System;
using FrameWorkDesign;

namespace CounterApp
{
public class CounterViewController : MonoBehaviour
{
private void Start()
{
OnCountChangedEvent.Register(UpdateView);
//CounterModel.OnCountChanged += UpdateView;
//UpdateView(CounterModel.Count);//第一次运行时主动调用一次
UpdateView();
transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count++;
});
transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count--;
});
}

void UpdateView()
{
transform.Find("Text_Count").GetComponent<Text>().text =CounterModel.Count.ToString();
}
private void OnDestroy()
{
//CounterModel.OnCountChanged -= UpdateView;
OnCountChangedEvent.Unregister(UpdateView);
}
}

public class CounterModel
{
private static int m_Count = 0;
//public static Action<int> OnCountChanged;

public static int Count
{
get => m_Count;
set
{
if(value != m_Count)
{
m_Count = value;
//OnCountChanged?.Invoke(value);
OnCountChangedEvent.Trigger();
}
}
}
}

public class OnCountChangedEvent : BaseEvent<OnCountChangedEvent> { }
}

编写事件时要注意,不可以在同一个类内调用Event.RegisterEvent.Trigger,这很明显是逻辑混乱

这里使用的事件时不能传参的,后面会优化

这样逻辑就改成了:

View——> 增删改数据 ——>Model:交互逻辑

View<—— 发送变更事件 <——Model:表现逻辑

CounterViewController脚本中方法调用、委托&回调、事件&消息都实现一遍了,我们可以发现 委托&回调、事件&消息 的方式可以让Controller的代码量更少,具体来讲:

在使用方法调用时,每次按钮点击之后,都需要主动调用一下表现逻辑,而CounterApp的数据不多,只有一个Count,当有更多数据的时候,这种方式会造成很多的代码调用,而且还容易造成疏忽,等等,其实严重的问题有两个

  • 表现逻辑需要交互逻辑之后主动调用,会增加开发者的负担
  • 容易让Controller变得臃肿,增加维护成本

在使用委托&回调或事件的时候,每次应用交互逻辑会自动触发表现逻辑,我们不需要再调用表现逻辑的代码,这就是所谓的数据驱动。所以通过以上比较就会发现,表现逻辑的实现使用 委托&回调 或者 事件是比直接方法调用更合适的。

以经验来看,如果是单个数值变化,用委托的方式更合适,比如金币、分数、等级、经验值等等,如果是颗粒度较大的更新用事件比较合适,比如从服务器拉取了一个任务列表数据,然后任务列表数据存到了Model,此时Model的任务列表数据发生了变更,这个时候就向View触发这个事件比较合适。

我们继续演化,首先是CounterModel这个类在实际应用中应该会有很多类似的数据,每一个都写一次里面包含的委托和属性很麻烦,用泛型解决,这也就是BindableProperty

将Framework文件夹拉取出来放在FrameworkDesign文件夹下,然后在Framework文件夹内新建BindableProperty文件夹,新建BindableProperty脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;

namespace FrameWorkDesign
{
public class BindableProperty<T> where T : IEquatable<T>
{
private T m_Value = default;
public Action<T> OnValueChanged;

public T Value
{
get => m_Value;
set
{
if (!value.Equals(m_Value))//没有重载运算符,不能使用!=
{
m_Value = value;
OnValueChanged?.Invoke(value);
}
}
}
}
}

然后再次修改CounterViewController脚本

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
41
using UnityEngine;
using UnityEngine.UI;
using System;
using FrameWorkDesign;

namespace CounterApp
{
public class CounterViewController : MonoBehaviour
{
private void Start()
{
CounterModel.Count.OnValueChanged += UpdateView;
UpdateView(CounterModel.Count.Value);//第一次运行时主动调用一次
transform.Find("Button_add").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count.Value++;
});
transform.Find("Button_sub").GetComponent<Button>().onClick.AddListener(() =>
{
CounterModel.Count.Value--;
});
}

void UpdateView(int value)
{
transform.Find("Text_Count").GetComponent<Text>().text =value.ToString();
}
private void OnDestroy()
{
CounterModel.Count.OnValueChanged -= UpdateView;
}
}

public class CounterModel
{
public static BindableProperty<int> Count = new BindableProperty<int>()
{
Value = 0
};
}
}

这个BindableProperty属于很简化的版本,可以拓展出很多功能,其思想借鉴自UniRx的ReactiveProperty。一个BindableProperty既能完成数据读写的基础功能,又能在数据变更时及时发布委托,减少了大量的样板代码,样板代码就是经常需要重复编写的代码,很多框架使用代码生成或者泛型来减少此类工作。

之前说过,子节点调用父节点可以使用委托或事件(子节点声明Delegate并Trigger,父节点Register和Unregister),这里的Model和View也用了委托和事件(Model声明Delegate和Trigger,View来Register和Unregister),一般来讲数据Model是项目的底层,View是项目的上层,和子节点和父节点一样是一个自下而上的逻辑关系,只要是下层调用(也可以说通知,触发)上层就可以用委托和事件,上层调用下层直接方法调用。

总结

  • 表现逻辑适合用事件或委托
  • 表现逻辑用方法调用会造成很多问题,Controller臃肿难维护
  • Model和View是自底向上的关系
  • 自底向上用事件或委托
  • 自顶向下用方法调用
  • Event工具类不能传参
  • BindableProperty是数据+数据变更事件,可以节省代码量

在《点点点》项目中引入BindableProperty

先看一下《点点点》项目目前的结构

更新引用关系

先将项目中的GameModel都变成BindableProperty

1
2
3
4
5
6
7
8
9
10
namespace FrameWorkDesign.Example
{
public class GameModel
{
public static BindableProperty<int> KillCount = new BindableProperty<int>() { Value = 0 };
public static BindableProperty<int> Gold = new BindableProperty<int>() { Value = 0 };
public static BindableProperty<int> Score = new BindableProperty<int>() { Value = 0 };
public static BindableProperty<int> BestScore = new BindableProperty<int>() { Value = 0 };
}
}

然后修改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()
{
GameModel.KillCount.Value++;//修改的部分
Destroy(gameObject);
}
}
}

修改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)
GameEndEvent.Trigger();
}

private void OnGameStart()
{
transform.Find("Enemies").gameObject.SetActive(true);
}

void OnDestroy()
{
GameStartEvent.Unregister(OnGameStart);
GameModel.KillCount.OnValueChanged -= OnCountChanged;//修改的部分
}
}
}

这样我们就不用之前创建的KilledOneEnemyEvent,代码更加简洁方便

修改后

数据变更事件是事件的一种,除了数据变更事件以外,我们还接触到了跨模块事件,这个跨模块事件会和KilledOneEnemyEvent一样,在未来会被其他的概念或工具替代。