监控模块的职责划分:采集、计算、展示、告警

模块化架构下,一个成熟的渲染帧率监控系统,我通常会把它拆成四个独立层。这样做的好处很直接——每层可以独立迭代、独立测试,出了问题也能快速定位。

我记得刚入行时,见过一个项目把采集和计算揉在一个类里,数据来源一换,整个模块都得重写。那叫一个痛苦。所以这节课,咱们就把四层的职责和接口说清楚。

渲染帧率监控模块——四层职责与数据流 采集层 Raw Data (vsync / present 回调) 计算层 fps / jank / 方差 滑动窗口算法 展示层 HUD / 图表 WebSocket 推送 告警层 阈值 / 通知 钉钉 / 飞书 回调 告警设置 核心原则:各层通过定义清晰的接口契约解耦 采集层只输出「时间戳+帧号」元组;计算层只输出「指标字典」;展示层只消费「指标」;告警层独立订阅。 我曾见过团队把告警逻辑写在采集层里,导致采集频率一高就死锁——血的教训。

1. 采集层:只负责「听到心跳」

采集层的职责非常单一:从渲染管线中拿到每一帧的时间点。说白了,它就是一个时间戳打点器。你想想看,不管是 Unity 的 Application.onBeforeRender,还是 Chrome 的 requestAnimationFrame 回调,本质都在做同一件事——记录当前时刻。

我个人习惯让采集层只输出一个结构体:

// 采集层输出接口定义
interface FrameSample {
  frameId: number;      // 帧序号
  timestampMs: number;  // 毫秒级时间戳
  source: string;       // 数据来源标识,如 "vblank" / "present"
}

我在项目中遇到过一个问题:采集层如果掺入「计算帧间隔」的逻辑,当多线程同时调用时,取值会乱。所以接口一定要轻,轻到只做一件事。

避坑建议: 使用环形缓冲区(Ring Buffer)存储最近 1024 个样本,避免高频采集时频繁分配内存。我曾经因为用 List<FrameSample> 导致 GC 抖动,帧率反而更卡了。

2. 计算层:把原始数据变成指标

这层是真正的「大脑」。它接收采集层的 FrameSample 流,计算出一组指标。常见的输出接口是这样的:

// 计算层输出接口
interface FrameMetrics {
  fps: number;              // 当前帧率(平滑值)
  minFps: number;           // 窗口期内最低帧率
  jankCount: number;        // 窗口期内卡顿次数(掉帧 > 16ms)
  avgFrameTimeMs: number;   // 平均帧耗时
  p99FrameTimeMs: number;   // 99 分位帧耗时
  windowSize: number;       // 滑动窗口大小
}

为什么需要滑动窗口?因为裸的「瞬时 FPS = 1000/帧间隔」会剧烈抖动,没法看。我一般用 1 秒窗口(约 60 个样本),算移动平均。

关键算法: Jank 检测。我习惯用「帧耗时超过 2 倍平均帧耗时」视为一次 jank。这个阈值可以配置,但不能放在计算层硬编码,要让上层注入。

计算层要保证自身是纯函数——输入一批样本,输出指标。不依赖外部状态,这样就能方便地做单元测试。

3. 展示层:只做渲染,不做运算

展示层拿到计算层的 FrameMetrics 后,唯一关心的是怎么画出来。你可以透过 HUD 叠加、WebSocket 推送到仪表盘,或者写日志文件。展示层不应该再做任何二次计算,比如「再求个平均值」——那是计算层的事。

展示层的接口定义很简单:

// 展示层消费接口
interface DisplayConsumer {
  onMetricsUpdated(metrics: FrameMetrics): void;
  onError?(error: string): void;  // 可选,展示错误状态
}

我记得有一次产品经理要求在 HUD 上同时显示「实时 FPS」和「历史 FPS 折线图」。团队直接让展示层去缓存历史指标,结果内存失控。正确的做法是:让计算层输出一个包含历史窗口的 FrameMetrics[],展示层只管遍历画图。

注意: 展示层如果涉及 WebSocket 推送,别忘了做心跳保活。我曾经因为没做重连机制,线上仪表盘突然变白,排查半天才发现是长连接断了。

4. 告警层:独自监听,独立触发

告警层是「守门员」。它订阅计算层的指标流,一旦指标超过阈值,就触发通知。它和展示层是兄弟关系,不是父子关系——也就是说,告警层不依赖展示层是否存在。

告警层的输入接口:

// 告警层输入接口
interface AlarmRule {
  metric: keyof FrameMetrics;  // 监控哪个指标,例如 "fps"
  operator: 'lt' | 'gt';      // 小于 / 大于
  threshold: number;           // 阈值
  durationMs: number;          // 持续多久才告警(防抖)
  action: 'dingtalk' | 'feishu' | 'console';
}

我个人建议把告警规则做成配置化,通过 JSON 文件或远程配置下发给告警层。为什么?因为不同环境阈值不同——开发版 30fps 就算流畅,线上版必须 60fps。

层名称核心职责典型接口输出注意事项
采集层记录帧时间戳FrameSample使用 Ring Buffer,避免 GC
计算层生成帧率指标FrameMetrics纯函数,可测试
展示层可视化呈现HUD / WebSocket不做计算,只管渲染
告警层阈值判断 + 通知告警规则 + 回调配置化,防抖降噪
另一种视角: 你完全可以把采集层和计算层合并在 Worker 线程里,展示层放在主线程,告警层另起一个线程。这样主线程不会被帧率计算拖慢。我曾经在项目中用 SharedArrayBuffer 实现零拷贝传递样本,效果极佳。

嗯,到这里四层的职责就梳理清楚了。记住一个原则:每层只做自己名字对应的事。采集就是采集,别去算帧率;展示就是展示,别去改阈值。接口定义清晰后,团队里不同成员可以并行开发不同层,互不干扰。

我自己最喜欢这种分层的点在于——当你想换一个采集方案(比如从 requestAnimationFrame 换成 PerformanceObserver),你只需要改采集层,其他三层完全不需要动。这种解耦带来的维护成本降低,比任何性能优化都值。