☰
Unity计时器管理器:用对象池统一调度,大幅降低GC分配
2026/10/6 3:21:38 网站建设 项目流程

做Unity客户端久了,你会发现一个挺有意思的现象:技能冷却、Buff倒计时、UI倒计时、网络超时重试、新手引导强制等待……这些功能背后,代码里几乎全是一堆飘在Update里的float变量。功能少的时候无所谓,各写各的就行;一旦模块多了,同一个场景里十几个脚本各自维护着倒计时,每帧都在做减法,还要提心吊胆地防着对象销毁之后回调炸了。

这篇要聊的,是一个我用对象池方案重构出来的Unity计时器管理器。核心思路很简单:不再让每个模块自己盯着一个float,而是把计时器对象统一交到一个管理器手里,由管理器每帧统一调度,同时所有计时器实例全部走对象池复用。这样做的直接收益是GC分配大幅下降、代码里不再到处是零散的倒计时逻辑、以及生命周期终于有人管了。适合正在做游戏优化、或者被各种倒计时和异步回调坑过的Unity开发者参考,哪怕你项目不大,这套思路也能直接抄。

1. 计时器需求梳理:为什么需要一个管理器

1.1 那些年我们手写的计时器

先说说我自己踩过来的经历。刚入行那阵子写技能冷却,最朴素的写法就是这样的:

private float _coolDown = 5f; private void Update() { if (_coolDown > 0f) { _coolDown -= Time.deltaTime; if (_coolDown <= 0f) { OnCoolDownEnd(); } } }

这段代码本身没什么问题,一个倒计时而已。但问题是,我后来接手的是一个副本系统,里面光战斗相关就有几十个这样的小倒计时。战士的怒气持续时间、法师的Buff层数、怪物的技能预警圈、队友的复活读条、背包道具的限时使用提示……每个模块都有一份自己的float _xxx,每个脚本的Update里都有一段自己减时间的逻辑。

那会儿还没觉得有什么不对,直到有一天用Profiler一看,一个普通副本场景里,每帧光Update调用的次数就已经非常难看,而且很多脚本即使没有激活任何倒计时,也照样在每帧做一次if (_xxx > 0f)的判断。当你的项目里这种“被动空转”的Update脚本一多,整个帧耗时就被这些无用功吃掉了。

1.2 高频计时场景里真正要命的问题

后来我认真统计过,战斗中真正的计时器热点在几个环节:Buff倒计时刷新UI、HitFlash闪白、技能释放延迟调用、子弹存活、受击后摇、连击窗口期。这些计时器的特点是数量多、生命周期短、且很多需要精确控制暂停恢复。

在项目刚上线那段时间,测试反馈手机端在多人战斗时偶尔有卡顿。我开着Profiler抓帧,发现Managed Heap的GC Alloc波动特别刺眼。逐帧排查下来,罪魁祸首之一就是战斗里大量新建的计时器对象和闭包委托。比如技能系统每释放一次技能,就可能new出两三个匿名函数做延迟回调,一场战斗打三分钟,这些临时对象堆在一起,隔一会儿就触发一次GC,而Unity的GC在移动端一卡就是几十毫秒。

另一个更隐蔽的问题是生命周期失控。UI上面挂一个倒计时,如果UI面板被关闭了但计时器没取消,到了点还是会去执行回调。回调里如果引用了已经销毁的UI控件,轻则空引用报错,重则整个UI流程跟着崩。类似的还有场景切换之后,旧场景的MonoBehaviour已经没了,但携程或者Invoke还在跑,各种灵异Bug大多从这里来。

1.3 管理器需要满足的功能清单

