Unity3D 中粒子特效不在摄像机渲染范围内 Play 之后导致特效延时播放的解决方法

从项目启动之初,我们就引进了 PoolManager 插件来做诸多对象的缓存,防止游戏中对象的频繁 Instantiate 和 Destroy,造成不必要的性能问题。PoolManager 是一个非常成熟的插件,也提供了很多非常友好的接口以供开发者使用,其中也有针对 ParticleSystem 对象的 Spawn/Despawn 机制,所以我们就很自然的使用了 PoolManager 插件来管理整个游戏中的所有特效对象。

particle_system_spawn_pool_management

对于特效对象,我们游戏中的管理逻辑大体如下:

也就是说我们的粒子特效对象是重用的,在每次重新被 Spawn 出来的时候都会重新调用一次 Play 方法来重新播放这个粒子特效,这个方案看上去很正常有木有?确实没有啥硬伤对吧。

但是在我们实际的测试过程中,我们很早就发现了一个很奇怪的问题,就是偶尔场景中会出现一些不应该出现的技能特效。例如,在场景中偶遇到一个小怪,小怪在攻击的过程中玩家控制主角快速跑开了(主摄像机一直跟随主角),然后灯小怪的攻击结束之后,主角再次跑回到小怪面前进行战斗,然后此时偶尔就会看到小怪并没有在进行任何攻击,只是在战斗待机或者移动,但是屏幕上会凭空播放一些这个小怪的攻击技能对应的特效(其实找到这个问题重现的方法,也就是这个 Bug 的规律也是费尽心思啊,想想看这货都存在快 TMD 一年了,直到现在我才真正地捉到它)。

既然已经找到了重现的方法,也清楚了 Bug 出现的规律,也就是当小怪释放技能时(技能动作带有技能粒子特效),由于主角的移动导致摄像机跟着主角在场景中移动,最终小怪释放的技能粒子特效并不在摄像机的 Culling 区域中,而此时我们通过了 PoolManager 插件 Spawn 出来了一个新的粒子特效,然后调用了 ParticleSystem 的 Play 方法来播放粒子特效。我们预想的是该粒子特效在我们调用 Play 之后就会播放,然后就交给 PoolManager 自行管理就好了(PoolManager 插件会监听粒子是否已经完全发射/播放结束,如果是,就会将这个粒子对象 Despawn 掉,放回到 PoolManager 中对应的 SpawnPool 中以备后续再次使用)。

问题就出在这里,PoolManager 中监听粒子对象是否完全发射/播放结束的方法是通过轮询 ParticleSystem 的 IsAlive 方法来进行判断的,代码如下:

// ParticleSystem (Shuriken) Version...
private IEnumerator ListenForEmitDespawn(ParticleSystem emitter)
{
    // Wait for the delay time to complete
    // Waiting the extra frame seems to be more stable and means at least one 
    //  frame will always pass
    yield return new WaitForSeconds(emitter.startDelay + 0.25f);

    // Do nothing until all particles die or the safecount hits a max value
    float safetimer = 0;   // Just in case! See Spawn() for more info
    while (emitter.IsAlive(true))
    {
        if (!PoolManagerUtils.activeInHierarchy(emitter.gameObject))
        {
            emitter.Clear(true);
            yield break;  // Do nothing, already despawned. Quit.
        }

        safetimer += Time.deltaTime;
        if (safetimer > this.maxParticleDespawnTime)
            Debug.LogWarning
            (
                string.Format
                (
                    "SpawnPool {0}: " +
                        "Timed out while listening for all particles to die. " +
                        "Waited for {1}sec.",
                    this.poolName,
                    this.maxParticleDespawnTime
                )
            );

        yield return null;
    }

    // Turn off emit before despawning
    //emitter.Clear(true);
    this.Despawn(emitter.transform);
}

