最近在某个项目里处理了一个看起来简单、实际埋了不少坑的需求:当场景里的每一个物体都处于隐藏状态时,把obj_fly_male_first这个对象一起隐藏掉;反过来,只要还有任何一个物体是显示的,obj_fly_male_first就必须保持显示。这问题在 Unity 项目里非常典型,凡是碰过多对象联动可见性的人应该都能一眼看出它属于“聚合状态判断”那一类。真正把它写对、写稳,需要关心的不只是 if 条件怎么写,还有状态从哪来、什么时候刷新、会不会漏判、会不会抖。
这篇文章我就以这套需求为例子,把从需求拆解、方案选型到脚本落地、踩坑排查的整个过程完整梳理一遍。内容不绑定某个特定项目,思路和代码可以直接抄到自己场景里改改用。如果你也遇到过“明明条件满足却不隐藏”“一运行就疯狂报错”“隐藏完又自己弹出来”这类现象,这篇文章正好能对上号。
1. 需求还原与问题定位
1.1 需求到底在说什么
先把这个需求翻译成人话:场景里有一组被你关注的物体,只要这组物体“全军覆没”(全部隐藏),那么联动对象obj_fly_male_first就得跟着隐藏;只要这一组里“还有一个活着的”(处于显示状态),obj_fly_male_first就得保持显示。
为什么会存在这种需求?我见过最常见的场景有两种。一种是战斗表现类:obj_fly_male_first是某个飞行单位的跟随标记或状态图标,飞行单位本体在场景里由好几个部件组成,部件全部隐身时,标记也得消失;只要还有部件可见,标记就不能提前消失。另一种是 UI 信息类:主界面上的某个提示元素要跟随一组玩法物体全部消失而消失,比如“所有目标都被清除后隐藏指引箭头”。
无论哪种,这条规则的本质都是同一个:目标对象的可见性,等于一组源对象可见性的“逻辑与”结果——全部隐藏 -> 目标隐藏;存在任意一个显示 -> 目标显示。
这跟“只要有一个隐藏就隐藏目标”不一样,后者是“逻辑或”。我为什么强调这一点?因为这两种需求虽然只差一个字,但在实现上的统计方式完全不同,后者的计数器逻辑是hiddenCount > 0,前者是hiddenCount == totalCount。别看就差一个等号,写成别的逻辑时你会得到完全相反的联动效果,而且调试时需要反复打断点才能发现。
1.2 为什么会出问题
这种需求如果直接“上手就写”,十有八九会写成这样:在Update里遍历所有目标物体,全部!activeSelf就执行obj_fly_male_first.SetActive(false)。
看起来没问题对吧?但真跑起来你会发现一堆奇怪的现场:
- 明明场景里还有一个物体是显示的,
obj_fly_male_first却已经隐藏了。 obj_fly_male_first隐藏之后,某些情况下又会被其他逻辑重新打开,然后在这一帧内和你这段遍历代码“打架”,表现为闪烁。- 物体多的时候,每帧遍历几十上百个对象,虽然 set 层面不至于卡死,但拖着 Editor 的 Profile 看耗时就是不舒服。
- 如果
obj_fly_male_first自己也被错误放进了遍历列表,那隐藏它之后,下一帧再遍历时它自己也“不显示了”,条件立刻变成“全部隐藏”,它自己又被隐藏一次。反复触发都不自知。
这些问题的根源,说到底就一句话:你在用“每帧去问每个物体‘你藏没藏’”这种笨办法,去维护一个本来可以“谁变化谁上报”的状态聚合结果。你每帧问一次,就会引入时序、顺序、自指这类额外复杂度。想要把这个需求写干净,第一步不是写代码,而是换思路。
2. 方案选型:为什么我不推荐每帧轮询
2.1 方案 A:Update 每帧轮询,什么时候够用
先别急着否定轮询。如果你的场景里被监控的物体总共就两三个、状态切换频率极低、也没有性能压力,那Update里写个foreach完全够用。代码短、逻辑直白、小学徒都能看懂,这是它的优势。
但我不推荐把它作为通用方案,原因有三个。第一是时序问题:同一帧内如果多个物体的显示状态发生改变,轮询代码只能看到这一帧结束时的“结果态”,而你很难知道中间到底哪一步先发生的;如果目标对象在这一帧内被多次 SetActive,表现上就会出现肉眼可见的闪烁。第二是性能浪费:哪怕场景里十年八年没人碰这些物体,只要脚本还活着,它每帧都在做同样的循环和判断。你说这种循环性能能有多差?单纯看遍历确实是小事,可 Unity 项目里最怕的不是某一段逻辑占了多少 CPU,而是“你根本不知道它占了 CPU”的隐性遍历太多。第三是维护性:将来需求改成“有 3 个以上隐藏才联动”,你得改循环内部逻辑;改成“按比例联动”,你还得改循环内部逻辑。每一处业务规则都塞在同一个循环里,代码自然越来越难读。
2.2 方案 B:在 SetActive 的调用点手动通知
第二种常见思路是:不管obj_fly_male_first是谁,反正每一处会改动被监控物体显示状态的地方,改完之后都手动调一下“刷新联动”方法。
这种思路的优点是响应即时,不会像轮询那样有帧间隔延迟。但它有一个致命弱点:只要有一个调用点漏了通知,整个联动就失效了。项目里新手同事可能在某个模块里直接SetActive(false),并不知道这里需要通知你的控制器,于是那个物体明明已经隐藏,控制器还是认为它是显示的,obj_fly_male_first就一直不隐藏。
而且“手动通知”这种写法会把业务逻辑紧紧耦合到每一个调用点上。今天目标是隐藏obj_fly_male_first,明天可能隐藏另一个物体,后天可能希望在“全隐”时额外播放一段动画,你总不能把每一种联动都塞到所有调用点里。
2.3 方案 C:事件驱动加中心控制器,我最终选它
第三个方案,也是我这次实际采用的方案:给每个被监控物体挂一个状态上报组件,组件在自己OnEnable/OnDisable时发出事件;由一个中心控制器统一接收这些事件、维护“当前有多少个物体处于隐藏状态”的计数器,计数等于总数时联动隐藏目标,不等于时联动显示目标。
这个方案的优点很突出:
- 没有每帧遍历,只有状态真正发生变化时才有计算,性能开销几乎可以忽略。
- 不需要在每一个
SetActive调用点手动通知,组件自己上报,调用方完全无感知。 - 业务规则收拢在中心控制器一个类里,以后想改成“隐藏数量大于 N”、“隐藏比例超过一半”,只需要改动控制器内部的判断,调用点一个都不用动。
- 由于每个物体自己负责上报状态,天然不存在“漏通知”这种问题——前提是这些物体都老老实实挂上了上报组件。
我用下面这张表做个对比,方便你直观一点:
| 方案 | 实时性 | 性能开销 | 调用点耦合 | 扩展性 | 推荐场景 |
|---|---|---|---|---|---|
| Update 轮询 | 有帧延迟 | 每帧固定开销 | 低 | 改动集中但逻辑杂 | 物体少、规则简单 |
| 调用点手动通知 | 即时 | 低 | 高 | 差,容易漏 | 很少,不推荐 |
| 事件驱动+控制器 | 即时 | 低 | 低 | 好,规则集中在控制器内 | 推荐通用 |
3. 核心实现:两个脚本搞定全隐则隐
3.1 VisibilityNotifier:让每个物体自己上报状态
先说第一个脚本VisibilityNotifier。它的职责只有一个:监听自己挂载的 GameObject 从显示变成隐藏、从隐藏变成显示,并把状态变化广播出去。
using System; using UnityEngine; public class VisibilityNotifier : MonoBehaviour { public event Action<VisibilityNotifier, bool> OnVisibilityChanged; private bool _visible; private void Awake() { _visible = gameObject.activeInHierarchy; } private void OnEnable() { if (!_visible) { _visible = true; OnVisibilityChanged?.Invoke(this, true); } } private void OnDisable() { if (_visible) { _visible = false; OnVisibilityChanged?.Invoke(this, false); } } public bool CurrentVisible => gameObject.activeInHierarchy; }几个关键点我拆开讲。
为什么用OnEnable/OnDisable而不是Update?因为SetActive(bool)以及父节点显隐变化都会触发这两个生命周期函数,这是 Unity 提供给我们的天然“状态变化事件源”。你不需要在这两个回调里做任何耗时操作,只是抛一个事件出去,成本极低。
_visible这个字段是干嘛的?它用来做防抖。Unity 的OnEnable和OnDisable在特殊情况下是有可能重复进入的,比如物体在场景卸载或程序退出过程中被销毁时。没有这个标志位,同一个状态变化会被误报多次,控制器那边连续收到两条相同的 false,计数器就会多加一次,出现“明明又显示了一个物体,隐藏计数却没降下来”的诡异问题。有了它,只有状态真正的跳变才会触发一次事件。
CurrentVisible属性为什么直接读gameObject.activeInHierarchy?因为activeSelf只能反映自己身上的 IsActive 勾选状态,一旦父节点被隐藏,子节点即使activeSelf是 true 也等于没显示。做联动判断时,我们关心的是“它现在到底显示在场景中没有”,所以应该用activeInHierarchy,它表示从父到子的最终计算结果。
3.2 VisibilityGroupController:聚合统计与联动
有了上报组件,控制器就很简单了。我给它起名叫VisibilityGroupController,挂在场景里一个专门放“逻辑对象”的空物体上就行。
using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; public class VisibilityGroupController : MonoBehaviour { [Header("被监控的物体(不要包含目标对象自身)")] [SerializeField] private List<GameObject> trackedObjects = new List<GameObject>(); [Header("联动目标:全隐则隐,有显则显")] [SerializeField] private GameObject targetObject; [Header("空列表时的默认行为:true 表示恢复显示目标")] [SerializeField] private bool showTargetWhenListEmpty = true; public UnityEvent onAllHidden; public UnityEvent onAnyVisible; private int hiddenCount; private int totalCount; private bool isAllHidden; private void Awake() { hiddenCount = 0; totalCount = 0; foreach (GameObject obj in trackedObjects) { if (obj == null) continue; if (obj == targetObject) { Debug.LogError($"{nameof(VisibilityGroupController)}: trackedObjects 中包含了目标对象自身,已自动跳过。", this); continue; } VisibilityNotifier notifier = obj.GetComponent<VisibilityNotifier>(); if (notifier == null) notifier = obj.AddComponent<VisibilityNotifier>(); notifier.OnVisibilityChanged += HandleVisibilityChanged; totalCount++; if (!notifier.CurrentVisible) hiddenCount++; } RefreshState(); } private void OnDestroy() { foreach (GameObject obj in trackedObjects) { if (obj == null) continue; VisibilityNotifier notifier = obj.GetComponent<VisibilityNotifier>(); if (notifier != null) notifier.OnVisibilityChanged -= HandleVisibilityChanged; } } private void HandleVisibilityChanged(VisibilityNotifier notifier, bool visible) { if (visible) hiddenCount--; else hiddenCount++; RefreshState(); } private void RefreshState() { if (targetObject == null) return; bool shouldHide; if (totalCount == 0) shouldHide = !showTargetWhenListEmpty; else shouldHide = hiddenCount == totalCount; if (isAllHidden == shouldHide) return; isAllHidden = shouldHide; targetObject.SetActive(!shouldHide); if (shouldHide) onAllHidden?.Invoke(); else onAnyVisible?.Invoke(); } }这段代码的核心逻辑是hiddenCount加totalCount两个数字。RefreshState里判断:如果hiddenCount == totalCount,说明列表里所有物体都隐藏了,联动隐藏;否则联动显示。事件回调里收到“visible = true”就减一,收到“visible = false”就加一,非常简单,不需要每次去遍历整张表重新数。
我特意做了几个保护性处理,这些是实际项目里最容易踩雷的地方:
trackedObjects中不能包含targetObject自身,否则隐藏目标后,目标自己的OnDisable会触发事件,控制器再统计一下,可能把目标本身也计入 hiddenCount,逻辑直接乱套。代码里我遇到这种情况会 LogError 并跳过。- 空列表单独处理。按数学定义,“空列表里所有物体都隐藏了”这句话是真的,所以如果不加处理,空列表会导致目标直接隐藏。但绝大多数业务里,空列表意味着“没有监控对象”,此时更合理的表现是恢复显示目标。所以我加了一个
showTargetWhenListEmpty开关,默认 true。 OnDestroy里反注册事件,防止场景切换或物体销毁后,控制器还被悬挂的引用拖着,造成泄漏和“无效回调报错”。RefreshState开头判断isAllHidden == shouldHide,也就是状态没有变化时直接 return。这是为了避免同一帧内多个物体连续上报导致SetActive被反复调用的抖动问题。
3.3 UnityEvent 回调与扩展
我在控制器里留了两个UnityEvent:onAllHidden和onAnyVisible。这俩不是摆设,实际项目里特别好用。
比如obj_fly_male_first是一个 UI 动效元素,除了要隐藏自身之外,你可能还想在“全隐”这个瞬间播一段收尾动画、播放一个音效、或者给另一个系统发一个“所有目标清除完毕”的信号。这些额外操作你不想写在控制器的代码里,直接在 Inspector 的 UnityEvent 面板上把回调拖进去就好。
再比如,有些 UI 对象不适合直接SetActive(false),因为用SetActive隐藏后,布局重建会有顿挫感。常见的替代做法是保留物体、用 CanvasGroup 控制 alpha 和 interactable。这种情况你可以不勾选需要隐藏的 GameObject,而是把它的 CanvasGroup 组件拖进 UnityEvent 回调里,在onAllHidden时把 alpha 设成 0,在onAnyVisible时恢复 alpha。这样控制器的核心逻辑完全不用改,只是把“隐藏动作”从 SetActive 换成了你自定义的动作。
4. 接入配置与验证流程
4.1 场景挂载步骤
实际接入时,操作步骤非常简单,我按你自己的项目情况列一下:
- 在场景里创建一个空物体,命名为
VisibilityLogic,用来放中心控制器脚本,纯逻辑载体,不需要有模型或 UI。 - 把
VisibilityGroupController组件挂上去。 - 把需要监控“是否隐藏”的所有物体拖进
trackedObjects列表。这里注意,列表里每一项都必须是一个“有能力发出状态变化的根对象”。如果你拖进去的子物体,父物体被隐藏了,子物体虽然activeInHierarchy变了,但OnEnable/OnDisable会不会触发?其实也会触发,Unity 的OnDisable在父节点隐藏时一样会被调用,所以没问题。 - 把
obj_fly_male_first拖到targetObject字段上。 - 如果你有额外联动逻辑,比如全隐之后播放音效,在
onAllHidden和onAnyVisible回调面板里挂好。
第一次接入时,建议先在 Inspector 里把所有被监控物体的 IsActive 勾选都去掉一遍,再勾回来,肉眼观察目标对象是不是在“全部隐藏时消失、任意一个显示时出现”。这类手工验证能帮你很快排除“拖错了对象”“条件反了”这种低级问题。
4.2 编辑器内快速验证的小技巧
Unity 编辑器里有个特别提高效率的玩法:给控制器脚本加一个[ContextMenu]标记的方法,你就能在 Inspector 组件右上角的下拉菜单里一键执行刷新。
比如你可以加上这个方法:
[ContextMenu("PrintCurrentState")] private void PrintCurrentState() { Debug.Log($"总物体数: {totalCount}, 隐藏数: {hiddenCount}, 当前目标隐藏状态: {isAllHidden}"); }然后你在编辑器里随意改几个物体的 active 状态,想确认“现在统计到什么程度了”,不用打断点,直接右键组件 ->PrintCurrentState,一条 Log 全出来了。配合上一条说的手工勾选验证,你几乎可以在不运行 Play 模式的情况下完成大部分联调。
实际项目里我会更丧心病狂一点:直接在编辑器扩展脚本里写一个批量工具,一键把trackedObjects所有物体设置为 hidden,再一键恢复,用来做回归测试。这个看你们项目有没有编辑器扩展的需求,没有的话手工验证也够用。
4.3 性能表现实测
按我这次接入的数据:被监控物体一共 31 个,目标对象一个,事件驱动模式跑起来之后,我只在有物体状态变化的那一帧里能看到几微秒的调用开销,其余帧完全没有计算成本。
作为对照组,我临时改成 Update 每帧遍历 31 个物体,Profile 里能看到稳定的几十微秒开销分配到每一帧。单独看都不算啥,但你要是场景里同时跑着几十个类似脚本,叠加起来就是肉眼可见的帧率下滑。所以我一直坚持一个原则:能用事件驱动解决的联动,绝不每帧遍历去轮询。这不是炫技,是为了把每一帧的固定开销降到最低,把 CPU 留给真正有需要的逻辑。
5. 常见问题与排查技巧实录
5.1 启动时目标对象状态不对,差一次全量同步
这个问题出现的频率非常高:场景刚加载时明明所有被监控物体都隐藏了,obj_fly_male_first却还显示着,要等下一次状态变化才恢复正常。
原因在于事件驱动的“先天缺陷”:场景加载过程中,很多物体触发OnEnable/OnDisable时,控制器的Awake还没跑,事件没被订阅,自然就错过了。我的代码为什么在Awake里要主动遍历一次、用notifier.CurrentVisible读取当前状态并统计?就是为了弥补这个缺陷——订阅事件的同时,把已经发生的状态一次性“补上账”。
所以你在抄这段代码时绝对不要把Awake里那个“统计当前状态”的循环删掉,那不是冗余,那是整个初始状态正确性的基石。
5.2 隐藏后又被自动打开,事件没反注册
如果你把obj_fly_male_first放在场景 A,场景切换后它明明已经被SetActive(false)了,却被一个来自场景 B 的物体“隔空唤醒”,那十有八九是控制器或某个 notifier 的生命周期没有处理好。
排查思路是这样的:先看控制器是不是设置了DontDestroyOnLoad,如果它在跨场景存活,trackedObjects里那些普通场景物体在销毁时触发了OnDisable,这些事件会传到一个还活着的控制器。控制器可能试图SetActive一个已经属于上一个场景、还没来得及销毁的对象,表现为“目标对象闪了一下又打开”。
解决办法就是我代码里的OnDestroy反注册。如果项目里存在跨场景存活的需求,建议在Start里主动扫描一遍trackedObjects,把已经为空已经销毁的引用清理一遍,别让空引用参和进计数。
5.3 列表里不小心包含了目标自身,逻辑死循环
这是最容易让人抓狂的问题,因为表现很诡异:物体隐藏后,目标对象跟着隐藏;然后目标对象隐藏又上报一次,计数多加一次;下一次再刷新时,条件怎么算都不对,表现成“目标对象在自己和自己之间反复横跳”。
我的代码在Awake里会LogError,并且把这种情况自动跳过。但如果你的项目里存在“运行时动态往列表里塞物体”的逻辑,那你需要在每次塞入新物体时做同样的一次校验,别只靠Awake里的保护。
5.4 父节点隐藏了,子节点上报状态吗
会。这是 Unity 行为里容易忽略的点:父节点SetActive(false)时,子节点确实会收到OnDisable回调。所以如果你监控的是父节点下的某个子部件,父节点一隐藏,子部件也会上报隐藏,这是对的。
但如果你在VisibilityNotifier里用_visible记录了状态,而父节点隐藏导致子节点OnDisable触发,随后父节点又快速恢复显示,子节点会依次收到OnDisable和OnEnable,这一组完整事件会被正确消化。唯一要注意的是:不要在OnDisable里访问已经被销毁的引用。Unity 里OnDisable在销毁时也会触发,访问别人的组件可能拿到空引用。
如果你实在担心销毁误报,更稳的写法是让VisibilityNotifier增一个IsApplicationQuitting或者场景卸载标记,在标记为 true 时不再触发事件。但按大多数项目的体量,OnDestroy反注册已经足够,这个额外保险不是必需。
5.5 明明显示着却统计成隐藏,检查 activeInHierarchy 和 activeSelf
前面提到过这两个 API 容易混。activeSelf只管自己的 IsActive,不管父节点;activeInHierarchy是把祖孙链全部算完的最终结果。
在这个需求里,我们应该用activeInHierarchy来反映“物体到底显不显示”。比如一个物体activeSelf是 true,但它父节点被隐藏了,那它在场景中就是不显示的,统计时应该算隐藏。如果你在VisibilityNotifier.CurrentVisible里用了activeSelf,就会出现“父节点隐藏了但它还是一个显示状态”的错误统计。所以千万注意这个细节。
5.6 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动时目标状态错误 | 初始状态没统计,错过事件 | 订阅事件后立即读取当前状态 |
| 目标隐藏后又自动显示 | 事件反注册缺失,跨场景误触发 | OnDestroy 里反注册 |
| 列表包含目标自身 | 自指造成计数错乱 | 跳过并 LogError |
| 父节点隐藏但目标不隐藏 | 误用 activeSelf | 改用 activeInHierarchy |
| 同一帧闪烁 | 多个物体连续上报导致多次 SetActive | 加状态变化判断,变化才执行 |
| 空列表时目标被隐藏 | 数学空集默认全隐 | 加 showTargetWhenListEmpty 开关 |
写在最后的一点体会
这类“全隐则隐、有显则显”的需求,本质上就是一个聚合可见性状态的判断。它看起来只是一个小小的 if 条件,实际写起来却牵扯到状态来源、事件时机、生命周期、边界条件。我个人在做这个需求时最大的体会是:先想清楚“谁负责上报状态、谁负责汇总状态、谁负责执行联动”,再动手写代码,比什么都重要。顺序理清了,事件驱动方案写起来半小时都用不了;顺序没理清,轮询、硬编码、手动通知,短短两三百行代码就能把后续维护的人坑到怀疑人生。
最后分享一个我每次做同类联动都会加的小功能:在控制器的调试面板上把hiddenCount、totalCount、isAllHidden这三个值用OnDrawGizmos或者 Inspector 的@字段直接露出来。排坑的时候眼睛扫一眼就能知道当前计数到哪儿了,比到处打 Log 快太多。这个习惯帮我省下了无数个本来要打断点才能解决的下午。