2025年11月4日 星期二

Unity:延遲執行的選擇

在 Unity 開發中,我們經常會遇到需要「延遲執行」某段程式碼的情境,例如動畫播放完才進行下一步、等待伺服器回應後再更新畫面,或是在遊戲中設定短暫的冷卻時間。

雖然目標都一樣 - 讓程式「稍後」再執行 - 而在 Unity 中,有好幾種不同的做法可以達成這件事,這些做法各有優缺點與適用時機,若使用不當,可能導致效能浪費、邏輯錯亂,甚至在非主執行緒呼叫 Unity API 時發生例外錯誤。


Unity:延遲執行的選擇




Unity 內建,MonoBehaviour 的 Invoke()

Invoke() 是最傳統也最簡單的方式,可透過字串指定函式名稱,讓 Unity 在指定秒數後自動呼叫該函式執行。具備以下這些特點:

  • 程式碼簡單,容易維護。
  • 適合簡單的一次性延遲。
  • 保證在 Unity 主執行緒上執行。
  • 會受到 Time.timeScale 影響其延遲時間的暫停或快慢,故無法完全實現真實時間延遲(如:真實時間倒數)。
  • 與 Unity 生命週期整合,自動管理生命週期,當 MonoBehaviour 被銷毀時就會自動取消。
  • 無法對所呼叫的函式傳遞參數。
  • 無法處理執行順序,進行複雜的流程控制。
  • 無法針對單一延遲執行進行取消,只能使用 CancelInvoke() 取消整個 MonoBehaviour 的 Invoke() 延遲執行。
C#
public class Worker : MonoBehaviour {

    // 物件銷毀時,會自動取消。
    public void Process() {
        Invoke(nameof(DoSomething), 1.0f);
    }

    private void DoSomething() {
        transform.position = Vector3.zero; // 在主執行緒上,安全執行。
        Debug.Log("1 秒後的訊息");
    }
}

無法傳遞參數,也無法有效控制執行順序。

C#
public class Worker : MonoBehaviour {

    public void Process() {
        // ❌ 無法傳遞參數
        Invoke(nameof(ProcessWithParam), 1f);

        // 無法處理執行順序,無法保證 Step2 是在 Step1 之後執行
        Invoke(nameof(Step1), 1f);
        Invoke(nameof(Step2), 2f);

        // 無法中途取消特定的 Invoke
        Invoke(nameof(Action1), 2f);
        Invoke(nameof(Action2), 3f);
        CancelInvoke(); // 只能全部取消
    }

    private void ProcessWithParam(string param) {
        // ...
    }

    private void Step1() {
        // ...
    }

    private void Step2() {
        // ...
    }

    private void Action1() {
        // ...
    }

    private void Action2() {
        // ...
    }
}

Unity 內建的 Coroutine

Coroutine 是在 Unity 中原生的延遲邏輯,使用 IEnumerator 與 yield return 進行可中斷的流程控制。具備以下這些特點:

  • 保證在主執行緒執行。
  • 需要寫 IEnumerator 方法。
  • 與 Unity 生命週期良好的整合,當 MonoBehaviour 被銷毀時就會自動取消。
  • 高度靈活,可進行複雜的流程控制,進行多步驟串接。
  • 可以傳遞參數。
  • 不能有返回值,需要用 callback 或其它方式來取得返回值。
  • 語法不如 async/await 語法那樣直覺。
  • 可使用 StopCoroutine 停止特定的 Coroutine。
  • 會受到 Time.timeScale 影響,但也可使用不受 Time.timeScale 影響的時間延遲方法。
C#
public class Worker : MonoBehaviour {

    public void Process() {
        StartCoroutine(DelayedProcess());
    }

    private IEnumerator DelayedProcess(){
        yield return new WaitForSeconds(1.0f);
        transform.position = Vector3.zero; // 在主執行緒上,安全執行。
        Debug.Log("1 秒後的訊息");
    }
}
Coroutine 可比較彈性的做後續處理,例如取消指定的 Coroutine、串接多個步驟、是否受 Time.timeScale 影響以及延遲條件等。
C#
public class CoroutineWorker : MonoBehaviour {
    public bool IsReady { set; private get; }

    private Coroutine _coroutine;

    // 將 Coroutine 保存下來,以方便後續操作
    public void StartProcess(float scaleTime) {
        if (_coroutine != null) StopCoroutine(_coroutine);
        _coroutine = StartCoroutine(DoProcess(scaleTime)); // 可以傳遞參數
    }

    // 可以取消 Coroutine 的執行
    public void StopProcess() {
        if (_coroutine == null) return;
        StopCoroutine(_coroutine);
        Debug.Log("Stopping coroutine");
    }

