0%

初步认识 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

  1. Release: Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity. (github.com)
  2. 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,他代表一个异步任务。

UniTaskVoidUniTask 的轻量版本,适用于完全无需 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
//int millisecondsDelay 也可换为 TimeSpan
//ignoreTimeScale 表示是否无视时间流速
//PlayerLoopTiming 表示在 PlayerLoop 的哪一个阶段处理延时操作
//CancellationToken 用于指定一个取消token,以便在合适的时候取消延时操作
//cancelImmediately 顾名思义,是用来调控取消操作是被否立即执行的布尔值。详细解释请看这个老哥的博文:https://qiita.com/Euglenach/items/6623d96d5b93ff52e816
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);

// 官方文档这样注释:Similar as UniTask.Yield but guaranteed run on next frame.
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
//GameRoom
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...";
}
}
}

//Player
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

WhenAnyWhenAll 类似,只不过从所有任务都完成变成了只要有一个完成就回到调用方法的上下文。其返回值的第一个是率先完成的任务的 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";
// string url2 = "https://img.moegirl.org.cn/common/thumb/9/93/Yamada_ryo_goods.jpg/375px-Yamada_ryo_goods.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)

从 hello world 开始

1
2
3
fn main() {
println!("Hello, world!");
}
阅读全文 »

Unity 的预计算实时 GI 或者烘焙 GI 只对静态的物体起作用,对于可移动的物体,需要用一种新的技术来解决光照问题。

为了让动态物体(如动态场景元素或角色)能够获得静态物体反弹的光线,需要将这些光照信息记录下来,并且在运行时能快速读取和使用。

通过在场景中放置采样点捕捉各个方向的光线来实现动态物体接收间接光的功能。这些采样点记录的光照信息被编码成可以在游戏过程中快速计算值。在 Unity 中,我们将这些采样点称为“光照探针”

  • 让动态物体从场景接受间接光
  • 使用该技术的动态物体不会产生反射光 => 使用于较小的物体
阅读全文 »

涉事代码

1
2
3
4
5
6
7
8
9
10
11
12
void Update()
{
if (Input.GetMouseButtonDown(0))
{
var pos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Debug.Log(pos);
var posInt = tileMap.WorldToCell(pos) + new Vector3Int(0,0,10);
Debug.Log(posInt);
tileMap.SetColor(posInt,Color.red);// BUG
Debug.Log(tileMap.HasTile(posInt)+" "+tileMap.GetSprite(posInt)+" "+tileMap.GetColor(posInt));
}
}
阅读全文 »