> 文章列表 > Unity基础框架从0到1(五)延时任务调度模块

Unity基础框架从0到1(五)延时任务调度模块

Unity基础框架从0到1(五)延时任务调度模块

索引

这是Unity基础框架从0到1的第五篇文章,前面的文章和对应的视频我一起列到这里:

文章

Unity基础框架从0到1 开篇

Unity游戏框架从0到1 (二) 单例模块

Unity基础框架从0到1(三)高效的全局消息系统

Unity基础框架从0到1(四)资源加载与管理模块

视频

一 开篇

二 单例模块

三 消息模块

四 资源加载模块

正文

本来这个框架的第五篇内容是对象池模块的,但是在处理对象池模块时发现自动回收类的资源需要在未来某个时间点触发回收事件,未来某个时间点触发事件是一个很通用的操作,因此这里插入一章来阐述这个延时任务调度模块。

在游戏开发过程中我们经常会碰到延时任务,比如敌人还有3秒到达战场,红蓝Buff还有25刷新,大龙还有1分钟刷新,任务还有3分钟更新等等。如果在每个处理延时任务的地方都自己编写一段计时的代码,那将是会非常痛苦的,不仅加大了业务的复杂度,并且在规模较大时可能会导致出现性能问题,此外,在一些时间点挨得很近且强调调用先后顺序的地方甚至会出现调用顺序不一致的情况。因此,我们需要有一个东西来将这些延时任务统一管理调配,确保执行顺序没有问题,并简化系统逻辑,提升程序性能。

设计思路

不难看出,前面说的这些种种,抽象来看都是在某个时间点到了后做某件事,因此我们可以将这里的计时逻辑与触发逻辑抽离出来,业务这边只负责注册任务与任务执行的时间,注册时调度器为这些任务做好排序,并在时间更新的时候轮询判断是否要执行任务。由于在调度器这边做了排序,因此可以确保任务执行顺序是没毛病的。

再深入思考一下,如果时间T1的任务没有执行,那任意大于T1的时间T对应任务也不应该被执行,且不用去判断。我们前面已经得到了一个有序的列表,那我们时间更新时仅需判断有序列表的第一个位置上任务是否要执行,如果当前时间比第一个位置任务执行时间小,那后续的时间都不需要判断了。如果第一个位置任务需要执行,则继续此判断,直到没有任务或者当前时间比第一个位置任务执行时间小。

由于我们加入了一个排序操作,这使得我们在添加新任务以及移除现有任务时开销变得比之前大,并且在执行任务时需要将后续任务移动到列表前面来,如果使用链表,移动操作消耗减少了,但随之而来的是节点前后指针的内存占用问题。为了平衡新增任务、移除任务和执行任务的代价,可以加入优先队列(其实就是最小堆或者最大堆),这样三个操作的代价都降低了。同时,我们可以加入区间划分,将同一个时间区间内的任务放到一起,这样在排序时,可以对时间区间做排序,然后区间内自行排序,借此来降低排序对象的数量。在本文完成之际,从New Bing这边得知了一种更高效的时间轮算法,这种算法思路和区间划分有点类似,不过更为巧妙,不仅内存占用更少,并且三种操作执行代价也更小,感兴趣的童鞋可以自行查阅学习。

实现方案简述

下面就不多BB了,直接上本文的实现方案:

本文使用了优先队列+区间划分的思路来实现任务存储,优先队列是为了快速取到最接近当前时间的一个任务,在插入新任务时,调整的代价是O(logN),每次判断时代价是O(1),移除任务时找到移除的元素代价是O(N),实际移除后调整的代价是O(LogN),执行任务时调整的代价是Log(N)。本文区间划分单位是1,因此只是把同一个时间点的任务划分在了一起,借此来减少队列的元素规模。下图是一个简单的例子,当没有做区间划分时,队列中元素个数为8,做完区间划分后队列中元素个数为3。通过简单的区间划分,有效减少了队列的长度。(队列中的元素顺序可能不满足优先队列的标准,由于时间关系没有仔细构造数据了,希望没有对大家理解思路造成困扰)

Unity基础框架从0到1(五)延时任务调度模块

