使用框架的目标:低耦合,高内聚,表现和数据分离
耦合:对象,类的双向引用,循环引用
内聚:相同类型的代码放在一起
表现和数据分离:需要共享的数据放在Model里
对象之间的交互一般有三种
- 方法调用,A持有B才能调用B的方法
- 委托或回调,A持有B才能注册B的委托,尽量避免嵌套调用
- 消息或事件,A不需要持有B
A调用B的方法,A就必须持有B,形成单向引用关系,为了避免耦合,B不应该引用A,如果B想调用A的方法,使用委托或回调。
总结:父节点调用子节点可以直接方法调用,子节点通知父节点用委托或事件,跨模块通信用事件
模块化一般有三种
- 单例,例如: Manager Of Managers
- IOC,例如: Extenject,uFrame的 Container,StrangelOC的绑定等等
- 分层,例如: MVC、三层架构、领域驱动分层等等
交互逻辑和表现逻辑
以计数器为例,用户操作界面修改数据叫交互逻辑,当数据变更之后或者初始化时,从Model里查询数据在View上显示叫表现逻辑
交互逻辑:View -> Model
表现逻辑:Model -> View
很多时候,我们不会真的去用 MVC 开发架构,而是使用表现(View)和数据(Model)分离这样的思想,我们只要知道 View 和 Model 之间有两种逻辑,即交互逻辑 和 表现逻辑,我们就不用管中间到底是 Controller、还是 ViewModel、还是 Presenter。只需要想清楚交互逻辑 和 交互逻辑如何实现的就可以了。
View和Model怎样交互比较好,或者说交互逻辑和表现逻辑怎样实现比较好?
<1> 直接方法调用,表现逻辑是在交互逻辑完成之后主动调用,伪代码如下
public class CounterViewController : MonoBehaviour
{
void Start()
{
transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑
CounterModel.Count++;
// 表现逻辑
UpdateView();
});
transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑
CounterModel.Count--;
// 表现逻辑
UpdateView();
});
// 表现逻辑
UpdateView();
}
void UpdateView()
{
transform.Find("CountText").GetComponent<Text>().text = CounterModel.Count.ToString();
}
}
public static class CounterModel
{
public static int Count = 0;
}
<2> 使用委托
public class CounterViewController : MonoBehaviour
{
void Start()
{
// 注册
CounterModel.OnCountChanged += OnCountChanged;
transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count++;
});
transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count--;
});
OnCountChanged(CounterModel.Count);
}
// 表现逻辑
private void OnCountChanged(int newCount)
{
transform.Find("CountText").GetComponent<Text>().text = newCount.ToString();
}
private void OnDestroy()
{
// 注销
CounterModel.OnCountChanged -= OnCountChanged;
}
}
public static class CounterModel
{
private static int mCount = 0;
public static event Action<int> OnCountChanged ;
public static int Count
{
get => mCount;
set
{
if (value != mCount)
{
mCount = value;
OnCountChanged?.Invoke(value);
}
}
}
}
<3> 使用事件,事件管理器写法差不多,这里忽略具体实现
public class CounterViewController : MonoBehaviour
{
void Start()
{
// 注册
EventManager.Instance.RegisterEvent(EventId, OnCountChanged);
transform.Find("BtnAdd").GetComponent<Button>()
.onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count++;
});
transform.Find("BtnSub").GetComponent<Button>()
.onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count--;
});
OnCountChanged();
}
// 表现逻辑
private void OnCountChanged()
{
transform.Find("CountText").GetComponent<Text>().text = CounterModel.Count.ToString();
}
private void OnDestroy()
{
// 注销
EventManager.Instance.UnRegisterEvent(EventId, OnCountChanged);
}
}
public static class CounterModel
{
private static int mCount = 0;
public static int Count
{
get => mCount;
set
{
if (value != mCount)
{
mCount = value;
// 触发事件
EventManager.Instance.FireEvent(EventId);
}
}
}
}
比较上面3种实现方式,当数据量很多的时候,使用第1种方法调用会写很多重复代码调用,代码臃肿,容易造成疏忽,使用委托或事件代码更精简,当数据变化时会自动触发表现逻辑,这就是所谓的数据驱动。
所以表现逻辑使用委托或事件更合适,如果是单个数值变化,用委托的方式更合适,比如金币、分数、等级、经验值等等,如果是颗粒度较大的更新用事件比较合适,比如从服务器拉取了一个任务列表数据,然后任务列表数据存到了Model
BindableProperty
上面的Model类,每新增一个数据就要写一遍类似的代码,很繁琐,我们使用泛型来简化代码
public class BindableProperty<T> where T : IEquatable<T>
{
private T mValue;
public T Value
{
get => mValue;
set
{
if (!mValue.Equals(value))
{
mValue = value;
OnValueChanged?.Invoke(value);
}
}
}
public Action<T> OnValueChanged;
}
BindableProperty 也就是可绑定的属性,是 数据 + 数据变更事件 的合体,它既存储了数据充当 C# 中的 属性这样的角色,也可以让别的地方监听它的数据变更事件,这样会减少大量的样板代码
public class CounterViewController : MonoBehaviour
{
void Start()
{
// 注册
CounterModel.Count.OnValueChanged += OnCountChanged;
transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count.Value++;
});
transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑:这个会自动触发表现逻辑
CounterModel.Count.Value--;
});
OnCountChanged(CounterModel.Count.Value);
}
// 表现逻辑
private void OnCountChanged(int newValue)
{
transform.Find("CountText").GetComponent<Text>().text = newValue.ToString();
}
private void OnDestroy()
{
// 注销
CounterModel.Count.OnValueChanged -= OnCountChanged;
}
}
public static class CounterModel
{
public static BindableProperty<int> Count = new BindableProperty<int>()
{
Value = 0
};
}
总结:
- 自顶向下的逻辑使用方法调用
- 自底向上的逻辑使用委托或事件,Model和View是底层和上层的关系,所以用委托或事件更合适
Command
实际的开发中交互逻辑的代码是很多的,随着功能需求越来越多,Controller的代码会越来越臃肿,解决办法是引入命令模式(Command),命令模式参考另一篇博客:Unity常用设计模式
先定义一个接口
public interface ICommand
{
void Execute();
}
添加一个命令,实现数据加一操作,注意这里是用 struct 实现的,而不是用的 class,这是因为游戏里边的交互逻辑有很多,如果每一个都用去 new 一个 class 的话,会造成很多性能消耗,比如 new 一个对象所需要的寻址操作、比如对象回收需要的 gc 等等,而 struct 内存管理效率要高很多
public struct AddCountCommand : ICommand
{
public void Execute()
{
CounterModel.Count.Value++;
}
}
实现数据减一操作
public struct SubCountCommand : ICommand
{
public void Execute()
{
CounterModel.Count.Value--;
}
}
更新交互逻辑的代码
public class CounterViewController : MonoBehaviour
{
void Start()
{
// 注册
CounterModel.Count.OnValueChanged += OnCountChanged;
transform.Find("BtnAdd").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑
new AddCountCommand().Execute();
});
transform.Find("BtnSub").GetComponent<Button>().onClick.AddListener(() =>
{
// 交互逻辑
new SubCountCommand().Execute();
});
OnCountChanged(CounterModel.Count.Value);
}
// 表现逻辑
private void OnCountChanged(int newValue)
{
transform.Find("CountText").GetComponent<Text>().text = newValue.ToString();
}
private void OnDestroy()
{
// 注销
CounterModel.Count.OnValueChanged -= OnCountChanged;
}
}
public static class CounterModel
{
public static BindableProperty<int> Count = new BindableProperty<int>()
{
Value = 0
};
}
使用 Command 符合读写分离原则(Comand Query Responsibility Segregation),简写为 CQRS ,这个概念在 StrangeIOC、uFrame、PureMVC、Loxodon Framework 都有实现,而在微服务领域比较火的 DDD(领域驱动设计)的实现一般也会实现 CQRS。它是一种软件架构模式,旨在将应用程序的读取和写入操作分离为不同的模型。在CQRS中,写操作通常由命令模型(Command Model)来处理,它负责处理业务逻辑和状态更改。而读操作则由查询模型(Query Model)来处理,它专门用于支持数据查询和读取展示。
Command 模式就是逻辑的调用和执行是分离的,我们知道一个方法的调用和执行是不分离的,因为一旦你调用方法了,方法也就执行了,而 Command 模式能够做到调用和执行在空间和时间上是能分离的。
空间分离的方法就是调用的地方和执行的地方放在两个文件里。
时间分离的方法就是调用的之后,Command 过了一点时间才被执行。
Command 分担 Controller 的交互逻辑,由于有了调用和执行分离这个特点,所以我们可以用不同的数据结构去组织 Command 调用,比如列表,队列,栈
底层系统层是可以共享给别的展现层使用的,切换表现层非常方便,表现层到系统层用 Command 改变底层系统的状态(数据),系统层通过事件或者委托通知表现层,在通知的时候可以推送数据,也可以让表现层收到通知后自己去查询数据。
模块化
使用单例
单例比静态类好一点就是其生命周期相对可控,而且访问单例对象比访问静态类多了一点限制,也就是需要通过 Instance 获取
每个模块继承 Singleton
public class Singleton<T> where T : class
{
public static T Instance
{
get
{
if (mInstance == null)
{
// 通过反射获取构造
var ctors = typeof(T).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic);
// 获取无参非 public 的构造
var ctor = Array.Find(ctors, c => c.GetParameters().Length == 0);
if (ctor == null)
{
throw new Exception("Non-Public Constructor() not found in " + typeof(T));
}
mInstance = ctor.Invoke(null) as T;
}
return mInstance;
}
}
private static T mInstance;
}
问题:单例没有访问限制,容易造成模块之间互相引用,关系混乱
IOC容器
IOC 容器可以理解为是一个字典,这个字典以 Type 为 key,以对象即 Instance 为 value,IOC 容器最少有两个核心的 API,即根据 Type 注册实例,根据 Type 获取实例
public class IOCContainer
{
/// <summary>
/// 实例
/// </summary>
public Dictionary<Type, object> mInstances = new Dictionary<Type, object>();
/// <summary>
/// 注册
/// </summary>
/// <param name="instance"></param>
/// <typeparam name="T"></typeparam>
public void Register<T>(T instance)
{
var key = typeof(T);
if (mInstances.ContainsKey(key))
{
mInstances[key] = instance;
}
else
{
mInstances.Add(key,instance);
}
}
/// <summary>
/// 获取
/// </summary>
public T Get<T>() where T : class
{
var key = typeof(T);
object retObj;
if(mInstances.TryGetValue(key,out retObj))
{
return retObj as T;
}
return null;
}
}
下面是一个简单的示例,IOC 容器创建,注册实际应当写在游戏初始化时,这里为了方便演示都写在一起了
public class IOCExample : MonoBehaviour
{
void Start()
{
// 创建一个 IOC 容器
var container = new IOCContainer();
// 注册一个蓝牙管理器的实例
container.Register(new BluetoothManager());
// 根据类型获取蓝牙管理器的实例
var bluetoothManager = container.Get<BluetoothManager>();
//连接蓝牙
bluetoothManager.Connect();
}
public class BluetoothManager
{
public void Connect()
{
Debug.Log("蓝牙连接成功");
}
}
}
为了避免样板代码,这里创建一个抽象类
/// <summary>
/// 架构
/// </summary>
public abstract class Architecture<T> where T : Architecture<T>, new()
{
#region 类似单例模式 但是仅在内部课访问
private static T mArchitecture = null;
// 确保 Container 是有实例的
static void MakeSureArchitecture()
{
if (mArchitecture == null)
{
mArchitecture = new T();
mArchitecture.Init();
}
}
#endregion
private IOCContainer mContainer = new IOCContainer();
// 留给子类注册模块
protected abstract void Init();
// 提供一个注册模块的 API
public void Register<T>(T instance)
{
MakeSureArchitecture();
mArchitecture.mContainer.Register<T>(instance);
}
// 提供一个获取模块的 API
public static T Get<T>() where T : class
{
MakeSureArchitecture();
return mArchitecture.mContainer.Get<T>();
}
}
子类注册多个模块
public class PointGame : Architecture<PointGame>
{
// 这里注册模块
protected override void Init()
{
Register(new GameModel1());
Register(new GameModel2());
Register(new GameModel3());
Register(new GameModel4());
}
}
使用 IOC 容器的目的是增加模块访问的限制
除了可以用来注册和获取模块,IOC 容器一般还会有一个隐藏的功能,即:注册接口模块
public class IOCExample : MonoBehaviour
{
void Start()
{
// 创建一个 IOC 容器
var container = new IOCContainer();
// 根据接口注册实例
container.Register<IBluetoothManager>(new BluetoothManager());
// 根据接口获取蓝牙管理器的实例
var bluetoothManager = container.Get<IBluetoothManager>();
//连接蓝牙
bluetoothManager.Connect();
}
/// <summary>
/// 定义接口
/// </summary>
public interface IBluetoothManager
{
void Connect();
}
/// <summary>
/// 实现接口
/// </summary>
public class BluetoothManager : IBluetoothManager
{
public void Connect()
{
Debug.Log("蓝牙连接成功");
}
}
}
抽象-实现 这种形式注册和获取对象的方式是符合依赖倒置原则的。
依赖倒置原则(Dependence Inversion Principle):程序要依赖于抽象接口,不要依赖于具体实现。依赖倒置原则是 SOLID 中的字母 D。
这种设计的好处:
- 接口设计与实现分成两个步骤,接口设计时可以专注于设计,实现时可以专注于实现。
- 实现是可以替换的,比如一个接口叫 IStorage,其实现可以是 PlayerPrefsStorage、EdtiroPrefsStorage,等切换时候只需要一行代码就可以切换了。
- 比较容易测试(单元测试等)
- 降低耦合。
接口的显式实现
public interface ICanSayHello
{
void SayHello();
void SayOther();
}
public class InterfaceDesignExample : MonoBehaviour, ICanSayHello
{
/// <summary>
/// 接口的隐式实现
/// </summary>
public void SayHello()
{
Debug.Log("Hello");
}
/// <summary>
/// 接口的显式实现,不能写访问权限关键字
/// </summary>
void ICanSayHello.SayOther()
{
Debug.Log("Other");
}
void Start()
{
// 隐式实现的方法可以直接通过对象调用
this.SayHello();
// 显式实现的接口不能通过对象调用
// this.SayOther() // 会报编译错误
// 显式实现的接口必须通过接口对象调用
(this as ICanSayHello).SayOther();
}
}
当需要实现多个签名一致的方法时,可以通过接口的显式声明来区分到底哪个方法是属于哪个接口的
利用接口的显示实现,子类想要调用必须先转成接口,这样就增加了调用显式实现的方法的成本,所以可以理解为这个方法被阉割了
分层
前面使用 Command 分担了 Controller 的交互逻辑的部分逻辑,并不是所有的交互逻辑都适合用 Command 来分担的,还有一部分交互逻辑是需要交给 System 层来分担。这里 System 层在概念等价于游戏的各个管理类 Manager。
Command 是没有状态的,有没有状态我们可以理解为这个对象需不需要维护数据,因为 Command 类似于是一个方法,只要调用然后执行一次就可以不用了,所以 Command 是没有状态的
梳理一下当前的架构
- 表现层:即 ViewController 或者 MonoBehaviour 脚本等,负责接受用户的输入,当状态变化时更新表现
- System 层:系统层,有状态,在多个表现层共享的逻辑,负责即提供 API 又有状态的对象,比如网络服务、蓝牙服务、商城系统等,也支持分数统计、成就系统这种硬编码比较多又需要把代码放在一个位置的需求。
- Model 层:管理数据,有状态,提供数据的增删改查。
- Utility 层:工具层,无状态,提供一些必备的基础工具,比如数据存储、网络链接、蓝牙、序列化反序列化等。
表现层改变 System、Model 层级的状态用 Command,System 层 和 Model 层 通知 表现层用事件,委托或 BindableProeprty,表现层查询状态时可以直接获取 System 和 Model 层
每个层级都有一些规则:
表现层
- 可以获取 System
- 可以获取 Model
- 可以发送 Command
- 可以监听 Event
系统层
- 可以获取其他 System
- 可以获取 Model
- 可以监听,发送 Event
- 可以获取 Utility
数据层
- 可以获取 Utility
- 可以发送 Event
工具层
- 啥都干不了,可以集成第三方库,或者封装 API
除了四个层级,还有一个核心概念就是 Command
Command
- 可以获取 System
- 可以获取 Model
- 可以获取 Utility
- 可以发送 Event
- 可以发送其他 Command
贫血模型和充血模型
我们有一个 User 对象,伪代码如下
public class User
{
public string Name {get;set;}
public int Age {get;set;}
public string Id {get;set;}
public string NickName {get;set;
public float Weight {get;set;}
}
总共有五个属性,但是在表现层的界面中,只需要显示三个属性,即:姓名 Name、年龄 Age、和 Id。
表现层查询一个用户数据的时候,返回了一个 完整的 User 对象,这种数据流向模型叫做贫血模型。就是表现层需要用到的数据结果给了一整个未经过筛选的数据对象过来。
定义了一个 UserInfo 类,伪代码如下;
public class UserInfo
{
public string Name {get;set;}
public int Age {get;set;}
public string Id {get;set;}
}
充血模型就是表现层需要哪些数据,就刚好返回哪些数据
充血模型 比 贫血模型 需要做跟多的工作,写更多的代码,甚至还有跟多的性能消耗。
但是在越大规模的项目中 充血模型 的好处就会更加明显。因为充血模型,可以让我们的代码更精确地描述业务,会提高代码的可读性,而贫血模型,会让我们的数据逐渐趋于混乱。
参考
凉鞋 《框架搭建 决定版》