这个 IsAlive 判断就是问题的关键,经过测试我们发现了一个 Unity3D 的机制,当我们首次将 PartileSystem 对象 Instantiate 出来时,不论其是否在摄像机的 Culling 区域内,调用 ParticleSystem 的 Play 方法或者将其 playOnAwake 设置为 True 都会播放该 ParticleSystem 对象。而在其首次 Instantiate 之后,再次调用 Play 方法来播放该粒子特效时,如果粒子不在任何一个 Camera 的Culling 区域内(包括 Culling Layer Mask 是否相符以及是否在 Camera 的可视范围内),实际上粒子并不会发射,直到其进入到某个 Camera 的 Culling 区域内才会触发粒子的发射(实际在 Unity Editor 中测试的时候,最终发现粒子进入 Scene Window 的可视区域和 Game Window 的可视区域都会触发粒子的发射,实际上 Scene Window 的 Preview 就是有一个我们不可见的 Camera 在进行渲染)。此处需要说明的是以下两点:

  1. Unity 版本为 4.6.9f1;
  2. 粒子特效的 Renderer Mode 是 Billboard。

至此,我们终于完全弄明白了为什么会出现某些技能的粒子特效无端地在场景里播放了,原因就是因为该特效在 Instantiate 出来调用过一次 Play 方法之后(由于我们所有的特效默认 playOnAwake 属性都是 true,所以我们通过 PoolManager 加载的粒子特效的 Prefab 实际上在首次 Spawn 之前都已经自动调用过一次 Play 方法了),再次调用 Play 方法来播放该粒子特效的时候,该粒子特效所处的坐标位置并不在主摄像机的 Culling 区域内,所以根本就不会触发粒子的发射而是在等候下次进入摄像机的 Culling 区域时再触发。在我们游戏中的最终表现就是在屏幕边缘与小怪发生战斗,当小怪释放技能的时候快速往小怪的反方向移动,等着小怪来追击,我们再折返跑回刚刚与小怪发生战斗的地方,这个时候我们就能看到几个小怪的技能特效凭空在场景中自动播放了,真乃煞是奇妙啊。

好吧,说了这么多,那么我们既然知道是这个原因了,那么怎么解决呢?我们看到了  ListenForEmitDespawn 方法中有一个针对粒子特效在 Spawn 出来之后时间的检测,其检测粒子对象自 Spawn 以来的时长是否超出 SpawnPool 中定义的最大 Despawn 时长,跟实际的粒子对象并无直接关系,而且即便该粒子对象 Spawn 出来的时长已经超出最大的 Despawn 时限,也只是会简单地输出了一个警告的 Log 信息。

那么我们自己的处理逻辑应该是怎样的呢?我的处理方法是在粒子对象被 Spawn 出来之后,除了通过其自身的 IsAlive 方法来检测该粒子对象是否还是激活状态,在其 Spawn 出来的同时就启动一个 Coroutine 用来检测其自 Spawn 出来的时长是否超出 ParticleSystem 的 duration 设置值,如果已超出,那么就将其 Clear 并且 Stop,然后再将其 Despawn 掉。依次修改后的 ListenForEmitDespawn 方法如下:

// ParticleSystem (Shuriken) Version...
private IEnumerator ListenForEmitDespawn(ParticleSystem emitter)
{
    // Wait for the delay time to complete
    // Waiting the extra frame seems to be more stable and means at least one 
    //  frame will always pass
    yield return new WaitForSeconds(emitter.startDelay + 0.25f);

    // Do nothing until all particles die or the safecount hits a max value
    float safetimer = 0;   // Just in case! See Spawn() for more info
    while (emitter.IsAlive(true))
    {
        if (!PoolManagerUtils.activeInHierarchy(emitter.gameObject))
        {
            emitter.Clear(true);
            yield break;  // Do nothing, already despawned. Quit.
        }

        safetimer += Time.deltaTime;
        if (safetimer > emitter.duration) {
	        Debug.LogWarning
	        (
	            string.Format
	            (
	                "SpawnPool {0}: " +
	                    "Timed out while listening for all particles to die. " +
	                    "Waited for {1}sec.",
	                this.poolName,
	                emitter.duration
	            )
	        );
			break;
		}
            
        yield return null;
    }

    // Turn off emit before despawning
	emitter.Stop(true);
    emitter.Clear(true);
    this.Despawn(emitter.transform);
}

如何搞定 UnityEngine.UI.dll is in timestamps but is not known in guidmapper… 错误

