1. 项目概述一个看似简单却暗藏玄机的需求在Unity开发中我们经常需要管理一组脚本让它们在特定游戏对象激活或失活时自动加入或离开某个“执行列表”。比如你有一个全局的“更新管理器”负责每帧驱动所有需要执行Update逻辑的脚本或者你有一个“事件广播器”需要动态地向活跃的物体发送消息。这个需求的核心就是在OnEnable和OnDisable这两个生命周期函数里优雅地完成脚本实例的注册与注销。听起来很简单对吧不就是在一个列表里Add和Remove吗但实际做起来新手甚至一些有经验的开发者都会踩进不少坑里。对象意外销毁导致的空引用、重复注册引发的逻辑错误、在错误的时机进行注册导致数据不同步……这些问题如果不处理好轻则功能异常重则直接崩溃。今天我就结合自己踩过的坑和总结的最佳实践来详细拆解这个“在物体激活/失活时从脚本列表中添加或删除”的完整解决方案。无论你是正在构建一个自定义的游戏框架还是优化现有代码的管理逻辑这篇文章都能给你提供可直接复用的代码和清晰的避坑指南。2. 核心设计思路与架构解析2.1 为什么不用简单的静态列表最直观的想法是创建一个静态的ListMyScript然后在MyScript的OnEnable和OnDisable里直接操作这个列表。public class UpdateManager : MonoBehaviour { public static ListMyScript activeScripts new ListMyScript(); } public class MyScript : MonoBehaviour { private void OnEnable() { UpdateManager.activeScripts.Add(this); } private void OnDisable() { UpdateManager.activeScripts.Remove(this); } }这个方案在Demo里或许能跑但在实际项目中是极其脆弱的主要原因有三点线程安全问题Unity虽然主逻辑是单线程的但OnEnable/OnDisable的调用时机复杂特别是在场景加载、对象池频繁启用/禁用时可能引发对列表的并发修改导致异常。空引用与重复项如果脚本所在的GameObject被直接DestroyOnDisable可能不会被调用取决于销毁顺序导致列表中残留“僵尸”引用。下次遍历列表时访问它就会抛出MissingReferenceException。同样如果启用/禁用逻辑有BUG可能导致同一个实例被多次加入列表。缺乏控制与扩展性静态列表是全局可变的任何代码都能随意修改它破坏了封装性。当我们需要根据脚本类型、优先级进行分组管理时静态列表就显得力不从心。因此我们的设计目标不仅仅是实现功能更要实现健壮性、可维护性和性能。2.2 稳健的双缓冲列表与接口驱动设计一个经过实战检验的设计模式是使用“双缓冲列表”配合“接口”定义接口创建一个公共接口例如IManagedBehaviour规定需要被管理的脚本必须实现的方法如ManagedUpdate。管理器使用双列表管理器内部维护两个列表一个用于注册/注销操作registrationList一个用于实际迭代activeList。在每帧的固定点如LateUpdate将注册列表的变化同步到活跃列表。这避免了在迭代过程中修改集合导致的异常。弱引用或唯一标识为了防止残留引用管理器不应直接持有脚本的强引用。可以使用WeakReference但Unity对象比较特殊或者更常见的让脚本在注册时提供一个唯一的标识符如实例ID管理器只保存这个ID和接口引用并在每次使用前检查对象是否已被销毁。这种架构将管理逻辑集中化脚本只需关心“我是谁”和“我是否活跃”而管理器负责“如何高效安全地执行所有活跃的脚本”。// 定义需要被管理的脚本接口 public interface IManagedBehaviour { void ManagedUpdate(); int InstanceID { get; } // 用于唯一标识和清理 }3. 核心细节解析与实操要点3.1 脚本生命周期与注册时机的精确把控OnEnable和OnDisable是Unity MonoBehaviour的生命周期函数但它们被调用的时机有细微差别理解这些差别是避免BUG的关键。OnEnable: 在脚本实例被创建并且游戏对象处于激活状态时调用。注意如果脚本被添加到已激活的游戏对象上它会立即调用OnEnable。如果游戏对象本身未激活即使脚本组件被启用OnEnable也不会被调用直到游戏对象被激活。OnDisable: 在游戏对象变为非激活状态或脚本组件被禁用时调用。当游戏对象被销毁时OnDisable会在OnDestroy之前被调用。关键陷阱假设你的管理器在Awake中初始化了自己的静态实例。如果一个脚本的OnEnable在管理器Awake之前执行了它尝试向一个为null的静态实例注册就会导致空引用异常。这就是常见的“脚本执行顺序”问题。解决方案不要在OnEnable中直接访问管理器实例。可以采用“延迟注册”机制或者让管理器在Awake阶段确保自己是最早被初始化的之一通过Unity的Script Execution Order设置。// 在脚本中安全的注册方式 public class MyManagedScript : MonoBehaviour, IManagedBehaviour { private void OnEnable() { // 不直接调用而是标记自己需要注册 // 管理器会在每帧开始时处理所有待注册的脚本 BehaviourManager.Instance?.ScheduleRegister(this); } private void OnDisable() { // 同样标记自己需要注销 BehaviourManager.Instance?.ScheduleUnregister(this); } }3.2 管理器的实现线程安全与性能考量下面是一个简化但健壮的管理器核心实现using System.Collections.Generic; using UnityEngine; public class BehaviourManager : MonoBehaviour { private static BehaviourManager _instance; public static BehaviourManager Instance { get { if (_instance null) { // 懒加载模式也可以使用[RuntimeInitializeOnLoadMethod]在游戏启动时创建 var go new GameObject(BehaviourManager); _instance go.AddComponentBehaviourManager(); DontDestroyOnLoad(go); } return _instance; } } // 双缓冲列表一个用于添加/删除操作一个用于遍历 private ListIManagedBehaviour _behavioursToAdd new ListIManagedBehaviour(); private ListIManagedBehaviour _behavioursToRemove new ListIManagedBehaviour(); private ListIManagedBehaviour _activeBehaviours new ListIManagedBehaviour(); // 用于快速查找和避免重复的字典Key为InstanceID private Dictionaryint, IManagedBehaviour _behaviourMap new Dictionaryint, IManagedBehaviour(); private object _lock new object(); // 简单的锁用于保证线程安全 public void ScheduleRegister(IManagedBehaviour behaviour) { lock (_lock) { _behavioursToAdd.Add(behaviour); } } public void ScheduleUnregister(IManagedBehaviour behaviour) { lock (_lock) { _behavioursToRemove.Add(behaviour); } } private void LateUpdate() { // 1. 处理注销 lock (_lock) { if (_behavioursToRemove.Count 0) { foreach (var behaviour in _behavioursToRemove) { // 从活跃列表和映射表中移除 _activeBehaviours.Remove(behaviour); _behaviourMap.Remove(behaviour.InstanceID); } _behavioursToRemove.Clear(); } // 2. 处理注册 if (_behavioursToAdd.Count 0) { foreach (var behaviour in _behavioursToAdd) { // 检查是否已销毁或已注册 if (behaviour null || _behaviourMap.ContainsKey(behaviour.InstanceID)) continue; _activeBehaviours.Add(behaviour); _behaviourMap.Add(behaviour.InstanceID, behaviour); } _behavioursToAdd.Clear(); } } // 3. 遍历执行活跃脚本的更新逻辑 // 注意这里遍历的是_activeBehaviours它在步骤1和2中已被安全更新 for (int i 0; i _activeBehaviours.Count; i) { var behaviour _activeBehaviours[i]; // 执行前的最终安全检查 if (behaviour ! null) { behaviour.ManagedUpdate(); } else { // 如果发现空引用理论上经过上述步骤应该很少安排在下帧移除 lock (_lock) { _behavioursToRemove.Add(behaviour); } } } } private void OnDestroy() { // 清理静态实例防止内存泄漏 if (_instance this) { _instance null; } } }这段代码的几个关键点双缓冲与锁_behavioursToAdd和_behavioursToRemove是缓冲列表所有注册/注销请求先放在这里。在LateUpdate中在一个lock块内将缓冲列表的变化应用到_activeBehaviours和_behaviourMap。这保证了修改操作是线程安全的尽管Unity主线程单线程但此设计能防止其他协程或异步操作带来的意外。字典去重使用_behaviourMap以InstanceID为Key来快速判断一个脚本是否已经注册避免重复添加。延迟清理在遍历_activeBehaviours执行ManagedUpdate时如果发现某个引用意外为null会将其加入待移除列表在下一帧清理。这是一个防御性编程。执行时机选择在LateUpdate中处理注册/注销和执行可以确保本帧内所有对象的OnEnable/OnDisable调用都已经完成状态是最新的。4. 实操过程与核心环节实现4.1 步骤一定义你的管理接口与抽象基类首先根据你的具体需求定义接口。除了基本的更新可能还需要固定更新、延迟更新等。// IManagedBehaviour.cs public interface IManagedBehaviour { /// summary /// 每帧调用等同于MonoBehaviour.Update /// /summary void ManagedUpdate(); /// summary /// 固定时间步长调用等同于MonoBehaviour.FixedUpdate /// /summary void ManagedFixedUpdate(); /// summary /// 在ManagedUpdate之后调用等同于MonoBehaviour.LateUpdate /// /summary void ManagedLateUpdate(); /// summary /// 脚本实例的唯一标识通常返回this.GetInstanceID() /// /summary int InstanceID { get; } /// summary /// 更新优先级用于排序执行顺序 /// /summary int UpdatePriority { get; } } // 提供一个可选的抽象基类简化实现 public abstract class ManagedBehaviourBase : MonoBehaviour, IManagedBehaviour { public int InstanceID this.GetInstanceID(); public virtual int UpdatePriority 0; public virtual void ManagedUpdate() { } public virtual void ManagedFixedUpdate() { } public virtual void ManagedLateUpdate() { } protected virtual void OnEnable() { BehaviourManager.Instance?.ScheduleRegister(this); } protected virtual void OnDisable() { BehaviourManager.Instance?.ScheduleUnregister(this); } }使用抽象基类ManagedBehaviourBase你的业务脚本只需要继承它并重写需要的方法即可无需手动处理注册逻辑大大减少了样板代码和出错几率。public class PlayerController : ManagedBehaviourBase { public override int UpdatePriority 10; // 玩家控制优先级较高 public override void ManagedUpdate() { // 你的每帧逻辑如输入处理 float moveX Input.GetAxis(Horizontal); // ... 移动逻辑 } public override void ManagedFixedUpdate() { // 你的物理逻辑 // _rigidbody.AddForce(...); } }4.2 步骤二实现支持优先级排序的管理器上面的基础管理器是按注册顺序执行的。对于复杂系统我们往往需要控制执行顺序。修改管理器使其支持按UpdatePriority排序。我们需要修改BehaviourManager的内部列表管理逻辑在将脚本从缓冲列表添加到活跃列表时进行排序或者维护一个已排序的列表。为了性能我们可以在每次添加后标记需要重新排序然后在执行前进行排序。// 在BehaviourManager类内部增加 private bool _needSort false; private void ProcessRegistrations() { lock (_lock) { // ... 处理 _behavioursToRemove ... if (_behavioursToAdd.Count 0) { foreach (var behaviour in _behavioursToAdd) { if (behaviour null || _behaviourMap.ContainsKey(behaviour.InstanceID)) continue; _activeBehaviours.Add(behaviour); _behaviourMap.Add(behaviour.InstanceID, behaviour); } _behavioursToAdd.Clear(); _needSort true; // 标记需要排序 } } // 如果需要对活跃列表进行排序 if (_needSort) { _activeBehaviours.Sort((a, b) a.UpdatePriority.CompareTo(b.UpdatePriority)); _needSort false; } } // 在LateUpdate中先调用ProcessRegistrations再遍历执行 private void LateUpdate() { ProcessRegistrations(); // ... 遍历执行逻辑 ... }4.3 步骤三处理场景加载与对象池当使用场景加载SceneManager.LoadScene或对象池时对象的激活/失活生命周期会更加复杂。场景加载新场景加载时旧场景的对象会被销毁。如果你的管理器是DontDestroyOnLoad的那么它持有的对旧场景对象的引用就会变成“僵尸引用”。必须在场景加载前后进行清理。对象池对象池中的对象会被频繁地SetActive(true/false)。这会导致OnEnable和OnDisable被频繁调用。我们的双缓冲机制能很好地处理这种高频调用但要注意从对象池取出的对象可能携带上一轮的状态确保你的脚本在OnEnable中进行了正确的初始化。为管理器添加场景加载监听using UnityEngine.SceneManagement; public class BehaviourManager : MonoBehaviour { // ... 其他代码 ... private void Awake() { if (_instance null) { _instance this; DontDestroyOnLoad(gameObject); SceneManager.sceneLoaded OnSceneLoaded; } else if (_instance ! this) { Destroy(gameObject); // 确保单例 } } private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { // 场景加载后清除所有可能来自旧场景的引用 // 因为旧场景的对象已被销毁其引用无效 lock (_lock) { // 快速清理直接清空列表和字典 // 更精细的做法是遍历并检查每个对象是否为null但清空在场景切换时通常是安全的 _activeBehaviours.Clear(); _behaviourMap.Clear(); _behavioursToAdd.Clear(); _behavioursToRemove.Clear(); _needSort false; } Debug.Log($BehaviourManager cleared for scene: {scene.name}); } private void OnDestroy() { if (_instance this) { _instance null; SceneManager.sceneLoaded - OnSceneLoaded; // 记得取消订阅 } } }5. 常见问题与排查技巧实录即使有了稳健的架构在实际开发中还是会遇到各种问题。下面是我总结的一些常见“坑”及其解决方法。5.1 问题一注册了但从未被调用脚本的ManagedUpdate不执行可能原因及排查管理器未运行检查BehaviourManager的实例是否在场景中唯一且处于激活状态。可以在Awake或Start里加一句Debug.Log。脚本的OnEnable未触发确认脚本所在的GameObject是否激活脚本组件自身的勾选框是否被勾选。有时在编辑器运行时动态添加脚本需要手动调用一次OnEnable的逻辑。优先级被误设如果你实现了优先级排序检查是否因为优先级设置错误比如设了一个非常大的值导致脚本被排序到很后面而你又提前退出了遍历比如设置了最大执行数量。缓冲列表未同步注册请求ScheduleRegister发出了但管理器当前帧的LateUpdate尚未执行。这意味着脚本在本帧不会被更新。这是设计使然通常下一帧就会正常。如果你的逻辑要求脚本在激活的同一帧就必须被更新可以考虑在ScheduleRegister后立即手动触发一次管理器的更新循环但这会破坏帧一致性慎用。5.2 问题二空引用异常MissingReferenceException这是最令人头疼的问题通常发生在对象已被销毁但管理器列表中还保留着对其的引用。我们的防御性编程已经很大程度上避免了它通过缓冲列表、字典检查和遍历时的null检查。但如果仍然出现请检查销毁路径是否统一是否有些地方直接调用了Destroy(gameObject)而有些地方是先SetActive(false)再延迟销毁确保对象销毁前OnDisable一定被调用从而触发ScheduleUnregister。异步操作如果在协程或异步回调中销毁对象要确保这些操作在主线程执行并且销毁前状态是可控的。使用GameObject.ReferenceEquals进行安全检查在极少数情况下Unity的运算符对于被销毁的对象可能判断不准。可以使用if (behaviour as UnityEngine.Object null)或if (System.Object.ReferenceEquals(behaviour, null))进行更严格的检查。5.3 问题三性能开销过大当管理的脚本数量成千上万时每帧遍历所有脚本并调用虚方法可能会成为性能瓶颈。优化策略分帧/分桶更新不要在同一帧更新所有脚本。可以将脚本分成多个桶Bucket每帧只更新一个桶。这会导致每个脚本的更新频率降低但能极大平滑CPU占用适用于对实时性要求不高的后台逻辑如AI感知更新、环境音效更新。// 在管理器中 private int _currentBucketIndex 0; private ListIManagedBehaviour[] _buckets new ListIManagedBehaviour[5]; // 假设5个桶 // 每帧只更新一个桶 _currentBucketIndex (_currentBucketIndex 1) % _buckets.Length; var currentBucket _buckets[_currentBucketIndex]; foreach(var behaviour in currentBucket) { ... }使用值类型和Burst Compiler如果更新逻辑是纯数学计算可以考虑使用Unity的ECS架构和Burst Compiler但这涉及更大的架构改动。减少虚方法调用虚方法调用有一定开销。如果性能极其敏感可以考虑使用委托列表Action但会失去接口带来的清晰结构。需要权衡。按需更新为IManagedBehaviour接口增加一个bool NeedsUpdate { get; }属性。管理器在遍历时先检查这个属性如果为false则跳过。脚本可以在内部状态改变时设置这个属性。5.4 问题四与其他系统如Unity事件系统的冲突你的自定义管理器可能会和Unity内置的UnityEvent或第三方的事件系统产生混淆。例如一个UI按钮的onClick事件里激活了一个物体这个物体的脚本会立即注册到你的管理器并可能在当前帧就被更新。如果更新逻辑依赖于该UI事件的后续处理完成就可能出现竞态条件。解决思路明确你的管理器更新的阶段。在LateUpdate中执行已经是比较靠后的阶段。如果还有冲突可以考虑提供不同的更新通道例如ManagedPreUpdate在Update前、ManagedPostUpdate在Update后、ManagedPreLateUpdate在LateUpdate前等让脚本根据依赖关系选择注册到不同的通道。6. 扩展与高级应用场景基础的管理器搭建好后我们可以根据项目需求进行扩展使其更加强大和灵活。6.1 场景一实现一个全局的“定时任务调度器”利用这个框架可以轻松实现一个定时器。创建一个TimerBehaviour它继承自ManagedBehaviourBase在ManagedUpdate中累计时间当达到指定间隔时触发一个回调。public class TimerBehaviour : ManagedBehaviourBase { private float _interval; private float _accumulator; private System.Action _onElapsed; private bool _isLooping; public void Setup(float interval, System.Action callback, bool loop false) { _interval interval; _onElapsed callback; _isLooping loop; _accumulator 0f; enabled true; // 确保自己处于激活状态以接收更新 } public override void ManagedUpdate() { _accumulator Time.deltaTime; if (_accumulator _interval) { _onElapsed?.Invoke(); if (_isLooping) { _accumulator - _interval; // 或 0f; } else { // 一次性定时器触发后自我注销 enabled false; // 这会触发OnDisable从而从管理器移除 // 或者直接 Destroy(this.gameObject); } } } } // 使用示例 var timer new GameObject(Timer).AddComponentTimerBehaviour(); timer.Setup(2.0f, () Debug.Log(2秒到了));管理器会自动管理这个TimerBehaviour的更新无需你手动创建GameObject并挂载MonoBehaviour来驱动它。6.2 场景二构建一个非MonoBehaviour的“纯C#类”更新系统有时我们有一些纯C#类不继承MonoBehaviour也需要每帧更新。我们可以让管理器支持任何实现了IManagedBehaviour接口的类而不仅仅是MonoBehaviour。public class PureCSharpUpdater : IManagedBehaviour { private int _id; public int InstanceID _id; public int UpdatePriority 0; private System.Action _updateAction; public PureCSharpUpdater(System.Action updateAction) { _id GetHashCode(); // 或者用一个静态计数器生成唯一ID _updateAction updateAction; // 主动向管理器注册自己 BehaviourManager.Instance?.ScheduleRegister(this); } public void Start() { // 模拟MonoBehaviour.Start注册后可能需要一帧才开始 } public void Stop() { // 主动注销 BehaviourManager.Instance?.ScheduleUnregister(this); } public void ManagedUpdate() { _updateAction?.Invoke(); } // 实现其他接口方法... } // 使用 var myUpdater new PureCSharpUpdater(() { // 你的纯C#逻辑 CalculatePathfinding(); }); // 当不需要时 myUpdater.Stop();这样我们就将Unity的帧更新能力扩展到了普通的C#类极大地增加了代码组织的灵活性。6.3 场景三与Unity的ScriptableObject结合实现数据驱动的行为ScriptableObject是存储数据和逻辑的利器。我们可以创建一种ScriptableBehaviour其逻辑写在ScriptableObject中但由管理器驱动更新。// 定义一个ScriptableObject接口 public interface IScriptableManagedBehaviour { void ManagedUpdate(GameObject context); int UpdatePriority { get; } } // 管理器持有ScriptableObject的引用列表 public class BehaviourManager : MonoBehaviour { private ListIScriptableManagedBehaviour _scriptableBehaviours new ListIScriptableManagedBehaviour(); // ... 类似的注册、注销、双缓冲逻辑 ... public void RegisterScriptableBehaviour(IScriptableManagedBehaviour behaviour, GameObject context) { // 需要将context关联的游戏对象也存储起来以便更新时传入 } } // 创建一个具体的ScriptableObject [CreateAssetMenu(fileName RotateBehaviour, menuName Behaviours/Rotate)] public class RotateBehaviour : ScriptableObject, IScriptableManagedBehaviour { public float speed 90f; public int UpdatePriority 0; public void ManagedUpdate(GameObject context) { if (context ! null) { context.transform.Rotate(Vector3.up, speed * Time.deltaTime); } } }然后在某个MonoBehaviour中将RotateBehaviour这个Asset注册到管理器并传入自己所在的GameObject作为上下文。这样旋转逻辑就完全和数据Asset绑定可以在多个物体间共享和通过AssetBundle动态加载实现了高度解耦和数据驱动。整个系统从最初简单的列表添加删除演变成了一个功能丰富、健壮可靠的行为管理框架。它解决了对象生命周期管理中的核心痛点并提供了良好的扩展点能够适应中小型Unity项目的多种复杂需求。记住好的框架不是一开始就设计得大而全而是像这样从一个具体问题出发逐步迭代和完善出来的。希望这个详细的拆解能帮助你构建出更优雅、更稳定的Unity项目代码结构。