游戏框架搭建

使用框架的目标:低耦合,高内聚,表现和数据分离
耦合:对象,类的双向引用,循环引用
内聚:相同类型的代码放在一起
表现和数据分离:需要共享的数据放在Model里

对象之间的交互一般有三种

  1. 方法调用,A持有B才能调用B的方法
  2. 委托或回调,A持有B才能注册B的委托,尽量避免嵌套调用
  3. 消息或事件,A不需要持有B

A调用B的方法,A就必须持有B,形成单向引用关系,为了避免耦合,B不应该引用A,如果B想调用A的方法,使用委托或回调。
总结:父节点调用子节点可以直接方法调用,子节点通知父节点用委托或事件,跨模块通信用事件

模块化一般有三种

  1. 单例,例如: Manager Of Managers
  2. IOC,例如: Extenject,uFrame的 Container,StrangelOC的绑定等等
  3. 分层,例如: 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();}}#endregionprivate IOCContainer mContainer = new IOCContainer();// 留给子类注册模块protected abstract void Init();// 提供一个注册模块的 APIpublic void Register<T>(T instance){MakeSureArchitecture();mArchitecture.mContainer.Register<T>(instance);}// 提供一个获取模块的 APIpublic 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;}
}

充血模型就是表现层需要哪些数据,就刚好返回哪些数据

充血模型 比 贫血模型 需要做跟多的工作,写更多的代码,甚至还有跟多的性能消耗。
但是在越大规模的项目中 充血模型 的好处就会更加明显。因为充血模型,可以让我们的代码更精确地描述业务,会提高代码的可读性,而贫血模型,会让我们的数据逐渐趋于混乱。

参考

凉鞋 《框架搭建 决定版》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/508207.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

XUbuntu22.04之显示实时网速(二百一十八)

简介&#xff1a; CSDN博客专家&#xff0c;专注Android/Linux系统&#xff0c;分享多mic语音方案、音视频、编解码等技术&#xff0c;与大家一起成长&#xff01; 优质专栏&#xff1a;Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 优质专栏&#xff1a;多媒…

Linux进程间通信3——共享内存

目录 1.原理 2.共享内存函数介绍 2.1 shmget 2.2 shmat 2.3 shmdt 2.4 shmctl 3.共享内存使用 例1 例2 1.原理 共享内存为多个进程之间共享和传递数据提供了一种有效的方式。共享内存是先在物理内存 上申请一块空间&#xff0c;多个进程可以将其映射到自己的虚拟地址空…

[C++]使用纯opencv去部署yolov9的onnx模型

【介绍】 部署 YOLOv9 ONNX 模型在 OpenCV 的 C 环境中涉及一系列步骤。以下是一个简化的部署方案概述&#xff0c;以及相关的文案。 部署方案概述&#xff1a; 模型准备&#xff1a;首先&#xff0c;你需要确保你有 YOLOv9 的 ONNX 模型文件。这个文件包含了模型的结构和权…

【排序】基于快速选择算法的快排实现

目录 一、常用排序算法比较 二、快速选择算法 快速选择 图解快速选择 三、代码实现 一、常用排序算法比较 排序 时间复杂度 空间复杂度 稳定性 插入排序 O(n) O(1) 稳定 希尔排序 O(nlogn)-O(n)取决于增量序列 O(1) 不稳定 选择排序 O(n) O(1) 不稳定 冒泡…

STM32(9)EXTI

EXTI工作原理 EXTI的寄存器组 每个寄存器都是20个比特位&#xff0c;对应EXTI的20路通道&#xff0c;如这6个寄存器的最左边就都是对应通道1的

Spring:EnclosingClass工具类分辨

Spring&#xff1a;EnclosingClass工具类分辨 1 前言 通过Spring的工具分辨EnclosingClass类。 测试类如下&#xff1a; package com.xiaoxu.test.enclosingClass;/*** author xiaoxu* date 2024-01-18* java_demo2:com.xiaoxu.test.enclosingClass.Outter*/ public class …

vue2 element 实现表格点击详情,返回时保留查询参数

先直观一点&#xff0c;上图 列表共5条数据&#xff0c;准备输入Author过滤条件进行查询 进入查看详情页&#xff0c;就随便搞了个按钮 啥都没调啦 点击返回后 一开始准备用vuex做这个功能&#xff0c;后来放弃了&#xff0c;想到直接用路由去做可能也不错。有时间再整一套…

Onenote软件新建笔记本时报错:无法在以下位置新建笔记本

报错现象&#xff1a; 当在OneNote软件上&#xff0c;新建笔记本时&#xff1a; 然后&#xff0c;尝试重新登录微软账户&#xff0c;也不行&#xff0c;提示报错&#xff1a; 解决办法&#xff1a; 打开一个新的记事本&#xff0c;复制粘贴以下内容&#xff1a; C:\Users\Adm…

pytest多重断言插件-pytest-assume

最近准备废弃之前用metersphere做的接口自动化&#xff0c;转战pytest了&#xff0c;先来分享下最近接触到的一个插件&#xff1a;pytest-assume。 在使用这个插件之前&#xff0c;如果一个用例里面有多个断言的话&#xff0c;前面的断言失败了&#xff0c;就不会去执行后面的断…

MATLAB环境下基于区域椭圆拟合的细胞分割方法

使用图像分割技术可以找到图像中的目标区域&#xff0c;目标区域可以定义为具有特定值的单个区域&#xff0c;也可以定义为具有相同值的多个区域。目前图像分割已经融入到生活中的方方面面&#xff0c;在遥感领域&#xff0c;它应用于航拍图中的地形、地貌的分割&#xff1b;在…

如何防止 Elasticsearch 服务 OOM ?

ES 和传统关系型数据库有很多区别&#xff0c; 比如传统数据中普遍都有一个叫“最大连接数”的设置。目的是使数据库系统工作在可控的负载下&#xff0c;避免出现负载过高&#xff0c;资源耗尽&#xff0c;谁也无法登录的局面。 那 ES 在这方面有类似参数吗&#xff1f;答案是…

中科大计网学习记录笔记(十七):拥塞控制原理 | TCP 拥塞控制

前言&#xff1a; 学习视频&#xff1a;中科大郑烇、杨坚全套《计算机网络&#xff08;自顶向下方法 第7版&#xff0c;James F.Kurose&#xff0c;Keith W.Ross&#xff09;》课程 该视频是B站非常著名的计网学习视频&#xff0c;但相信很多朋友和我一样在听完前面的部分发现信…