Article

C# 实战课 04:时间与节流:实现可测试的 Timer/Scheduler(替代散落的 Update)

这章要解决一个 Unity 项目里非常隐蔽、但长期一定会爆炸的问题:

  • 技能冷却在 Update 里减时间
  • Buff 到期在 Update 里判断
  • 引导流程在 Update 里等条件
  • UI 动画在 Update 里跑计时

一开始你会觉得“这样写挺快”,但当项目长到 3 个月后,你会遇到:

  • 逻辑重复、难排查(到底谁在改这个倒计时?)
  • 时序 Bug(暂停/切场景/时间缩放后逻辑乱套)
  • 难测试(靠手工点来点去验证)
  • GC 抖动(协程/闭包/委托无意识分配)

解决方法:把时间相关逻辑收敛到一个可测试的调度器。

本章做一个最小但够用的 Scheduler

  • Delay:延时执行
  • Every:固定间隔重复执行
  • Cancel:取消任务
  • Throttle:节流(限制频率)
  • Debounce:防抖(停止输入一段时间后执行)

并且做到两件事:

  1. 运行时不靠协程也能工作(你可以在一个 MonoBehaviour 里统一驱动)。
  2. 核心逻辑可单测(不用跑 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

驱动层只做三件事:

  1. 提供 now
  2. 调 Tick
  3. 捕获异常并输出 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);
    }
}

本章验收

  1. 项目中不再出现“到处散落的计时器 Update”,统一走 Scheduler。
  2. 延时任务可取消(切场景/关闭 UI 时不再误触发)。
  3. 用假时间推进 SchedulerCore.Tick(now),可以做简单单测(比如 0.1s、0.2s 到期)。

Foundation 对齐(章节代码沉淀)

本章的约定已沉淀到可复用小库,后续 Unity 章节直接引用:

  • foundation/Runtime/Timing/Scheduler.cs
  • foundation/Runtime/Timing/RateLimiter.cs

下一章预告

下一章我们写“对象池”:

  • 把 Instantiate/Destroy 变成可控成本
  • 避免运行时大量分配

这会直接影响你 Unity 项目的帧率稳定性。