这章要解决一个 Unity 项目里非常隐蔽、但长期一定会爆炸的问题:
- 技能冷却在
Update里减时间 - Buff 到期在
Update里判断 - 引导流程在
Update里等条件 - UI 动画在
Update里跑计时
一开始你会觉得“这样写挺快”,但当项目长到 3 个月后,你会遇到:
- 逻辑重复、难排查(到底谁在改这个倒计时?)
- 时序 Bug(暂停/切场景/时间缩放后逻辑乱套)
- 难测试(靠手工点来点去验证)
- GC 抖动(协程/闭包/委托无意识分配)
解决方法:把时间相关逻辑收敛到一个可测试的调度器。
本章做一个最小但够用的 Scheduler:
Delay:延时执行Every:固定间隔重复执行Cancel:取消任务Throttle:节流(限制频率)Debounce:防抖(停止输入一段时间后执行)
并且做到两件事:
- 运行时不靠协程也能工作(你可以在一个 MonoBehaviour 里统一驱动)。
- 核心逻辑可单测(不用跑 Unity 也能测时序)。
本章交付物
SchedulerCore:纯 C# 的调度核心(可单测)SchedulerBehaviour:Unity 驱动脚本(只负责喂 deltaTime)Handle:可取消句柄Throttle/Debounce:可复用工具
设计:先定“时间源”
很多项目会把 Time.deltaTime 直接写死在逻辑里,导致:
- 暂停难处理
- 单测没法做
所以我们第一步是抽象时间源:
public interface ITimeSource
{
float DeltaTime { get; }
float Now { get; }
}
在 Unity 里实现一个:
using UnityEngine;
public sealed class UnityTimeSource : ITimeSource
{
public float DeltaTime { get { return Time.unscaledDeltaTime; } }
public float Now { get { return Time.realtimeSinceStartup; } }
}
这里我选 unscaled,原因是:
- UI/引导/网络心跳等通常不希望被
timeScale影响。 - 玩法相关如果需要受 timeScale 影响,你可以再提供另一个 TimeSource。
SchedulerCore:最小可用实现
核心思想:维护一个任务列表,每帧推进,符合条件就执行。
我们用数组+swap remove,避免 List.Remove 的 O(n) 移动成本。
using System;
public sealed class SchedulerCore
{
public struct Handle
{
internal int Id;
public bool IsValid { get { return Id != 0; } }
}
private struct Task
{
public int Id;
public float Due;
public float Interval;
public bool Repeat;
public Action Action;
}
private Task[] _tasks = new Task[32];
private int _count;
private int _nextId = 1;
public Handle Delay(float seconds, Action action)
{
return Schedule(seconds, 0f, false, action);
}
public Handle Every(float intervalSeconds, Action action)
{
if (intervalSeconds <= 0f) throw new ArgumentOutOfRangeException("intervalSeconds");
return Schedule(intervalSeconds, intervalSeconds, true, action);
}
public void Cancel(Handle handle)
{
if (!handle.IsValid) return;
for (int i = 0; i < _count; i++)
{
if (_tasks[i].Id != handle.Id) continue;
RemoveAt(i);
return;
}
}
public void Tick(float now)
{
// 允许一个 Tick 中执行多个到期任务
for (int i = 0; i < _count; i++)
{
var t = _tasks[i];
if (now < t.Due) continue;
try
{
if (t.Action != null) t.Action();
}
catch (Exception ex)
{
// 核心层不依赖 Unity,这里只抛出,让上层决定如何记录
throw new Exception("Scheduler task failed. id=" + t.Id, ex);
}
if (t.Repeat)
{
t.Due = now + t.Interval;
_tasks[i] = t;
}
else
{
RemoveAt(i);
i--; // swap remove 后需要回退一格
}
}
}
private Handle Schedule(float delay, float interval, bool repeat, Action action)
{
if (action == null) throw new ArgumentNullException("action");
if (delay < 0f) delay = 0f;
if (_count == _tasks.Length)
{
var next = new Task[_tasks.Length * 2];
Array.Copy(_tasks, next, _tasks.Length);
_tasks = next;
}
var id = _nextId++;
_tasks[_count++] = new Task
{
Id = id,
Due = delay, // 注意:这里先存相对值,Unity 驱动时会转成绝对值
Interval = interval,
Repeat = repeat,
Action = action,
};
return new Handle { Id = id };
}
private void RemoveAt(int index)
{
_tasks[index] = _tasks[_count - 1];
_tasks[_count - 1] = default(Task);
_count--;
}
public void NormalizeToAbsolute(float now)
{
// 首帧把 Due 从“相对延时”转为“绝对时间”
for (int i = 0; i < _count; i++)
{
var t = _tasks[i];
t.Due = now + t.Due;
_tasks[i] = t;
}
}
}
关键点解释:
Tick(now)只吃“现在的时间”,使核心可在单测里用假时间推进。NormalizeToAbsolute用来把延时转成绝对到期时间。- 为什么这么做?因为 Unity 驱动创建任务时可能不知道当前 now,统一在 Start 之后做一次归一化。
Unity 驱动层:SchedulerBehaviour
驱动层只做三件事:
- 提供 now
- 调 Tick
- 捕获异常并输出 Log
using UnityEngine;
public sealed class SchedulerBehaviour : MonoBehaviour
{
private readonly SchedulerCore _core = new SchedulerCore();
private readonly UnityTimeSource _time = new UnityTimeSource();
private bool _normalized;
public SchedulerCore.Handle Delay(float seconds, System.Action action)
{
return _core.Delay(seconds, action);
}
public SchedulerCore.Handle Every(float intervalSeconds, System.Action action)
{
return _core.Every(intervalSeconds, action);
}
public void Cancel(SchedulerCore.Handle handle)
{
_core.Cancel(handle);
}
private void Update()
{
var now = _time.Now;
if (!_normalized)
{
_core.NormalizeToAbsolute(now);
_normalized = true;
}
try
{
_core.Tick(now);
}
catch (System.Exception ex)
{
Log.Error("scheduler", ex.Message);
}
}
}
你可以把它挂在一个全局 Bootstrap 物体上,后续所有系统都通过它调度。
Throttle 与 Debounce(项目里最常用)
Throttle:限定频率
例子:点击按钮/上报统计不要每帧发。
public sealed class Throttle
{
private float _next;
public bool TryPass(float now, float interval)
{
if (now < _next) return false;
_next = now + interval;
return true;
}
}
用法:
- 在输入高频触发处:
if (!throttle.TryPass(now, 0.2f)) return;
Debounce:停止一段时间后执行
例子:搜索框输入停止 300ms 后才发请求。
public sealed class Debounce
{
private SchedulerCore.Handle _handle;
private readonly SchedulerBehaviour _scheduler;
public Debounce(SchedulerBehaviour scheduler)
{
_scheduler = scheduler;
}
public void Run(float delaySeconds, System.Action action)
{
if (_handle.IsValid) _scheduler.Cancel(_handle);
_handle = _scheduler.Delay(delaySeconds, action);
}
}
本章验收
- 项目中不再出现“到处散落的计时器 Update”,统一走 Scheduler。
- 延时任务可取消(切场景/关闭 UI 时不再误触发)。
- 用假时间推进
SchedulerCore.Tick(now),可以做简单单测(比如 0.1s、0.2s 到期)。
Foundation 对齐(章节代码沉淀)
本章的约定已沉淀到可复用小库,后续 Unity 章节直接引用:
foundation/Runtime/Timing/Scheduler.csfoundation/Runtime/Timing/RateLimiter.cs
下一章预告
下一章我们写“对象池”:
- 把 Instantiate/Destroy 变成可控成本
- 避免运行时大量分配
这会直接影响你 Unity 项目的帧率稳定性。