初步认识 UniTask
UniTask Provides an efficient allocation free async/await integration for Unity.
也就是说 UniTask 为 Unity 提供高效的无 GC async/await 集成。
我们已经有了 C# Task, 为什么还要 UniTask 呢?
JavaScript 不支持多线程,因此其支持的 WebGL 平台也是单线程的。
Unity 的主循环也是单线程的,一旦该线程死锁,一切跑在主循环上的 GO 等都会无法正常工作,而你在别的线程也无法操作主循环中的 GO。
在这些环境中,多线程的 Task 自然没法大展拳脚。
而 UniTask 不使用线程和 SynchronizationContext/ExecutionContext,完全跑在 PlayerLoop 上。
安装 UniTask
Release: Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity. (github.com)
UPM Package: Cysharp/UniTask:为 Unity 提供高效的免费分配异步/等待集成。 — Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity. (github.com)
基础 Level 0
引入——延时操作
情景:实现攻击 CD。
Update 写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public float attackCooldown = 2f ;public float currentAttackCooldown = 0f ;public void Update (){ if (currentAttackCooldown > 0 ) { currentAttackCooldown -= Time.deltaTime; } else { currentAttackCooldown = 0 ; } if (Input.GetKeyDown(KeyCode.Space) && currentAttackCooldown == 0 ) { Attack(); currentAttackCooldown = attackCooldown; } } private void Attack (){ Debug.Log("Attack" ); }
协程写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public float attackCooldown = 2f ;public bool canAttack = true ;public void Update (){ if (Input.GetKeyDown(KeyCode.Space) && canAttack) { StartCoroutine(Attack()); } } private IEnumerator Attack (){ canAttack = false ; Debug.Log("Attack" ); yield return new WaitForSeconds (attackCooldown ) ; canAttack = true ; }
UniTask 写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public float attackCooldown = 2f ;public bool canAttack = true ;public void Update (){ if (Input.GetKeyDown(KeyCode.Space) && canAttack) { Attack().Forget(); } } private async UniTaskVoid Attack (){ canAttack = false ; Debug.Log("Attack" ); await UniTask.Delay((int )(attackCooldown * 1000 )); canAttack = true ; }
像正常方法一样调用,方便且酷。
UniTask 比 coroutine 好在哪?不需要绑定 monobehaviour, 可用 try - catch, 可以有返回值。
认识异步对象
比起上面的传统方法,我们发现使用 UniTask 编写的代码里多出了几个我们不认识的标识符/语法:async/await/UniTaskVoid/.Forget()。我们一个一个来看
async
使用 async
修饰符可将方法、lambda 表达式 或 匿名方法 指定为异步。 如果对方法或表达式使用此修饰符,则其称为异步方法 。
也就是说,使用 async 修饰的方法,是异步方法,可能 是异步执行的。
await
await 运算符暂停对其所属的 async 方法的求值,直到其操作数表示的异步操作完成。 异步操作完成后,await
运算符将返回操作的结果(如果有)。当 await
运算符应用到表示已完成操作的操作数时,它将立即返回操作的结果,而不会暂停其所属的方法。 当 await
运算符暂停其所属的异步方法时,控件将返回到方法的调用方。
什么意思呢?我们更改的之前的代码,添加了一些输出以便于你理解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void Update (){ if (Input.GetKeyDown(KeyCode.Space) && canAttack) { Attack().Forget(); Debug.Log("Attack Finished" ); } } private async UniTaskVoid Attack (){ canAttack = false ; Debug.Log("Attack" ); await UniTask.Delay((int )(attackCooldown * 1000 )); Debug.Log("Attack Cooldown Finished" ); canAttack = true ; }
output
1 2 3 [21:39:03:878 ] Attack [21:39:03:882 ] Attack Finished [21:39:05:879 ] Attack Cooldown Finished
显然代码运行到 [21:39:05:879] Attack Cooldown Finisheds
停了两秒,并从异步方法执行返回至异步方法调用的上下文,并输出了“Attack Finish”,在 2s 后“Attack Cooldown Finished”如期而至。
方法返回了什么?
一个 struct,他代表一个异步任务。
UniTaskVoid
是 UniTask
的轻量版本,适用于完全无需 await 的场景,这时仍然需要 .Forgot()
消除警告。当我们在返回值为 UniTaskVoid
的方法调用前加上 await,会发生报错。
UniTask
可以被等待,但是他不会返回任何值。
UniTask<T>
可以返回值,通过 C# 元组,你可以轻松返回多个值。
.Forget()
.Forget()
方法可以让你同步执行异步方法,一般我们称其为“Fire and Forget”。虽然执行了这个异步方法,但我们并不准备等待他,而是向后执行。下面这段代码的执行效果和使用 UniTaskVoid 的原方法效果相同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void Update (){ if (Input.GetKeyDown(KeyCode.Space) && canAttack) { Attack().Forget(); Debug.Log("Attack Finished" ); } } private async UniTask Attack (){ canAttack = false ; Debug.Log("Attack" ); await UniTask.Delay((int )(attackCooldown * 1000 )); Debug.Log("Attack Cooldown Finished" ); canAttack = true ; }
延时常用的 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 await UniTask.Delay(int millisecondsDelay, bool ignoreTimeScale = false , PlayerLoopTiming delayTiming = PlayerLoopTiming.Update, CancellationToken cancellationToken = default (CancellationToken), bool cancelImmediately = false );await UniTask.DelayFrame(int delayFrameCount, PlayerLoopTiming delayTiming = PlayerLoopTiming.Update, CancellationToken cancellationToken = default (CancellationToken), bool cancelImmediately = false );await UniTask.NextFrame(PlayerLoopTiming timing, CancellationToken cancellationToken, bool cancelImmediately = false );当然,还有 await UniTask.WaitForEndOfFrame();await UniTask.WaitForFixedUpdate();await UniTask.WaitForSeconds();...
UniTask.Yield()&UniTask.NexFrame()&yield return null 的辨析
yield return null
and UniTask.Yield
are similar but different. yield return null
always returns next frame but UniTask.Yield
returns next called. That is, call UniTask.Yield(PlayerLoopTiming.Update)
on PreUpdate
, it returns same frame. UniTask.NextFrame()
guarantees return next frame, you can expect this to behave exactly the same as yield return null
.
也就是说 UniTask.Yield
的调用依据是下一次调用(Call), 如果你再本帧的 PreUpdate
阶段调用了 UniTask.Yield(PlayerLoopTiming.Update)
那么他就是在本帧的 Update
阶段被调用,UniTask.NextFrame()
则能确保一定是在下一帧被调用。
如果你对 Unity Script 生命周期和执行顺序还不够了解,请看:事件函数的执行顺序 - Unity 手册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public enum PlayerLoopTiming{ Initialization = 0 , LastInitialization = 1 , EarlyUpdate = 2 , LastEarlyUpdate = 3 , FixedUpdate = 4 , LastFixedUpdate = 5 , PreUpdate = 6 , LastPreUpdate = 7 , Update = 8 , LastUpdate = 9 , PreLateUpdate = 10 , LastPreLateUpdate = 11 , PostLateUpdate = 12 , LastPostLateUpdate = 13 #if UNITY_2020_2_OR_NEWER TimeUpdate = 14 , LastTimeUpdate = 15 , #endif }
Wait 操作
WaitUntil
我们的需求有时不仅仅是等待一段时间这么简单,以多人游戏中当所有人都按下确认后游戏开始为例,不使用 UniTask,你会怎么实现?下面的程序模拟了这一情景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class GameRoom : MonoBehaviour { public List<Player> players; public TextMeshProUGUI textMeshProUGUI; public void Update () { WaitForPlayers(); } private void WaitForPlayers () { if (players.All(player => player.isReady)) { textMeshProUGUI.text = "All players are ready, starting game..." ; } } } public class Player : MonoBehaviour { public Button button; public bool isReady = false ; private void Start () { button.onClick.AddListener(() => { isReady = true ; }); } }
在 GameRoom
里,我们不得不在 Update
里处理我们的逻辑,当这样的需求大量出现,Update
里的东西会变得越来越让人糟心。如果使用 UniTask, 这样的逻辑可以写的更简约。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class GameRoom : MonoBehaviour { public List<Player> players; public TextMeshProUGUI textMeshProUGUI; private void Start () { WaitForPlayers().Forget(); } async UniTaskVoid WaitForPlayers () { await UniTask.WaitUntil(() => players.All(player => player.isReady)); textMeshProUGUI.text = "All players are ready!" ; } }
我们甚至完全’抛弃’了 Update
,至少他不再需要我们关心。
WaitWhile
WaitWhile(Func<bool> predicate)
你需要传入一个 Func<bool>
委托,当其返回值为 true 时, 程序将会在此等待。
我们以玩家受伤死亡这个情景为例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class Player : MonoBehaviour { public float health = 10 ; public void Start () { Die().Forget(); } public void Update () { if (Input.GetKeyDown(KeyCode.Space)) { health -= 3 ; Debug.Log("Player health: " + health); } } async UniTaskVoid Die () { await UniTask.WaitWhile(() => health > 0 ); Debug.Log("Player is dead!" ); } }
output:
1 2 3 4 5 [16:48:10:022 ] Player health: 7 [16:48:10:768 ] Player health: 4 [16:48:11:421 ] Player health: 1 [16:48:12:089 ] Player health: -2 [16:48:12:094 ] Player is dead!
WaitUntilValueChanged
WaitUntilValueChanged<T, U>(T target, Func<T, U> monitorFunction) where T : class
需要你传入一个类,和一个 Func<T,TResult>
。当返回值 U
发生变化时, 程序会继续执行。凭借这个方法,我们可以实现一个简单的数据绑定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class Player : MonoBehaviour { public float health = 10 ; public void Start () { BindValueChange(this , a => a.health, OnHealthChanged).Forget(); } public void Update () { if (Input.GetKeyDown(KeyCode.Space)) { health -= 3 ; } } private async UniTaskVoid BindValueChange <T , U >(T target, Func<T, U> getValue, Action action ) where T : class { await UniTask.WaitUntilValueChanged(target, getValue, cancellationToken: this .GetCancellationTokenOnDestroy()); if (this .GetCancellationTokenOnDestroy().IsCancellationRequested) return ; action(); BindValueChange(target, getValue, action).Forget(); } private void OnHealthChanged () { Debug.Log("Health changed!" ); } }
现在每当 Player
类的成员变量 health
的值发生变化,传入的委托 OnHealthChanged
就会被调用,当该 GO 被销毁时,生成的 cancellationToken 就会阻断递归并取消当前的异步方法。
条件并发
WhenAll
UniTask.WhenAll(params UniTask[] tasks)
需要你传入多个 UniTask
。你也可以传入不多于 15 个 UniTask<T>
,他会返回一个元组。当所有传入 UniTask
都完成后,程序正常执行。
设想你在负责网络资源拉取,比如当用户点击按钮去拉取网络图片,你需要在所有图片拉取结束后告知用户加载完成,并且这些图片拉取的速度是不一样的,为了不让画面感到违和,你需要让这些图片同时显示。你会怎么完成这个需求呢?写一大堆 bool 在 Update
里循环判断?让我们来看看用 UniTask 如何优雅实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class Test : MonoBehaviour { public Button button; public Image image; public Image image2; public TextMeshProUGUI textMeshProUGUI; void Start () { button.onClick.AddListener(() => { GetImages().Forget(); }); } async UniTaskVoid GetImages () { string url1 = "https://img.moegirl.org.cn/common/thumb/9/93/Yamada_ryo_goods.jpg/375px-Yamada_ryo_goods.jpg" ; string url2 = "https://games-cn.org/wp-content/uploads/2024/02/-01-1-scaled-520x245-c.jpg" ; var result = await UniTask.WhenAll( GetImage(url1), GetImage(url2)); if (result.Item1.Item1) { image.sprite = Sprite.Create(result.Item1.Item2, new Rect(0 , 0 , result.Item1.Item2.width, result.Item1.Item2.height), new Vector2(0.5f , 0.5f )); textMeshProUGUI.text += url1 + " loaded success!\n" ; } else { textMeshProUGUI.text += url1 + " loaded failed!\n" ; } if (result.Item2.Item1) { image2.sprite = Sprite.Create(result.Item2.Item2, new Rect(0 , 0 , result.Item2.Item2.width, result.Item2.Item2.height), new Vector2(0.5f , 0.5f )); textMeshProUGUI.text += url2 + " loaded success!\n" ; } else { textMeshProUGUI.text += url2 + " loaded failed!\n" ; } } async UniTask<(bool , Texture2D)> GetImage(string url) { var www = UnityWebRequestTexture.GetTexture(url); await www.SendWebRequest(); return (www.result == UnityWebRequest.Result.Success, ((DownloadHandlerTexture)www.downloadHandler).texture); } }
WhenAny
WhenAny
和 WhenAll
类似,只不过从所有任务都完成变成了只要有一个完成就回到调用方法的上下文。其返回值的第一个是率先完成的任务的 Id。我们稍微修改下之前的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 public class Test : MonoBehaviour { public Button button; public Image image; public Image image2; public TextMeshProUGUI textMeshProUGUI; void Start () { button.onClick.AddListener(() => { GetImages().Forget(); }); } async UniTaskVoid GetImages () { string url1 = "https://img.moegirl.org.cn/common/thumb/9/93/Yamada_ryo_goods.jpg/375px-Yamada_ryo_goods.jpg" ; string url2 = "https://games-cn.org/wp-content/uploads/2024/02/-01-1-scaled-520x245-c.jpg" ; var task1 = GetImage(url1); var task2 = GetImage(url2); var result = await UniTask.WhenAny(task1, task2); Debug.Log(result.winArgumentIndex switch { 0 => url1, 1 => url2, _ => throw new ArgumentOutOfRangeException() } + " finished first" ); if (result.result1.Item1) { image.sprite = Sprite.Create(result.result1.Item2, new Rect(0 , 0 , result.result1.Item2.width, result.result1.Item2.height), new Vector2(0.5f , 0.5f )); image.rectTransform.sizeDelta = new Vector2(result.result1.Item2.width, result.result1.Item2.height); Debug.Log("image1 loaded" ); } else { textMeshProUGUI.text = "\nFailed to load image from " + url1; Debug.Log("image1 failed to load" ); } if (result.result2.Item1) { image2.sprite = Sprite.Create(result.result2.Item2, new Rect(0 , 0 , result.result2.Item2.width, result.result2.Item2.height), new Vector2(0.5f , 0.5f )); image2.rectTransform.sizeDelta = new Vector2(result.result2.Item2.width, result.result2.Item2.height); Debug.Log("image2 loaded" ); } else { textMeshProUGUI.text += "\nFailed to load image from " + url2; Debug.Log("image2 failed to load" ); } } async UniTask<(bool , Texture2D)> GetImage(string url) { var www = UnityWebRequestTexture.GetTexture(url); await www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) Debug.Log("error: " + www.error); else Debug.Log("success" ); return (www.result == UnityWebRequest.Result.Success, ((DownloadHandlerTexture)www.downloadHandler).texture); } }
可以看到,只有一张图片加载了出来,这是为什么呢?这是一个随堂问题。
通过观察日志我们可以发现,未能成功加载出来的图片其实也成功获取了图片,但是在任务组返回的时候,他还没有成功获取,所以传回去的是初始值。
WhenAny
的返回值中,被正常赋值的只有率先完成的异步任务的返回值,这是值得我们谨记在心的。
取消和异常处理
超时处理
超时处理一直是网络请求中的重难点,同时,在游戏逻辑中,这个任务更加常见,对于他的处理方法需要我们牢牢掌握。我们回到之前获取网络图片的案例,看看这里存在的超时任务需求和解决方案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public class Test : MonoBehaviour { public Button button; public Image image; public Image image2; public TextMeshProUGUI textMeshProUGUI; void Start () { button.onClick.AddListener(() => { GetImages().Forget(); }); } async UniTaskVoid GetImages () { string url1 = "https://img.moegirl.org.cn/common/thumb/9/93/Yamada_ryo_goods.jpg/375px-Yamada_ryo_goods.jpg" ; string url2 = "https://i.pximg.net/c/240x480/img-master/img/2024/03/09/00/00/53/116740385_p0_master1200.jpg" ; var task1 = GetImage(url1, 10f ); var task2 = GetImage(url2, 10f ); var result = await UniTask.WhenAll(task1, task2); if (result.Item1.Item1) { image.sprite = Sprite.Create(result.Item1.Item2, new Rect(0 , 0 , result.Item1.Item2.width, result.Item1.Item2.height), Vector2.zero); image.rectTransform.sizeDelta = new Vector2(result.Item1.Item2.width, result.Item1.Item2.height); } else { textMeshProUGUI.text = "Image 1 failed to load.\n" ; } if (result.Item2.Item1) { image2.sprite = Sprite.Create(result.Item2.Item2, new Rect(0 , 0 , result.Item2.Item2.width, result.Item2.Item2.height), Vector2.zero); image2.rectTransform.sizeDelta = new Vector2(result.Item2.Item2.width, result.Item2.Item2.height); } else { textMeshProUGUI.text += "Image 2 failed to load.\n" ; } } async UniTask<(bool , Texture2D)> GetImage(string url, float timeout) { var cts = new CancellationTokenSource(); cts.CancelAfterSlim(TimeSpan.FromSeconds(timeout)); var www = UnityWebRequestTexture.GetTexture(url); var result = await www.SendWebRequest().WithCancellation(cts.Token).SuppressCancellationThrow(); if (!result.IsCanceled) { return (true , ((DownloadHandlerTexture)www.downloadHandler).texture); } return (false , null ); } }
在 10s 后,我们发现,来自 pixiv 的图片在规定时间内未能成功加载。按道理来说,如果你不做超时操作,这个任务会永远卡死在这里,因为我们可能永远无法拉取到这张图片,但其实 UniTask 内部对该操作同样有超时判断,当你不做超时判断时,在默认时间到达后,程序会抛出一个 timeout error。
取消
不仅仅是超时——比如说你在制作一款 RTS 游戏,你希望能给玩家提供一个取消建造单位操作的功能,无论是手动取消还是因为资源不够自动取消。我们来试一试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class CancelExampleRTS : MonoBehaviour { public CancellationTokenSource cancellationTokenSource; async void OnClickTrainTroop () { cancellationTokenSource = new CancellationTokenSource(); var isCanceled = await TrainTroopAsync(cancellationTokenSource.Token).SuppressCancellationThrow(); if (isCanceled) { Debug.Log("Training canceled." ); } } public async UniTask TrainTroopAsync (CancellationToken token ) { float progress = 0 ; Debug.Log($"Training progress: {progress} %" ); while (progress < 100 ) { progress += Time.deltaTime * 15 ; await UniTask.NextFrame(token); Debug.Log($"Training progress: {progress} %" ); } Debug.Log("Training completed." ); } private void Update () { if (Input.GetKeyDown(KeyCode.Space)) { OnClickTrainTroop(); } if (Input.GetKeyDown(KeyCode.A)) { cancellationTokenSource.Cancel(); } } }
连锁取消
CancellationTokenSource.CreateLinkedTokenSource(params System.Threading.CancellationToken[] tokens)
该方法可以链接多个 CancellationToken
,只要有一个被取消,其他 CancellationToken
都会被取消。
基础 Level 1
类型转换
委托和 Lambda
UniTaskCompletionSource
Thread Pool
Progress
单元测试
应用专题
资源加载
UniTaskTracker
UniTask.TextMeshPro
UniTask.DOTween
进阶篇
AsyncEnumerable 和 Async LINQ
创建统一的事件异步流
响应式组件
Channel
原理篇
官方文档
首页 |UniTask 的 — Home | UniTask (cysharp.github.io)