〇它是干什么的
先花 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 | 表现、动画、战斗演出 | 不走 | 冻结 |
Unscaled | unscaledDeltaTime | UI 倒计时、请求超时、轮询、音频收尾 | 不走 | 照走 |
Fixed | fixedDeltaTime | 帧率无关的战斗推进(对齐参考实现 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 个坑
每条都有人因此白干半天。
| # | 坑 | 正解 |
|---|---|---|
| 1 | UI 倒计时用默认的 Scaled 域 | timeScale = 0 时倒计时会冻住 → 传 RevTimeDomain.Unscaled |
| 2 | 循环计时器忘了停 | owner: this + OnDestroy 里 CancelAllOf(this),或用 OpenScope() |
| 3 | At(...) 之前没 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.cs | 215 | ★ 唯一入口:4 个创建入口 + 清理 + 暂停 + 读数 + 事件 |
Core\RevTimerDefines.cs | 83 | 四种时间域语义表 + 失败原因码 + 上限(纯 C#) |
Core\RevTimerHandle.cs | 81 | 句柄:过期即安全空操作;Left / Progress(纯 C#) |
Core\RevTimerTable.cs | 196 | 槽位表:代际号 / 延迟复用 / 校验(纯 C#,可工程外单测) |
Core\RevServerClock.cs | 81 | 服务器时间:一次校准 + 本地外推(纯 C#) |
Core\RevStopwatch.cs | 78 | 秒表(纯 C#) |
Implementation\RevTimerCore.cs | 490 | 内核:四域推进 → 到期收集 → 执行 → 回收(不用读) |
Support\RevTimerDriver.cs | 73 | 隐藏宿主:Update 推三域 + FixedUpdate 推 Fixed(不用读) |
Support\RevTimerUnityHooks.cs | 53 | 出口接管 + 进 Play 复位(不用读) |
Support\RevTimerScope.cs | 69 | using 一块,退出全停 |
10.2 验收情况
| 项 | 结果 |
|---|---|
| 工程外行为断言(假时钟,无 Unity) | 55 / 55 通过:代际失效(过期句柄打不中新计时器)、四域隔离、误差契约(1 秒循环跑 10 分钟 = 恰好 600 次)、余量结转、重入安全、异常隔离、上限拒绝、owner/作用域清理、全局暂停、秒表 |
| 零 GC | 10 万次 创建+停止 总分配 < 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 个坑 + 对照表),内容与本文一致、更适合对着代码看。