【案例设计】基于时间的事件响应管理器

开发平台:Unity 2021
编程语言:CSharp
编程平台:Visual Studio 2022

一、前言


  程序运行的特性是 逐行关联即执行。在执行单行代码语句的效率上基于物理设备基础与底层架构逻辑。对一般程序员来看,只需要学会应用即可。但有时候,并不期望于其立即执行,甚至是不断的自我检测、等待若干时间、等待若干帧后执行。于是有了计时器的说法。本文重点记录计时器的构想与设计。

二、思考:计时器类型表现


类型 描述
顺序 从单位时间 0 开始计数或计次。每次值累加固定单位值。默认情况下,无上限值。即启用即不停止,直至应用程序结束。
逆序 从固定时间 任意值 开始计数或计次。每次值累减固定值。默认情况下,存在下限值 0。当应用程序强制关闭或到达下限值时,停止当前逻辑。

三、思考:目前能设想的计时器实现方式(从 Unity 角度构想)


3.1 实现一:Unity 生命周期 Update/FixedUpdate

public void Updaete() 		{
    
     while(timer <= 0)  {
    
    	timer -= Time.deltaTime; } }
public void FixedUpdate()   {
    
     while(timer <= 0)  {
    
      timer -= Time.FixedTime; } }

  使用 Update 更新,在一定程度上解决计时所带来的问题。但在其他方面上,Update 中计算计时选项不是明举的实现方法,于是较于 Update 有了 FixedUpdate 的更新方案。因为计时器,记录的是事件发生的时间节点。一般的,对于个人开发者而言,时间的计算精确并不需要精确至 毫秒所以 FixedUpdate 在整体刷新上并不会较 Update 次数多且耗能大。也是不错的优化方案。但最终不是最佳。

3.2 实现二:IEnumerator 协程(常用)

在这里插入图片描述

  协程是目前 Unity 开发者中最为常用的计时器实现方式。其特点用时则用,无用时无需调用(即 与 Update 相比,不需要持续更新,或在 Update 中添加 bool 判断环节是否执行等特点),每次使用 yield return new WaitForSecond()\WaitForEndOfFrame() 等来延迟执行。

public IEnumerator DoTimeCount()
{
    
    
	yield return new WaitForSeconds(1f);
	timer += 1f;
	TimerEvent?.Invoke(timer);
	StartCoroutine(DoTimeCount());	
}
  • 协程特点:基于主线程上,单开一分支独立运行。不影响原主分支执行。
  • 依赖于 MonoBahaviour 实现 IEnumerator 操作(对 非 MonoBehaviour 的类对象无法调用)

四、思考:基于 IEnumerator 的计时器类封装


4.1 解决依赖问题

  IEnumerator 的启用基于 MonoBehaviour 基础上进行。非 MonoBehaviour 继承的子项无法使用 IEnumerator 进行协程操作。故其实现上应基于该对象上进行。但从以下两个角度进行思考,选择 继承 MonoBahviour 的 Mono单例 将更具备优势。

  • 单例模式下,调用方法更加直观、快速。无需实例化对象。
  • 计时器作为辅助功能,无需准备额外的 Prefab 对象用于加载时程序添加对象,或 持续存在于场景内上。从调用与项目维护上,这是不可取的行为。但在 MonoBehaviour 环境下的单例模式,建立调用时,若场景无该对象,则程序创建该对象并完成引用的行为。减少了资产中需存储特定对象的 Prefab 并管理的麻烦性。

4.2 考虑计时器的基础属性

  计时器作为统筹时间变化的重要,必要的参数包括如下:

参数 数据类型 说明
StartTime decimal 计时器 起始时间
EndTime decimal 计时器 终止时间
LerpTime decimal 计时器 插值时间(限制时间精度)
IsPositiveTime bool 判断 计时器为 正序 or 逆序,
OnTimeEvent Dictionary<decimal, Action> 记录 时间戳事件
  • 从精度角度考虑,float 的精确度存在较小的误差,但为了保证计时器在确切的时间。例如 0.1m,而非 0.1000002231f。使用十进制 decimal 数据类型作为标准,后续设计提供基础奠定。
  • 从程序设计角度上考虑,参数变量过多的情况,并不建议使用复数的局部变量作为配置。相比较可参考 事件 的设计思想,将变量集中于 类 中以供调用实现。

