GameFramework框架学习:原理篇
参考写在前面!!!
本文是在腾讯大佬花桑和大佬猫刀刀的GF解析文章的基础上,阅读源码并尝试总结、应用、拓展的个人笔记!无其他用途,水印以示尊敬。
运行流程
启动
基于MonoBehaviour来实现。
1 | public abstract class GameFrameworkComponent : MonoBehaviour { |
初始化
GameFrameworkModule
GameFrameworkModule(GF层中各个模块的基类)采用懒加载。在Awake中GetModule的时候时,GameFrameworkEntry会先检测内部有没有这个模块对象,没有时再调用内部的CreateModule来实例化该模块。
CreateModule是通过传入的接口去掉第一个字符I,然后反射调用构造器的:
1 | // -----------GameFrameworkEntry.GetModule<T>() where T : class----------- |
最后这个类(比如 ProcedureManager),就在GF框架里而不是UGF框架了。这个是一个很好的解耦。
然后我们讲讲CreateModule方法里省略的代码,①里做的事:
根据instance.Priority
优先级,插入到GameFrameworkEntry。s_GameFrameworkModules
全局队列中合适的节点位置。这么做是为了保证链队始终保持优先级从大到小排序,等后面Update的时候就直接foreach遍历就完事了!
Tick
Tick部分逻辑比较轻:
1 | public abstract class GameFrameworkComponent : MonoBehaviour { } |
ShutDown
卸载。
1 | public enum ShutdownType : byte |
GF书写习惯
GF整体代码的书写习惯是利用 接口 + Dictionary + List 来实现对外的api调用。
而接口的实际对象正如前面所说,绝大部分都是Awake的时候反射构造 同名实现类 实现的。
这一点对于扩展来说非常开闭,就是提高了熵。
知道了这一点后,下面的探究就不再对GF的接口进行关注了,而是只看他们的实现类了。
有限状态机
使用
1 | public class Player : MonoBehaviour |
FsmManager
FsmManager 和 IFsmManager。状态机最外层的存在,有Update会被循环,外部直接调用它的CreateFsm
方法。
值得注意的是(下面)用了2次foreach完成1次轮询,为什么要这么做?
因为如果在第一次foreach的时候就直接调用fsm.Update很可能导致m_Fsms的更改,从而导致迭代器的损坏。
1 | // 优先级60 |
Fsm< T >
一个状态机。继承自FsmBase,也就是上面字典的申明类型。FsmBase抽象类 就不介绍了,是一些状态机的通用属性比如Name、IsDestory之类的属性。
T传进去的是你的数据类型,和结点FsmState<T>
传的T一致。
1 | sealed class Fsm<T> : FsmBase, IReference, IFsm<T> where T : class |
FsmState< T >
一个状态结点。一个状态机对应多个状态结点,想象一下状态机的图就行了。
一般来说,我们需要自己实现的就只有这个类。
对上面调用过的方法进行展示:
1 | public abstract class FsmState<T> where T : class |
自己实现这个抽象类的时候,ChangeState调用的时机一般写在 OnUpdate 方法里。比如每帧都判断一个flag,flag为true就切换到其他状态。
思考
其实可以更深层次的拓展出状态栈:如果状态被某个状态打断,可以恢复到之前的状态。
流程
使用
在ProcedureComponent组件(下面)中提到,是通过m_AvailableProcedureTypeNames来创建实例,并以m_EntranceProcedure为起始状态,启动流程状态机,那么这两个变量是怎么来的呢。
如图所示,我们直接通过流程组件的Inspector来配置,GF会通过反射获取所有继承ProcedureBase的子类,并展示在此面板,我们只需要勾选需要流程即可把它加入到m_AvailableProcedureTypeNames中,而面板上的Entrance Procedure则代表了m_EntranceProcedure,这里我们选择了StarForce.ProcedureLaunch作为起始状态,那么ProcedureLaunch类中的OnEnter方法中的逻辑,就是我们游戏启动后最先执行的游戏业务逻辑。
代码上看,是对有限状态机的封装。功能上看,如果把游戏整体看作一部电影的话,那玩家游玩时刻总是在整个游戏的某个流程结点罢了。
常见的二次元游戏流程线:打开游戏app > 热更 > 登陆 > 主城: 各种丰富的业务(任务系统、商城系统、养成系统、etc) ,选择了副本系统 > 战斗,战斗结束回到主城
我个人认为这么划分基本就涵盖了所有的业务。在花卷的博客里写了:
一般地说,一个游戏拥有的流程数量是非常有限的,如果规划出数十个流程出来,很可能是对流程的理解有所偏差。例如一个塔防游戏有数十个关卡,每个关卡的内容都不一样,但关卡中的地图,炮塔,敌人生成等,其实都是数据驱动的,而他们的逻辑其实是一样的,只是数据不同造成表现不同,所以无论是哪个关卡,他们都应该属于同一个流程。
所以我觉得 主城系统里的各种养成系统,都是一个流程结点;战斗系统里各种副本,也属于一个流程结点。
ProcedureBase
非常简单,就是继承了一下 FsmState 并指定类型 IProcedureManager 罢了。本质就是个状态结点。
1 | public abstract class ProcedureBase : FsmState<IProcedureManager> |
ProcedureManager
内部包裹了一个 FsmManager 和 一个Type为ProcedureManager的状态机,也就是状态机的最外层。
- 字段m_FsmManager为有限状态机管理器,会在Initialize方法初始化时作为参数传入,m_ProcedureFsm为管理流程用的有限状态机。
- 方法Initialize会取得FsmManager实例和包括所有流程(继承ProcedureBase的对象)的列表,并用FsmManager创建出一个状态机实例储存于m_ProcedureFsm中。
- 与Fsm模块类似,流程模块提供HasProcedure、GetProcedure接口来查询和获取指定流程对象,CurrentProcedure获得当前处于的流程,CurrentProcedureTime获取当前流程持续时间。
- StartProcedure方法,令状态机从指定流程启动,这里是游戏框架正式启动游戏的关键入口。该方法其实就是调用了内部状态机的Start。
1 | // 优先级-10 |
ProcedureComponent
ProcedureManager的Initialize方法会取得FsmManager实例和包括所有流程(继承ProcedureBase的对象)的列表,并用FsmManager创建出一个状态机实例储存于m_ProcedureFsm中。这些是在ProcedureComponent里完成的:
1 | public sealed class ProcedureComponent : GameFrameworkComponent{ |
ProcedureComponent继承了Mono类,上面的Start方法会被Unity内部主动调用,调用后会根据m_AvailableProcedureTypeNames通过反射来创建流程对象,进行一系列初始化,再以m_EntranceProcedure为起始状态,启动流程状态机。
思考
其实 流程模块 完全是状态机实例完成,那么功能上没必要单独做成一个模块,但是GF却单独提取成一个模块。很大的原因是因为状态机的状态内部对外是不希望透明的,而流程内部的流程结点是希望对外透明、可访问的。
完整的启动流程
UI模块
数据结构
花卷博客UI篇很细,这里只简述。
UIManager
UIManager是外部访问框架UI模块的入口。
UIManager 持有 Dictionary<string, **UIGroup**>。
UIManager内部会用GF的对象池模块创建一个对象池,用于缓存UIForm对象的GameObject实例,外部调用OpenUIForm来打开UI时,会先尝试从对象池获取该界面,若对象池中有同类型的空闲实例,则直接取出使用,若没有则从资源模块加载,加载成功后,会注册到对象池中,再交给UIManager使用。而调用CloseUIForm来关闭UI时,UIForm会被加到Queue类型的字段m_RecycleQueue中,在下一次Update时,会把队列所有元素取出,回收到对象池中。
内部维护了一个私有字段m_Serial,每次调用OpenUIForm的时候,m_Serial都会自增1,他表示了每个UIForm在其生命周期内的唯一标识符,即使是同一个UIForm实例,被关闭后放回对象池,再被取出来使用,其m_Serial也会发生变化。
UIGroup
UIGroup是一系列窗口集合,内部用链队存储了一个 LinkedListNode< **UIFormInfo** >,用链表来模栈式结构(链头在最上层)。有Depth的概念。
Refresh:UIGroup的核心逻辑,根据链表顺序以及UIForm的属性,去调用UIForm的OnDepthChanged、OnCover、OnReveal、OnPause、OnResume这些方法。
UIFormInfo
UIFormInfo持有UIForm的引用。
既然UIForm是窗口,那为什么要对它再包一层Info呢?它只多了Paused和Covered两个状态,用来表示状态,提供给UIGroup或外界。
UIForm
UIForm是UI窗口类,被UIGroup直接管理,每个UI窗口都会有一个UIForm实例,同时也拥有一个UIFormLogic实例(一般是它的派生类)。有唯一标识符字段m_SerialId。
UIFormLogic
UIFormLogic为UI界面的具体逻辑类,类内有UIForm的所有生命周期方法。
游戏业务层不对UIForm做扩展,而是对UIFormLogic继承进行扩展,也就是自己的脚本继承UIFormLogic,正常写。
生命周期
引用池
就是对象池结构。
关于对象池我的一些个人理解:对象池本身的存在理由是,因为频繁创建-销毁对象,会让堆产生大量的内存碎片,导致gc的压缩更频繁。这不好,所以直接申请一大块内存,保持在游戏运行时不释放它。
但在GF中并不要求开发者在创建的时候就传入引用池capacity,默认会是0,然后动态扩容。我个人认为,可能是因为分配大量内存而不用的危害远比频繁压缩大,所以gf中对象池的核心作用只是复用对象,也就是减少mono对象和类对象的创建。
每个对象的内存大小是固定的,未使用的对象会保留在内存中。
GF中池子有两种,一种叫引用池,一种叫对象池,两者原理一样,但具体实现和针对的对象不同,引用池一般用来储存普通的C#类型对象,而对象池则一般用于储存UnityEngine下的对象(如Unity中的GameObject对象)。
ReferencePool
ReferencePool 静态类,是外部访问引用池模块的入口。
内部维护了一个Dictionary<Type, **ReferenceCollection**>,这个字典就是所有的引用池,Typa对应引用池类型。
采用惰性初始化:当内部有需要获取某引用池实例(比如外界调用API获取引用,内部就先需要get到这个池子),如果在这个字典里Type的池子并不存在,则构造一个加入字典再返回。
ReferenceCollection
引用池。每一个引用池都有自己对应的Type,不同类型的对象储存在各自类型的池子中。
① 需要对构造器传入一个Type,在初期化时会保存好Type。
② Queue存储引用:内部维护了一个Queue< **IReference** >,来储存池子中的对象。
③ 池容量动态扩容:创建时不需要指定池的capacity,设置为0。容量的扩大,完全靠动态扩容(具体思考看本节开头)。
IReference
IReference接口只包含一个Clear方法,此方法会在对象回收池被调用,每一个需要被引用池储存的类型都需要实现此接口,以能清空当前状态,恢复到初始状态,供下次使用。
引用池->对象复用 的根本。
使用
引用池一般用来储存普通的C#类型对象。
比如可以实现复用状态机的状态:
- 状态内实现Create方法,去引用池Acquire(取)一个空闲的相同状态。
- 状态内实现Clear方法(IReference接口),清空所有字段变回初始值。
之后调用,只需要在new状态机的时候Create,Destory时Clear就可以了。
1 | // from 花桑 |
Inspector面板监视
Enable Strick Check:开启类型检测。对象池内部操作时,一方面检测Type是不是非抽象Class且实现了IReference接口的Class;另一方面是释放对象(也就是把对象放回对象池时),需要检查这个对象是不是已经空闲着了。如果 类型不满足、空闲还要求释放 ,就抛错。不过这个检测开启会影响性能,所以只建议测试时开。
面板用途:可以通过此面板方便地检查业务逻辑中有没有正确使用引用池,例如某个对象只会在某个流程中会使用,我们可以检测在流程循环中,这个对象的Acquire和Release是否相等,而流程结束时,Using是否为0,Unused是否与Add相等。
对象池
GF中对象池与引用池作用类似,一般用于储存UnityEngine下的对象(如Unity中的GameObject对象)。
对象池的实现我们可以把他分成3部分,上图中从上到下每一行就是一部分,分别是
- 物体部分(抽象类ObjectBase,Object< T >,结构体ObjectInfo),
- 对象池部分(抽象类ObjectPoolBase,ObjectPool< T >,委托ReleaseObjectFilterCallback),
- 对象池管理器部分(接口IObjectPoolManager,类ObjectPoolManager)。
其中Object和ObjectPool是ObjectPoolManager的内部私有类。
整体关系⭐
对象池:ObjectPool< T >类。它继承 ObjectPoolBase抽象类,且内部持有一个**Object< T >**一对多GF字典。
对象:Object< T > 类。它内部持有一个T对象的引用,且要求T必须继承自 ObjectBase抽象类。它实现 IReference接口,所以Create的时候是从引用池分配的。
对象抽象:ObjectBase 类,也就是上面2个类中限定的T 。内部持有一个object字段(m_Target),这才是真正的 System.Object对象。
物体部分
ObjectBase
通过Initialize方法可把目标对象传递给m_Target字段,这才是object。通过重写OnSpawn、OnUnspawn方法实现对象获取、回收时执行的逻辑。
Object< T >
它实现 IReference接口,Create的时候是从引用池分配的,Release。
1 | public static Object<T> Create(T obj, bool spawned) |
对象池
ObjectPool< T >
对象池,继承自 ObjectPoolBase抽象类 和 IObjectPool接口。两者其实很相似,但是却要做成2个是因为IObjectPool是泛型接口,而ObjectPoolBase不是泛型,后者在使用起来的时候可以不需要明确ObjectBase类型。
内部持有:一个<string, Object<T>>
类型类型的一对多GF字典,一个<object, Object<T>>
类型的普通字典。
Spawn方法:获取对象。字典中,有空闲的就返回,没有就返回null。也就是说,并不会新建对象。
Register方法:创建对象。
AutoReleaseInterval 属性:执行Release的频率。
Release方法:自动释放,频率由AutoReleaseInterval 决定,每个池可不同。Release过程会先获取可释放对象序列,然后通过委托ReleaseObjectFilterCallback对可释放物体序列进行筛选后,最后仅对筛选后的对象调用ReleaseObject进行释放。
1 | public void Register(T obj, bool spawned) |
① GetCanReleaseObjects(m_CachedCanReleaseObjects);
方法:遍历m_ObjectMap的Value值,非使用中状态、非锁定状态、以及自定义释放标记为True时被认为是可释放对象。将所有可释放对象传入m_CachedCanReleaseObjects。
② releaseObjectFilterCallback(m_CachedCanReleaseObjects, toReleaseCount, expireTime)
委托方法:这个方法负责从可释放对象序列中进一步选出符合要求的对象,之后再进行释放。默认有DefaultReleaseObjectFilterCallback方法,也可以自己传入委托。
③ ReleaseObject 方法:会把从对应的Object<T>
对象从m_Objects和m_ObjectMap中移除。
对象池管理
ObjectPoolManager
在内部使用字典保存所有对象池 ObjectPool< T >。
CreateSingleSpawnObjectPool方法 和 CreateMultiSpawnObjectPool方法 创建对象池,分别对应一个对象同时只能被获取一次的对象,以及一个对象能被同时获取多次两种类型的对象池。正常对象是必须用Single模式的,只有一些资源部分可以用Multi。
这一层在
使用
需要一个逻辑物体ObjectBase,以及继承Mono的表现物体GameFrameworkComponent。
官方Demo的示例:
HPBarItemObject
1 | public class HPBarItemObject : ObjectBase |
HPBarComponent
1 | public class HPBarComponent : GameFrameworkComponent |
Inspector面板监视
引用池与对象池的区别
- 引用池从池子内部通过默认构造方法创建对象,只适合普通的C#对象。对象池是在外部自行创建对象后再注册进去,能用于必须通过Unity API才能实例化的对象。
- 引用池仅提供Clear接口来清除对象状态,在移除对象时没有任何额外处理,仅仅是去掉引用,适用于受GC管理的类型。而对象池提供OnSpawn,OnUnspawn两个操作,且在移除对象时,提供Release接口,对于Unity中的GameObject需要在Release写上Destroy(gameObject)的逻辑才能销毁。
- 对象池提供自行释放的机制,可指定每个池子自动释放周期、物体过期时长、池子容量,并在可一定程度上自定义每个池子的释放策略。引用池没有以上机制,仅可通过Remove接口主动移除对象。
- 对象池提供锁定物体、自定义释放标记功能,可进一步定制释放策略。
思考
同一个对象池中,为什么还要以Name区分对象集合?
在同一个prefab上挂上相同的脚本,最后以他们的资源路径名字作为Name,则可在一个对象池中对不同外形的陨石进行区分,以实现向一个对象池取不同外型的陨石的需求。
官方Demo StarForce中的陨石对象池,虽然他们都是同一个类型,具有相同的逻辑,但他们可能有不一样的外型。我们把外型不同的陨石做成单独prefab,并在这些prefab上挂上相同的脚本,最后以他们的资源路径名字作为Name,则可在一个对象池中对不同外形的陨石进行区分,以实现向一个对象池取不同外型的陨石的需求。
在同一个对象池中以Name区分对象,与用多个对象池储存不同Name的对象有什么区别?
让开发者能更好地规划释放策略。
主要区别就在于一个对象池执行同一个释放逻辑,而多个对象池是各自执行各自的释放逻辑。继续以上面的陨石为例子,我们一共有3种陨石,我希望储存陨石的对象池总容量是60,我们随机去生成不同种类的陨石,如果随机结果不均匀,最终池子里可能有种类一40个,种类二15个,种类三5个,在我们把他们放在同一对象池下管理情况下,这没有什么问题,无论怎样它都很好地以总数量为60个的策略去管理。但如果我们把不同外形的陨石分到不同的对象池去管理,我们很难去动态调整3个池子的容量平衡,以达到总数量为60的策略。
为什么既有引用池又有对象池,全部用对象池不是就可以满足需求了吗?
对象池太繁琐,引用池使用更轻便。