玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWorldController.lua 里的心跳三兄弟:CheckSendHeartBeat、CheckRecvHeartBeat、OnGameSuspend。网络协议那篇讲过心跳包该发什么,这一篇讲心跳的生命周期:谁在发、谁在等、切后台时发生了什么。
心跳在引擎里的身份要摆正:它是网络层的听诊器加起搏器——听诊器是接收侧的超时判定(网络死了它能听出来),起搏器是发送侧的定时施压(网络活着它持续证明活着)。主循环工位表里它垫底不是不重要,是它依赖前面所有系统"还活着"这个事实——心跳检查必须在一切正常之后,才能得出"异常只剩网络"的结论。顺序即语义,又见一例。
心跳方案还有一段进化史值得交代。最早的版本没有心跳,掉线全靠"发包失败"感知——问题是 TCP 的失败感知要等内核重传超时,慢的时候两三分钟,玩家早就原地罚站了。业务心跳的本质是把"发现断线"的权力从内核超时(分钟级)收回到应用层(秒级):感知速度是最贵的体验指标,为了把两分钟缩到十秒,全服每连接每五秒多一个小包——这笔账怎么算都是赚的。
发送侧的代码短到可以全贴:
function GameWorldController:CheckSendHeartBeat()
-- 发心跳
if OS_TIME() - self.mHeartBeatSendT < HEART_BEAT_SEND_TIME then
return -- 还没到点,歇着
end
self.mHeartBeatSendT = OS_TIME() -- 刷新发送戳
LuaSendMsg(global.MsgType.MSG_CS_HEART_BEAT_KEEP, self.mHeartBeatSendT)
end
标准到教科书:距上次发送超过 HEART_BEAT_SEND_TIME 就发一跳 MSG_CS_HEART_BEAT_KEEP,载荷就是发送时间戳。每帧被主循环调用(上一篇的工位表里垫底那位),到点干活不到点 return——心跳发送是主循环里最便宜的一拍。
"每帧调用、到点干活"的写法也顺带说一句:这比"每 5 秒注册一个定时器"的写法稳。定时器方案在主循环停摆(切后台)时跟着停,而主循环调用方案里心跳的生命周期和主循环天然绑定——切后台的语义就是"心跳也停",正好和 OnGameSuspend 的补发逻辑接龙。心跳的驱动方式决定了它的挂起语义,这是选型时要想清楚的暗线。
时间戳当载荷还有个妙用:服务端拿它做单程延迟估算。心跳包从发出到服务端收到的时间差就是下行延迟的采样,几十秒一跳顺带完成网络质量画像,一根管子两种用途。协议篇说过"心跳只管活着不带业务",但带个时间戳不算业务——它服务于传输层自己。
接收侧比发送侧多两道弯:
function GameWorldController:CheckRecvHeartBeat()
-- 心跳超时
if OS_TIME() - self.mHeartBeatRecvT < HEART_BEAT_RECV_TIME then
return -- 没超时,活着
end
-- 断线重连中...
local NetworkProxy = global.Facade:retrieveProxy(global.ProxyTable.NetworkProxy)
if NetworkProxy:IsDisconnect() then
self:ResetRecvHeartBeat() -- 已经在重连了,别重复判死
return
end
global.networkCtl:OnHeartBeatTimeout() -- 判死,进入重连流程
end
第一道弯是"正在重连就别添乱":断线后重连模块接管了,心跳检查如果继续报 OnHeartBeatTimeout,重连流程会被反复打断。IsDisconnect 的判断让判死只发生一次,后续的等待归重连模块管。判死是一次性事件,不是持续状态——这个区分新手最容易漏,漏了的症状是断线后重连弹窗闪个不停。
第二道弯藏在 ResetRecvHeartBeat 的调用时机里:重连开始时重置接收戳,给重连流程留足窗口(可能要试好几次),窗口内心跳检查完全闭嘴。两个系统交接时,计时器的归属要跟着交接——心跳计时在重连期间归重连模块管,这条边界不清就是无限弹窗。
计时比较用的 OS_TIME() 也交代一句:它是秒级的服务端对齐时间(时间工具那篇的 GetServerTime 家族),不是毫秒秤。心跳判定精度到秒足够,用秒秤还能天然免疫毫秒抖动——判定精度跟着用途走,这条在 A* 的 h 函数、切比雪夫距离里都应验过,计时器上再应验一次。
flowchart TD
A[主循环每帧] --> B[CheckSendHeartBeat]
A --> C[CheckRecvHeartBeat]
B --> D{距上次发送 ≥ SEND间隔?}
D -->|是| E[LuaSendMsg 心跳包]
D -->|否| F[return]
C --> G{距上次收到 ≥ RECV超时?}
G -->|否| H[活着, return]
G -->|是| I{NetworkProxy:IsDisconnect?}
I -->|是,重连中| J[ResetRecv 重置, 闭嘴等待]
I -->|否| K[OnHeartBeatTimeout 判死 → 重连流程]
L[OnGameSuspend 切后台] --> M[先补发一跳心跳] --> N[挂起, 不判死]
移动端的特殊场景:玩家切后台,游戏进程挂起,主循环停摆——心跳发送停了。服务端那边不知道你切了屏,只看到"这小子心跳停了",按超时逻辑把你踢下线。锁屏一分钟回来发现掉线重连,就是没处理这个场景的裸奔状态。
引擎的 OnGameSuspend 给了答案:
function GameWorldController:OnGameSuspend()
-- 暂停前发心跳
self.mHeartBeatSendT = OS_TIME()
LuaSendMsg(global.MsgType.MSG_CS_HEART_BEAT_KEEP, self.mHeartBeatSendT)
end
挂起前补发一跳心跳:服务端收到这跳就知道"玩家是切后台了"(系统会下发挂起事件),把超时判定放宽到切回时刻,而不是按固定超时踢人。一脚刹车的功夫,锁屏掉线的差评就没了。这个函数的哲学是:在自己即将失联前的最后一刻,把状态说清楚——挂起、崩溃前的存档、退出前的上报,全是同一个思想的变体。
有个实现细节值得抠:切后台的瞬间引擎还能发出去这一跳吗?能——挂起通知到达时进程还有短暂的收尾时间(毫秒级),这一跳赶在挂起前写进了内核发送缓冲,网络层会尽力送达。但如果玩家是直接杀进程,这跳就发不出去,服务端按超时正常判死——所以补发心跳是"尽力而为"的优化,不是"保证不死"的承诺,服务端的超时兜底永远要在。客户端的临终遗言可以发,服务端的天塌预案不能省,两头都做齐才算完整。
反方向的 OnGameResume(切回)也有配套动作:立即发一跳心跳加请求全量快照(网络篇的重连三段式),切回来的玩家几秒内满血复活继续玩。挂起和恢复是一对动作,只做一半等于没做。
切回时的快照请求还有个细节:必须先等心跳对上再发快照请求——心跳没对上说明网络还没真正通,快照请求会石沉大海。顺序是"补跳心跳 → 心跳确认 → 请求快照",三级火箭式的恢复流程,每级确认后再点下一级的火。乱序的写法(切回立刻怼快照请求)在网络半通时会造成请求丢失加界面卡死,这是切后台功能上线初期的真实工单,后来用"心跳先通"做了闸门才治好。
下面这个演示把心跳时间轴铺开了:正常时蓝箭头(发送)和绿箭头(回包)规律交替;点"模拟断网",回包消失、接收计时累积、超时红线判死进重连;点"切后台"看紫色补发心跳——三个按钮把心跳的一生演完,倒计时数字实时显示距离发送和判死还有多久:
skill-heartbeat-life-1003f
演示里的参数还能玩出教学效果:把发送间隔拉到 10 秒、接收超时拉到 10 秒(1:1 的错误配比),再点断网——你会发现偶尔一次回包迟到就被误判死亡;调回 2:1 以上配比,同样的网络抖动安然无恙。判死阈值的手感,亲手拧一次胜过读十遍。
某服的数据里有个奇怪现象:晚高峰时段(18:00-19:30)的掉线率是其他时段的三倍,且这批掉线玩家的回流率只有四成。排查发现主因不是服务器,是地铁隧道——断网 30 秒到 2 分钟,服务端心跳超时踢人,客户端重连后还要重走全量快照,玩家干脆不回了。
治理三步。第一,服务端对"收到挂起补发心跳"的连接放宽超时到 5 分钟(切后台是合法状态)。第二,客户端重连成功后的全量快照做增量优化(网络篇的断点续传同思路),重连恢复时间从 11 秒压到 3 秒。第三,重连期间给玩家看"重连中,进度 2/3"的进度条,替代原来的转圈。三步落地后晚高峰掉线率回落到正常水平,回流率从四成回升到八成一——掉线不可避免,掉线体验可以经营,这批"地铁党"后来成了那个服最稳的日活基本盘。
工时账:服务端放宽判定半天,增量快照两天,进度条半天。三天换回一批最忠实的玩家,这是全套教程里 ROI 最高的一单。
那次复盘还挖出一个隐藏收益:挂起补发心跳的机制上线后,服务端第一次有了"谁在什么时候切后台"的真实数据——统计发现晚高峰切后台峰值比想象的早 40 分钟(18:20 而非 19:00),运营据此把晚高峰活动的开启时间提前,参与率涨了两成。为一个目的搭的管道,常常能输送意料之外的情报——挂起心跳本来是保命的,结果成了用户行为研究的采样器。
数据化还有一个反方向的警醒:挂起数据上线后,有运营同事提议"对长时间切后台的玩家推送召回通知"。讨论后被否了——切后台是合法行为,推送召回越界就是骚扰。数据能告诉你玩家在哪,但"要不要伸手"是产品判断,管道和权限分开管,这条边界在数据化浪潮里越来越值钱。
再补一个边界案例:切后台期间服务端要不要继续给这个玩家算挂机收益?游戏性问题的答案在策划,但心跳数据给了策划决策的依据——切后台玩家的资源产出是否减半,从"拍脑袋"变成了"看切后台时长分布再定"。心跳管不了游戏规则,但它让规则有数可依,这就是基础设施对玩法设计的反哺。
问:发送间隔和接收超时怎么配比?
接收超时要大于发送间隔的至少两倍(允许丢一跳不误判):发 5 秒一跳,丢一跳还有下一跳兜着,超时 10 秒起。配成 1:1 的团队都会遇到"网络抖一下就掉线"的灵异工单,比例错了判死就是误杀。
问:心跳超时后直接踢还是给重连机会?
给重连机会,且重连期间的服务端会话保留(几十秒窗口)。玩家视角"网络抖动不该被踢回登录页",服务端视角"会话保留要防顶号"——两全的做法是保留会话但拒绝新登录顶号,重连窗口过了再回收。这个策略在网络篇的断线重连里展开过,心跳是它的起搏器。
问:OnGameSuspend 在 PC 端有对应场景吗?
有,最小化。PC 客户端最小化后渲染降帧但主循环通常还在,心跳不断;真正需要补发的是"进程被系统冻结"的极端情况。移动端是主战场,PC 端做个空实现保持接口对称即可——接口对称的价值在跨平台代码不分支。
问:心跳超时和 TCP 自带的断线检测重复吗?
不重复,Tcp 的 keepalive 默认两小时才探一次,游戏 10 秒级的业务心跳是它的几百倍密度。TCP 层只保证"连接管道活着",业务心跳保证"游戏会话活着"——管道在会话死(服务端重启、顶号)的场景 TCP 毫无感知,两层检测各管各的协议层次。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…