4.3 考虑计时器的拓展设计

  实际上,计时器并非仅用于计时,在应用层面上,涉及程序逻辑响应 + 时间显示。例如 0 -10s 的计时器,以单位1s作为过渡。在每个时间戳结点下,调用对应的方法与内容,以强化时间事件效果。具体如下:

  • 加入 第2s时,执行方法体 Debug.Log("Hello 2s, taking fire! Now!")
  • 加入 第2s时,执行方法体 Debug.Log("Hello 2s, Where are u from ?")
  • 加入 第4s时,执行方法体 Debug.Log("4s, R U Hungry?")
  • 加入 第9s时,执行方法体 Debug.Log("The Time will over soon")

  那么 Action 将毫无疑问的成为程序开发的首选选项。为什么?每一秒时间响应的方法内容并不局限于一种方法的内容执行。例如 在第 2s 时,添加新的方法体内容以执行。从解耦度角度考虑,新添方法体 + 原方法体 之间互不影响干涉,即两者之一的有无均不会影响 第2s 时刻的时间事件执行。则 委托、事件 是最直接可用的方法。在时间事件的注册上也更便捷快捷。

五、程序设计


5.1 关于 GameTime 的说明

public class GameTime : MonoSington<GameTime>
{
    
    
	public static Coroutine DoCoroutine(TimeData data)
	{
    
    
		return Instance.StartCoroutine(DoPositiveTime(data));
	}

	private static IEnumerator DoPositiveTime(TimeData data)
	{
    
    
		yield return new WaitForSeconds(data.LerpTime);
		// 更新当前时间值
		data.CurrentTime += data.LerpTime;
		data.TimeAction[data.CurrentTime]?.Invoke();
		
		if(data.CurrentTime <= data.EndTIime)
		{
    
    	
			// 迭代继续
			Instance.StartCoroutine(DoPositiveTime(data));
			yield break;
		}
		
		Debug.Log($"[Time Coroutinue] The Time Event Is Finish");
	}
}
  • 从实现角度上:
    • 使用 MonoSingleton 的 Mono 单例模式实现 Mono 环境下调用 Coroutinue 的前置条件(重要)
    • 选择静态方法。直接同通过 GameTime.DoCoroutine(new Time()); 快速启用协程,降低程序上调用的复杂性。
  • 从方法命名角度上,
    • 使用 Positive / Nagetive 以区分计时器的正反顺序。
  • 更多可优化与补充内容:
    • TimeData 中注册的 TimeAction 未提供便捷的初始化方法。
      可构建 public TimeAction(decimal startTime, decimal endTime, decimal lerpTime) 用于初始化 TimeData 类。
    • 在使用习惯角度上,集成 事件注册、注销、启用、禁用 方法于 GameTime 类是最佳的选择。
      无需在 new 之后,引用新建对象中的事件注册进行。(记住多的API倒不如集合在一起,自己找)。
    • 在判断时间事件属于 顺序计时/逆序计时 上可进行数据判断,执行对应的数据内容。
      扩展 DoCoroutine ,增加判断计时器应用类型方法。
    • 考虑多类型的计时器可能同时运行,未区别其计时器具体属于何种应用的计时器,可添加 Name 属性用于 Debug 识别。

5.2 关于 TimeData 的说明

public class TimeData
{
    
    
	private decimal currentTime;
	
	public decimal CurrentTime {
    
     get {
    
     return currentTime; }}
	public decimal StartTime   {
    
     get; set; }
	public decimal EndTime     {
    
     get; set; }
	public decimal LerpTime    {
    
     get; set; }

	public Dictionary<decimal, Action> TimeAction {
    
     get; set; }
	
	public TimeData(decimal startTime, decimal endTime, decimal lerpTime)
	{
    
    
		this.currenTime = startTime;
		this.StartTime = startTime;
        this.EndTime = endTime;
        this.LerpTime = lerpTime;
        this.OnTimeEvent = new Dictionary<decimal, Action>();
	}
}
  • TimeData:配置事件计时器相关属性与事件的配置文件。
  • 一般情况下,一个时间触发计时器需要 开始时间、结束时间、时间插值。随着需求变化,增加展示当前数据、区别计时器类型等内容。(视实际情况而定)或许 Action 也会因为实际需求改用 Actoin<int> 或其他也说不定。

六、后记


  关于时间管理的事件管理器是作者在 BILIBILI 上偶然看见同开发者自己开发作品时萌发的设想。虽然他的视频内容我并没有太多关于他讲解时的记忆。但基于时间驱动事件这一需求设计,深深的给我留下了印象。以思考 —— 如果是我,我会怎么去实现这个需求?这篇文章也并非完整的内容。仅提供实现方式的思路参考。后续仍不定期更新完善内容。

猜你喜欢

转载自blog.csdn.net/qq_51026638/article/details/126901405