折腾过这些破事后,我给自己定了一个计时器管理器必须满足的功能清单,你可以直接拿去做需求评审:

  • 支持一次性计时和循环计时,循环次数可配置,0次或负数表示无限循环;
  • 支持暂停、恢复、立即停止,且暂停时剩余时间不能丢;
  • 回调触发时有异常保护,不能因为一个回调出错就拖垮整个调度循环;
  • 支持不受Time.timeScale影响的真实时间倒计时,用于网络超时、跨场景等待这种场景;
  • 能查询Timer当前剩余时间、是否在运行,方便UI进度条、技能冷却图标联动;
  • 所有计时器对象统一池化,运行时尽量不new、不产生GC;
  • 提供全局清理入口,场景卸载、退出战斗时一键清空所有计时器。

看上去功能不少,但如果你耐着性子读完后面的实现,会发现这些需求在同一个设计里是能自然落地的,不冲突。

2. 对象池设计:为什么选它,池子怎么搭

2.1 对象池解决的三个核心痛点

对象池在很多涉及时序、异步的项目里都有应用,从网络层到游戏实体管理,再到我们今天聊的计时器。它本质上就是一件事:把反复创建销毁的对象缓存起来,用的时候从池子里取,用完了放回池子,不让垃圾回收器插手。

回到计时器这个场景,对象池解决的第一个痛点就是GC分配。想象一下,你手里有一个战斗副本,平均每秒要创建和销毁几百个Timer对象。如果这些对象全靠new Timer()来产生,那一局打下来就有几万个短命对象堆积在托管堆上。用对象池之后,这些对象就像健身房的储物柜一样,你用完了锁上门,下一个人打开接着用,柜子永远只是那几把。

第二个痛点是初始化成本。虽然Timer对象构造的CPU开销不高,但在Unity的Profiler里,虽然构造一次无所谓,但构造一万次就不是无所谓了。类对象的内存分配在大量发生时,Mono的老版本GC分配效率你们懂的,能省则省。

第三个痛点是使用纪律。一旦你决定“所有Timer实例必须从管理器借出、用后归池”,那么项目里就不可能再出现散落的new Timer()。所有计时器的生命周期都过管理器的手,你才能对它做集中控制,比如遍历可见、全局清理、异常兜底。这就像你家里所有电器都插在同一个带开关的插线板上,出门才能一按全关。

2.2 池子放哪、谁负责回收:接口设计

设计对象池的时候有个分叉路口:是单独做一个通用的对象池类,还是把池子直接做进TimerManager里。我试过两条路,最终还是选择了后者。

通用对象池类听着更“复用”,但在这个场景里反而引入不必要的抽象。你要知道,Timer对象的池化不止是“拿一个实例出来、放回去”这么简单,它还牵扯到Timer实体本身的Reset、委托清理、状态校验。这些逻辑如果放在通用池里,通用池就得知道Timer的内部字段,耦合度一下就上去了。

我最后的做法很简单直接:

