标签归档:NGUI

由于误用 NGUITools.Destroy 导致所有 UI 控件的层都被重置的问题

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

NGUI 中如何通过代码让列表自动滑动到指定位置

虽然 Unity3D 自 4.6.x 开始就已经推出了全新的 uGUI 系统,不过目前为止我们项目中还木有使用,鉴于之前一直都在使用 NGUI,所以我们依然选择了继续使用我们熟悉的 NGUI 来进行这个项目的开发,也许后续的新项目会迁移到 uGUI 上,不过那是后话了。

在做 UI 开发的过程中,我们都有碰到类似聊天界面中对话列表自动滑到列表最底端,以便于对话列表中显示的永远都是最新的聊天消息的需求,那么这个怎么做呢?依着我们程序猿的直觉,那当然是通过列表控件的 ScrollToPosition 类似的方法来搞定了对吧,说得木有错。

首先,我们要先明确一点,在 NGUI 中的 UITable 控件只是一个容器,其自身并不提供滑动的功能,而是依托于其父对象上的 UIScrollView 来完成所有与滑动相关的操作的。那么我们就来看看 UIScrollView 中有哪些方法是可以用来做 Scroll 相关操作的。

	/// <summary>
	/// Move the scroll view by the specified local space amount.
	/// </summary>

	public virtual void MoveRelative (Vector3 relative)
	{
		mTrans.localPosition += relative;
		Vector2 co = mPanel.clipOffset;
		co.x -= relative.x;
		co.y -= relative.y;
		mPanel.clipOffset = co;

		// Update the scroll bars
		UpdateScrollbars(false);
	}

	/// <summary>
	/// Move the scroll view by the specified world space amount.
	/// </summary>

	public void MoveAbsolute (Vector3 absolute)
	{
		Vector3 a = mTrans.InverseTransformPoint(absolute);
		Vector3 b = mTrans.InverseTransformPoint(Vector3.zero);
		MoveRelative(a - b);
	}

这两个方法看起来非常符合我们的需求,在我们通过计算得出了列表需要滑动的向量之后,使用这两个方法会是非常方便的,那么问题现在已经转化为我们如何计算得出需要滑动的向量值了。这里我就不考虑 MoveAbsolute 显然这货看起来都不是我们想要的,因为其计算的相对位置直接采用了世界坐标的原点来进行计算,最终滑到哪里去,谁泥煤晓得捏。那么我们现在锁定了 MoveRelative 方法了,那么我们就只需要想办法计算出列表滑动到页面最底端时与当前时刻,列表中最后一个条目之间的位置差就好了。看看这个图,我们就能找出列表滑动到最底端的时候,其特征是啥了,然后根据这个特征来进行计算就好了。

列表滑动到最底端时的示意图

这下我们只需要得到 UIScrollview 所在的 Panel 的底端坐标和当前时刻列表最底端条目的底端坐标就可以计算这两者之间的差值了,下面的这个计算方法来源于 NGUI 中的 UICenterOnChild 脚本。

    public void MoveToLastVertical ()
    {
        Vector3[] corners = scrollView.panel.worldCorners;
        Vector3 panelBottom = (corners [0] + corners [3]) * 0.5f;

        Transform panelTrans = scrollView.panel.transform;

        Vector3 cp = panelTrans.InverseTransformPoint (lastItem.position);
        // UITable 中的条目根对象为 UISprite,居中对齐高度为 150,所以得到条目中心点在 Panel 中的坐标之后再加上其高度的一半,获得底端条目的底端坐标
        cp = new Vector3 (cp.x, cp.y + 75, cp.z); 
        Vector3 cc = panelTrans.InverseTransformPoint (panelBottom);
        Vector3 localOffset = cp - cc;

        // Offset shouldn't occur if blocked
        if (!scrollView.canMoveHorizontally) {
            localOffset.x = 0f;
        }
        if (!scrollView.canMoveVertically) {
            localOffset.y = 0f;
        }
        localOffset.z = 0f;

        //        SpringPanel.Begin (scrollView.panel.cachedGameObject,
        //            panelTrans.localPosition - localOffset, 13);
        scrollView.DisableSpring ();
        scrollView.MoveRelative (new Vector3 (localOffset.x, -localOffset.y, localOffset.z));
    }

应用这个原理,针对垂直方向上的列表,我们可以实现滑动到列表的最顶端和最底端,针对水平方向上的列表,我们可以实现滑动到列表的最左侧和最右侧功能。另外如果觉得直接使用 MoveRelative 方法不是很优雅没有动画效果过于生硬的话,那么就只需要将上面那段代码中注视掉的使用 SpringPanel 来移动列表的代码放出来,把下面的两行使用 MoveRelative 方法的代码给关起来就 OK 哒。其实还是蛮好实现的呢,有木有?