在使用 Unity 开发的过程中,不知道什么情况下就会碰到这么两个错误(根据平台和使用的 Unity 版本不同,路径可能不一样,我这边列出的也不是完整的路径):

UnityExtensions/Unity/GUISystem/4.6.9/UnityEngine.UI.dll is in timestamps but is not known in guidmapper…

UnityExtensions/Unity/GUISystem/4.6.9/Editor/UnityEditor.UI.dll is in timestamps but is not known in guidmapper…

有的人建议是直接把我们的 Project 目录下的 Library 目录直接删除掉,不过这样一来,重新再次打开 Unity Editor 的时候,整个工程的资源都需要重新导入,而且通常我们还是在移动平台模式下进行开发,也就是等它把资源导入进来之后,我们还需要做一次 Switch Platform 的操作,真的好慢好慢好慢啊(也许可以通过 Asset Cache Server 来提速,但是我没有用过,所以不敢乱说)。

那么我们有什么办法呢,这里介绍一个比较好的方法,总共分三步:

  1. 进入出错的两个 dll 文件所在目录中,将出错的两个 dll 文件重命名为任意文件名,例如:UnityEngine.UI.dll.backup 和 UnityEditor.UI.dll.backup;
  2. 重启 Unity Editor,等整个工程加载结束之后,我们会看到很多红色的错误日志输出,甭理它就好了,关闭 Unity Editor;
  3. 回到那两个 dll 文件所在的目录,将刚刚重命名的两个 dll 文件分别修改回来,UnityEngine.UI.dll 和 UnityEditor.UI.dll,然后再次启动 Unity Editor,接下来就是见证奇迹的时刻了。

好了,就是这么简单。

互联网品牌贩卖的是什么?

先说明我的观点:互联网品牌贩卖的是一种微妙而完整的体验。

1.

很难想象在乔布斯发布 iPhone 之前会有哪家手机厂商给自己的一款手机开一个盛大的发布会(也许有,只是我不知道而已),但是现在如果哪家厂商发布一款新的手机而不搞一个发布会,就显得那么的异类了。那么发布会是不是很有必要呢?有,非常有。

想象一下 3 年前或者更远一些 5 年前自己要去买手机的时候,我们心中的第一个念头是啥?去中关村看看,去苏宁/国美看看。再对比一下现在,我们其实已经在计划自己下一次要换手机的时间和机型了,也许我们手上正在使用的手机也才使用了 8 ~ 10 个月而已,这个就是发布会的力量。

我们现在回头数数今年一年里,叫得上号的手机发布会有多少,叫得上号的智能电视发布会有几个,叫得上号的智能手环发布会有几个,这个列表还可以有很长。发布会是一个非常直接的渠道和手段,通过发布会能让诸多的媒体参与到发布会里头来(这年头科技媒体的主营业务应该就是跑发布会了吧),媒体会在发布会前后发布各种相关的资讯,让读者和消费者们充分的了解到这款产品的特点。同时这还会造成一种现象就是「这款产品真的蛮不错的,看起来还挺火的呢,很多媒体上都在报道他们的产品发布会呢」。配合产品发布会呢,还有诸多衍生的产品线,例如:谍照泄漏,工信部网站公示信息、专利申请被披露、公司高层在社交媒体上各种秀和各种暗示,这种在未见其面之前就勾起大家深思的蛛丝马迹;再例如:工艺名词解析、参数大比拼等,这种将原本属于工业制造和实验室领域的各种专有名词的普及运动;还例如:期货抢购、工匠情怀、体验至上等,这种更高层次的营销策略和手段。

这一切造成一个很直接的结果:「这款产品不错,体验很好,参数很高,性能很强,得赶紧预约才能买到」,这让我们对一款新推出的产品有了一个很好的想象。然后我们来到某家手机厂商的在线商城,打开首页就是一个四周留白,页面正中间放着一张硕大的产品图片(图片通常都美得让你觉得不是实物,实际上也确实仅供参考),然后你可以通过滚动鼠标或者左右滑动手指等等方式将这些精美的图片浏览一遍,然后会有一个产品参数页面,总之这个页面是精心设计非常精美的,通常以白色为主色调,兼而可能使用产品主色调作为辅助,产品图片无一不是那么的精致美丽,哪怕只是一节电池,除了当作模特拍上好些漂亮照片之外,可能还要惊喜绘制一些剖面图/截面图/内部构造拆解图之类的。每次当我看到这些美丽的图片的时候,无一例外,我都想立刻能拥有它们,哪怕它们实际上并不是我急需要的东西,而我可能正囊中羞涩。

