使用说明 · 从零到能写业务

计时器系统 · 使用说明

这份文档只回答一个问题:我该怎么用它?(一行创建、零配置、不泄漏)

读完你能做到:3 分钟写出第一个倒计时 · 分清四种时间域(切后台不会再错)· 一行防泄漏 · 不触发时 5 分钟定位

〇它是干什么的

先花 30 秒建立直觉,再动手写。

只学 4 个东西就能干活(真的)
RevTimer.After(2f, () => RevUI.Close("Loading"));               // ① 2 秒后一次(最常用)
RevTimer.NextFrame(() => go.SetActive(true));                 // ② 下一帧
RevTimer.Every(1f, RefreshHp, times: 10);                     // ③ 每秒一次,共 10 次(省略 = 无限)
var h = RevTimer.Every(0.5f, UpdateCountdown); h.Left;      // ④ 句柄:剩几秒 / 进度多少

就这 4 个入口覆盖 90% 的需求。要停就 h.Stop();随对象销毁一行 RevTimer.CancelAllOf(this)。
剩下的(作用域、秒表、服务器时刻、四时间域……)都是用到再看的增值项。

人话 计时器系统 = 你只说"多久之后 / 每隔多久 / 到哪个时刻做什么",剩下的(每帧推进、到期判定、循环计数、暂停恢复、回收)它全包。

它也不需要你配置:第一次用到就自动创建一个隐藏宿主每帧推进;时间用 double 秒累积,所以没有"每帧丢 0.67ms、跑半小时差几秒"那种漂移。

它替你解决的问题怎么做的
切后台回来倒计时全错四种时间域显式选择(UI 倒计时用 Unscaled,活动用 Server 绝对时刻)
忘了停循环计时器 → 泄漏owner 一行批量清理 + using 作用域
句柄复用误停别人句柄 = 槽位 + 代际号:过期句柄所有操作都是安全空操作
回调里停别人 / 新建 → 炸先收集后执行:派发期间不动表,回调里随便增删
一个回调抛异常拖垮整个系统逐回调隔离(其余照常推进),异常走 RevTimer.OnException / Failed 事件
长倒计时越跑越偏double 累积 + 余量结转(实测:1 秒循环跑 10 分钟 = 恰好 600 次)

一3 分钟跑起来(可粘贴)

不用配置、不用挂脚本、不用写 Update。

// ① 延迟做一件事
RevTimer.After(1.5f, () => RevLog.Info("1.5 秒到了"));

// ② 每帧刷倒计时(UI 文本 + 填充条)
void OnEnable()
{
    _handle = RevTimer.Every(1f, Refresh, times: 10);        // 每秒一次,共 10 次
}

void Update()
{
    if (!_handle.IsAlive) return;
    countText.text = Mathf.CeilToInt((float)_handle.Left).ToString();
    fillImage.fillAmount = (float)_handle.Progress;
}

// ③ 随对象销毁一行清干净(防泄漏的关键一行)
void OnDestroy() => RevTimer.CancelAllOf(this);
就这样,没有第 4 步 不摆物体、不挂脚本、不写 Update 驱动 —— 第一次创建计时器时,框架自动创建一个隐藏宿主每帧推进它。

二"我要做 X" 对照表(全部 API)

左边找需求,右边抄一行。所有方法签名都写在一行里,参数不用换行。

我想…这么写
N 秒后一次RevTimer.After(2f, cb)
下一帧一次RevTimer.NextFrame(cb)
每 N 秒一次(无限 / 有限)RevTimer.Every(1f, cb) / RevTimer.Every(1f, cb, times: 10)
每 N 秒一次 + 知道第几次RevTimer.Every(1f, i => tip.text = $"{i}/10", times: 10)
到服务器某个绝对时刻RevTimer.SyncServerTime(服务器UTC) → RevTimer.At(activityEndUtc, cb)
换时间域(不受 timeScale 影响等)任一入口传 RevTimeDomain.Unscaled / .Fixed
停止 / 暂停 / 恢复 / 重置h.Stop() · h.Pause() · h.Resume() · h.Restart() · h.IsAlive
读剩余时间 / 进度(做 UI)h.Left(秒) · h.Progress(0~1)
随对象销毁清干净RevTimer.CancelAllOf(this)
一块创建、一块停using (var s = RevTimer.OpenScope()) { s.Every(1f, Refresh); }
秒表var sw = RevTimer.StartStopwatch(); sw.Elapsed
全局暂停(打开设置面板 / 加载中)RevTimer.Paused = true;
清空 / 看数量 / 看时间读数RevTimer.Clear() · RevTimer.Count · RevTimer.GameTime / RealTime
自己驱动(跟你的逻辑帧走)RevTimer.Tick(Time.deltaTime, Time.unscaledDeltaTime, Time.realtimeSinceStartup) + RevTimer.TickFixed(Time.fixedDeltaTime)