public class TimerManager : MonoBehaviour { static TimerManager _instance; public static TimerManager Instance { get { if (_instance == null) { GameObject go = new GameObject("TimerManager"); _instance = go.AddComponent<TimerManager>(); DontDestroyOnLoad(go); } return _instance; } } Stack<Timer> _pool; List<Timer> _activeTimers; List<Timer> _toRemoveList; const int MaxPoolSize = 256;

池子就是Stack<Timer>,容量上限256。为什么用Stack而不是Queue?很简单,后进先出。一个刚释放的Timer对象,它的CPU缓存热度还很高,下一次分配优先把它拿出来,能少一点冷缓存加载的损耗。这点性能提升在PC上无所谓,但移动端这种小优化积少成多也是好事。

回收策略有点讲究:当池子里的数量超过256时,新的Timer不再回池,让它自然被GC回收。为什么要设这个上限?因为对象池最容易翻车的点就是“池子里的东西只进不出”,战斗高峰期几万个Timer全部囤在池里,内存永远降不下来,玩家玩了一小时,内存一动不动,这就成了另一种泄漏。有了上限,高峰期多余的内存能被GC收走一部分,池子本身又保留了热数据。

2.3 池化之外的一个隐形收益:纪律性

很多人看对象池只看性能,但我现在越来越觉得,它更大的价值是带来了一种强制性的项目规范。

当项目里的计时器创建只有一条路——TimerMgr.Instance.AddTimer(...)——那么你就不太可能再看到有人在一个随机脚本里偷偷写一个携程用来倒计时,或者用Invoke实现延迟。因为代码审查的时候,只要出现new Timer(),立刻就能发现这不是走正规流程的对象,问题定位会清晰很多。

这就像统一日志框架一样。你说日志打印很多地方自己拼字符串不也能用吗?但一旦出了线上问题,统一框架可以自动带上文件行号、调用栈、等级过滤,可维护性和散装实现完全不是一个量级。计时器管理器也一样,池化只是手段,让所有计时器进入同一个管理体系才是真正的目的。

3. 核心实现:从Timer实体到调度循环

3.1 Timer实体:状态机与回调安全校验

计时器的核心实体并不复杂,我把它设计成一个普通类,类里面所有字段都是内部可见,外部只能通过暴露的属性查询状态,不能直接改。

public class Timer { public bool IsActive { get; private set; } public bool IsPaused { get; private set; } public float RemainTime { get; private set; } float _interval; int _loopCount; bool _ignoreTimeScale; Action _onComplete; object _target; public bool IgnoreTimeScale { get { return _ignoreTimeScale; } } public void Init(float interval, int loopCount, bool ignoreTimeScale, Action onComplete, object target) { _interval = interval; _loopCount = loopCount; _ignoreTimeScale = ignoreTimeScale; _onComplete = onComplete; _target = target; RemainTime = interval; IsPaused = false; IsActive = true; } public bool Tick(float deltaTime) { if (!IsActive || IsPaused) return false; RemainTime -= deltaTime; if (RemainTime > 0f) return false; if (_loopCount > 0) { _loopCount--; if (_loopCount == 0) { TriggerComplete(); IsActive = false; return true; } } RemainTime += _interval; TriggerComplete(); return false; }

注意重点来了,回调触发时我做了两件事。第一件事是检查_target的存活状态,这个字段通常就是一个MonoBehaviour或者一个UI控件。当回调真正的业务逻辑执行前,我先看一眼目标还在不在。这里不需要用WeakReference,因为Unity的Object本身就重写了==操作符,可以直接判空。

void TriggerComplete() { if (_target != null) { var unityObj = _target as UnityEngine.Object; if (unityObj != null && unityObj == null) { return; } } if (_onComplete == null) return; try { _onComplete.Invoke(); } catch (System.Exception e) { UnityEngine.Debug.LogError("[TimerManager] Timer回调发生异常: " + e); Stop(); } }

第二件事是try/catch。一个回调方法在生命周期的尽头往往要操作一堆外部对象,谁也保不准这里会不会抛异常。如果不做保护,异常会直接炸穿Update里的调度循环,导致后面的所有Timer全部停摆。有了try/catch,就算某个回调出问题,我也只中止这一个Timer,其他的继续跑。

这里要说一下Stop方法的设计:

public void Stop() { IsActive = false; _onComplete = null; _target = null; }

每次把Timer归还对象池之前,必须把委托引用和target引用全部清掉。这是个很关键的细节,我在第5章会专门讲由此引发的“回调串场”事故,这里先记住原则:计时器结束的一瞬间,它的回调引用就已经没有存在的必要了,清掉还能顺手解掉一部分内存引用链,让目标对象能更早地被GC回收。

3.2 调度器:Update里的O(n)遍历为什么够用

管理器本体是一个挂在场景里的MonoBehaviour,依靠Update驱动整个计时器系统。调度循环没有用什么黑魔法,就是一个简单的线性遍历:

void Update() { float unscaledDt = Time.unscaledDeltaTime; float scaledDt = Time.deltaTime; for (int i = 0; i < _activeTimers.Count; i++) { Timer timer = _activeTimers[i]; if (timer == null || !timer.IsActive) continue; float dt = timer.IgnoreTimeScale ? unscaledDt : scaledDt; bool finished = timer.Tick(dt); if (finished) { _toRemoveList.Add(timer); } } if (_toRemoveList.Count > 0) { for (int i = 0; i < _toRemoveList.Count; i++) { _activeTimers.Remove(_toRemoveList[i]); Release(_toRemoveList[i]); } _toRemoveList.Clear(); } }

可能有人看到这立刻会问:线性遍历每帧O(n),怎么不搞个最小堆按到期时间排序?我一开始也是这么想的,还真的写过一个最小堆版本,后来实测下来在游戏场景里完全没必要。

为什么?因为游戏中同时活跃的计时器数量通常不会超过几百个。我拿太子战斗场景做统计,Buff、技能、UI、AI这些全算上,同时活跃的也就一两百个。线性遍历一次,每帧不过几百次比较和减法,这在Profiler里几乎看不到耗时,连0.01毫秒都不到。最小堆的插入删除操作虽然有log n的复杂度,但删除一个非堆顶节点需要先找到它,这里就需要额外的索引映射,而撤消计时器在游戏里又是高频操作,最后算下来反而更费。

真正的性能瓶颈根本不在这层遍历,而在于回调本身。如果100个Timer都在做帧级回调,每帧执行100个委托,那开销全在委托调用里面,调度循环那点开销根本排不上号。所以我的结论是:几百个Timer以内的场景,O(n)线性遍历是最稳、最简单、最好维护的方案。你要是哪天真做到同时几千个计时器活跃,再考虑时间轮或者红黑树也不迟。

3.3 暂停、循环、帧回调与时间缩放

暂停功能用了一个很朴素的标记位IsPaused。当计时器进入暂停状态,Tick里直接就return false了,剩余时间原地保留。恢复的时候什么都不用做,下一帧开始继续累减就行。这个方案要求你在Pause的时候不能去动RemainTime字段,人一急容易写成清零,那就不是暂停是取消了。

循环计时我做了两种语义。如果loopCount传的是正整数N,那这个Timer最多触发N次回调,触发完后自动回收;如果传0或者负数,就是无限循环,直到外部调用Stop才结束。无限循环的Timer在游戏里要特别小心,忘记取消就是一场无限执行的回调,内存和性能都被白耗。我的建议是无限循环Timer一律在创建的地方用using风格做生命周期绑定,或者至少注册到所属模块的OnDispose钩子里。

帧回调这块,interval传0是不正确的。因为如果interval为0,第一次RemainTime -= deltaTime之后就是负数,还没等到下一帧就已经触发一次,而RemainTime += 0f之后又持续为负,每帧都触发多次,完全不是想要的“下一帧执行一次”。

我专门给帧回调加了一个字段,语义独立:

int _frameCount; int _frameRemain; public void InitByFrame(int frameCount, Action onComplete, object target) { _frameCount = frameCount; _frameRemain = frameCount; // ... 复用其他初始化逻辑 } public bool TickFrame() { if (--_frameRemain <= 0) { TriggerComplete(); return true; } return false; }

帧回调在UI动画里用处很大,比如“等下一帧再取某个布局容器的尺寸”,用时间计时器反而不稳定,因为你不知道这一帧要跑多久,只有等到下一个渲染帧才是确定的安全点。

时间缩放的处理则是在管理器Update里区分了两种deltaTime。Time.deltaTime受timeScale影响,游戏暂停或者放慢动作时它跟着变化;Time.unscaledDeltaTime是真实时间。一个需要“后台真实计时”的Timer,比如“不管游戏是否暂停,网络请求超时时间是真实计算”,就走unscaled的路径。

3.4 场景卸载与全局清理的兜底策略

跨场景的计时器泄漏是很多Unity项目的通病。场景A里有一个Timer还活着,你切换到了场景B,场景A里的MonoBehaviour已经销毁,但Timer管理器还在,Timer脱离了控制继续跑,回调方法里的引用全部是死对象。

我的处理策略分了三级。

第一级是目标存活校验。就是我上面写的那个unityObj == null的检查,目标死了我就不触发回调,并且立刻把这个Timer回收。

第二级是场景切换时手动清理。在自己的场景加载管理器里,加载新场景前调用TimerMgr.Instance.ClearAll(),把当前所有活跃Timer全部归还对象池。注意这个操作必须在场景卸载前做,而且要保证ClearAll方法幂等——也就是调用两次也不会出问题,这样你的走廊代码不需要费心判断是否已经清过。

第三级是针对常驻场景的管理器,比如主菜单、全局HUD。这些组件要跟随整个游戏生命周期,它们创建的Timer不要通过场景清理干掉,而是由模块自己在销毁时单独Stop。这里有个经验判断:全局常驻的东西,Timer的生命周期绑定模块自身;场景内的东西,Timer的生命周期绑定场景。

4. 接入实战:替换手写计时器的完整过程

4.1 从私有float到统一调度的改造对照

接入管理器的时候,最怕的就是一上来推倒重来,把所有手写float全部改掉,那工作量会劝退你自己。我推荐渐进式替换,从战斗里最疼的几个点入手。

拿最典型的技能冷却举例,改造前:

private float _coolDownRemain; private bool _cooling; void Update() { if (_cooling) { _coolDownRemain -= Time.deltaTime; if (_coolDownRemain <= 0f) { _cooling = false; OnSkillReady(); } } } public void StartCoolDown() { _cooling = true; _coolDownRemain = 5f; }

改造后:

Timer _coolDownTimer; public void StartCoolDown() { _coolDownTimer?.Stop(); _coolDownTimer = TimerMgr.Instance.AddTimer( 5f, OnSkillReady, loopCount: 1, target: this ); }

注意我把this传进了target参数。这样当这个技能对象被销毁时,TimerMgr在回调前做存活校验,发现目标已经不在了,就不会执行OnSkillReady,也不会去访问一个已经销毁的MonoBehaviour。

一次循环的UI倒计时也差不多。原来UI上每帧刷新剩余时间的写法,现在可以改成在回调里一次性把整个UI流程走完,期间用Timer自身的RemainTime属性刷进度条。

_timer = TimerMgr.Instance.AddTimer(3f, () => { _text.text = "倒计时结束"; }, target: this); void Update() { _progress.fillAmount = _timer != null && _timer.IsActive ? _timer.RemainTime / _timer.Duration : 0f; }

这里顺带提一句,Timer实体上要暴露Duration属性,也就是创建时传入的interval。有了它,UI进度条才能用RemainTime / Duration算出0到1的比例,否则你还得自己在外部再存一份总时长,那就没做到封装透彻。

4.2 性能实测:对象池到底省了多少GC

下面说点硬数据。我在Unity 2021.3.16f1上跑了这么一组基准测试,场景是模拟一场高强度战斗:每帧创建20个一次性Timer,每个Timer持续0.5到2秒不等,回调里做一次简单的坐标偏移操作。跑60秒,大概创建了1200个Timer实例。

不开对象池,全部new Timer()然后不用了让GC自己收,Profiler里显示的GC Alloc大概在每帧12KB到20KB之间浮动,高峰期能到30KB。开了对象池,把Timer回收复用之后,这个数字直接压到接近0,在Profiler的帧详情里搜索Timer相关分配,只剩偶发的List扩容和闭包分配。

你可能觉得每帧20KB不算多,别急,这只是一路计时器。你的项目里技能、UI、AI、战斗飘字、自动寻路全加起来,这个数字乘以十也不夸张。而且移动端GC是在主线程里做的,一次不到1MB的托管堆回收都可能带来明显卡顿,所以“每帧少分配一点”在移动端是非常有价值的事。

具体的Benchmark代码我就简单贴一下,你自己跑跑就知道了:

// 模拟60秒高强度战斗 float timer = 0f; while (timer < 60f) { timer += Time.deltaTime; for (int i = 0; i < 20; i++) { TimerMgr.Instance.AddTimer(Random.Range(0.5f, 2f), OnDummyCall, 1, true, this); } yield return null; }

同样的代码,把AddTimer内部换成直接new Timer,跑两趟你在Profiler里对比GC Alloc曲线,对象池的优势一眼就能看出来。

4.3 使用约束与最佳实践:回调里别干重活

计时器管理器虽然把调度集中了,但真正坑你的往往不是框架,而是使用者的习惯。下面几条是我在项目里定下的使用军规,你们可以直接抄进代码规范里。

第一条,回调委托尽量别用lambda闭包。() => Something()这种写法,如果捕获了外部变量,每次创建Timer都会产生一个新的闭包对象,池化Timer对象本身不解决闭包分配的问题。在热路径上你应该用实例方法绑定,或者至少缓存一个静态委托。要是实在避免不了lambda,请确保这个Timer本身是低频创建的,比如每秒一个的UI倒计时,不会造成明显GC;但如果每帧几百个,就一定要处理闭包分配了。

第二条,回调里的操作要轻。计时器回调往往是在Update调度循环的中间执行的,你在这个回调里做资源加载、复杂寻路、List排序,都会阻塞整个主线程。我项目里优先级高一点的定时任务,回调里只做“通知状态变更、置一个标志位”,真正的重计算丢到后面的流程里,这样即使在战斗高峰期,计时器调度也不会成为帧耗时的瓶颈。

第三条,关注一下你的活动Timer数量。我在管理器里加了一个ActiveCount属性,然后在内存监控面板上直接画出来。正常战斗模式下,活动Timer数量应该稳定在一个小范围内波动。如果你发现这个数字随着游戏时长持续增长不回落,那说明有模块在无限延生命周期,这个基本可以断定是“计时器泄漏”,查起来比查托管堆快多了。

5. 常见问题与排查速查:踩过的坑都在这里

5.1 回调串场的排查与修复

这是我第一次把对象池方案上线后遇到最大的坑,没有之一。

现象很诡异:角色A释放了一个技能,一秒之后技能回到了角色B身上执行,表现为B莫名其妙播放A的受击动作。我开始还以为是技能系统状态机串了,查了半天最后才定位到是Timer对象复用后回调串场。

原因很简单,有人改了Stop方法实现,把里面的_onComplete = null;这行删了。他觉得Timer在Tick结束之后自然返回finished = true,马上就会被回收,回调也触发完了,清不清无所谓。问题是,他忘了一种情况:回调序列里,某个回调方法主动又创建了一个Timer,而当时计数器池里正好有这个刚触发了回调还没被回收的Timer,于是新Timer拿到了堆栈里残留的回调引用,看起来就像“旧任务的回调跑到了新任务身上”。

排查思路给到你们:先看池化对象在归还和取出的时候,State有没有完整重置。归还时的Stop必须把委托、目标对象、剩余时间全清掉;取出时的Init必须把每个字段重新赋值一遍。遗漏任何一个字段,串场只是时间问题。我后来在Release方法里加了个断言,Debug模式下检查timer._onComplete确实为null才允许入池,这类问题当场就能暴露出来。

5.2 池内对象堆积与内存只涨不降

另一个常见投诉是:用了对象池之后,Managed Heap曲线还是持续上涨。很多人的第一反应是“对象池坏了”,但多半是池的容量上限设计有问题。

你想想,如果在无限循环计时器归池时,池子里的对象只进不出,那么高峰期创建的Timer对象全部滞留在池里。假设你是一场高强度团战打了一小时,积累的Timer对象数量可能是你的峰值活跃量的几十倍,这些对象占用着堆内存但丝毫不干活,内存曲线自然只涨不降。

我的处理办法前面已经提过,就是给池子加MaxPoolSize上限。超过上限的对象不回池,直接交给GC回收。别担心这样会产生GC分配,峰值的临时对象本来GC也会处理,池子只是把平稳期的分配抹掉了,两者配合反而能让内存维持在一个合理的稳态。

5.3 场景切换后的回调崩溃

场景切换是回调崩溃的高发区,我统计过,最常见的两种报错一种是“MissingReferenceException”,回调访问了已经销毁的Unity对象;另一种是“NullReferenceException”,回调访问了已经被设为null的托管对象。

前一种被unityObj == null判空挡住,后一种就没办法靠目标判空解决了,因为你可能引用的是一个纯C#的List或者一个配置表Sheet。我的建议是在场景切换的流程里增加一级Timber清理钩子。具体大家现实点的做法是:把它挂到一个地方,在主场景加载之前统一调用。比如我写了一个静态类,在场景切换管理器里调用,就能把当前画面上的Timer全清掉,这个流程你们直接照抄就行。

public static class SceneHook { public static void OnBeforeSceneChanged() { TimerMgr.Instance.ClearAll(); } }

5.4 时间缩放、线程安全与精度问题

时间缩放有一个常见坑是这样:某个Buff倒计时用Time.deltaTime,当游戏因为弹出暂停菜单而timeScale = 0时,Timer冻结了,这很正常。但如果你同时挂了一个网络超时Timer,并且错把它的ignoreTimeScale设成了false,那这个网络超时也会在暂停菜单下面跟着冻结,玩家切出去看个商店回来,网络请求早就超时了,却一直没触发超时回调。所以创建Timer的时候一定要想清楚:这个计时逻辑是“游戏内时间”还是“真实时间”,网络层、支付回调、广告冷却这些一律走ignoreTimeScale = true。

线程安全这块也提一嘴。我见过有人把TimerMgr.Instance.AddTimer(...)放进网络子线程的回调里调,结果Unity主线程的Update在遍历_activeTimers的同时,子线程往里面Add,List直接报“集合已修改”。我的建议是,所有TimerMgr的调用都必须发生在主线程。子线程那边用了回调或事件,就先把数据扔到一个主线程队列里,等下一帧主线程消费,不要在子线程里直接操作管理器。

精度方面,我们用的deltaTime累减方案天然存在一点漂移。比如你想每0.1秒触发一次回调,用RemainTime += _interval的写法,帧长不是0.1的整数倍,实际触发间隔会在0.09到0.11左右波动。大部分游戏UI和技能逻辑不敏感,可以接受。如果有一天你写的是音游或者帧精确的打击判定,那这个方案就不够用了,需要改成基于Time.realtimeSinceStartup的绝对时间对齐,调度时把每个Timer的下一触发时间算出来,再用最小堆取最近的一次。两种方案的切换我当时在文章里纠结了很久,最后是分了两套接口,游戏逻辑用帧块时间方案,对时敏感的走绝对时间方案。

最后说点我个人的实操体会

计时器管理器这个事,看着小,做起来牵扯的东西其实不少。我踩过的最大跟头就是一开始只考虑了性能,把对象池做得花里胡哨,结果在回调安全上栽了两次跟头。现在回头看,所有框架类的东西,设计核心其实不在于你用的是什么数据结构、什么算法,而在于对象生命周期的边界是否清晰,用完的东西是否该清就清、该还就还。计时器尤其如此,因为它天然就是“约定未来某一刻干某件事”的机制,越是这样,越要把那一点未来的不确定性兜住。

如果你们项目里也想上这套方案,我建议第一步先别急着写代码,先把你项目里所有用float倒计时的场景列一张表,标出哪些是UI展示、哪些是战斗逻辑、哪些是网络超时,然后按类别一个一个往管理器上迁移。迁移过程中注意观察Profiler里GC Alloc的变化,还有Unity的TimerMgr.Instance.ActiveCount的变化趋势。等你跑一两个版本以后,你会发现代码里少了很多零散的Update,战斗性能更稳定了,UI倒计时的Bug也不像以前那样此起彼伏了。

最后给大家一个小经验,计时器管理器这种底层工具,调试可视化一定要做趁早。我在管理器上挂了一个简单的Debug图,把活跃Timer数量按时间画成折线,就能看到系统有没有泄漏、有没有异常峰值。这个小东西帮我抓出过好几次“某个模块在后台无限刷Timer”的问题,比看Profiler省事多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询