    // 可以串接多個步驟
    private IEnumerator DoProcess(float scaleTime) {
        Debug.Log("Coroutine started");

        if (scaleTime <= 0) {
            Debug.Log("Scale time must be greater than zero");
            yield break;
        }

        Time.timeScale = scaleTime;

        yield return new WaitForSeconds(1f); // 受 Time.timeScale 影響的時間
        Step(1);
        yield return new WaitForSecondsRealtime(1f); // 不受 Time.timeScale 影響的時間
        Step(2);
        yield return new WaitUntil(() => IsReady); // 延遲直到符合條件
        Step(3);
        _coroutine = null;
    }

    private void Step(int step) {
        Debug.Log("Step " + step);
    }
}

C# 的非同步延遲,Task.Delay()

使用 .NET 的 Task 與 async/await 進行延遲執行。其特點如下:

  • .NET 標準,可與非 Unity 程式碼共用。
  • 可與其它 .NET async 程式碼整合。
  • 不保證在主執行緒上執行,延遲後的程式碼可能不在 Unity 主執行緒上運行,而導致存取 Unity API 可能出錯。
  • 不會自動取消,即使物件或 MonoBehaviour 被銷毀仍會繼續運行,需要另行處理。
  • 使用真實時間,不會受到 Time.timeScale 影響。
  • 可與多執行緒或資料處理結合。
  • 可能需要額外處理執行緒同步。
C#
public class Worker : MonoBehaviour {

    public async void Process() {
        try{
            await Task.Delay(1000);
            Debug.Log("這可能在物件被銷毀後執行");
            // 這裡可能不在主執行緒,存取 Unity API 會出錯
            transform.position = Vector3.zero; // 可能拋出例外!
        } catch (Exception e) {
            Debug.LogException(e);
        }
    }
}

使用 Task.Delay() 來進行延遲,因為不是在主執行緒運行,所以不會受到 Time.scaleTime 影響,延遲時間會比較準確,但也因此在延遲之後可能無法直接操作 Unity 物件,需要確保回到主執行緒之後才可操作 Unity 物件。 然而,以目前的 Unity 6.2(6000.2.9f1),在 Task.Delay() 執行完畢之後,應該會自動返回主執行緒,除非使用較舊的 Unity 版本或者輸出到不同的平台運行,可能會有所不同; 另外,即使 Task.Delay 會自動返回主執行緒,但因為 Task.Delay 會離開主執行緒運行之後再回到主執行緒,如果程式流程中,多次執行了類似的行為,那麼在切換執行緒方面可能也會犧牲掉一些效能, 此時,可以利用 ConfigureAwait(false) 使其不要自動返回主執行緒,之後再手動返回主執行緒。

C#
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TaskDelayWorker : MonoBehaviour {

    private SynchronizationContext _unitySyncContext;

    private void Awake() {
        _unitySyncContext = SynchronizationContext.Current;
    }

    public async void Process() {
        try {
            Debug.Log($"延遲前的執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");

            await Task.Delay(1000).ConfigureAwait(false);
            Debug.Log($"延遲後的執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");

            // Do something on other thread ...

            SynchronizationContext.SetSynchronizationContext(_unitySyncContext);
            Debug.Log($"設置 Unity 的 SynchronizationContext 之後的執行緒 ID: {Thread.CurrentThread.ManagedThreadId}");

            await Task.Yield();
            Debug.Log($"讓出當前執行緒後的執行緒 ID: {Thread.CurrentThread.ManagedThreadId}");

            transform.position = Vector3.zero;
        } catch (Exception e) {
            Debug.LogException(e);
        }
    }
}

另一個做法

C#
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TaskDelayWorker : MonoBehaviour {

    private SynchronizationContext _unitySyncContext;

    private void Awake() {
        _unitySyncContext = SynchronizationContext.Current;
    }

    public async void Process() {
        try {
            Debug.Log($"延遲前的執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");

            await Task.Delay(1000).ConfigureAwait(false);
            Debug.Log($"延遲前的執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");

            _unitySyncContext.Post(_ => {
                Debug.Log($"回到 Unity 的執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");
                transform.position = Vector3.zero;
            }, null);
        } catch (Exception e) {
            Debug.LogException(e);
        }
    }
}

使用 CancellationTokenSource 取消非同步操作,避免物件銷毀後仍執行。

C#
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TaskDelayCancel : MonoBehaviour {

    private CancellationTokenSource _cts;

    private void Awake() {
        // 建立取消令牌
        _cts = new CancellationTokenSource();
    }

    public async void Process() {
        try {
            Debug.Log("開始延遲...");

            // 傳入 CancellationToken 讓 Task.Delay 可以被取消
            await Task.Delay(5000, _cts.Token);

            Debug.Log("延遲結束");

            // 執行後續的動作
            transform.position = Vector3.zero;
        } catch (TaskCanceledException) {
            // 當 Task 被取消時會進入這裡
            Debug.Log("延遲被取消了");
        } catch (Exception e) {
            Debug.LogException(e);
        }
    }

    // 手動取消功能
    public void Cancel() {
        _cts.Cancel();
    }

    // 當物件銷毀時自動取消,避免記憶體洩漏或錯誤
    private void OnDestroy() {
        _cts.Cancel();
        _cts.Dispose();
    }
}

