Unity 的延时调用

冬天吃雪糕2022年7月7日
大约 5 分钟

Unity 的延时调用

通常我们会遇到延时调用的需求,例如等待一段时间后执行,在一段时间内逐渐变化,以及等待某个事件发生后执行。

对于此类需求,在 Unity 中可以使用逐帧判断、Invoke 以及协程三种方法来完成。

逐帧判断

通过使用Time.timeTime.deltaTime等 Unity 内置的时间变量来记录时间的流逝,并在 Update 中执行相应的代码。优点是很灵活,缺点是容易造成Update的代码臃肿,使用起来不太方便。

要使用该方法实现定时任务,定义一个时间变量timer,每帧将此时间减去帧间隔时间Time.deltaTime,如果小于或者等于零,说明定时器到期了,执行相应功能代码。如果要重复执行,则在定时器到期后重置定时器,重新开始计时。

在下面的例子中,游戏对象会在等待3秒后自动销毁。

using UnityEngine;

public class Example : MonoBehaviour
{
    public float timer = 3.0f;

    void Update()
    {
        timer -= Time.deltaTime;
        if(timer <= 0){
            Destroy(gameObject);
        }
    }
}

在某些场景下,我们需要游戏对象根据时间逐渐发生变化,例如在一定时间内逐渐移动到目标点。

在下面的例子中,游戏对象会在3秒内逐渐移动到目标点。

using UnityEngine;
using System.Collections;

public class Example : MonoBehaviour
{
    public Vector3 p0 = Vector3.zero;
    public Vector3 p1 = new Vector3(3, 4, 5);
    public float timeDuration = 3f;

    bool onMoving = false;
    float timeStart;

    void Start()
    {
        p0 = transform.position;
        onMoving = true;
        timeStart = Time.time;
    }

    void Update()
    {
        if (onMoving)
        {
            float t = Mathf.InverseLerp(timeStart, timeStart + timeDuration, Time.time);

            if (t >= 1)
            {
                onMoving = false;
            }

            transform.position = Vector3.Lerp(p0, p1, t);
        }
    }
}

使用 Invoke

使用MonoBehaviour.Invoke,两个参数分别是要调用的方法名和延时调用的时间。使用Invoke不能调用带有参数的方法,而且性能上不够优秀。另外由于Invoke的第一个参数是要调用的方法名,如果找不到对应的方法(比如说拼写错误),无法在编译时发现问题。

如果要重复执行,使用MonoBehaviour.InvokeRepeating代替。

在下面的例子中,我们改用Invoke实现游戏对象在等待3秒后自动销毁的功能。

using UnityEngine;

public class Example : MonoBehaviour
{
    public float timer = 3.0f;

    void Start()
    {
        Invoke("TimeOutHandler", timer);
    }

    void TimeOutHandler()
    {
        Destroy(gameObject);
    }
}

在下面的例子中,我们使用MonoBehaviour.InvokeRepeating实现重复定时执行的效果,该程序会先等待3秒后实例化一个火箭并发射,然后每隔0.5秒重复发射一个。

如果你想在达到某个条件后停止执行定时任务,可以使用CancelInvoke()CancelInvoke(string MethodName),前者会停止当前脚本的所有InvokeInvokeRepeating任务,而后者接受一个方法名作为参数,会停止所有调用该方法InvokeInvokeRepeating任务。

using UnityEngine;
using System.Collections;

public class Example : MonoBehaviour
{
    public Rigibody projectile;

    void Start()
    {
        InvokeRepeating("LaunchProjectile", 3.0f, 0.5f);
    }

    void LaunchProjectile()
    {
        Rigidbody instance = Instantiate(projectile);

        instance.velocity = Random.insideUnitSphere * 5;
    }
}

使用协程(推荐)

在处理延时调用的需求时,逐帧判断和使用 Invoke 都可以实现效果,但使用协程来执行此类任务通常会更方便。

在下面的例子中,我们使用协程来实现逐渐减少对象的不透明度,直到对象变得完全不可见的效果。

using UnityEngine;
using System.Collections;

public class Example : MonoBehaviour
{
    // 声明协程
    IEnumerator Fade() 
    {
        for (float ft = 1f; ft >= 0; ft -= 0.1f) 
        {
            Color c = GetComponent<Renderer>().material.color;
            c.a = ft;
            renderer.material.color = c;
            yield return null;
        }
    }

    void Update()
    {
        // 按下 F 键对象开始逐渐隐藏
        if (Input.GetKeyDown("f")) 
        {
            StartCoroutine(Fade());
        }
    }
}

协程本质上是一个返回类型为IEnumerator的函数,并在主体中的某个位置包含yield return语句。当程序执行到yield return语句时,会暂停执行并将控制权返还给 Unity,然后在yield return之后的表达式返回结果后(对于yield return null,即为下一帧)恢复到此处继续执行。

在声明协程后,使用StartCoroutine将协程设置为运行状态。

如果要引入时间延迟,可以使用yield return new WaitForSeconds(t),该语句表示暂停执行直到 t 秒后的下一帧再继续执行。

因此,如果使用协程来实现游戏对象定时销毁的效果,代码如下:

using UnityEngine;

public class Example : MonoBehaviour
{
    float timer = 3.0f;

    IEnumerator AutoDestroy(float waitTime) 
    {
        yield return new WaitForSeconds(waitTime);
        Destroy(gameObject);
    }

    void Start()
    {
        StartCoroutine(AutoDestroy(timer));
    }
}

通过这个例子也可以看到,使用协程允许我们延时调用带有参数的方法。

我们还可以使用协程的串联调用,实现等待某个事件发生后继续执行的效果,见下面的例子:

using UnityEngine;

public class Example : MonoBehaviour
{
    public Vector3 target;

    public speed = 1.0f;

    IEnumerator WaitForReachTarget() 
    {
        Vector3 curPos = transform.position;
        while (!Mathf.Approximately(Vector3.Distance(curPos, target), 0))
        {
            transform.position = Vector3.MoveTowards(transform.position, target, speed * Time.deltaTime);
            curPos = transform.position;
            yield return null;
        }
    }

    IEnumerator Move() 
    {
        Debug.Log("start move");
        yield return StartCoroutine(WaitForReachTarget());
        Debug.Log("reach Target");
    }

    void Start()
    {
        StartCoroutine(Move());
    }
}

在上面的例子中,开始游戏后会先在控制台打印“start move”,然后游戏对象开始向目标点逐渐移动,在此过程中 Unity 的主线程不会被阻塞,也就是说其他的对象以及脚本也可以正常地运行。最后在游戏对象到达目标点后,控制台打印“reach Target”。

另外要注意协程的生命周期,可以使用StopCoroutineStopAllCoroutines来停止协程。当用SetActive(false)禁用或使用Destroy销毁某个协程所附加到的游戏对象时,该协程也将停止。协程附加的游戏对象是启用协程的对象,而不是定义协程的对象,例如在对象A中开启在对象B中定义的协程,A销毁后协程就会停止。

协程的局限性

对于延时调用这种异步编程需求,Unity 建议使用协程来实现,但协程没有返回值,也不方便调试(不能将yield return语句置于try-catch块中)。

UniTask

上次编辑于: 2022/10/6 11:40:35
贡献者: WingSnow