《移动游戏产品的架构·客户端与移动运行时》9.2 每帧预算与主线程任务切片

一帧不是一整块时间,而是输入、逻辑、渲染、提交四段共享同一份预算。本节用《潮汐消消》一键收菜造成的 200ms 长帧,说明如何先归因再切片:输入与提交不切,渲染不跨帧,逻辑在安全点按批次让帧,并用可运行模拟器验证长帧归属与延迟,最后给出双案例的参数差异。

事故:一次收菜把主线程按住了 200ms

《潮汐消消》的家园版本做过一次「一键收菜」:玩家点一下,客户端要结算 200 多个装饰的产出、刷新列表、更新货币、播放收获特效。案例设定中,上线后低端机出现明显迟滞——点击后约 0.2 秒没有反馈,随后画面跳一下才恢复。

最初判断是渲染压力:200 个装饰同时出现,粒子太多。团队把分辨率从 1.0 降到 0.75,卡顿几乎没变。把逻辑耗时打出来后才发现,单帧逻辑结算达到 28ms,叠加输入与渲染后连续 6 帧超过 30fps 的 33.3ms 预算;输入事件排在长任务后面,所以点击要等结算跑完才被处理。

这不是渲染管线问题,而是主线程任务没有切片:一个不可中断的逻辑块占满了帧预算,输入、渲染、提交只能排队。降低分辨率只会让画面变糊,不会让 28ms 的逻辑变短。

更麻烦的是它连续发生了 6 帧。单帧 28ms 只是掉一帧,连续 6 帧超预算就会让输入延迟累积到 200ms 量级;玩家感知的不是「偶尔掉帧」,而是「点了没反应」。

一帧的四段:输入、逻辑、渲染、提交

把一帧拆成四段,不是为了画架构图,而是为了回答「超预算时该动哪一段」。

阶段典型工作能否跨帧切片超预算时的表现
输入触摸、手势、传感器采样转意图不切,最多合并点击延迟,操作不跟手
逻辑结算、AI、动画状态、UI 数据刷新可以,在安全点切画面停顿,输入排队
渲染场景与 UI 提交绘制命令不跨帧,单帧内完成掉帧,GPU 等待
提交present、交换缓冲、同步不切帧末阻塞,整体节拍后移

四段共享同一份帧预算:30fps 为 33.3ms,60fps 为 16.7ms。预算不是给四段各分 25%,而是先扣除输入、渲染、提交这些不可切片的部分,剩下的才是逻辑可以使用的空间。

这个顺序很重要。很多团队把「逻辑预算」当成固定配额,先给逻辑 10ms,再让渲染自己想办法;正确做法是先保证渲染与提交能完成,再把剩余时间交给可切片的逻辑。否则逻辑一忙,渲染就错过提交窗口,表现为整帧掉。

长帧归因:先量再切

长帧不是一个数。至少要区分三种情况:

  • 单次尖峰:偶发一帧 50ms,前后正常;多半是资源解码、GC 或一次性结算,重点看触发条件。
  • 连续超预算:连续多帧超过预算,玩家能感知为停顿;这类问题必须切片或降负载。
  • 固定阶段超预算:输入、渲染、提交本身已经超过预算,逻辑再切片也救不回来;必须先减固定成本。

模拟器把每帧的切片、已用时间、超预算量与「哪个阶段顶穿预算」都记录下来。判断长帧原因时,先看 crossedStage,再看 byStage。

场景输入逻辑渲染提交预算结论
正常帧2ms4ms5ms1ms16.7ms未超预算
渲染顶穿3ms0ms14ms2ms16.7ms超 2.3ms,降分辨率可能有效
固定阶段已超3ms6ms14ms2ms16.7ms固定阶段 19ms,切逻辑无用

第三行是关键反例:即使把 6ms 逻辑切成 3 份,固定阶段仍然超过预算,长帧不会消失。此时应该减少渲染批次、合并提交或降低特效,而不是继续切逻辑。

优先级:先保输入与渲染,再保逻辑完成

同为逻辑任务,优先级也不一样。调度器至少要区分四层,避免后台任务把玩家正在等的结算挤到后面。

