Unity插件之UniTask
在我们介绍UniTask之前,先来复习一下协程。
异步加载的演变
核心前置知识点
- 同步代码:一行执行完才走下一行,会卡主线程
- 异步代码:不阻塞主线程,等待 IO / 延时 / 网络时主线程继续渲染(玩家可移动)
异步加载的演变
- Unity传统异步方案:协程
Coroutine (IEnumerator) - C#原生异步:
async/await + Task - 第三方优化方案:
UniTask
协程
1 | IEnumerator TestCoroutine() |
不过,由于限制的原因,协程无法处理返回值和错误情况,因此通常需要与委托一起使用。
Task(async/await)
1 | async Task<int> GetResultAsync() |
异步方法的返回类型通常是Task或Task<T>,其中Task表示异步操作,Task<T>表示异步操作完成后返回一个类型为T的结果。
在异步操作中,可能会遇到需要取消任务的情况,例如用户取消操作或超时。C#提供了CancellationToken来支持任务的取消机制。通过传递取消令牌,提高程序的灵活性和响应性。
创建取消令牌:
1 | public async Task MainAsync() |
然而,当仅使用回调时,由于嵌套层次较多,内部发生的错误无法被传播出去,从而导致错误处理变得困难。
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 | using Cysharp.Threading.Tasks; |
- UniTask
- 基础内置
await
1 | async UniTask<int> TestUniTask() |
其中,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 | public async UniTask<List<Vector3Int>> WaitForPlayerInput() |
在另一个函数里,我这样去得到返回的格子列表:
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 | private void Start() |
其中,LoadStartScene()是一个异步方法,.Forget()表示不等待它执行完。
正常来说可以这样写:
1 | await LoadStartScene(); |
但在 Unity 的Start() 不是 async 方法,不能直接写 await。所以用了 .Forget()。










