摘要

战斗记录 App 的实时模式会不断抓取游戏窗口的画面,送进一个“选帧服务”,由它挑出值得读取的关键帧。前一轮修好选帧服务的速度后,实时回放中仍只有约 74% 的抓帧被送进服务(6,458/8,700),瓶颈被怀疑在 App 一侧。问题在于:一个只停留 3 秒的战斗日志画面,可能整个被漏掉。

本实验先诊断丢帧的位置,再做两处改动:给实时源加一个最多 8 帧的待发队列;减少 GUI 线程上的长任务(存储层每次都重新读取、解析全部读数文件)。在同一段 300 秒回放上,送达率从 79% 提到 96%;在之后的另一处优化落地后重测,达到 98.4%。

要回答的问题

抓到的帧为什么没有送进选帧服务?在不改变手动模式语义的前提下,能把送达率提高到多少?

数据与来源

  • 用一段录像(22:53 开始录制的一局)的第 900–1200 秒,按墙钟速度播放来代替抓取游戏窗口;游戏没有打开。墙钟时长 300 秒。
  • 真实的 Battle Notes 实时模式在离屏状态下运行。

方法

诊断(不改 App 代码)。 在脚本里包一层计数:抓帧数、送进服务的帧数、因服务落后 8 帧而停发的轮询次数、GUI 线程上自动分析两次轮询之间的间隔。

诊断发现改前“因服务落后停发”的次数是 0,所以不是服务慢。原因在取帧链路:

  1. 采集线程把最新帧写进一个“容量为 1”的槽;
  2. 自动分析每次轮询时,先让后台线程去取;
  3. 后台线程还要经过一个 10 帧/秒的节拍器(距上次至少 100 毫秒,不补发);
  4. 取到后放进另一个单帧槽,下一次轮询才取走。

两个时钟各走各的,加上 GUI 线程偶尔卡顿(最长 3 秒),新帧在被取走之前就被下一帧覆盖了。

改动一:待发队列。

  • 实时源多存一个最多 8 帧的待发队列,按先后交出未发的帧,并统计溢出数;原来“只给最新帧”的接口保持不变(手动模式语义不变)。
  • 实时源打开后,直接从待发队列取帧,不经过后台线程和节拍器。
  • 实时模式有待发帧时,一次按先后全部送出,最多到服务落后上限;视频回放和其他来源仍一次一帧。
  • 额外内存最多 8 张原生帧(约 88 MB),只有 GUI 线程卡住时才会攒满。

改动二:减少 GUI 线程卡顿。 加一个看门狗线程:GUI 线程 150 毫秒没有轮询时,每 50 毫秒抓一次它的调用栈。300 秒内抓到 600 个卡顿样本(约 30 秒,占 10%),最多的是存储层的“取已缓存读数”函数(230 个):每读完一帧和每次刷新“敌方构筑条件”面板时都会调用,每次都把本局全部已存读数文件(每个可达数 MB 的 JSON)重新读取、解析、校验,组装请求的函数还被调用了两次。修改为:

  • 只组装一次请求,同时用于投影校验和上下文引用核对;
  • 按(记录内容,相关文件的大小、修改时间、inode)缓存成功结果,最多 256 条;文件一变缓存键就变,重新读取与校验;命中时仍重新做依赖当前索引的上下文校验,返回深拷贝。

设置

同一段 300 秒回放反复测量。后续重测时机器空闲(游戏未开,也没有其他 OCR 任务在跑)。

结果

待发队列改动前后:

指标 改前 改后
抓帧(采集线程,约 9 帧/秒) 2,730 2,713
送进选帧服务 2,153(79%) 2,606(96%)
因服务落后停发的轮询 0 12
轮询间隔 p50 / p95 / 最大 32 / 62 毫秒 / 3.0 秒 31 / 47 毫秒 / 2.0 秒
间隔超过 200 毫秒的次数 62 65

存储读取缓存前后:

指标 改前 改后
单次读取已缓存读数(18 个读数的会话) 630 毫秒 首次 465 毫秒,之后 305 毫秒
GUI 卡顿样本(300 秒) 600 467
其中存储读取函数 230 136
送进服务 / 抓帧 2,582 / 2,706(95.4%) 2,634 / 2,728(96.6%)

之后另一处“投影校验结果记忆”优化落地,在机器空闲时重测:

指标 上次 这次
送进服务 / 抓帧 2,634 / 2,728(96.6%) 2,670 / 2,714(98.4%)
因服务落后停发的轮询 11 7
轮询间隔 p50 / p95 / 最大 32 / 47 毫秒 / 2.0 秒 32 / 47 毫秒 / 1.06 秒
间隔超过 200 毫秒的次数 74 60
GUI 卡顿样本(300 秒) 467 262

失败与负结果

  • 改动二之后,超过 200 毫秒的长卡顿次数没有减少(65→74,属于波动)。剩下的卡顿来自投影结果的完整校验(包括整体编码量)、每帧保存时的上下文校验、界面刷新本身;要再减少需要把这些工作移出 GUI 线程,改动较大,当时没有做。
  • 重测后剩下的卡顿样本主要在:保存读数(46–49 个)、视图编码(约 42 个)、界面同步与发布(51、48 个)、数值面板刷新(31 个)。
  • 在第一次改动后,剩下约 4% 的帧丢在 GUI 线程卡顿超过约 0.8 秒的时候。

局限

  • 只有一段 300 秒的回放,而且是用录像代替真实游戏窗口;游戏运行时的资源竞争没有测。
  • 送达率只说明帧进了选帧服务,不代表关键帧被正确挑出或读对。
  • 不同测量之间存在机器负载波动。

结论与下一步

丢帧的主要原因是取帧链路设计(两个独立时钟 + 单帧槽),而不是选帧服务太慢;加一个小的待发队列就把送达率从 79% 提到 96%,再配合减少 GUI 线程上的重复文件读取与校验,重测达到 98.4%。要进一步减少,需要把保存、编码、校验等长任务移出 GUI 线程,或把送帧本身移出 GUI 线程。