ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity计时器管理器:用对象池统一调度,大幅降低GC分配

Unity计时器管理器:用对象池统一调度,大幅降低GC分配 做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.AddComponentTimerManager(); DontDestroyOnLoad(go); } return _instance; } } StackTimer _pool; ListTimer _activeTimers; ListTimer _toRemoveList; const int MaxPoolSize 256;池子就是StackTimer容量上限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的同时子线程往里面AddList直接报“集合已修改”。我的建议是所有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省事多了。
返回列表