MeteorCat / Unity携程库 - UniTask

Created Sat, 09 Nov 2024 15:36:50 +0800 Modified Wed, 29 Oct 2025 23:25:05 +0800
2286 Words

UniTask 说明

虽然 Unity 提供自带协程方案, 但是本身开销庞大且要求 UnityEngine API 必须从主线程调用才能被执行, 也就没有比较好的办法在其他线程调用 UnityEngine API 相关调度方式( 无法脱离 MonoBehaviour 去进行调用任务 ).

所以社区提出了 UniTask 方案, 在保证Unity线程安全的前提下还支持 C# 的异步编程模型,而且不需要依赖 MonoBehaviour 便可以运行.

对于需要用到 Action 回调处理地方, 用 UniTask 处理更加方便

配置安装

这里采用官方的 Git 导出方案, 直接在 Package Manager 点击 add package from git URL 输入以下地址自动安装:

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

这样默认就在项目组件当中追加好 UniTask 第三方依赖了.

常规使用

UniTask 性能极佳且对第三方插件没有兼容性问题( 如 DoTween ), 这里提供常用到的使用说明:

// 使用UniTask所需的命名空间
using Cysharp.Threading.Tasks;

// 你可以返回一个形如 UniTask<T>(或 UniTask) 的类型,这种类型事为Unity定制的,作为替代原生Task<T>的轻量级方案
// 为Unity集成的 0GC,快速调用,0消耗的 async/await 方案
async UniTask<string> DemoAsync()
{
    // 你可以等待一个Unity异步对象
    var asset = await Resources.LoadAsync<TextAsset>("foo");
    var txt = (await UnityWebRequest.Get("https://...").SendWebRequest()).downloadHandler.text;
    await SceneManager.LoadSceneAsync("scene2");

    // .WithCancellation 会启用取消功能,GetCancellationTokenOnDestroy 表示获取一个依赖对象生命周期的Cancel句柄,当对象被销毁时,将会调用这个Cancel句柄,从而实现取消的功能
    var asset2 = await Resources.LoadAsync<TextAsset>("bar").WithCancellation(this.GetCancellationTokenOnDestroy());

    // .ToUniTask 可接收一个 progress 回调以及一些配置参数,Progress.Create是IProgress<T>的轻量级替代方案
    var asset3 = await Resources.LoadAsync<TextAsset>("baz").ToUniTask(Progress.Create<float>(x => Debug.Log(x)));

    // 等待一个基于帧的延时操作(就像一个协程一样)
    await UniTask.DelayFrame(100); 

    // yield return new WaitForSeconds/WaitForSecondsRealtime 的替代方案
    await UniTask.Delay(TimeSpan.FromSeconds(10), ignoreTimeScale: false);
    
    // 可以等待任何 playerloop 的生命周期(PreUpdate, Update, LateUpdate, 等...)
    await UniTask.Yield(PlayerLoopTiming.PreLateUpdate);

    // yield return null 替代方案
    await UniTask.Yield();
    await UniTask.NextFrame();

    // WaitForEndOfFrame 替代方案 (需要 MonoBehaviour(CoroutineRunner))
    await UniTask.WaitForEndOfFrame(this); // this 是一个 MonoBehaviour

    // yield return new WaitForFixedUpdate 替代方案,(和 UniTask.Yield(PlayerLoopTiming.FixedUpdate) 效果一样)
    await UniTask.WaitForFixedUpdate();
    
    // yield return WaitUntil 替代方案
    await UniTask.WaitUntil(() => isActive == false);

    // WaitUntil拓展,指定某个值改变时触发
    await UniTask.WaitUntilValueChanged(this, x => x.isActive);

    // 你可以直接 await 一个 IEnumerator 协程
    await FooCoroutineEnumerator();

    // 你可以直接 await 一个原生 task
    await Task.Run(() => 100);

    // 多线程示例,在此行代码后的内容都运行在一个线程池上
    await UniTask.SwitchToThreadPool();

    /* 工作在线程池上的代码 */

    // 转回主线程
    await UniTask.SwitchToMainThread();

    // 获取异步的 webrequest
    async UniTask<string> GetTextAsync(UnityWebRequest req)
    {
        var op = await req.SendWebRequest();
        return op.downloadHandler.text;
    }

    var task1 = GetTextAsync(UnityWebRequest.Get("http://google.com"));
    var task2 = GetTextAsync(UnityWebRequest.Get("http://bing.com"));
    var task3 = GetTextAsync(UnityWebRequest.Get("http://yahoo.com"));

    // 构造一个async-wait,并通过元组语义轻松获取所有结果
    var (google, bing, yahoo) = await UniTask.WhenAll(task1, task2, task3);

    // WhenAll简写形式
    var (google2, bing2, yahoo2) = await (task1, task2, task3);

    // 返回一个异步值,或者你也可以使用`UniTask`(无结果), `UniTaskVoid`(协程,不可等待)
    return (asset as TextAsset)?.text ?? throw new InvalidOperationException("Asset not found");
}

