初步认识 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
类型转换
我们可以直接等待一个协程
1 2 3 4 5 6 7 8 9 10 11 12 private async void Start (){ Debug.Log("Start" ); await Say(); Debug.Log("End" ); } IEnumerator Say () { yield return new WaitForSeconds (2 ) ; Debug.Log("Hello" ); }
也可以通过 ToUniTask()
把协程转换成 UniTask
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void Start (){ DoSth(Say().ToUniTask()).Forget(); } async UniTaskVoid DoSth (UniTask task ){ Debug.Log(Time.time); await task; Debug.Log(Time.time); } IEnumerator Say () { yield return new WaitForSeconds (2 ) ; Debug.Log("Hello" ); }
如果你想将异步转换为协程,你可以使用 .ToCoroutine()
1 2 3 4 5 6 7 8 9 10 11 private void Start (){ StartCoroutine(DoSth().ToCoroutine()); } async UniTask DoSth (){ Debug.Log(Time.time); await UniTask.Delay(1000 ); Debug.Log(Time.time); }
IEnumerator.ToUniTask 限制:
不支持 WaitForEndOfFrame,WaitForFixedUpdate,Coroutine
Loop 生命周期与 StartCoroutine 不一样,它使用指定 PlayerLoopTiming 的并且默认情况下,PlayerLoopTiming.Update 在 MonoBehaviourUpdate 和 StartCoroutine 的循环之前运行。
如果您想要从协程到异步的完全兼容转换,请使用 IEnumerator.ToUniTask(MonoBehaviour coroutineRunner)重载。它在参数 MonoBehaviour 的实例上执行 StartCoroutine 并等待它在 UniTask 中完成。
委托和 Lambda
我们可以使用 UniTask.Action
或 UniTask.UniTaskAction
创建注册到 Event
的异步委托
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 public class LambdaExample : MonoBehaviour { private Action action; private UnityAction unityAction; public void Start () { action += UniTask.Action(async () => { await UniTask.Delay(1000 ); Debug.Log("Action" ); }); unityAction += UniTask.UnityAction(async () => { await UniTask.Delay(1000 ); Debug.Log("UnityAction" ); }); } public void Update () { if (Input.GetKeyDown(KeyCode.Space)) { action?.Invoke(); unityAction?.Invoke(); } } }
要注意“当委托类型返回 ‘void’ 时避免使用 ‘async’ lambda”。
如果你不想等待该异步草错,我们还可以使用 UniTask.Void
即发即弃地执行异步委托,并且该语句没有返回值
1 2 3 4 5 6 7 8 9 10 11 12 13 public void Update (){ if (Input.GetKeyDown(KeyCode.Space)) { UniTask.Void(async () => { Debug.Log("Start" ); await UniTask.Delay(1000 ); Debug.Log("End" ); }); Debug.Log("Space" ); } }
UniTaskCompletionSource
UniTaskCompletionSource
是一个类似于 TaskCompletionSource
的类,用于创建创建异步操作的完成源,他可以返回异步回调任务的状态
1 2 3 4 5 6 7 8 9 10 public UniTask<int > WrapByUniTaskCompletionSource (){ var utcs = new UniTaskCompletionSource<int >(); return utcs.Task; }
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 67 68 69 70 71 72 73 74 public class UniTaskCompletionSourceExample : MonoBehaviour { public int resourceCount = 0 ; public int resourceCost = 10 ; private UniTaskCompletionSource<bool > trainingCompletionSource; private async void StartTraining (CancellationToken token ) { if (resourceCount < resourceCost) { Debug.Log("资源不足,无法训练军队!" ); trainingCompletionSource.TrySetResult(false ); return ; } resourceCount -= resourceCost; float trainingTime = 5 ; while (trainingTime > 0 ) { Debug.Log("军队训练中..." ); await UniTask.NextFrame(token).SuppressCancellationThrow(); if (token.IsCancellationRequested) { Debug.Log("训练被取消!" ); trainingCompletionSource.TrySetResult(false ); return ; } trainingTime -= Time.deltaTime; } Debug.Log("军队训练完成!" ); trainingCompletionSource.TrySetResult(true ); } async UniTaskVoid CancelTraining (CancellationTokenSource cts, CancellationToken token ) { await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space), cancellationToken: token); cts.Cancel(); } private async UniTask<bool > TrainSoldierAsync () { trainingCompletionSource = new UniTaskCompletionSource<bool >(); var cts = new CancellationTokenSource(); var cancelInputCts = new CancellationTokenSource(); CancelTraining(cts, cancelInputCts.Token).Forget(); StartTraining(cts.Token); bool trainingResult = await trainingCompletionSource.Task; trainingCompletionSource = null ; cancelInputCts.Cancel(); return trainingResult; } private async void Start () { var result = await TrainSoldierAsync(); Debug.Log(result ? "训练成功!" : "训练失败!" ); } }
trainingCompletionSource.Task
可以被多次 await
Thread Pool
切换到线程池
1 await UniTask.SwitchToThreadPool();
切换回主线程
1 2 await UniTask.SwitchToMainThread();await UniTask.Yield();
1 2 3 [21:34:10:422 ] Main Thread: 1 [21:34:10:424 ] Task Pool: 99 [21:34:10:426 ] Main Thread: 1
UniTask.Run
现已被弃用,可以改用 UniTask.RunOnThreadPool
如果你使用线程池,那么工程将不能在 WebGL 等平台上运行
进阶篇
Progress
IProgress<T>
是一个接口,用于报告异步操作的进度。我们可以使用 UniTask.Progress
创建一个进度报告器。
1 2 3 4 5 6 7 var progress = Progress.Create<float >(x => Debug.Log((int )(x * 100 ) + "%" ));var request = await UnityWebRequest .Get("https://github.com/Cysharp/UniTask/releases/download/2.5.4/UniTask.2.5.4.unitypackage" ) .SendWebRequest() .ToUniTask(progress: progress); Debug.Log(request.downloadedBytes);
您不应该使用原生的 new System.Progress <T>
,因为它每次都会导致 GC 分配。改为使用 Cysharp.Threading.Tasks.Progress。这个 progress factory 有两个方法,Create 和 CreateOnlyValueChanged. CreateOnlyValueChanged 仅在进度值更新时调用。为调用者实现 IProgress 接口会更好,因为这样可以没有 lambda 分配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class Foo : MonoBehaviour , IProgress <float >{ public void Report (float value ) { UnityEngine.Debug.Log(value ); } public async UniTaskVoid WebRequest () { var request = await UnityWebRequest.Get("https://github.com/Cysharp/UniTask/releases/download/2.5.4/UniTask.2.5.4.unitypackage" ) .SendWebRequest() .ToUniTask(progress: this ); } }
AsyncEnumerable 和 Async LINQ
考虑一个情景,假设你正在制作一款集换式卡牌游戏,其中一张卡牌的效果是你出的前三张奇数牌会给你回复等同于当前回合出牌数的生命值。我们将出牌操作简化为按钮的点击事件。
这是一个相当容易实现的需求,我们只需要一个额外的计数器来为记录我们的出牌,这并不会导致我们的代码添加太多无关的东西,因为可以想到,不止这一个需求会依赖出牌数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class CardGame : MonoBehaviour { public int health = 100 ; public Button button; public int timer = 0 ; private void Start () { timer = 0 ; button.onClick.AddListener(() => { timer++; if (timer > 5 ) return ; if (timer % 2 == 1 ) { health += timer; Debug.Log($"增加了{timer} 点血量,当前血量为{health} " ); } }); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class CardGame : MonoBehaviour { public int health = 100 ; public Button button; private async void Start () { await button.OnClickAsAsyncEnumerable().Where((_, index) => index % 2 == 0 ).Take(3 ).ForEachAsync((_, index) => { health += index * 2 + 1 ; Debug.Log($"增加了{index * 2 + 1 } 点血量,当前血量为{health} " ); }); } }
现在我们拓展这个需求,假设你在开发一款 2D 动作游戏中的连招系统,策划设计了一个连招“第一下轻击,第二下重击,第三下按住蓄力一秒松手向前发波,所有的操作在 0.5s 内没有衔接上就要重新计数”。你会如何实现这个功能呢?假设我们的攻击键是 J
显然,我们很轻松就能想出基于 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 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 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 public class ComboExample : MonoBehaviour { public float comboTimeLimit = 0.5f ; public float currentComBoTime = 0 ; public float storageTimeLimit = 1f ; public float currentStorageTime = 0 ; public int comboIndex = 0 ; public KeyCode attackKey = KeyCode.J; public bool isStorage = false ; public void Update () { if (currentComBoTime < comboTimeLimit) { currentComBoTime += Time.deltaTime; } else { if (comboIndex != 2 ) { comboIndex = 0 ; } currentComBoTime = comboTimeLimit; } switch (comboIndex) { case 0 : if (Input.GetKeyDown(attackKey)) { comboIndex++; currentComBoTime = 0 ; Debug.Log("发起轻攻击" ); } break ; case 1 : if (Input.GetKeyDown(attackKey)) { comboIndex++; currentComBoTime = 0 ; Debug.Log("发起重攻击" ); } break ; case 2 : if (!isStorage) { if (Input.GetKeyDown(attackKey)) { Debug.Log("蓄力开始" ); isStorage = true ; } } if (isStorage) { if (Input.GetKey(attackKey)) { if (currentStorageTime < storageTimeLimit) { currentStorageTime += Time.deltaTime; Debug.Log($"蓄力中 {(int )(currentStorageTime * 100 )} %" ); } else if (currentStorageTime >= storageTimeLimit) { Debug.Log("蓄力完成,发起蓄力攻击" ); isStorage = false ; currentStorageTime = 0 ; comboIndex = 0 ; } } else { Debug.Log("蓄力失败" ); isStorage = false ; currentStorageTime = 0 ; comboIndex = 0 ; } } break ; } } }
采用 AsyncEnumerable
和 Async LINQ
我们可以有另一套实现
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 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 public class ComboExample : MonoBehaviour { public float comboTimeLimit = 0.5f ; public float storageTimeLimit = 1f ; public int comboIndex = 0 ; public KeyCode attackKey = KeyCode.J; public bool isStorage = false ; public void Start () { Combo(default ).Forget(); } public IUniTaskAsyncEnumerable <int > EveryKeyCode (KeyCode keyCode ) { return UniTaskAsyncEnumerable.Create<int >(async (writer, token) => { await UniTask.Yield(); while (!token.IsCancellationRequested) { var result = 0 ; if (Input.GetKeyDown(keyCode)) { result = 1 ; } else if (Input.GetKeyUp(keyCode)) { result = 2 ; } else if (Input.GetKey(keyCode)) { result = 3 ; } else { result = 0 ; } await writer.YieldAsync(result); await UniTask.Yield(); } }); } public async UniTask Combo (CancellationToken token ) { async UniTask WaitForNextInput (Func<bool > antiCondition, Func<bool > condition ) { await UniTask.WaitUntil(antiCondition, cancellationToken: token); await UniTask.WaitUntil(condition, cancellationToken: token); } async UniTaskVoid CheckTimeOut (UniTask<int > task ) { var result = await task; if (result == 0 ) { comboIndex = 0 ; Debug.Log("操作超时" ); } } await EveryKeyCode(KeyCode.J).Where((input, index) => input != 0 ).ForEachAwaitAsync(async input => { switch (comboIndex) { case 0 : if (input == 1 ) { comboIndex = 1 ; Debug.Log("发起轻攻击" ); } break ; case 1 : if (input == 1 ) { comboIndex = 2 ; Debug.Log("发起重攻击" ); } break ; case 2 : if (!isStorage) { if (input == 1 ) { Debug.Log("蓄力开始" ); isStorage = true ; var result = await UniTask.WhenAny( UniTask.Delay(TimeSpan.FromSeconds(storageTimeLimit), cancellationToken: token), UniTask.WaitUntil(() => Input.GetKeyUp(KeyCode.J), cancellationToken: token)); if (result == 0 ) { Debug.Log("蓄力结束,发动蓄力攻击" ); isStorage = false ; comboIndex = 0 ; } else { Debug.Log("蓄力中止" ); isStorage = false ; comboIndex = 0 ; } } } break ; } if (input == 1 && !isStorage) { var order = UniTask.WhenAny( UniTask.Delay(TimeSpan.FromSeconds(comboTimeLimit), cancellationToken: token), WaitForNextInput(() => Input.GetKeyUp(attackKey), () => Input.GetKeyDown(attackKey))); CheckTimeOut(order).Forget(); } }, cancellationToken: token); } }
上文自定义的 EveryKeyCode
可以更换为 UniTaskAsyncEnumerable.EveryUpdate
,上文的 异步迭代器 用于过滤没有输入事件的帧,但在实际开发中,我们并没有太强的理由去做这件事,这里仅供参考。
同时,我们基于 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 public Button doubleClickButton;private void Start (){ InitDoubleButton(); } void InitDoubleButton (){ bool isDoubleClick = false ; async UniTaskVoid DoubleClick () { if (isDoubleClick) return ; isDoubleClick = true ; var clickAsync2 = doubleClickButton.OnClickAsync(); var result = await UniTask.WhenAny(UniTask.Delay(TimeSpan.FromSeconds(0.5f )), clickAsync2); Debug.Log(result == 1 ? "Double Clicked" : "双击超时" ); isDoubleClick = false ; } doubleClickButton.OnClickAsAsyncEnumerable() .Subscribe(_ => DoubleClick().Forget()); }
这里采用了一个局部变量 isDoubleClick
去控制双击事件的入口。除此之外,我们还有另一种策略去实现这个逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private void Start (){ DoubleClick().Forget(); } async UniTaskVoid DoubleClick (){ while (gameObject.activeInHierarchy) { var firstClick = doubleClickButton.OnClickAsync(); await firstClick; var doubleClick = doubleClickButton.OnClickAsync(); var result = await UniTask.WhenAny(UniTask.Delay(TimeSpan.FromSeconds(0.5f )), doubleClick); Debug.Log(result == 1 ? "Double Clicked" : "双击超时" ); } }
这里我们使用了一个 while
循环去监听双击事件,并且在循环中等待第一次点击事件的完成,这样我们就不需要额外的变量去控制双击事件的入口。
所有 uGUI 组件都实现了 ***AsAsyncEnumerable 来转换异步事件流,你可以像上文一样使用最新最潮的 AsyncEnumerable 和 Async LINQ 来处理你的 UI 逻辑。
官方文档上也给了很好的示范 =>
1 2 3 4 5 await foreach (var _ in UniTaskAsyncEnumerable.EveryUpdate().WithCancellation(token)){ Debug.Log("Update() " + Time.frameCount); }
1 2 3 4 5 await UniTaskAsyncEnumerable.EveryUpdate().ForEachAsync(_ =>{ Debug.Log("Update() " + Time.frameCount); }, token);
1 2 3 okButton.OnClickAsAsyncEnumerable().Where((x, i) => i % 2 == 0 ).Subscribe(_ => { });
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 async UniTask TripleClick (){ await button.OnClickAsync(); await button.OnClickAsync(); await button.OnClickAsync(); Debug.Log("Three times clicked" ); } async UniTask TripleClick (){ using (var handler = button.GetAsyncClickEventHandler()) { await handler.OnClickAsync(); await handler.OnClickAsync(); await handler.OnClickAsync(); Debug.Log("Three times clicked" ); } } async UniTask TripleClick (CancellationToken token ){ await button.OnClickAsAsyncEnumerable().Take(3 ).Last(); Debug.Log("Three times clicked" ); } async UniTask TripleClick (CancellationToken token ){ await button.OnClickAsAsyncEnumerable().Take(3 ).ForEachAsync(_ => { Debug.Log("Every clicked" ); }); Debug.Log("Three times clicked, complete." ); }
将 MonoBehaviour 消息事件转换为异步流
在 Unity 2020.2.0a12 以后,C# 8.0 被引入,我们可以将 Update
轻松转换成异步流
1 2 3 4 5 await foreach (var _ in UniTaskAsyncEnumerable.EveryUpdate(token)){ Debug.Log("Update() " + Time.frameCount); }
当然,这只是一个事件,没有 Start
,没有 OnDestroy
,更不要说 OnEnable
或者 OnCollision2DEnter
.事实上,我们有更完善的工具将你所能用到的 MonoBehaviour
事件转换成异步流。他就是 AsyncTrigger
,在引用 Cysharp.Threading.Tasks.Triggers
后,你可以使用 GetAsync***Trigger
创建 AsyncTrigger
,并将其作为 UniTaskAsyncEnumerable
触发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class AsyncTriggerExample : MonoBehaviour { private async void Start () { this .GetAsyncStartTrigger().Subscribe(_ => Debug.Log("Start!" )); this .GetAsyncUpdateTrigger().Subscribe(_ => { if (Input.GetKeyDown(KeyCode.Space)) { Debug.Log("Space key down!" ); } }); this .GetAsyncCollisionEnter2DTrigger().Take(3 ).Select((_, i) => i).Subscribe(index => { Debug.Log("Collision Enter 2D: " + index); }); await this .GetAsyncDestroyTrigger().OnDestroyAsync(); Debug.Log("Destroy!" ); } }
响应式组件
这一节我们会讲解使用 AsyncReactiveProperty
快速构建响应式组件的方法。
我们选中一个游戏开发中常见的情景:血条更新,来看我们是如何实现的。
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 public class HealthBar : MonoBehaviour { public Image insideBar; public Image outsideBar; public AsyncReactiveProperty<float > health = new AsyncReactiveProperty<float >(100 ); public float lastHealth = 100 ; #region 模拟更新 [Header("模拟更新" ) ] public Button add ; public Button sub; public void OnClickAdd () { health.Value += 10 ; } public void OnClickSub () { health.Value -= 20 ; } #endregion private void Start () { insideBar.fillAmount = 1 ; outsideBar.fillAmount = 1 ; health.Value = 100 ; lastHealth = health.Value; health.WithoutCurrent().Queue().SubscribeAwait(async x => { Debug.Log($"当前血量为{x} ,上一次的血量为{lastHealth} ,差值为{x - lastHealth} ,开始同步血量..." ); await Sync(x - lastHealth, this .GetCancellationTokenOnDestroy()); Debug.Log("同步血量完成!" ); lastHealth = x; Debug.Log($"把上一次的血量更新为{lastHealth} " ); }); } public async UniTask Sync (float value , CancellationToken token = default ) { if (value == 0 ) return ; Image quickBar = value > 0 ? insideBar : outsideBar; Image slowBar = value > 0 ? outsideBar : insideBar; quickBar.fillAmount = Mathf.Clamp01((quickBar.fillAmount * 100 + value ) / 100f ); float changeTime = 0.75f ; while (changeTime > 0 ) { await UniTask.DelayFrame(1 , cancellationToken: token); changeTime -= Time.deltaTime; slowBar.fillAmount = Mathf.Lerp(quickBar.fillAmount, slowBar.fillAmount, changeTime / 0.75f ); } slowBar.fillAmount = quickBar.fillAmount; } }
值得注意的有我们对血量同步时间的 Queue
操作,同时,我们并没有限制生命值的上下限,如果你有这个需求,请不要放入血量更新事件中,因为为了确保血量更新的有序性,我们采用了 SubscribeAwait
.
为了让血量同步动画更丝滑,我们可以把快慢血条更新拆开,当生命值发生变化,我们会立刻更新快血条,并且设置慢血条的目标值,然后在下一帧开始同步慢血条。
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 67 68 69 70 71 72 73 74 75 76 77 78 79 80 using System;using System.Collections;using System.Collections.Generic;using System.Threading;using Cysharp.Threading.Tasks;using Cysharp.Threading.Tasks.Linq;using Unity.VisualScripting;using UnityEngine;using UnityEngine.UI;public class HealthBar : MonoBehaviour { public Image insideBar; public Image outsideBar; public AsyncReactiveProperty<float > health = new AsyncReactiveProperty<float >(100 ); public Image quickBar; public Image slowBar; public float lastHealth = 100 ; #region 模拟更新 [Header("模拟更新" ) ] public Button add ; public Button sub; public void OnClickAdd () { health.Value += 10 ; } public void OnClickSub () { health.Value -= 20 ; } #endregion private void Start () { insideBar.fillAmount = 1 ; outsideBar.fillAmount = 1 ; health.Value = 100 ; lastHealth = health.Value; SyncSlow().Forget(); health.WithoutCurrent().Queue().Subscribe(async x => { await SyncQuick(x - lastHealth, x, this .GetCancellationTokenOnDestroy()); lastHealth = x; }); } public async UniTask SyncQuick (float value , float x, CancellationToken token = default ) { if (value == 0 ) return ; quickBar = value > 0 ? insideBar : outsideBar; slowBar = value > 0 ? outsideBar : insideBar; quickBar.fillAmount = x / 100 ; } public async UniTaskVoid SyncSlow () { float targetValue = 0 ; while (!gameObject.IsDestroyed()) { await UniTask.NextFrame(); await UniTask.WaitUntil(() => quickBar && slowBar); targetValue = quickBar.fillAmount; if (Mathf.Abs(slowBar.fillAmount - quickBar.fillAmount) <= 0.01f ) { slowBar.fillAmount = quickBar.fillAmount; continue ; } slowBar.fillAmount = Mathf.Lerp(slowBar.fillAmount, targetValue, Time.deltaTime * 3f ); Debug.Log($"slowBar.fillAmount:{slowBar.fillAmount} " ); } } }
Channel
原理篇
官方文档
首页 |UniTask 的 — Home | UniTask (cysharp.github.io)
UniTask/README_CN.md at master · Cysharp/UniTask (github.com)