互联网公司天生离用户的距离比较近,与互联网媒体的亲密度也相对较高,通过发布会以及相关的事件,让你的微博时间线、微信朋友圈和同事午间吃饭闲聊都离不开某款新手机的屏幕尺寸和芯片型号,这让我们快速地与自己当前手上拥有的设备产生了对比,然后我们很自然地就发现了更好的更新的设备,这下我们已经有了对这款产品的渴望了。

2.

接下来我们会从各大测评网站和咨询媒体那儿看到诸多「记一次亲密接触-XX 手机开箱与轻体验」类似标题的文章,各大媒体的小编们在收到快递之后迅速拆开包装把玩一番之后,我们就看到了前面的这篇文章,接下里的几天里,除了媒体小编收到了测试设备,也有一些发烧友或资深玩家会纷纷分享自己的开箱体验,其中会有诸多对新款设备不足槽点的吐槽和埋怨,但是请相信任何一款新设备只要不是极其坑爹,通常大方向上其与老旧设备的对比都是鲜明的,而且通常也确实会比老旧设备更胜一筹的。

然后我们才慢慢发现公交车上、地铁上和办公室里,慢慢地多出来了不少新的设备,看到别人优雅地用拇指解锁屏幕,看到别人轻轻按压手机屏幕便可呼出支付宝的向商家付款界面时,那前几日在发布会上或者在线上观看发布会视频时萌发的渴望愈发强烈了一些。

3.

比别人更先一步体验到流行就是时尚,如今互联网品牌已经成为了某种时尚,第一时间知晓某品牌新产品的各项详细参数以及不同型号之间的区别和售价,会让你成为周边同事和朋友的购物指导人,这也是目前诸多自媒体公众号做买手生意的由头,毕竟很多人都想赶时尚但是就是没有这个能力啊。当我们比别人更先一步拥有这新款设备的时候,这种优越感来得更为直接,很多人也对这新设备感兴趣呢,当他们开口向我们询问这款产品怎么样的时候,我想除非这款产品真的伤害到了我们,通常自己都不会做过分负面的评价的,毕竟我们更倾向于相信自己的选择是明智的,眼光是独到的,品味是高雅的,所以很多的时候我们的评价实际上并不是客观的。

所以我们看到那么多的微信公众号在我们的心里种草,有的种草手账,有的种草钢笔,有的种草家居装饰,有的种草皮具等等等等。一旦你成为读者和消费者,你已经被引领了,你会认可这种时尚你会追随这种时尚,成为「米粉」成为「煤油」等等各种奇怪的粉丝,然后等着被满足,等着被薅羊毛。

4.

我们看到越来越多的内容创业者最终落地到了卖货上,貌似「一条」视频团队也要通过美食系列节目开始卖「视频里头的炊具」,数字尾巴做了贩卖数码周边的尾巴良品业务,我想他们家卖得最多最好的恐怕就是移动电源了。数字尾巴上有着非常优质的产品测评分享,以数码科技产品为主,周边为辅,各种高大上和 DIY 一应俱全,里头的高端分享玩家也确实够土豪,分享的产品也确实逼格满满。那么为什么我觉得移动电源卖得最好呢?因为他家卖的移动电源单品最多,各种品牌的各种类型款式,而且测评的文章也不好还非常的专业。在这么一个高质量的数码产品测评分享社区里头,这些移动电源一样显得很不错,有高端的感觉,而且这些东西都不贵,很多人都可以轻松获得。在获得实物的同时,我们也获得了之前通过社区里头的文章构建出来的美好想象和拥有此物的渴望被满足的快感。