上面就是常规会用到异步方式, 可以按照说明和需求来选择对应调用.

任务注册

上面都是展示从本来 UniTask 构建起来的异步任务, 但是实际上很多第三方库的方法并没有对其有依赖, 这时候就需要自己手动对第三方库的方法进行改造从而直接集成进来.

一些 UniTask 工厂方法有个 CancellationToken cancellationToken = default 参数; Unity 的一些异步操作也有 WithCancellation(CancellationToken)ToUniTask(..., CancellationToken cancellation = default) 拓展方法, 这些方法就是提供给我们进行对方法进行注册的扩展参数.

可以传递原生 CancellationTokenSource 给参数 CancellationToken

// 构建注册任务句柄对象
var cts = new CancellationTokenSource();

// 这里假设某个UI随便点击触发取消任务
cancelButton.onClick.AddListener(() =>{
    // 将任务句柄对象任务取消
    cts.Cancel();
});

// 这里调用 UnityWebRequest.Get 方法是 Unity 内置的 Http 请求方法
// 这个方法实际上和 UniTask 没有关系, 但现在就是准备注册成 UniTask 来实现异步任务  
await UnityWebRequest.Get("https://www.bing.com")
.SendWebRequest()
.WithCancellation(cts.Token);

// 任务延迟 1000s, 并且任务 Token 为常规构建任务实例 Token
await UniTask.DelayFrame(1000, cancellationToken: cts.Token);

这里定制自己的异步任务方法:

/// <summary>
/// UniTask异步请求
/// </summary>
/// <param name="url"></param>
/// <param name="timeout"></param>
private async UniTaskVoid SendRequest(string url, double timeout)
{
    var cts = new CancellationTokenSource();
    cts.CancelAfterSlim(TimeSpan.FromSeconds(timeout)); // 超时

    try
    {
        // UniTask 已经为其派生 WithCancellation 方法, 用于关联异步任务
        await UnityWebRequest
            .Get(url)
            .SendWebRequest()
            .WithCancellation(cts.Token);
    }
    catch (OperationCanceledException ex)
    {
        // 确认异常拦截错误
        if (ex.CancellationToken == cts.Token)
        {
            Debug.Log("UniTask Timeout!");
        }
    }
    
    // 确认请求
    Debug.Log("Success!");
}


/// <summary>
/// 帧更新
/// </summary>
private void Update()
{
    if (Input.GetMouseButtonDown(0))
    {
        SendRequest("https://www.bing.com", 5).Forget();
    }
}

这里 Forget 代表直接获取回调执行, 这里就是简单的事件绑定实现.

合并任务

UniTask 可以使用 UniTask.WhenAllUniTask.WhenAny 等实用函数; 它们就像 Task.WhenAll/Task.WhenAny, 它们会返回值元组内容, 因此可以传递多种类型并解构每个结果:

