From 1e3c060c8d09ea7a3a4eed4c597b0d4846605a5e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 31 Jul 2026 16:03:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E5=AF=B9=E8=B1=A1=E6=B1=A0?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E9=80=9A=E7=9F=A5=20IPoolable=EF=BC=8C?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=9A=84=E6=95=8C=E4=BA=BA=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BB=A5=E6=AD=BB=E4=BA=A1=E7=8A=B6=E6=80=81=E5=87=BA=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IPoolable 的接口文档写着「由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现,并在 PooledObject.OnSpawn/OnDespawn 中手动驱动」, 但那两个方法是空的 public virtual,全库无任何 PooledObject 子类, GlobalObjectPool 也从不查找 IPoolable 组件——这份契约从未生效过。 全库 .OnSpawn(/.OnDespawn( 的调用点只有 4 处,真正驱动 EnemyBase (唯一的 IPoolable 实现者)的只有 EnemyRespawner 一处手动补调。后果: - EnemyBase.OnDespawn 在生产中从未被调用,其中的 _nav?.Stop() 是死代码; - 更严重的是取出侧:PerformDeath 会 ForceState(Dead) 并禁用全部碰撞体后归池, 而 EnemySpawnerOnEvent / ChaoFengBoss 召唤 / RangedEnemy 这些取用方 没有补调 OnSpawn,拿到的复用敌人仍停在 Dead 态、碰撞体全关、HP 为 0—— 一个不可交互也不会动的幽灵。EnemyRespawner 路径侥幸没暴露,只因为它手动补了。 修法是让池履行它自己声明的契约,而不是让各调用方继续手动补调: PooledObject.OnSpawn/OnDespawn 转发给本物体上的 IPoolable。只扫本物体、 不向下扫子物体——接口文档限定的就是「同 GameObject」,向下扫会让嵌套的 可池化对象被两个 PooledObject 各通知一次。组件集惰性解析一次并缓存 (池化本就是为省开销,不能每次进出都遍历组件)。 同时统一两条归池路径的顺序:GlobalObjectPool.Despawn 原本先 SetActive(false) 再 OnDespawn,而 PooledObject.ForceReturnToPool 是反过来的。转发接通后这个 顺序就成了可观察行为,两条路径必须给出同一份契约——统一为「先通知再停用」, 清理才跑在还活跃的对象上。 EnemyRespawner 那次手动 OnSpawn 随之改为只在兜底实例化路径调用:池化路径 现在由转发完成,重复调用会让 OnSpawn 末尾的 Spawned?.Invoke() 触发两次, 即重复执行出生能力。(今天重复触发会被 Execute() 的 !_isRunning 门挡住, 但那是运气不是设计。) 验证:编译 0 错;EditMode 273/273(269 + 新增 4)。 分阶段观察过失败面:只加转发时,2 条转发测试转绿、顺序测试仍红在 WasActiveOnDespawn 上;补顺序后全绿。 变异验证——把扫描范围改成 GetComponentsInChildren 后,边界测试恰好变红 (该测试在修复前是平凡通过的,需确认它真有约束力),还原后复验 273/273。 Co-Authored-By: Claude Opus 5 --- .../Core/PooledObjectForwardingTests.cs | 119 ++++++++++++++++++ .../Core/PooledObjectForwardingTests.cs.meta | 11 ++ .../Scripts/Core/Pool/GlobalObjectPool.cs | 4 +- .../_Game/Scripts/Core/Pool/PooledObject.cs | 33 ++++- .../_Game/Scripts/Enemies/EnemyRespawner.cs | 8 +- 5 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs create mode 100644 Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta diff --git a/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs new file mode 100644 index 00000000..980eebcc --- /dev/null +++ b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs @@ -0,0 +1,119 @@ +using NUnit.Framework; +using UnityEngine; +using BaseGames.Core.Pool; + +namespace BaseGames.Tests.EditMode.Core +{ + /// + /// 对象池必须真正通知 。 + /// + /// IPoolable 的接口文档写着「由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现, + /// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动」,但 PooledObject.OnSpawn/OnDespawn 是空的 + /// public virtual、全库无任何子类,GlobalObjectPool 也从不查找 IPoolable 组件—— + /// 于是这份契约从未生效。 + /// + /// 具体后果:EnemyBase(全库唯一的 IPoolable 实现者)死亡时 PerformDeath 会 ForceState(Dead) + /// 并禁用全部碰撞体,然后归池;除 EnemyRespawner 手动补调 OnSpawn 外,其余取用方 + /// (EnemySpawnerOnEvent / ChaoFengBoss 召唤 / RangedEnemy)拿到的复用敌人仍停在 Dead 态、 + /// 碰撞体全关——一个不可交互也不会动的幽灵。 + /// + public class PooledObjectForwardingTests + { + /// 记录通知次数与「被通知时对象是否还活跃」的探针。 + private sealed class SpyPoolable : MonoBehaviour, IPoolable + { + public int SpawnCalls; + public int DespawnCalls; + public bool WasActiveOnDespawn; + + public void OnSpawn() => SpawnCalls++; + public void OnDespawn() + { + DespawnCalls++; + WasActiveOnDespawn = gameObject.activeSelf; + } + } + + private GameObject _host; + + [TearDown] + public void TearDown() + { + // 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句, + // 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。 + if (_host != null) Object.DestroyImmediate(_host); + _host = null; + } + + /// + /// 按生产路径建对象:两条创建路径(预热 EnqueueNew 与按需 Instantiate) + /// 都在首次 Spawn 前调 Setup,所以 Setup 是可靠的解析时机。 + /// + private (PooledObject po, SpyPoolable spy) MakePooled() + { + _host = new GameObject("pooled"); + var po = _host.AddComponent(); + var spy = _host.AddComponent(); + po.Setup("TEST_Key", null); + return (po, spy); + } + + [Test] + public void OnSpawn_NotifiesPoolableOnSameGameObject() + { + var (po, spy) = MakePooled(); + + po.OnSpawn(); + + Assert.AreEqual(1, spy.SpawnCalls, + "池取出对象时必须通知 IPoolable,否则复用的敌人带着上一条命的死亡状态出场"); + } + + [Test] + public void OnDespawn_NotifiesPoolableOnSameGameObject() + { + var (po, spy) = MakePooled(); + + po.OnDespawn(); + + Assert.AreEqual(1, spy.DespawnCalls, + "归池时必须通知 IPoolable,否则 EnemyBase.OnDespawn 里的清理(_nav.Stop)永远不执行"); + } + + [Test] + public void Notification_IsScopedToOwnGameObject_NotChildren() + { + // IPoolable 的接口文档限定为「同 GameObject 上的其他 MonoBehaviour」。 + // 不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。 + var (po, _) = MakePooled(); + var childGo = new GameObject("child"); + childGo.transform.SetParent(_host.transform); + var childSpy = childGo.AddComponent(); + + po.OnSpawn(); + po.OnDespawn(); + + Assert.AreEqual(0, childSpy.SpawnCalls, "子物体上的 IPoolable 不在本组件职责内"); + Assert.AreEqual(0, childSpy.DespawnCalls, "子物体上的 IPoolable 不在本组件职责内"); + } + + [Test] + public void Despawn_NotifiesBeforeDeactivating() + { + // 清理要在对象还活跃时跑(可用协程、可读组件状态), + // 且 PooledObject.ForceReturnToPool 本来就是「先 OnDespawn 再 SetActive(false)」。 + // GlobalObjectPool.Despawn 顺序相反,两条归池路径给出的契约必须一致。 + var (po, spy) = MakePooled(); + var pool = new GameObject("pool").AddComponent(); + + pool.Despawn("TEST_Key", po); + + Assert.AreEqual(1, spy.DespawnCalls); + Assert.IsTrue(spy.WasActiveOnDespawn, + "OnDespawn 必须在 SetActive(false) 之前调用,否则清理跑在一个已停用的对象上"); + Assert.IsFalse(po.gameObject.activeSelf, "通知之后仍然要停用对象"); + + Object.DestroyImmediate(pool.gameObject); + } + } +} diff --git a/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta new file mode 100644 index 00000000..927c6c63 --- /dev/null +++ b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2987c38d1c03d74db0a93f908048e3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs b/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs index 88ae7731..9ab83001 100644 --- a/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs +++ b/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs @@ -154,8 +154,10 @@ namespace BaseGames.Core.Pool aliveRef.Remove(po.AliveNode); po.AliveNode = null; } - po.gameObject.SetActive(false); + // 先通知再停用:清理要跑在还活跃的对象上(可用协程、可读组件状态)。 + // 与 PooledObject.ForceReturnToPool 的顺序一致——两条归池路径必须给出同一份契约。 po.OnDespawn(); + po.gameObject.SetActive(false); int maxCount = _maxCounts.GetValueOrDefault(key, 0); int queueSize = _pools.TryGetValue(key, out var queue) ? queue.Count : 0; diff --git a/Assets/_Game/Scripts/Core/Pool/PooledObject.cs b/Assets/_Game/Scripts/Core/Pool/PooledObject.cs index 5d8792ae..7096164d 100644 --- a/Assets/_Game/Scripts/Core/Pool/PooledObject.cs +++ b/Assets/_Game/Scripts/Core/Pool/PooledObject.cs @@ -24,14 +24,37 @@ namespace BaseGames.Core.Pool // 组件缓存(避免反复 GetComponent) private readonly Dictionary _componentCache = new(); + // 本物体上的 IPoolable 实现者。首次使用时解析一次并缓存——池化本就是为省开销, + // 不能每次 Spawn/Despawn 都遍历一遍组件。惰性解析而非依赖 Setup: + // 结果与解析时机无关,也就不存在"忘了调 Setup 就静默不通知"的失败模式。 + private IPoolable[] _poolables; + private IPoolable[] Poolables => _poolables ??= GetComponents(); + public void Setup(string key, GlobalObjectPool pool) { AddressKey = key; _pool = pool; } - public virtual void OnSpawn() { } - public virtual void OnDespawn(){ } + /// + /// 由池在取出对象后调用,转发给本物体上的所有 。 + /// 子类覆盖时必须调 base,否则 IPoolable 实现者收不到通知。 + /// + public virtual void OnSpawn() + { + var ps = Poolables; + for (int i = 0; i < ps.Length; i++) ps[i].OnSpawn(); + } + + /// + /// 由池在归还对象时调用(对象仍处于活跃状态),转发给本物体上的所有 。 + /// 子类覆盖时必须调 base。 + /// + public virtual void OnDespawn() + { + var ps = Poolables; + for (int i = 0; i < ps.Length; i++) ps[i].OnDespawn(); + } // ── 归还 API ────────────────────────────────────────────────────── /// 立即归还到对象池。 @@ -67,8 +90,10 @@ namespace BaseGames.Core.Pool /// /// 可选接口:若池化对象需要在 Spawn/Despawn 时执行额外逻辑, - /// 由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现, - /// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动。 + /// 由 PooledObject 子类或**同 GameObject 上**的其他 MonoBehaviour 实现。 + /// / 会自动转发, + /// 调用方无需手动驱动。 + /// 只扫本物体、不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。 /// public interface IPoolable { diff --git a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs index 7bc90d72..defe8e25 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs @@ -127,12 +127,14 @@ namespace BaseGames.Enemies private void SpawnEnemy() { GameObject go = null; + bool fromPool = false; // 优先:对象池 if (!string.IsNullOrEmpty(_poolKey)) { var pool = ServiceLocator.GetOrDefault(); go = pool?.Spawn(_poolKey, transform.position, transform.rotation); + fromPool = go != null; } // 兜底:直接实例化 @@ -152,8 +154,10 @@ namespace BaseGames.Enemies } _activeEnemy = enemy; - // 确保对象池复用路径也能正确重置运行时状态 - _activeEnemy.OnSpawn(); + // 池化路径的重置由 PooledObject.OnSpawn 转发 IPoolable 完成,这里不能再调一次: + // OnSpawn 末尾会 Spawned?.Invoke(),重复触发即重复执行出生能力。 + // 兜底实例化路径没有池,需要在这里补一次。 + if (!fromPool) _activeEnemy.OnSpawn(); _activeEnemy.OnDied += OnEnemyDied; }