最近在项目中碰到一个很奇怪的 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,可是目前我能想到的这个可能是最简单的方法,当然我们也可以自己写一个工具方法,直接把这段代码拷贝过去,然后换个类名或者方法名都是可以的啦,看大家喜好吧。