public async UniTaskVoid LoadManyAsync()
{
    // 并行加载.
    var (a, b, c) = await UniTask.WhenAll(
        LoadAsSprite("foo"),
        LoadAsSprite("bar"),
        LoadAsSprite("baz"));
}

async UniTask<Sprite> LoadAsSprite(string path)
{
    var resource = await Resources.LoadAsync<Sprite>(path);
    return (resource as Sprite);
}

如果你想转换成回调逻辑块让其变成 UniTask 的话, 可以使用 UniTaskCompletionSource<T> (TaskCompletionSource轻量级魔改版):

public UniTask<int> WrapByUniTaskCompletionSource()
{
    var utcs = new UniTaskCompletionSource<int>();

    // 当操作完成时,调用 utcs.TrySetResult();
    // 当操作失败时, 调用 utcs.TrySetException();
    // 当操作取消时, 调用 utcs.TrySetCanceled();

    return utcs.Task; //本质上就是返回了一个UniTask<int>
}

可以进行如下转换方式:

  • Task -> UniTask : 使用 AsUniTask
  • UniTask -> UniTask<AsyncUnit> : 使用 AsAsyncUnitUniTask
  • UniTask<T> -> UniTask : 使用 AsUniTask, 这两者的转换是无消耗的

需要注意 UniTask不能await两次:

var task = UniTask.DelayFrame(10);
await task;
await task; // 抛出异常

对于 UniTaskValueTask 绝对不要做的操作:

  1. 多次await实例
  2. 多次调用 AsTask
  3. 在操作尚未完成时调用 .Result.GetAwaiter().GetResult(), 多次调用也是不允许的
  4. 混用上述行为更是不被允许的

如果实在需要多次 await 一个异步操作, 可以使用 UniTask.Lazy 来支持多次调用; .Preserve() 同样允许多次调用(由UniTask内部缓存的结果); 这种方法在函数范围内有多个调用时很有用.

async void 是原生的 C# Task系统, 因此它不能在 UniTask 系统上运行, 也最好不要使用它.

async UniTaskVoidasync UniTask 的轻量级版本, 因为它没有等待完成并立即向 UniTaskScheduler.UnobservedTaskException 报告错误; 如果您不需要等待(即发即弃), 那么使用 UniTaskVoid 会更好; 如果要解除警告, 需要在尾部添加 Forget():

public async UniTaskVoid FireAndForgetMethod()
{
    // do anything...
    await UniTask.Yield();
}

// 直接采用 Forget 忽视非 async 调用的异步等待警告
public void Caller()
{
    FireAndForgetMethod().Forget();
}

要使用注册到事件的异步 lambda, 请不要使用 async void; 可以使用 UniTask.ActionUniTask.UnityAction, 两者都通过 async UniTaskVoid lambda 创建委托:

Action actEvent; // C# 内置委托
UnityAction unityEvent; // UGUI特供

// 这样是不好的: async void
actEvent += async () => { };
unityEvent += async () => { };

// 这样是可以的: 通过lamada创建Action
actEvent += UniTask.Action(async () => { await UniTask.Yield(); });
unityEvent += UniTask.UnityAction(async () => { await UniTask.Yield(); });

UniTaskVoid 也可以用在 MonoBehaviourStart 方法中:

class Sample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        // async init code.
    }
}

Unity 通用异步任务

这里提供常用 Unity 异步加载继承 UniTask 的材质资源异步加载样例:

// 加载Unity资源异步处理
public async UniTask<AsyncOperationHandle<T>> LoadAssetAsync<T>(string path){
    AsyncOperationHandle<T> handler = Addressables.LoadAssetAsync<T>(path);
    await handler;
    return handler;
}

上面在游戏热更新的时候会配合运行的功能, 当然后续 UniTask 异步处理在其他应用方面也需要按自己需要变动