在我们介绍UniTask之前,先来复习一下协程。

异步加载的演变

核心前置知识点

  • 同步代码:一行执行完才走下一行,会卡主线程
  • 异步代码:不阻塞主线程,等待 IO / 延时 / 网络时主线程继续渲染(玩家可移动)

异步加载的演变

  • Unity传统异步方案:协程Coroutine (IEnumerator)
  • C#原生异步:async/await + Task
  • 第三方优化方案:UniTask

协程

1
2
3
4
IEnumerator TestCoroutine()
{
yield return new WaitForSeconds(1);
}

不过,由于限制的原因,协程无法处理返回值和错误情况,因此通常需要与委托一起使用。

Task(async/await)

1
2
3
4
5
async Task<int> GetResultAsync()
{
await Task.Delay(1000); // 等待1000ms
return 42; // 返回结果
}

异步方法的返回类型通常是Task或Task<T>,其中Task表示异步操作,Task<T>表示异步操作完成后返回一个类型为T的结果。

在异步操作中,可能会遇到需要取消任务的情况,例如用户取消操作或超时。C#提供了CancellationToken来支持任务的取消机制。通过传递取消令牌,提高程序的灵活性和响应性。

创建取消令牌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public async Task MainAsync()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task task = DoWorkAsync(token);

// 模拟用户取消操作
await Task.Delay(500);
cts.Cancel();

try
{
await task; // 等待任务完成
}
catch(OperationCanceledException ex)
{
// 处理取消异常
Console.WriteLine("任务取消");
}
}

然而,当仅使用回调时,由于嵌套层次较多,内部发生的错误无法被传播出去,从而导致错误处理变得困难。

UniTask

UniTask 是专为Unity引擎打造的高性能、零GC异步编程库。


回顾GC

GC是什么?我们先来回顾一下GC的概念。

GC(Garbage Collection),顾名思义,他是垃圾回收的意思。

我们都知道,内存分为两块:栈、堆。

  • 栈内存:速度极快、空间小、自动分配自动释放。
    • 存:int、float、bool、结构体(struct)、局部变量。
    • 用完立刻自己消失,完全不用 GC 管。
  • 堆内存:空间大、速度慢、需要人管理。
    • 存:所有 class 类对象、new 出来的东西、字符串、数组、集合。

GC在垃圾回收的时候做了三件事:

  • 标记:遍历所有内存,标记哪些对象还在被使用,哪些已经废弃。
  • 清除:把废弃的垃圾对象直接删掉,释放内存。
  • 整理(压缩):把零散空闲内存拼在一起,避免内存碎片。

当堆内存不够用了或者达到系统阈值时,就会触发GC。

但是现在有一个关键问题:GC工作时,会短暂暂停游戏主线程,表现就是:瞬间卡顿、掉帧。而协程(非泛型)的一大痛点在于:每次yield return null或者返回别的什么,返回值就会用object来装载,经历一次装箱,也就是把值类型装成引用类型的object,这样会频繁触发GC,导致卡顿。

而UniTask基于值(struct)设计,零GC,解决Unity异步开发的核心痛点。

使用UniTask

使用UniTask在 Window - Package Manager - 左上角+号 - Add package from git URL - 输入UniTask的Git地址:

1
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask

下载UniTask导入项目:

  1. 需要引用命名空间
1
using Cysharp.Threading.Tasks;
  1. UniTask
  • 基础内置await
1
2
3
4
5
6
7
8
9
async UniTask<int> TestUniTask()
{
await UniTask.Delay(1000); //等待1000ms
await UniTask.DelayFrame(2); //等待2帧
await UniTask.Yield(); //让出一帧,等价 yield return null
await UniTask.NextFrame(); //等待下一帧
await UniTask.WaitForSeconds(1); // 等待两秒
return 10;
}

其中,UniTask是一个值类型,本身代表无返回值,对应void,UniTask<T>表明了返回值的类型。

  • 等待外部事件完成

普通 async/await 的任务是方法内部跑完就自动完成。但有些场景:什么时候结束,不由当前函数决定,要等外部事件(玩家点击、UI 交互、网络回调)。这时就用 UniTaskCompletionSource<T>。它的意思是:

  • 创建它,得到一个 UniTask<T> Task 属性;
  • await 这个 Task,代码挂起;
  • 在别的回调函数里手动调用方法,让任务结束,返回结果。

关键成员:

关键成员 作用
.Task 拿到可供 await 的 UniTask<T>
TrySetResult(T result) 任务成功完成,传出返回值,await 处继续执行
TrySetCanceled() 任务取消
TrySetException(Exception ex) 任务抛异常

注意! async函数内部 await,只会暂停自己这个函数;想要调用方也等它做完,调用的时候必须也写 await。

比如,下面举个例子:

1
2
3
4
5
public async UniTask<List<Vector3Int>> WaitForPlayerInput()
{
inputTcs = new UniTaskCompletionSource<List<Vector3Int>>(); // 新创建一个UniTask,返回的是格子列表
return await inputTcs.Task;
}

在另一个函数里,我这样去得到返回的格子列表:

1
var input = await tilemapInteract.WaitForPlayerInput();

我们把上面的代码块称为A,下面的代码块称为B。可以发现,await写了两次。这两次分别有什么作用呢?接下来来讲解:

在A中,我们先创建了一个UniTaskCompletionSource,表示UniTask需要等待一个外部事件,.Task后我们可以拿到UniTask<List<Vector3Int>>,解包后就可以得到List<Vector3Int>,也就是玩家输入的格子列表。

第一个await的意思就是,当玩家输入完成之前,这个函数会被直接挂起,停在return ...这一行,不过遇到了await。就会把一个未完成的UniTask<List<Vector3Int>>任务对象返回给调用方 B(类似于一个“未完成任务”的标志)。直到得到玩家输入,才将得到的列表返回出去。

所以,如果在B中没有await,就拿不到这个列表,只会拿到一个没有完成的任务标志。因此,为了确保input拿到的是玩家输入的格子列表,我们需要加上一个await,这里的var其实代表的就是List<Vector3Int>。


除了这种标准用法外,我们还需要注意如果想在一个不是异步的方法中使用异步方法,我们需要用到.Forget()

例如,当我们加载一个场景时,可能会这样写:

1
2
3
4
private void Start()
{
LoadStartScene().Forget();
}

其中,LoadStartScene()是一个异步方法,.Forget()表示不等待它执行完。

正常来说可以这样写:

1
await LoadStartScene();

但在 Unity 的Start() 不是 async 方法,不能直接写 await。所以用了 .Forget()。