本文参考:
https://zhuanlan.zhihu.com/p/426136370
GameFramework概述
Game Framework 是一个基于 Unity 引擎的游戏框架,主要对游戏开发过程中常用模块进行了封装,很大程度地规范开发过程、加快开发速度并保证产品质量。在最新的 Game Framework 版本中,包含 19 个内置模块。
如图是Game Framework的结构框图,完整的 Game Framework 包含三部分:
- GameFramework – 封装基础游戏逻辑,如数据管理、资源管理、文件系统、对象池、有限状态机、本地化、事件、实体、网络、界面、声音等,此部分逻辑实现不依赖于 Unity 引擎,以程序集的形式提供。
- UnityGameFramework.Runtime – 依赖 UnityEngine.dll 进行对 GameFramework.dll 的补充实现。为了方便兼容 Unity 的各个版本,此部分已经以代码的形式包含在 Unity 插件中。
- UnityGameFramework.Editor – 依赖 UnityEditor.dll 进行对工具、Inspector 的实现。为了方便兼容 Unity 的各个版本,此部分已经以代码的形式包含在 Unity 插件中。
在该框架中GF是不包含Unity实现的核心逻辑部分,完全使用C#编写,也是本次作业分析的目标。显然,对于Unity开发而言,GF层仅靠自身是不能够完成部分逻辑的,通过Helper接口将UGF的功能抽象出来提供给GF层使用是GF和UGF配合的主要方式。
UGF组件是对GF模块的包装,同时也负责初始化模块、设置helper,真正的框架逻辑包含在GF模块中,而GF模块无法自身决定的部分功能预设为IHelper接口,自身只调用接口,由外部提供具体实现。
框架费尽周折将Unity的逻辑分成两部分,主要是为了抽离出游戏开发中的核心逻辑,保证核心代码不与开发中不停迭代的逻辑混淆,未来开发中只需扩展UGF的Component和Helper即可。这一点充分体现了面向对象开发中的对扩展开放,对修改封闭的原则。另外,不依赖Unity的部分使得框架的跨平台成为可能,在其他支持C#的游戏引擎诸如Godot同样适用。
模块分析
1.Sound
模块简介
音频模块主要负责游戏中各种音效、音乐的播放管理。具备优先级管理、SoundGroup管理、音频播放参数调整等功能。主要解决了以下三种使用场景,对同一类型的音效统一管理,包括声音的大小,音效等,同时还要应对随时加入和去除音效;对大量重复音效设置最大同时播放数量;对音效设计优先级管理,允许优先级更高的音效打断更低优先级的音效。
类的结构
SoundAgent
- SoundAgent是声音代理,主要用于具体播放声音,SoundAgent通过ISoundAgentHelper与Unity通讯,ISoundAgentHelper的实现则基于反射,在Unity中一般通过AudioSource播放声音,这一部分的实现在UGF层中。创建一个SoundAgent意味着有一个AudioSource,意味着同时播放的音频数量+1,因此SoundAgent就像可用于播放声音的槽,等待声音资源加载进来并播放。
SoundGroup
- SoundGroup是将同一类型的声音整理起来统一管理的模块,可以添加或移除SoundGroup中的SoundAgent,SoundGroup具有Mute、Volume等声音基本属性,这样当不同类别的声音分配到对应的组,就能同一控制他们的音量等属性,当需要播放声音时,声音被加载到对应的SoundGroup中的SoundAgent中。
- SoundGroup中以List来存储多个SoundAgent的引用,当需要播放声音时,SoundGroup寻找可用的SoundAgent(IsPlaying=false),如果全部正在播放,那么将通过优先级来决定是否要打断某个Agent。或者播放失败。优先级的选择类似于打擂台找最小值,找到优先级最小的且比当前播放优先级还小的agent,如果出现了不在播放的agent则立刻退出。对于相同优先级,Sound还设置了m_AvoidBeingReplacedBySamePriority字段来决定相同优先级声音之间是否能被打断。这里解决了优先级管理和最大同时播放数量的问题。
SoundManager
- SoundManager是用户访问也就是UGF访问Sound模块的入口,也就是说用户需要通过SoundManager来访问上述描绘的声音播放逻辑。同时SoundManager负责管理若干个SoundGroup,用户通过HasSoundGroup、GetSoundGroup、GetAllSoundGroups、AddSoundGroup、AddSoundAgentHelper等对SoundGroup进行调整。通过PlaySound、StopSound、StopAllLoadedSounds、PauseSound、ResumeSound等方法进行声音的播放。
PlaySoundParams
- PlaySoundParams是一个实现了引用接口的类。它主要是负责存储一次播放的全部播放参数,这样可以控制每次播放的具体参数,实现统一调整。接入了引用池,由框架引用池管理,通过引用复用减少频繁销毁创建。
UML类图
一种播放声音情况的序列图
2.EventPool和Event
模块简介
EventPool是Base模块中提供的基本工具,是GameFramework提供的基本事件系统,GameFramework各个组件的事件管理都使用EventPool。Event是基于EventPool实现的,提供给用户使用的游戏内事件系统。EventPool基于C#的Event,使用了发布-订阅模式。
EventPool
EventPool实现了线程安全的事件触发和订阅系统。EventPool通过一个多值字典GameFrameworkMultiDictionary来整理事件。GameFrameworkMultiDictionary即一键多值词典,通过一个链表和一个字典来组织,字典的Value是一个链表上节点的区间。实现遍历和一键多值。这里我想也可以使用C#的委托合并来实现多播委托。
触发事件时,EventPool将其加入队列,在执行Update时,将队列中的所有事件逐一取出执行,同样的Event实现了IReferance接口接入了引用池。另外,通过lock管程实现了线程安全。
/// <summary>
/// 事件池轮询。
/// </summary>
/// <param name="elapseSeconds">逻辑流逝时间,以秒为单位。</param>
/// <param name="realElapseSeconds">真实流逝时间,以秒为单位。</param>
public void Update(float elapseSeconds, float realElapseSeconds)
{
lock (m_Events)
{
while (m_Events.Count > 0)
{
Event eventNode = m_Events.Dequeue();
HandleEvent(eventNode.Sender, eventNode.EventArgs);
ReferencePool.Release(eventNode);
}
}
}
EventPool最核心的两个功能是事件的触发和事件的订阅。
EventPool提供了Fire和FireNow两个事件触发函数,其中FireNow会立刻触发事件,因此它不是线程安全的,Fire则会在下一帧调用Update时才会触发是线程安全的。
/// <summary>
/// 抛出事件立即模式,这个操作不是线程安全的,事件会立刻分发。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
public void FireNow(object sender, T e)
{
if (e == null)
{
throw new GameFrameworkException("Event is invalid.");
}
HandleEvent(sender, e);
}
/// <summary>
/// 抛出事件,这个操作是线程安全的,即使不在主线程中抛出,也可保证在主线程中回调事件处理函数,但事件会在抛出后的下一帧分发。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
public void Fire(object sender, T e)
{
if (e == null)
{
throw new GameFrameworkException("Event is invalid.");
}
Event eventNode = Event.Create(sender, e);
lock (m_Events)
{
m_Events.Enqueue(eventNode);
}
}
订阅部分通过事件编号和EventHandler来订阅事件,这里的事件编号来自BaseEventArgs,根据EventPoolMode,EventPool允许一个事件对应多个Handler,这也是为什么使用一键多值字典。通过一系列检测,id-Handler对被加入字典。这样以后事件触发时能够执行对应的Handler。
EventPool对m_Events上锁保证了Fire时的线程安全,但没有对m_EventHandlers上锁。当一个非主线程取消订阅时,此时主线程正在处理该Handler,会出现问题。因此EventPool使用缓存节点来解决多线程访问Unsubcribe的安全问题。
事实上,游戏开发中使用多线程的情况较少,GF框架此处的处理并不能保证线程安全,它只解决了一种情况,那就是当一个线程触发FireNow遍历执行委托时,另一个线程Unsubcribe导致链表断开。
发现,在HandleEvent中,遍历e.Id对应的所有Handler,m_CachedNodes存储了下一个要访问的Handler,然后执行当前的Handler。在Unsubscribe中,将m_CachedNodes跳跃到下一个节点,来保证后面删除时,所有线程的遍历能够正常进行。但是这里没有处理m_CachedNodes只能存储接下来执行的一个节点,并不能保证后面删除时没有线程又遍历到待删除的Handler了。因此多线程使用GF时应该避免使用Unsubcribe,或者使用锁来保证线程安全。
Event
Event实质上是对EventPool的二次封装,通过继承自GameFrameworkModule,Event收到框架管理生命周期,可以被UGF层直接调用,也就是用户可以在Unity中使用。除了GameFramework内置的各种事件,通过继承GameEventArgs,用户可以创建各种自定义事件。
可以看到在EventManager的构造函数中,创建了一个允许无handler和多handler的EventPool。
public EventManager()
{
m_EventPool = new EventPool<GameEventArgs>(EventPoolMode.AllowNoHandler | EventPoolMode.AllowMultiHandler);
}
在Subcribe、Fire等函数中也只是调用EventPool的相关函数。