Unity 新生代的非同步支援 Awaitable

大約是 Unity 2023 引入的 Awaitable 類別,可直接在 await 中等待 Unity 內部流程。其特點如下:

  • 保證在主執行緒執行。
  • 可搭配 async/await 寫出更乾淨的流程控制。
  • 可以有返回值。
  • 不會自動取消,需要手動處理。
  • 並非所有 Unity 版本都支援,需 2023 以上版本才可使用。
  • 會受到 Time.timeScale 影響,沒有 Realtime 版本(須自己實作)。
C#
using System;
using UnityEngine;

public class AwaitableWorker : MonoBehaviour {

    // Unity 原生的 async/await,自動處理執行緒
    public async void Process() {
        try {
            // 使用 Unity 的 Awaitable,自動在主執行緒
            await Awaitable.WaitForSecondsAsync(5f);

            // 保證在主執行緒;但如果物件在 5 秒內銷毀,會發生錯誤
            transform.position = Vector3.zero;

        } catch (Exception e) {
            Debug.LogException(e);
        }
    }
}

使用 CancellationTokenSource 取消非同步操作,避免物件銷毀後仍執行。

C#
using System;
using System.Threading;
using UnityEngine;

public class AwaitableWorker : MonoBehaviour {
    private CancellationTokenSource _cts;

    private void Awake() {
        _cts = new CancellationTokenSource();
    }

    public async void Process() {
        try {
            await Awaitable.WaitForSecondsAsync(5f, _cts.Token);
            transform.position = Vector3.zero;
        } catch (OperationCanceledException) {
            Debug.Log("操作已取消");
        } catch (Exception e) {
            Debug.LogException(e);
        }
    }

    private void OnDestroy() {
        _cts.Cancel();
        _cts.Dispose();
    }
}

即使 Awaitable 會受到 Time.timeScale 影響,我們仍然可以使用以下方法自己實現不受 Time.timeScale 影響的延遲:

C#
using System;
using System.Threading;
using UnityEngine;

namespace TryDelayOptions.Runtime {
    public class AwaitableWorker : MonoBehaviour {

        private CancellationTokenSource _cts;

        private void Awake() {
            _cts = new CancellationTokenSource();
        }

        public async void ProcessRealTime() {
            try {
                await WaitForSecondsRealTimeAsync(5f, _cts.Token);
                transform.position = Vector3.zero;
            } catch (OperationCanceledException) {
                Debug.Log("操作已取消");
            } catch (Exception e) {
                Debug.LogException(e);
            }
        }

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

        private void OnDestroy() {
            _cts.Cancel();
            _cts.Dispose();
        }

        private async Awaitable WaitForSecondsRealTimeAsync(float seconds, CancellationToken cancellationToken = default) {
            var startTime = Time.realtimeSinceStartup;
            var endTime = startTime + seconds;
            while (Time.realtimeSinceStartup < endTime) {
                cancellationToken.ThrowIfCancellationRequested();
                await Awaitable.NextFrameAsync(cancellationToken);
            }
        }
    }
}

特點比較表

特點 Invoke() Coroutine Task.Delay() Awaitable
在主執行緒執行 ✓ 是 ✓ 是 ✗ 否 ✓ 是
當物件銷毀,會自動取消 ✓ 是 ✓ 是 ✗ 否 ✗ 否
可取消特定操作 ✗ 只能全部取消 ✓ 是 ✓ 是 ✓ 是
受到 Time.timeScale 影響 ✓ 是 ✓ 是 ✗ 否 ✓ 是
可使用真實時間 ✗ 否 ✓ 是 ✓ 是 ✓ 需自己實作
可傳遞參數 ✗ 否 ✓ 是 ✓ 是 ✓ 是
程式複雜度 ★ 簡單 ★★ 中等 ★★★ 複雜 ★★ 中等
額外處理 CancellationToken + 執行緒操作 CancellationToken
適用 Unity 版本 全部 全部 Unity 2019 以上 Unity 2023 以上
適合用途 簡單延遲 遊戲流程控制 非 Unity 延遲任務 現代非同步架構