三四种时间域:选错就是线上事故

"切后台回来倒计时全错" 是这类系统最常见的线上事故 —— 根因都是没选对时间轴。

域时间源用在哪切后台 / 卡顿timeScale=0
Scaled
默认
deltaTime表现、动画、战斗演出不走冻结
UnscaledunscaledDeltaTimeUI 倒计时、请求超时、轮询、音频收尾不走照走
FixedfixedDeltaTime帧率无关的战斗推进(对齐参考实现 FrameSync)走照走
Server服务器 UTC 绝对时刻活动结束、榜单刷新、防改设备时间无关 ★照走
★ Server 域是"时刻式",不是"从此刻起 X 秒" 它比的是绝对 UTC 时刻,所以天然免疫卡顿、掉帧、切后台、玩家改设备时间。 参考实现"恢复帧一次性补 60 万毫秒"的补偿尖峰问题,在这里用来选对域就消化掉了,不需要任何补偿代码。
✗ UI 倒计时用默认的 Scaled
RevTimer.Every(1f, Refresh);
// 暂停游戏(timeScale=0)→ 倒计时不动了
// 玩家:这 UI 卡死了?
✓ 显式用 Unscaled
RevTimer.Every(1f, Refresh,
    domain: RevTimeDomain.Unscaled);
// 暂停/慢放时倒计时照走(这才是玩家预期)
活动结束时间一定要用 Server 域
RevTimer.SyncServerTime(服务器下发的UTC);                        // 收到时间就校准一次(可反复校准)
RevTimer.At(activityEndUtc, OnActivityEnd);                       // 到点做事:改设备时间也骗不过它
没校准就用 At 会返回空句柄并报 NoServerTime —— 框架不瞎猜时间。

四句柄:停 / 暂停 / 剩余 / 进度

创建时拿到句柄;句柄过期后所有操作都是安全空操作。

var h = RevTimer.Every(0.5f, UpdateCountdown);

h.IsAlive;      // 还在跑吗(过期 → false)
h.Left;         // 剩余秒数(UI 倒计时直接用它;过期 → 0)
h.Progress;     // 0~1(UI 填充条直接用它;过期 → 1)
h.Pause();      // 冻结时间(剩余量保留)
h.Resume();
h.Restart();    // 重新计时(已走时长清零、次数复原)
h.Stop();       // 停止并回收(重复调用安全)
代际号:过期句柄不会误停别人 句柄 = 槽位 + 代际号。一个早就结束的句柄,即使它占用的槽位已经被新计时器复用, h.Stop() 也打不中新计时器(也不会抛异常)。早期实现的裸 int 句柄做不到这一点 —— "悬空 id 误停新计时器"是它的事故之一。
取舍 框架不提供"现在几条在跑、卡在哪一步"这类查询(要感知结束就写在回调里); 但 Left / Progress 是数据不是状态,所以刻意提供——倒计时 UI 不该自己算时间。

五一行防泄漏:owner 与作用域

"循环计时器忘了停"是唯一能把整个计时器模块拖死的错法 —— 用两种方式根治。

方式一:owner(推荐给"跟随对象"的计时器)
面板、角色、玩法管理器:只要它还活着,这些计时器才有意义
RevTimer.Every(1f, RefreshHp, owner: this);
RevTimer.After(3f, Close, owner: this);

void OnDestroy() => RevTimer.CancelAllOf(this);      // ← 一行,全停
owner 是显式参数(不是反射委托的 Target),闭包 / 静态方法 / 多个委托都不会漏。
方式二:作用域(推荐给"一段流程"的计时器)
打开一个面板、进入一段过场、跑一个玩法:这段时间内的计时器都归它
using (var scope = RevTimer.OpenScope())
{
    scope.Every(1f, RefreshHp);          // 这块里建的全归 scope 管
    scope.After(3f, Close);
}   // ← 出块立刻全停;不用记句柄、新增计时器不用改收尾代码
为什么要这么在意"忘停" 循环计时器忘了停 = 永久泄漏 + 回调打到已销毁对象 + 数量累积(到上限 1024 后新建会被拒绝,报 Overflow)。 正解永远是上面两种之一,而不是"记得手动 Stop"。