优先级任务调度规则超时处理
P0输入、关键 UI 反馈每帧帧首执行不允许排队
P1渲染与提交每帧预留时间不允许被逻辑挤占
P2玩家触发的结算优先使用剩余逻辑空间给即时反馈,跨帧完成
P3预加载、统计、非可见刷新只在预算有余量时执行可暂停或延后

优先级的意义是「超预算时先牺牲谁」。P3 任务可以先停,P2 任务可以跨帧,P1 不能少,P0 不能被推迟。没有优先级,所有任务按提交顺序排队,后台刷新就可能挡在点击结算前面。

切片原则:安全点、批次与让帧

切片不是把任意函数拆成两半。切错位置会留下半更新状态,下一次输入读到脏数据,问题比卡顿更难查。

任务类型是否切片安全点失败后果
单实体结算可以实体之间,状态完整提交后重复结算或漏结算
批量产出可以每批实体处理完货币与列表不一致
跨实体事务不切整段完成后再让帧中间态被其他系统读到
动画与特效可以按播放批次特效跳变
输入处理不切帧首一次处理操作延迟
渲染与提交不切单帧内完成画面撕裂或丢帧

切片粒度按时间定,不按条数定。案例模型用 2–4ms 作为单次逻辑切片上限:太小会让调度开销占比上升,太大则一次切片就可能顶穿预算。这个范围是模型参数,项目要按自己的逻辑耗时分布调整。

「让帧」是切片后的动作:当前帧只跑允许的切片,剩余部分留到下一帧,输入与渲染照常执行。让帧不是 sleep,也不是把任务丢到后台线程;它只是把同一段逻辑拆到多个帧,主线程在每帧仍然先处理输入。

状态一致性要靠事务边界保证。以收菜为例,可以按装饰分批结算,但「扣除体力、增加货币、标记已收取」必须作为一个小事务完成;不能出现体力已扣、货币未加的中间态。批量任务要么按实体隔离,要么在批次之间保存可恢复的快照。

调度顺序:输入优先、预留渲染、逻辑填缝

模型按固定顺序排一帧:输入 → 逻辑 → 渲染 → 提交。逻辑只使用「预算减去输入、渲染、提交」后的剩余空间。

这个顺序有两个直接后果:

  • 输入永远在帧首处理,不会被逻辑切片挤到下一帧;玩家的点击至少能被看到。
  • 渲染与提交先被预留,逻辑只能在缝隙里填;固定阶段超预算时,长帧被明确记录,而不是被逻辑悄悄吃掉。
帧输入逻辑切片渲染提交已用结果
02ms3ms4ms1ms10ms剩余 3ms 逻辑留到下一帧
12ms3ms4ms1ms10ms继续
22ms3ms4ms1ms10ms继续
32ms1ms4ms1ms8ms逻辑完成

如果固定阶段已经超过预算,逻辑切片会被标成「无法单独装入」,仍然执行但记录长帧;这样报告不会假装问题已经解决。模型宁可暴露长帧,也不把超预算藏进平均值。

切片的代价:调度、状态与延迟

切片不是免费的。每多切一刀,就多一次调度、一次状态保存和一次跨帧恢复;切片过度会把一个 6ms 的任务拆成六个 1ms 的小任务,总耗时反而上升。

代价来源控制方法
调度开销每帧多次入队、出队与恢复单次切片 2–4ms,短任务保持原子
状态保存跨帧任务需要快照或可恢复游标按实体隔离,批次之间提交
完成延迟任务跨多帧,玩家可见结果被推迟先给即时反馈,再异步完成
调试复杂度调用栈跨帧,日志难以串联任务 id 贯穿日志,记录帧号与偏移

最容易被忽略的是完成延迟。把收菜拆成 10 帧完成,帧率可能稳定了,但玩家点击后要等 10 帧才看到货币变化,体感仍然慢。正确做法是先给一个即时反馈(按钮按下、进度圈、音效),把结算结果分帧完成;玩家感知的是「有响应」,而不是「所有数字立刻到位」。

一个可操作的切片流程

切片可以按固定步骤落地,避免一上来就改代码:

  1. 先用 9.1 的窗口口径确认是持续超预算,而不是一次资源尖峰。
  2. 把一帧拆成输入、逻辑、渲染、提交,记录各阶段耗时与 crossedStage。
  3. 标出不可切阶段:输入、渲染、提交,以及跨实体事务。
  4. 只对超过单帧剩余空间的逻辑任务切片,短任务保持原样。
  5. 按 2–4ms 定切片上限,按事务边界定切点,不按代码行数切。
  6. 预留渲染与提交时间,逻辑只填剩余空间;固定阶段超预算时直接报警。
  7. 用合成任务验证时间账本,再用真机长任务验证状态一致性与体感延迟。