選擇流程圖


    需要延遲執行功能
    │
    ├─ 只是簡單延遲執行?
    │  └─ ✓ 是 → 使用 Invoke()
    │
    ├─ 需要複雜流程控制?
    │  ├─ 需要真實時間 (不受 timeScale 影響)?
    │  │  └─ ✓ 是 → 使用 Coroutine + WaitForSecondsRealtime
    │  │  └─ ✗ 否 → 使用 Coroutine 或 Awaitable
    │  │
    │  └─ 需要與其他 async 程式碼整合?
    │     └─ ✓ 是 → 使用 Awaitable
    │
    └─ 只需要真實時間,不用 Unity API?
       └─ ✓ 是 → 可考慮 Task.Delay()

幾個實際應用範例

1. 簡單的延遲顯示訊息

C#
public void ShowDelayedMessage() {
    Invoke(nameof(DisplayMessage), 2f);
}

private void DisplayMessage() {
    Debug.Log("2 秒後的訊息");
}

2. 遊戲暫停時的真實時間倒數

C#
public void StartRealTimeCountdown() {
    StartCoroutine(CountdownCoroutine());
}

private IEnumerator CountdownCoroutine() {
    for (int i = 3; i > 0; i--) {
        Debug.Log(i);
        yield return new WaitForSecondsRealtime(1f); // 不受暫停影響
    }
    Debug.Log("開始!");
}

3. 複雜的關卡載入流程

C#
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace TryDelayOptions.Runtime {
    public class LevelLoading : MonoBehaviour {
        private CancellationTokenSource _cts;

        private void Awake() {
            _cts = new CancellationTokenSource();
        }

        private void OnDestroy() {
            _cts.Cancel();
            _cts.Dispose();
        }

        public async void Process() {
            try {
                ShowLoadingScreen();

                await Awaitable.WaitForSecondsAsync(0.5f, _cts.Token);
                await LoadAssetsAsync(_cts.Token);

                await Awaitable.WaitForSecondsAsync(0.5f, _cts.Token);
                await InitializeEnemiesAsync(_cts.Token);

                await Awaitable.WaitForSecondsAsync(0.5f, _cts.Token);
                HideLoadingScreen();
            } catch (OperationCanceledException) {
                Debug.Log("Operation cancelled");
            } catch (Exception e) {
                Debug.LogException(e);
            }
        }

        private void ShowLoadingScreen() {
            Debug.Log("顯示載入畫面");
        }

        private void HideLoadingScreen() {
            Debug.Log("隱藏載入畫面");
        }

        private async Task LoadAssetsAsync(CancellationToken cancellationToken) {
            Debug.Log("開始載入資源");
            await Awaitable.WaitForSecondsAsync(0.5f, cancellationToken);
            Debug.Log("載入資源完成");
        }

        private async Task InitializeEnemiesAsync(CancellationToken cancellationToken) {
            Debug.Log("開始初始化敵人");
            await Awaitable.WaitForSecondsAsync(0.5f, cancellationToken);
            Debug.Log("敵人初始化完成");
        }
    }
}

4. 持續傷害效果

C#
using System.Collections;
using UnityEngine;

namespace TryDelayOptions.Runtime {
    public class PlayerHealth : MonoBehaviour {

        public float Health { get; private set; } = 100f;
        private Coroutine _damageCoroutine;

        public void ApplyDamageOverTime(float damagePerSecond, float duration) {
            TryStopDamageCoroutine(); // 取消舊的
            _damageCoroutine = StartCoroutine(DamagePerSecondCoroutine(damagePerSecond, duration));
        }

        public void TryStopDamageCoroutine() {
            if (_damageCoroutine == null) return;
            StopCoroutine(_damageCoroutine);
            _damageCoroutine = null;
        }

        private IEnumerator DamagePerSecondCoroutine(float damage, float duration) {
            float elapsed = 0;
            while (elapsed < duration) {
                Health -= damage;
                Debug.Log($"Received {damage} damage, Health: {Health}");

                yield return new WaitForSeconds(1f);
                ++elapsed;
            }

            _damageCoroutine = null;
        }
    }
}

最後的簡單總結

如果需求只是簡單的延遲執行,推薦使用 Invoke() 方法,可以最簡單且最安全的達到目的。 如果需要複雜一點的流程並且需要傳遞參數的話,可以選擇使用 Coroutine 做法,這樣既保持了高度的彈性,也可依據物件的銷毀而自動取消; 而且,如果也需要依據真實時間計算的話,還有 Unity 原生支援的 WaitForSecondsRealtime 可以用。 如果想使用比較現代化的非同步程式碼的做法,則可以選擇 Awaitable 的做法,這樣也會更符合 C# 標準,只是需要自行處理取消的部分。 如果是純後台計算、以真實時間為主,且沒有要直接緊密使用 Unity API 的話,可考慮使用 Task.Delay() 的做法,但需要比較謹慎使用,注意處理執行緒問題。