六秒表(测耗时)

它和计时器共用同一套时间源,所以不用自己算时间,也不会泄漏。

var sw = RevTimer.StartStopwatch();                  // 默认 Scaled:测"游戏内实际耗时"
// ... 干活 ...
sw.Stop();
RevLog.Info($"耗时 {(sw.Elapsed * 1000):F1} ms");

var real = RevTimer.StartStopwatch(RevTimeDomain.Unscaled);   // 测"真实经过时间"(性能/网络/加载)
方法说明
Elapsed已累计秒数(读的时候实时算,不会读到上一步的旧值)
Start / Stop停表后累计值保留,可再 Start 继续
Reset / Restart清零(并停表 / 并重新开始)
两个域的分工Scaled = 玩家实际经历的游戏内耗时(timeScale=0 时冻结);Unscaled = 真实经过时间
为什么秒表不会泄漏 它不注册、不订阅、不进任何列表 —— 起止时只记"当时的读数",读 Elapsed 时做一次减法。 早期实现那种"每创建一个计时器都要记得销毁"的纪律,秒表这里不需要。

七全局暂停与"我想自己驱动"

两个高级但常用的开关。

7.1 全局暂停:RevTimer.Paused

RevTimer.Paused = true;      // 打开设置面板 / 加载中:整个模块停止推进(含 Server / Fixed 域)
RevTimer.Paused = false;     // 恢复(未到期的时间从原处继续)
它是模块开关,不是 Unity 的 Time.timeScale(后者管的是 Scaled 域的时间源)。 两者互不干扰:可以"timeScale = 0 让画面静止"同时"UI 倒计时照走"。

7.2 自己驱动:让计时器跟着你的逻辑帧走

void Update()      => RevTimer.Tick(Time.deltaTime, Time.unscaledDeltaTime, Time.realtimeSinceStartup);
void FixedUpdate() => RevTimer.TickFixed(Time.fixedDeltaTime);
什么时候需要说明
你有自己的逻辑帧(战斗帧同步)用 Fixed 域 + TickFixed,让技能 CD 在 60fps / 30fps 手机上推进一致
想和你的更新顺序对齐调一次 RevTimer.Tick(...) 之后,隐藏宿主会自动让位(避免同一帧推进两次:那会让所有倒计时走快一倍)

八新手最容易踩的 8 个坑

每条都有人因此白干半天。

#坑正解
1UI 倒计时用默认的 Scaled 域timeScale = 0 时倒计时会冻住 → 传 RevTimeDomain.Unscaled
2循环计时器忘了停owner: this + OnDestroy 里 CancelAllOf(this),或用 OpenScope()
3At(...) 之前没 SyncServerTime会返回空句柄 + 报 NoServerTime(不瞎猜是刻意的)
4以为 Paused 只管 Scaled 域它是整个模块的推进开关(含 Server / Fixed)
5在回调里干重活回调跑在主线程 Tick 里,会直接吃掉这一帧;重活丢给异步设施
6指望回调精确到帧帧级抖动不可避免;要帧精确用 Fixed 域 + 自己数帧
7把 After(0) 当"立刻执行"它是下一帧;要在当前调用栈里立刻做就直接调用
8把高频间隔(0.01s)当"一秒几十次"每个计时器每帧最多触发一次,余量保留、下帧继续补;真正吞吐上限是帧率(这是不让回调风暴卡死一帧的刻意设计)

九没触发怎么查(5 分钟定位)

按顺序排除,每步都能立刻验证。

① 域选对了吗
→
② 被全局暂停?
→
③ 句柄还活着吗
→
④ 请求被拒了吗
现象查这里
完全没触发域是不是 Fixed(需要 FixedUpdate 在跑)?Server(需要先校准)?RevTimer.Paused 是 true 吗?h.IsAlive 呢?
创建时就返回空句柄订阅 RevTimer.Failed 看原因码:InvalidDuration(时长/间隔非法)、Overflow(超过 1024 个)、NoServerTime(没校准)
次数比预期多 / 少看第八节第 8 条:每帧最多一次,节奏 = 你给的间隔 + 帧长量化
数量一直在涨有循环计时器漏停(RevTimer.Count 看数量;用 owner / 作用域清)
回调里的异常在刷屏订阅 RevTimer.OnException 拿原始堆栈;出错的计时器不会被自动停(参考实现契约),要停就自己 Stop()
一次"暂停/恢复"后时间不对确认用的是 h.Pause()(冻结时间、保留剩余)而不是 h.Restart()(清零重来)