当然,在很多时候,我们的任务时间并不会这么密集,所以可以根据实际情况来修改区间划分长度与要划分的区间层数。如下图,可以增加了初始队列的区间划分长度,将[20-29]内的任务都放在了第一个元素中。在每个元素中,我们又对齐做了长度为1的区间划分,将AA放在一个元素,BBB放在一个元素。这样在每次插入和移除时,我们初始队列以及实际内部的队列调整代价规模都比较小。PS.多区间划分本文暂时没有处理,仅给出思路抛砖引玉。

Unity基础框架从0到1(五)延时任务调度模块

Show Code

该模块核心部分是调度类DelayedTaskModule、延时任务列表类DelayedTaskList和延时任务数据类DelayedTaskDataTimerUtil类提供两个获取时间的接口,Heap类用来实现优先队列提高检测的效率,避开了消息轮询判断。

调度类DelayedTaskModule负责对外提供相关的操作接口,比如注册延时任务的方法,回收延时任务的方法。并在内部提供一个更新当前时间的方法,在更新时间时去判断是否需要触发对应任务。

延时任务列表类DelayedTaskList存储了某个确定时间点注册的延时任务数据。

延时任务数据类DelayedTaskData存储了一个时间,一个到点触发的任务和一个被提前移除的任务。

具体设计

堆的代码比较长,这里就不贴了,感兴趣可以去这里https://github.com/tang-xiaolong/MapGridInUnity/blob/main/Assets/LMapModule/LDataStruct/Heap.cs看。堆内部根据外界传入的最小最大枚举值来确定比较方法,比较方法会调用堆内元素的CompareTo方法来比较,因此这也是DelayedTaskList需要实现IComparable的原因。

数据类比较简单,内部只有几个变量负责存储必要的数据。

DelayedTaskData.CS

public class DelayedTaskData
{public long Time;public Action Action;public Action EarlyRemoveCallback;
}

列表类也比较简单,里面存了一个列表,存储时间点注册的所有任务数据。类实现了IComparable是为了后续可以放入优先队列中对所有时间点对应的列表进行排序,避免每次更新时间都遍历所有列表。

DelayedTaskList.CS

public class DelayedTaskList : IComparable, IEnumerable<DelayedTaskData>, IDisposable
{private bool _disposed = false;public long Time;public List<DelayedTaskData> DelayedTaskDataList;public int CompareTo(object obj){if (obj == null)return 1;return CompareTo((DelayedTaskList)obj);}public int CompareTo(DelayedTaskList obj){return Time.CompareTo(obj.Time);}IEnumerator<DelayedTaskData> IEnumerable<DelayedTaskData>.GetEnumerator(){return DelayedTaskDataList.GetEnumerator();}public IEnumerator GetEnumerator(){return DelayedTaskDataList.GetEnumerator();}public void Dispose(){Dispose(true);GC.SuppressFinalize(this);}private void Dispose(bool disposing){if (_disposed)return;if (disposing){DelayedTaskDataList.Clear();DelayedTaskDataList = null;}_disposed = true;}~DelayedTaskList(){Dispose(false);}
}

调度类内部有一个字典_delayedTaskDict,Key是注册过的时间点,Value是这个时间点对应的任务列表。_delayedTaskQueue是一个使用最小堆实现的优先队列,队列中存储了每个任务点注册的任务列表,即每个元素都是一个列表对象。

UpdateTime方法更新时间:

先更新当前的时间,并判断当前时间是否大于等于队列最前面的元素的时间,如果大于等于了,说明需要触发这个时间点对应列表里的所有任务了,先将这个元素从队列中移除并遍历任务列表依次触发注册的任务,处理完任务后继续判断,直到队列为空或者是队列头元素的时间大于当前时间

AddDelayedTask方法注册任务:

外界传入一个毫秒级别的时间戳,如果是一个过时的时间,直接结束。如果没注册过这个时间戳,则创建一个任务列表,并将任务列表加入到字典里。最后将新任务保存到一个新的任务对象中并加入到这个时间戳对应的任务列表,并返回新创建的这个任务对象给调用方,使得调用方可以持有任务对象做移除操作。

RemoveDelayedTask方法移除任务:

传入一个任务对象,如果判断存在这个任务,则将其从列表中移除。如果列表移除对象后数量为0,则将列表也从字典中移除。

PS. 需要注意的是,在不同的项目中,计算时间和更新时间的方法不尽相同,需要根据自己项目来修改。本项目是直接获取的UTC毫秒级别的时间戳,并在Update方法中调用更新时间的方法。

DelayedTaskScheduler.CS

