最近在项目中碰到一个很奇怪的 Bug,表现是在背包中消耗掉某些道具之后,会将所有 UIRoot 下的子对象的层全部都修改的 UI 层,这个会造成很多奇怪的问题。由于我们游戏中使用了多个用于 UI 显示的 Camera,针对不同层的 GameObject 使用了不同的 Camera,这么一整搞得完全乱了套了。
当然确定是因为消耗了道具之后才造成 Bug 出现的这一步也是花费了很长的时间才搞明白的,在此之前我已经把我能想到的所有情况都给试了一遍(当然这个少不了我们士海和杨威童鞋两个人在旁边的指点和敲击),最终发现只能给要被 Destroy 掉的控件添加一个 UIPanel 组件就可以彻底避免这个问题,当然这个还是还是有点懵,直到追查到了 NGUITools 中的 CreateUI 方法中,看到了 SetChildLayer 方法,才知道最终是因为调用了这个方法,那么接下来就是一步步定位了。
通过各种日志输出和调试,最终确认了就是因为调用 NGUITools.Destroy 销毁掉的控件在被彻底的销毁之前调用了自身的 Update 方法,然后调用到了 OnUpdate 这个方法:
protected override void OnUpdate () { if (panel == null) CreatePanel(); #if UNITY_EDITOR else if (!mPlayMode) ParentHasChanged(); #endif }
正常情况下,CreatePanel 这个方法是不会调用的,但是在我们这个游戏中,道具使用的时候会跳转到另一个 UI 界面下,而背包界面的整个 UIPanel 在玩家操作道具使用时是隐藏的,这样一来背包中所有条目的 UIWidget 中的 panel 属性就都为空了(这个 panel 属性设置为空是在整个背包面板被隐藏的时候 UIWidget 的 RemoveFromPanel 方法被自动调用了),所以在我们消耗掉某个指定的道具回到背包界面的时候,我们会调用 NGUITools.Destroy 方法来将被消耗掉的这个道具的 Item 给销毁掉,这样背包中剩余的所有条目就是当前实际背包中所有的道具条目了。但是我们忽略了 NGUITools.Destroy 方法中对目标销毁条目做了什么操作,这个可以参考我的另一篇文章,最终导致调用 CreatePanel 方法的时候错误的将整个 UIRoot 下的所有 UI 控件的 layer 都给设置成了目标销毁对象所在的层。
public UIPanel CreatePanel () { if (mStarted && panel == null && enabled && NGUITools.GetActive(gameObject)) { panel = UIPanel.Find(cachedTransform, true, cachedGameObject.layer); if (panel != null) { mParentFound = false; panel.AddWidget(this); CheckLayer(); Invalidate(true); } } return panel; }
就是在 CreatePanel 方法中调用到了 UIPanel.Find 方法:
static public UIPanel Find (Transform trans, bool createIfMissing, int layer) { UIPanel panel = NGUITools.FindInParents<UIPanel>(trans); if (panel != null) return panel; return createIfMissing ? NGUITools.CreateUI(trans, false, layer) : null; }
继而又调用了 NGUITools.CreateUI 方法:
static public UIPanel CreateUI (Transform trans, bool advanced3D, int layer) { // Find the existing UI Root UIRoot root = (trans != null) ? NGUITools.FindInParents<UIRoot>(trans.gameObject) : null; if (root == null && UIRoot.list.Count > 0) { foreach (UIRoot r in UIRoot.list) { if (r.gameObject.layer == layer) { root = r; break; } } } // If we are working with a different UI type, we need to treat it as a brand-new one instead if (root != null) { UICamera cam = root.GetComponentInChildren<UICamera>(); #if UNITY_4_3 || UNITY_4_5 || UNITY_4_6 if (cam != null && cam.camera.isOrthoGraphic == advanced3D) #else if (cam != null && cam.GetComponent<Camera>().orthographic == advanced3D) #endif { trans = null; root = null; } } // If no root found, create one if (root == null) { GameObject go = NGUITools.AddChild(null, false); root = go.AddComponent<UIRoot>(); // Automatically find the layers if none were specified if (layer == -1) layer = LayerMask.NameToLayer("UI"); if (layer == -1) layer = LayerMask.NameToLayer("2D UI"); go.layer = layer; if (advanced3D) { go.name = "UI Root (3D)"; root.scalingStyle = UIRoot.Scaling.Constrained; } else { go.name = "UI Root"; root.scalingStyle = UIRoot.Scaling.Flexible; } } // Find the first panel UIPanel panel = root.GetComponentInChildren<UIPanel>(); if (panel == null) { // Find other active cameras in the scene Camera[] cameras = NGUITools.FindActive<Camera>(); float depth = -1f; bool colorCleared = false; int mask = (1 << root.gameObject.layer); for (int i = 0; i < cameras.Length; ++i) { Camera c = cameras[i]; // If the color is being cleared, we won't need to if (c.clearFlags == CameraClearFlags.Color || c.clearFlags == CameraClearFlags.Skybox) colorCleared = true; // Choose the maximum depth depth = Mathf.Max(depth, c.depth); // Make sure this camera can't see the UI c.cullingMask = (c.cullingMask & (~mask)); } // Create a camera that will draw the UI Camera cam = NGUITools.AddChild<Camera>(root.gameObject, false); cam.gameObject.AddComponent<UICamera>(); cam.clearFlags = colorCleared ? CameraClearFlags.Depth : CameraClearFlags.Color; cam.backgroundColor = Color.grey; cam.cullingMask = mask; cam.depth = depth + 1f; if (advanced3D) { cam.nearClipPlane = 0.1f; cam.farClipPlane = 4f; cam.transform.localPosition = new Vector3(0f, 0f, -700f); } else { cam.orthographic = true; cam.orthographicSize = 1; cam.nearClipPlane = -10; cam.farClipPlane = 10; } // Make sure there is an audio listener present AudioListener[] listeners = NGUITools.FindActive<AudioListener>(); if (listeners == null || listeners.Length == 0) cam.gameObject.AddComponent<AudioListener>(); // Add a panel to the root panel = root.gameObject.AddComponent<UIPanel>(); #if UNITY_EDITOR UnityEditor.Selection.activeGameObject = panel.gameObject; #endif } if (trans != null) { // Find the root object while (trans.parent != null) trans = trans.parent; if (NGUITools.IsChild(trans, panel.transform)) { // Odd hierarchy -- can't reparent panel = trans.gameObject.AddComponent<UIPanel>(); } else { // Reparent this root object to be a child of the panel trans.parent = panel.transform; trans.localScale = Vector3.one; trans.localPosition = Vector3.zero; SetChildLayer(panel.cachedTransform, panel.cachedGameObject.layer); } } return panel; }
我们在设置整个背包界面可见之前已经调用了 NGUITools.Destroy 方法将我们消耗掉的道具的 Item 销毁了,而在道具被消耗掉的同时我们除了调用了 NGUITools.Destroy 方法将其销毁掉,还同时关闭了道具使用的界面回到了背包界面,这样一来这个在道具使用界面被销毁掉的道具 Item 实际上在背包界面再次显示的时候还是会调用 Update 方法,直到其真正被销毁掉 Update 才不会再次被调用,而这一次 Update 的调用就导致了前面一连串的反应,最终导致了错误地将整个 UIRoot 下的所有子控件的 layer 都设置为了被销毁道具 Item 所在的层。
那么我们应该怎么来避免这个问题呢?是不是就应该不是用 NGUITools.Destroy 方法,当然这是一个不错的想法,也是很直接的想法,但是如果你参考了我的另一篇文章之后,也许你就不会这么想了,原来 NGUITools.Destroy 方法还是蛮有门道的,如果盲目的替换成 UnityEngine.Object.Destroy 方法,也许带来的只会是麻烦。那么我是怎么解决的呢?好吧,代码来了:
static public void Destroy (UnityEngine.Object obj) { if (obj != null) { if (Application.isPlaying) { if (obj is GameObject) { GameObject go = obj as GameObject; SetActive (go, false); go.transform.parent = null; } UnityEngine.Object.Destroy(obj); } else UnityEngine.Object.DestroyImmediate(obj); } }
我在 go.transform.parent = null; 之前加入了一行代码 SetActive (go, false); 这样就避免了已经要被销毁了的对象还会再次调用 Update 的问题,所有后续引发的问题就都一一化解了。不过于我而言,实际上我并不太喜欢这种直接修改 nGUI 代码的修改方式,这样的修改总是会在你更新 nGUI 插件之后丢失掉(Unity3D Editor 在更新插件的时候都是直接覆盖的,并不存在智能合并这种东西的亲),所以呢还是有点小遗憾和 Dirty,可是目前我能想到的这个可能是最简单的方法,当然我们也可以自己写一个工具方法,直接把这段代码拷贝过去,然后换个类名或者方法名都是可以的啦,看大家喜好吧。