第 7 步不能省。合成任务只能证明「账算对了」,不能证明「切点安全」;真实业务里的跨系统依赖,往往在合成用例里不存在。

什么时候不该切片

切片只适用于「可中断、可恢复、对完成时间不敏感」的逻辑。下面几类任务不应该切:

  • 短任务:本身只有 1–2ms,切片后的调度开销可能超过收益。
  • 原子事务:扣体力、加货币、写背包必须一次完成,切开会产生中间态。
  • 输入处理:切片会把点击延迟到下一帧,直接损害操作感。
  • 渲染与提交:跨帧会破坏画面完整性,应该减负载而不是切。
  • 强实时反馈:技能命中、受击反馈需要当帧完成,延迟比掉一帧更伤。

不能切时,替代手段是减少工作量:合并重复计算、缓存中间结果、降低特效数量、把可并行的纯计算移到工作线程。工作线程不是万能解,跨线程通信与状态同步会引入新的复杂度,只有在任务足够大且数据边界清晰时才值得。

观测与回归

切片上线后,至少观测四类指标,才能判断「长帧少了」还是「长帧被摊薄了」。

指标含义异常信号
长帧数单帧超过预算 2 倍的次数切片后不降,说明固定阶段仍超预算
任务跨帧数一个逻辑任务横跨的帧数持续增大,说明切片太小或预算被挤占
最大切片耗时单次逻辑切片的峰值接近预算,说明切片上限设得太大
任务年龄从入队到完成的帧数超过玩家可感知阈值,需要即时反馈或提优先级

回归要同时跑合成用例与真机采样。合成用例锁定边界:固定阶段超预算、跨实体事务、最后一片不满、帧数上限;真机采样锁定体感:点击延迟、任务完成时间、长帧是否集中在特定场景。

可运行模型:frame-slicing-demo.ts

完整实现仍在 src/runtime/frame-budget.ts,与 9.1 共用同一份文件。下面的 demo 把 10ms 逻辑按 3ms 切片,观察四帧内逻辑如何让帧、渲染如何每帧照常执行。为了在少量帧内展示切片,demo 把帧预算缩小到 10ms;真实 30fps 预算是 33.3ms,10ms 只是演示参数。

// file: fixtures/frame-slicing-demo.ts
// tier: L1
// verify: node fixtures/frame-slicing-demo.ts

import { sliceFrames } from "../src/runtime/frame-budget.ts";

const report = sliceFrames([
  { id: "tap", stage: "input", costUs: 2_000 },
  { id: "settle", stage: "logic", costUs: 10_000 },
  { id: "scene", stage: "render", costUs: 4_000 },
  { id: "present", stage: "submit", costUs: 1_000 },
], { frameBudgetUs: 10_000, maxLogicSliceUs: 3_000 });

console.log(JSON.stringify({
  frames: report.frames.map((frame) => ({
    index: frame.frameIndex,
    usedUs: frame.usedUs,
    logicSlices: frame.slices.filter((slice) => slice.stage === "logic").length,
    deferred: frame.deferredTaskIds,
  })),
  longFrames: report.longFrames,
  byStage: report.byStage,
  deferredLogicUs: report.deferredLogicUs,
}, null, 2));

运行 node fixtures/frame-slicing-demo.ts,输出可以读成一张四帧计划表:

帧已用时间逻辑切片数剩余逻辑说明
010000us1settle固定阶段 7000us,逻辑只剩 3000us
110000us1settle继续让帧
210000us1settle继续让帧
38000us1无最后 1000us 逻辑完成

报告里的 longFrames 为 0,说明四帧都在 10ms 预算内;byStage 显示输入 8000us、逻辑 10000us、渲染 16000us、提交 4000us,是四帧累计值。判断瓶颈时要按帧平均,不能直接拿累计值比较,否则固定阶段会被帧数放大。

