在 Unity 開發中,我們經常會遇到需要「延遲執行」某段程式碼的情境,例如動畫播放完才進行下一步、等待伺服器回應後再更新畫面,或是在遊戲中設定短暫的冷卻時間。
雖然目標都一樣 - 讓程式「稍後」再執行 - 而在 Unity 中,有好幾種不同的做法可以達成這件事,這些做法各有優缺點與適用時機,若使用不當,可能導致效能浪費、邏輯錯亂,甚至在非主執行緒呼叫 Unity API 時發生例外錯誤。
Unity 內建,MonoBehaviour 的 Invoke()
Invoke() 是最傳統也最簡單的方式,可透過字串指定函式名稱,讓 Unity 在指定秒數後自動呼叫該函式執行。具備以下這些特點:
- 程式碼簡單,容易維護。
- 適合簡單的一次性延遲。
- 保證在 Unity 主執行緒上執行。
- 會受到 Time.timeScale 影響其延遲時間的暫停或快慢,故無法完全實現真實時間延遲(如:真實時間倒數)。
- 與 Unity 生命週期整合,自動管理生命週期,當 MonoBehaviour 被銷毀時就會自動取消。
- 無法對所呼叫的函式傳遞參數。
- 無法處理執行順序,進行複雜的流程控制。
- 無法針對單一延遲執行進行取消,只能使用 CancelInvoke() 取消整個 MonoBehaviour 的 Invoke() 延遲執行。
public class Worker : MonoBehaviour {
// 物件銷毀時,會自動取消。
public void Process() {
Invoke(nameof(DoSomething), 1.0f);
}
private void DoSomething() {
transform.position = Vector3.zero; // 在主執行緒上,安全執行。
Debug.Log("1 秒後的訊息");
}
}
無法傳遞參數,也無法有效控制執行順序。
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 影響的時間延遲方法。
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 秒後的訊息");
}
}
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 影響。
- 可與多執行緒或資料處理結合。
- 可能需要額外處理執行緒同步。
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) 使其不要自動返回主執行緒,之後再手動返回主執行緒。
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);
}
}
}
另一個做法
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 取消非同步操作,避免物件銷毀後仍執行。
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 版本(須自己實作)。
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 取消非同步操作,避免物件銷毀後仍執行。
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 影響的延遲:
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. 簡單的延遲顯示訊息
public void ShowDelayedMessage() {
Invoke(nameof(DisplayMessage), 2f);
}
private void DisplayMessage() {
Debug.Log("2 秒後的訊息");
}
2. 遊戲暫停時的真實時間倒數
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. 複雜的關卡載入流程
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. 持續傷害效果
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() 的做法,但需要比較謹慎使用,注意處理執行緒問題。