这也可以从某个角度说明为何有那么多的低收入群体的人会渴望拥有一台售价不菲的 iPhone 手机,这是一个可以通过相对低廉的价格获得一次超高性价比的体验的机会。这个体验绝不只是限于使用手机的时候获得的便利,因为使用其他款式其他品牌的手机,他们也一样可以出色的满足发朋友圈、聊 QQ 、看视频和听音乐这几大核心需求。但是 iPhone 能提供的体验绝不止于此,它在长达一年甚至更长的时间里(苹果发布新产品的事件周期通常为一年,库克也许正在打破这个节奏的路上),能给我们带来一种与时尚比肩的优越感,试想一下「沙特阿拉伯的王子如果不用 VERTU 手机的话,他估计也就用的 iPhone 吧」,当然我们更可以扩大一下「贝克汉姆」「Justin · Bieber」也都用的是 iPhone 噢,是不是有一种顿时跟偶像零距离的感觉呢?时尚从未离我们如此之近。

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

UnityEngine.Object.Destory 和 NGUITools.Destroy 方法之间的区别

我们先来看看 NGUITools.Destroy 方法:

static public void Destroy (UnityEngine.Object obj)
{
    if (obj != null)
    {
        if (Application.isPlaying)
        {
            if (obj is GameObject)
            {
                GameObject go = obj as GameObject;
                go.transform.parent = null;
            }

            UnityEngine.Object.Destroy(obj);
        }
        else UnityEngine.Object.DestroyImmediate(obj);
    }
}

我们可以看到这个方法最终也是调用的 Unity3D 提供的 UnityEngine.Object.Destroy 方法,区别一是根据当前游戏是否在运行,然后选择调用 Destroy 还是 DestroyImmediate 方法,区别二是在调用 Destroy 的逻辑中加入了将销毁对象的 Transform 的 parent 设置为 null 了,这个小细节有什么作用呢?NGUI 这么一个成熟的插件为什么在这个地方会做这样的处理呢?之前我并不知晓其中的缘由,直到今天我碰到了一个跟 Transform.childCount 相关的问题,我才懂了。接下来我们来看一个栗子,这段代码中,我们要做的就是从一个父控件中移除子控件,直到父控件中子控件的个数与目标数量一致。

void DestroyChildenToCount (Transform parentTransform, int destChildCount)
{
    if (parentTransform == null)
        return;
    if (parentTransform.childCount <= destChildCount)
        return;
    while (parentTransform.childCount > destChildCount) {
        UnityEngine.Object.Destroy (parentTransform.GetChild (destChildCount));
    }
}

上面的这段代码,我们有看出什么问题来吗?我是没有看出来,所以我就运行了这段代码,然后我的 Unity3D 就把电脑的 CPU 全给吃掉了,Unity3D 就无法正常响应操作了然后就崩溃了。通常我们这个时候的第一反应就是死循环了对吧,那么问题出在哪儿,这段代码里头就一个 While 循环,那么肯定就是这个循环出问题了,对吧。我们再来看看这段代码可能出问题的地方,我们以为只要持续的每次删掉索引为 destChildCount 的子控件,直到父控件的所有子控件的数目与目标数一致之后退出循环,达到我们预期目的,但是事实上这个 UnityEngine.Object.Destroy 方法的执行并不是及时的,这就导致了 While 循环一直无法退出的问题。我们来看看 Unity3D 官方文档上对 Destroy 方法的描述:

Removes a gameobject, component or asset.

The object obj will be destroyed now or if a time is specified t seconds from now. If obj is a Component it will remove the component from the GameObject and destroy it. If obj is a GameObject it will destroy the GameObject, all its components and all transform children of the GameObject. Actual object destruction is always delayed until after the current Update loop, but will always be done before rendering.

红色字体部分明确说明了 Destroy 的操作会在本次刷新的 Update 循环之后执行,但是会在整体渲染之前完成,这就意味着这个里头还是存在时间差的,例如每帧 0.033333 秒执行时间,有这么多的时间留给 While 循环,它不给你整出事儿来都很难啊,对吧。

所以现在我们就能理解 NGUITools.Destroy 方法中在 Destroy 对象之前将其 Transform 的 parent 指向了 null 的意图了,这样就可以及时地将被销毁对象从其原本的父对象节点上移除了,这样我们的 While 循环就可以及时的退出了。如果你跟我一样有类似的销毁某个对象的指定数目子控件的需求的话,我想这会是一个蛮有意思的知识点的。