/// <summary>
/// 延时任务调度器
/// </summary>
[DefaultExecutionOrder(1)]
public class DelayedTaskScheduler : MonoBehaviour, IDisposable
{private Dictionary<long, DelayedTaskList> _delayedTaskDict = new Dictionary<long, DelayedTaskList>();private Heap<DelayedTaskList> _delayedTaskQueue = new Heap<DelayedTaskList>(10, HeapType.MinHeap);private bool _disposed = false;[SerializeField] private long CurrentTime;public static DelayedTaskScheduler Instance { get; private set; }#region 时间事件管理/// <summary>/// 增加一个时间事件对象/// </summary>/// <param name="time">毫秒数</param>/// <param name="action"></param>public DelayedTaskData AddDelayedTask(long time, Action action, Action earlyRemoveCallback = null){if (time < CurrentTime){Debug.LogError($"The time is pass. Time is {time} CurrentTime is {CurrentTime}");return null;}if (!_delayedTaskDict.TryGetValue(time, out var delayedTaskList)){delayedTaskList = ObjectPoolFactory.Instance.GetItem<DelayedTaskList>();delayedTaskList.Time = time;delayedTaskList.DelayedTaskDataList = ObjectPoolFactory.Instance.GetItem<List<DelayedTaskData>>();delayedTaskList.DelayedTaskDataList.Clear();_delayedTaskQueue.Insert(delayedTaskList);_delayedTaskDict.Add(time, delayedTaskList);}var newEventData = ObjectPoolFactory.Instance.GetItem<DelayedTaskData>();newEventData.Time = time;newEventData.Action = action;newEventData.EarlyRemoveCallback = earlyRemoveCallback;delayedTaskList.DelayedTaskDataList.Add(newEventData);return newEventData;}/// <summary>/// 移除一个时间事件对象/// </summary>/// <param name="delayedTaskData"></param>/// <exception cref="Exception"></exception>public void RemoveDelayedTask(DelayedTaskData delayedTaskData){if (delayedTaskData == null)return;if (_delayedTaskDict.TryGetValue(delayedTaskData.Time, out var delayedTaskList)){bool removeSuccess = delayedTaskList.DelayedTaskDataList.Remove(delayedTaskData);if (removeSuccess)delayedTaskData.EarlyRemoveCallback?.Invoke();if (delayedTaskList.DelayedTaskDataList.Count == 0){_delayedTaskDict.Remove(delayedTaskData.Time);if (_delayedTaskQueue.Delete(delayedTaskList)){ObjectPoolFactory.Instance.RecycleItem(delayedTaskList.DelayedTaskDataList);ObjectPoolFactory.Instance.RecycleItem(delayedTaskList);ObjectPoolFactory.Instance.RecycleItem(delayedTaskData);}else{ObjectPoolFactory.Instance.RecycleItem(delayedTaskData);throw new Exception("DelayedTaskScheduler RemoveDelayedTask Error");}}}else{ObjectPoolFactory.Instance.RecycleItem(delayedTaskData);}}/// <summary>/// TODO:根据自己游戏的逻辑调整调用时机/// </summary>/// <param name="time"></param>public void UpdateTime(long time){CurrentTime = time;while (_delayedTaskQueue.Count > 0 && _delayedTaskQueue.GetHead().Time <= time){long targetTime = _delayedTaskQueue.GetHead().Time;_delayedTaskDict.Remove(targetTime);var delayedTaskList = _delayedTaskQueue.DeleteHead();foreach (DelayedTaskData delayedTaskData in delayedTaskList){delayedTaskData.Action?.Invoke();ObjectPoolFactory.Instance.RecycleItem(delayedTaskData);}//回收时记得把列表清空,防止下次使用时出现问题!!!!!不要问我为什么这么多感叹号 delayedTaskList.DelayedTaskDataList.Clear();ObjectPoolFactory.Instance.RecycleItem(delayedTaskList.DelayedTaskDataList);ObjectPoolFactory.Instance.RecycleItem(delayedTaskList);}}#endregion#region Mono方法与测试的设置时间代码private void Awake(){Instance = this;UpdateTime(TimerUtil.GetTimeStamp(true));}public void Update(){UpdateTime(TimerUtil.GetTimeStamp(true));}private void OnDestroy(){Dispose();}#endregion#region Disposepublic void Dispose(){Dispose(true);GC.SuppressFinalize(this);}private void Dispose(bool disposing){if (!_disposed){if (disposing){_delayedTaskQueue?.Dispose();Instance = null;}_disposed = true;}}~DelayedTaskScheduler(){Dispose(false);}#endregion
}