十附:文件清单 / 验收 / 设计来源

想深入看代码或判断可信度时读这一节。

10.1 文件都在哪(你只需要读一个)

文件行数说明
Facade\RevTimer.cs215★ 唯一入口:4 个创建入口 + 清理 + 暂停 + 读数 + 事件
Core\RevTimerDefines.cs83四种时间域语义表 + 失败原因码 + 上限(纯 C#)
Core\RevTimerHandle.cs81句柄:过期即安全空操作;Left / Progress(纯 C#)
Core\RevTimerTable.cs196槽位表:代际号 / 延迟复用 / 校验(纯 C#,可工程外单测)
Core\RevServerClock.cs81服务器时间:一次校准 + 本地外推(纯 C#)
Core\RevStopwatch.cs78秒表(纯 C#)
Implementation\RevTimerCore.cs490内核:四域推进 → 到期收集 → 执行 → 回收(不用读)
Support\RevTimerDriver.cs73隐藏宿主:Update 推三域 + FixedUpdate 推 Fixed(不用读)
Support\RevTimerUnityHooks.cs53出口接管 + 进 Play 复位(不用读)
Support\RevTimerScope.cs69using 一块,退出全停

10.2 验收情况

项结果
工程外行为断言(假时钟,无 Unity)55 / 55 通过:代际失效(过期句柄打不中新计时器)、四域隔离、误差契约(1 秒循环跑 10 分钟 = 恰好 600 次)、余量结转、重入安全、异常隔离、上限拒绝、owner/作用域清理、全局暂停、秒表
零 GC10 万次 创建+停止 总分配 < 4KB(池化生效)
每帧成本1000 个存活计时器 + 1024 槽位线性扫描 = 0.0139 ms/帧
编译Revolution.Runtime 0 错 0 警;Revolution.Editor 0 错
依赖RevObjectPool(计时器对象池,纯 C#);其余无

10.3 设计来源(参考实现计时器 + 你的早期实现)

抄来的(参考实现精华)本框架怎么落
四时间轴显式分离(Normal / FrameSync / Accurate / Server)合成四种时间域(Scaled / Unscaled / Fixed / Server),语义表见第三节
双到期模型(累积式 + 时刻式)After / Every 累积式;At 时刻式(比绝对 UTC)
池化纪律:复用前彻底归零 + 解绑委托RevTimerEntry.ResetAll() 清时间并解绑回调与 owner
异常隔离到单个回调逐回调 try/catch → Failed(CallbackThrew) + OnException;不自动终止(与参考实现契约一致)
先收集后执行(LuaTimer 的重入安全)到期先收集清单,收集完再派发 → 回调里 Stop/新建都安全
简单结构优先(不做时间轮)固定 1024 槽位 + 线性扫描(参考实现自己的结论:量级不需要的地方用复杂算法是负资产)
服务器时间:锚点 + 本地 realtime 外推RevServerClock;允许反复校准抑制漂移
早期实现的硬伤(已根治)怎么修的
回调抛异常 → 常驻 async 循环 faulted → 整个计时器系统静默死亡逐回调隔离;没有任何常驻循环,不存在"整条泵死掉"的形态
边遍历边改字典 → 回调里建/删必抛异常先收集后执行 + 派发期间不动表
interval 默认 0 → 每 100ms 回调一次的风暴创建时校验:循环必须给正间隔 → InvalidDuration
恒定 100ms 减法 → 拖帧时计时不足、亚帧需求做不到按真实 delta 累积 + double 精度;NextFrame 支持"下一帧"
裸 int 句柄 → 不可失效 / 溢出撞键 / 停不中也不报错槽位 + 代际号;失败有明确原因码
结束后对象两次归还池 → 池里两份、回调 NRE池化走 RevPoolCore(重复归还拦截 + 空壳懒清扫)
单例 + 私有构造里直接起常驻循环;暂停/重置语义混淆内核是普通实例(可多开、可单测);Pause/Resume/Restart 分开
配套文档 代码目录里还有一份 Assets/Revolution/Runtime/RevTimer/README.md(3 分钟上手 + 8 个坑 + 对照表),内容与本文一致、更适合对着代码看。