配套测试 test/runtime/frame-budget.test.ts 覆盖四阶段归因、10ms 逻辑跨四帧、长帧阶段归属、固定阶段超预算、CPU/GPU 分类与帧数上限。运行命令是 node --test test/runtime/frame-budget.test.ts。

模型只模拟时间账本,不执行真实任务,也不测量 GC、锁竞争或系统调度。这里是模型,不是真机测量结果;真实长帧仍要回到设备上用同一套阶段口径采样。

双案例:方舟的 16.7ms 更紧

方舟默认 60fps,帧预算只有 16.7ms,战斗逻辑与技能结算更重。照搬消消的 3ms 切片上限可能切得太碎,调度开销反而上升;它需要按战斗帧边界重新定安全点。

消消的单局只有 90 秒,切片优先保证点击反馈;方舟单场 8–12 分钟,切片还要考虑长时间运行后的状态一致性。两者可以共用模型,不能共用参数。

与相邻章节的关系

9.1 决定档位与分辨率,本节解释「为什么超预算、该切哪一段」;9.3 处理长时间运行后的温控降频。分层边界与依赖方向可以参考分层职责 与依赖方向 ,UI 批次与渲染开销可以参考UI 性能与合批 ,长帧与卡顿的观测口径可以参考崩溃与卡顿 。

切片逻辑本身也应该有边界测试:给定固定阶段、逻辑总量与切片上限,输出帧数、长帧数与超预算阶段必须稳定。把这类测试放在模块层,业务改动时就不必每次重跑整局;模块化与依赖整理可以参考模块化 ,分层测试的组织方式可以参考分层测试 。

小结

  • 一帧由输入、逻辑、渲染、提交四段共享预算;先预留固定阶段,再把剩余时间给可切片逻辑。
  • 长帧要分清单次尖峰、连续超预算与固定阶段超预算;固定阶段已超时,切逻辑救不了。
  • 切片只在安全点进行,按 2–4ms 量级分批,跨实体事务不切;让帧不是丢任务,而是把剩余切片留到下一帧。
  • 调度顺序固定为输入优先、渲染预留、逻辑填缝;超预算要记录 crossedStage,不能藏进平均值。
  • 模型只验证时间账本与归因逻辑,真实 GC、锁竞争与系统调度仍要真机采样。

阅读导航:上一节:9.1 帧率分级与动态分辨率 · 下一节:9.3 功耗与发热的验收方法 。

常见错误与边界

  • 把长帧都当成渲染问题。先看 crossedStage 与阶段耗时;逻辑顶穿时降分辨率只会牺牲画质,不会缩短长帧。
  • 在任意语句之间切片。半更新状态会被下一次输入读到,轻则重复结算,重则货币与背包不一致;切片必须落在事务边界。
  • 切片粒度只按条数定。200 个装饰每个 0.1ms 和 20 个装饰每个 1ms 是两种负载;按时间切片才能控制单帧占用。
  • 把让帧实现成 sleep 或后台线程。主线程让帧是「下一帧继续」,不是阻塞等待;丢到后台还会引入跨线程状态问题。
  • 固定阶段超预算仍继续切逻辑。渲染 14ms、提交 2ms 已经超过 16.7ms 时,切逻辑不会减少长帧,应先减固定成本。
  • 只记录帧率不记录任务年龄。切片后帧率可能稳定,但结算结果要等十几帧才出现;任务年龄必须和帧时间一起观测。
  • 所有任务共用一个队列。后台预加载挡在玩家结算前面时,帧率可能正常,操作却明显延迟;至少分出 P0–P3 四层。
  • 切片后不做状态回归。帧率改善但重复结算、漏发奖励,是切点落在事务中间;必须补状态一致性用例。
  • 用条数而不是时间衡量切片。不同实体的结算成本差异很大,按条数切会让单帧占用忽高忽低。
  • 切片上限跟着逻辑总量放大。逻辑从 6ms 涨到 20ms 时仍用旧的 4ms 上限,会让帧数变多但单帧更满,必须重新标定。
  • 边界:本节不写引擎渲染管线,也不测量真实 GC 与调度。模型输出是时间账本,真机长帧要用同一阶段口径重新采样。
  • 把让帧当成免费。让帧只是把成本挪到下一帧,任务年龄仍然在涨;没有年龄上限的让帧,最终会在结算界面集中爆发。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查