使用范例

在下面的例子中,我们在Start方法中注册了两个任务,让其在1.5秒后和4.5秒后执行打印的方法。并在Update中设置了通过按键来创建一个随机时间注册任务,以及通过按键移除随机创建的任务。

打开测试场景后,执行了Start方法里注册的任务,按下C键也执行了一个随机时间任务,再次按下C键并提前按下R键,将这个任务提前移除了,并且打印执行的方法没有再执行。

Unity基础框架从0到1(五)延时任务调度模块

TestDelayTask.CS

public class TestDelayTask : MonoBehaviour
{private void Start(){AddLaterExecuteFunc(1.5f);AddLaterExecuteFunc(1.5f);AddLaterExecuteFunc(1.5f);AddLaterExecuteFunc(4.5f);AddLaterExecuteFunc(4.5f);AddLaterExecuteFunc(4.5f);}public int forceTestCount = 100000;List<DelayedTaskData> futureEventDataList = new List<DelayedTaskData>(100000);List<long> testTimes = new List<long>(100000);[ContextMenu("暴力测试")]public void ForceTest(){testTimes.Clear();for (int i = 0; i < forceTestCount; i++){testTimes.Add(TimerUtil.GetLaterMilliSecondsBySecond(UnityEngine.Random.Range(1, 15.0f)));}futureEventDataList.Clear();Stopwatch stopwatch = new Stopwatch();stopwatch.Start();for (int i = 0; i < forceTestCount; i++){futureEventDataList.Add(DelayedTaskScheduler.Instance.AddDelayedTask(testTimes[i], TestFunc));}for (int i = 0; i < forceTestCount; i++){DelayedTaskScheduler.Instance.RemoveDelayedTask(futureEventDataList[i]);}stopwatch.Stop();Debug.Log($"暴力测试完成,共耗时{stopwatch.ElapsedMilliseconds / 1000.0f}秒");}void TestFunc(){Debug.Log("测试方法执行了");}private DelayedTaskData AddLaterExecuteFunc(float time, Action completeAction = null, Action earlyRemoveAction = null){var pressTime = Time.time;Stopwatch stopwatch = ObjectPoolFactory.Instance.GetItem<Stopwatch>();stopwatch.Restart();return DelayedTaskScheduler.Instance.AddDelayedTask(TimerUtil.GetLaterMilliSecondsBySecond(time),() =>{stopwatch.Stop();ObjectPoolFactory.Instance.RecycleItem(stopwatch);// Debug.Log($"{time}秒后了,执行了对应方法。实际过去了{Time.time - pressTime}秒");Debug.Log($"{time}秒后了,执行了对应方法。实际过去了{stopwatch.ElapsedMilliseconds / 1000.0f}秒");completeAction?.Invoke();}, () =>{earlyRemoveAction?.Invoke();stopwatch.Stop();Debug.Log($"提前移除了,已经过去了{stopwatch.ElapsedMilliseconds / 1000.0f}秒");ObjectPoolFactory.Instance.RecycleItem(stopwatch);});}DelayedTaskData _delayedTaskData;void RecycleDelayedTask(){if (_delayedTaskData != null){DelayedTaskScheduler.Instance.RemoveDelayedTask(_delayedTaskData);_delayedTaskData = null;}}private void Update(){//持续按下时不断创建和回收if (Input.GetKey(KeyCode.C)){RecycleDelayedTask();_delayedTaskData = AddLaterExecuteFunc(UnityEngine.Random.Range(1, 5.0f), () => _delayedTaskData = null);}if (Input.GetKeyDown(KeyCode.R)){RecycleDelayedTask();}}
}

总结

在本文中,我们使用C#语言实现了一个毫秒级别的延时任务调度器,为游戏中通用的延时行为提供管理调度,加入排序思想,来保证任务按顺序执行;使用优先队列快速索引对象,以及减少各个操作带来的消耗;使用区间划分来减少优先队列中元素数量。这个调度器在客户端已经基本够用了,但是如果想拓展到服务器那边使用,我们还需要继续优化算法来减少各个操作的时间以及内存占用,感兴趣的童鞋可以继续深入研究,后续有时间我也会将多轮区间划分和时间轮算法整合进这个延时任务调度模块中,欢迎大家与我交流。