游戏客户端里的“实体”听起来像一个很抽象的词,实际落到项目里,就是玩家角色、怪物、NPC、子弹、陷阱、召唤物、场景交互物、掉落物、临时特效挂点和战斗中的各种表现对象。它们被创建、初始化、进入场景、参与逻辑、播放表现、被隐藏、回收到对象池,最后可能再次被取出来使用。
实体生命周期管理做得好,玩家不会注意到它。做得不好,问题会非常诡异:已经死亡的怪物还在吃伤害,子弹命中了上一个目标,召唤物消失后 Buff 仍然存在,回收后的角色再次出现时带着旧描边,战斗结束后内存不降,下一局第一波怪物刚刷出来就播放了上一局的受击动画。
这些问题很少是单行代码错误。它们通常来自生命周期阶段不清楚、引用没有断干净、对象池重置不完整、表现层和逻辑层互相持有,以及网络状态和本地表现不在同一个时间点结束。
一个真实感很强的线上问题
某个多人副本里曾经出现过一个问题:Boss 分裂出的小怪死亡后,玩家偶尔还能看到一条伤害连线挂在空气中。测试复现了几次,发现小怪确实已经从场景中消失,碰撞也没了,但连线特效仍然跟着一个“空目标”更新。继续查引用链,发现连线系统缓存了目标实体引用,而目标实体回收到对象池后没有通知表现订阅者;更糟的是,下一次对象池复用这个实体时,它可能已经变成另一个怪物,于是连线又短暂指向了新怪物。
这个问题表面是特效没关,根因是实体销毁阶段没有统一事件。逻辑系统认为实体死亡就结束了,表现系统认为自己会收到解绑通知,对象池认为只要隐藏对象就能复用。三个系统各自都“合理”,合在一起就变成线上 Bug。
生命周期要显式分阶段
实体不要只有 Create 和 Destroy 两个概念。游戏里一个实体至少应该经历这些阶段:
- 分配对象或从对象池取出。
- 绑定配置和唯一实例 ID。
- 初始化逻辑状态。
- 绑定表现资源。
- 进入场景并注册到查询系统。
- 开始接收输入、AI 或网络状态。
- 进入死亡、隐藏、脱战或回收流程。
- 解绑事件、计时器、Buff、表现挂点。
- 从场景查询、目标选择和碰撞系统移除。
- 回收到对象池或真正释放。
这些阶段最好在代码里有明确入口,而不是散落在多个系统里。比如 OnSpawned、OnActivated、OnDeactivating、OnDespawned、OnBeforeRecycle。名字不重要,重要的是每个阶段的职责稳定,调用顺序可预测。
最怕的是某个系统觉得“我只要在 SetActive(false) 前做一点清理就行”,另一个系统又在动画结束回调里做清理,第三个系统在网络删除消息里做清理。清理点越分散,越容易出现时序问题。
唯一实例 ID 比对象引用更可靠
对象池复用会带来一个典型风险:对象引用还在,但实体已经不是原来的实体。比如某个技能锁定了怪物 A,怪物 A 死亡后对象被回收,几秒后对象池把同一个 GameObject 拿来生成怪物 B。如果技能表现还持有旧引用,就可能误把 B 当成 A。
解决方式之一是给每次生成的实体分配唯一实例 ID。外部系统不只保存对象引用,还保存当时的实例 ID。每次回调或延迟执行前,都检查对象当前实例 ID 是否仍然匹配。不匹配就丢弃。
这对投射物、延迟伤害、锁定特效、AI 目标、引导箭头特别有用。它不会替代清理,但能防住很多对象池复用带来的幽灵引用。
对象池重置要有清单
对象池最常见的问题不是没用对象池,而是回收时只隐藏了对象,没有真正重置状态。一个实体可能残留:
- 动画状态和播放速度。
- 材质颜色、描边、透明度。
- Buff 图标和状态层。
- 粒子播放状态。
- 音频句柄。
- 碰撞层和碰撞开关。
- AI 目标和路径。
- 阵营、仇恨、锁定状态。
- UI 血条绑定。
- 计时器和协程。
- 事件订阅。
建议每类实体都有一份回收清单,并且能在开发包里做断言。例如实体回收前仍然有事件订阅,就打印警告;仍然在目标查询系统里,就阻止回收;仍然有未结束的延迟回调,就取消或标记失效。
对象池的目的不是把内存永远留住,而是降低高频创建销毁成本。它应该有容量上限、空闲回收和场景域清理。否则对象池很容易从优化工具变成内存仓库。
逻辑销毁和表现销毁要分开
怪物死亡以后,逻辑上可能已经不能被选中、不能吃伤害、不能参与碰撞,但表现上还要播放死亡动画、掉落特效、音效和飘字。这个阶段如果处理不好,就会出现两类问题:要么表现还没播完实体就被回收,要么逻辑已经结束但仍能被其他技能锁定。
比较稳的做法是拆成两个状态:
- 逻辑失效:从目标选择、碰撞、AI、伤害系统中移除。
- 表现结束:动画、特效、音频和 UI 清理完成后再回收。
中间这段时间实体可以保留显示对象,但必须明确它不再是有效战斗目标。对服务端同步游戏来说,服务端删除实体也应该映射到客户端的逻辑失效,而不是强行立刻删表现。
引用关系要能查
实体生命周期问题难排,是因为引用链很长。一个怪物可能被目标系统、血条、Buff、连线特效、任务追踪、AI、镜头锁定、音频区域和调试面板同时引用。某个引用没断,实体就无法释放;某个引用延迟触发,实体就会被“复活”。
开发包里可以做一个实体调试视图:
- 当前实体 ID、配置 ID、实例 ID。
- 当前生命周期阶段。
- 注册在哪些系统里。
- 有哪些事件订阅。
- 有哪些 Buff 和计时器。
- 是否仍被 UI 或特效引用。
- 最近一次状态变化来源。
这些信息看起来繁琐,但排查幽灵实体时很有价值。没有工具时,团队只能在日志和断点里慢慢找。
上线前检查清单
- 对象池复用是否会生成新的实例 ID。
- 延迟回调是否检查实体仍然有效。
- 逻辑死亡和表现回收是否分阶段。
- 实体回收前是否解绑事件、计时器、Buff 和 UI。
- 目标选择、碰撞、AI、镜头锁定是否会清理实体引用。
- 对象池是否有容量上限和空闲释放策略。
- 开发包是否能查看实体当前注册在哪些系统里。
- 连续多局战斗后,实体数量是否回到预期范围。
结语
实体生命周期管理是客户端稳定性的底层工作。它不显眼,却影响战斗、表现、内存、网络同步和问题复现。真正可靠的做法,是把生成、激活、逻辑失效、表现结束和回收拆清楚,让引用可查,让对象池可控,让每个系统知道自己什么时候该放手。怪物消失以后,引用也应该知道自己该结束了。
进一步工程化落地
如果要把实体生命周期从“大家都知道应该清理”变成真正稳定的工程能力,第一步是做生命周期事件审计。挑一类最复杂的实体,比如 Boss、召唤物或带追踪效果的投射物,把它从创建到回收的所有事件列出来:谁创建它,谁给它绑定配置,谁注册碰撞,谁注册 UI 血条,谁监听它死亡,谁在它回收时还有引用。这个表写出来以后,通常会发现很多系统默认别人会清理。
第二步是把生命周期阶段接入日志,但日志要克制。开发包可以记录完整阶段,灰度包只记录异常路径。例如实体回收时仍然在目标选择系统里,就记录实体 ID、配置 ID、当前场景、持有系统和最近状态变化。不要等到线上出现幽灵目标以后再猜。
第三步是建立自动化压力场景。连续刷怪、连续清场、连续切副本、连续断线重连,观察实体数量、对象池容量、事件订阅数量和资源引用是否回到基线。很多泄漏在单局里看不出来,连续 20 次循环才会暴露。
最后要让表现同学和战斗同学共享这套边界。特效需要知道什么时候解绑,动画需要知道死亡表现结束后是否还能触发事件,战斗需要知道实体逻辑失效后不能再被命中。生命周期不是某个基础类的私事,它是客户端多个系统共同遵守的契约。
团队协作与验收方式
实体生命周期最容易在跨模块协作中出问题,所以验收不能只由写实体基类的人完成。战斗同学要验证死亡、控制、位移、召唤和清场;表现同学要验证动画事件、特效挂点、受击反馈和死亡演出;UI 同学要验证血条、目标框、锁定提示和任务追踪;测试同学要验证连续刷怪、断线重连、切场景和低端机长测。
比较实用的验收方式是做一张“实体残留表”。每轮战斗结束后记录场景中还剩多少战斗实体、多少 UI 绑定、多少事件订阅、多少对象池活跃对象、多少延迟回调。第一轮和第十轮的数据应该接近。如果每轮都涨一点,就说明生命周期清理有问题。
还可以准备几个故意刁钻的测试用例:怪物死亡同时被击退,投射物命中瞬间目标被回收,召唤物存在时玩家退出副本,Boss 死亡动画未播完就切结算,网络删除消息比本地死亡表现晚到。能扛住这些边界,普通战斗才会稳定。
排查指标与复盘模板
这类系统上线后,建议保留一份简单复盘模板:问题发生的版本、命中的资源和配置、玩家操作路径、最近一次状态变化、是否有异常日志、是否可回放、最终根因属于规则、表现、资源、网络还是工具缺失。复盘不要只写“已修复”,还要写“下次如何提前发现”。如果是事件没解绑,就补事件订阅检查;如果是配置引用错误,就补构建校验;如果是低端机长测才出现,就补自动长测场景。
指标也要持续观察。实体数量、对象池峰值、未释放资源、事件订阅数、UI 绑定数、重连恢复耗时、异常降级次数,都可以成为开发包或灰度包里的诊断指标。它们不需要全部上报到正式环境,但团队要有办法在问题出现时快速查看。
真正有效的工程改进,往往不是修一次 Bug,而是把这次 Bug 变成一个检查点、一个自动测试、一个调试面板字段或一个构建期错误。这样文章里讲的经验才不会只停留在经验,而会变成项目的一部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。