找回密码
 立即注册
查看: 274|回复: 0

Unity中的异步编程

[复制链接]
发表于 2022-6-3 18:19 | 显示全部楼层 |阅读模式
在开发中经常会有异步的需求,比如等待一个操作的结束,又不影响后续同步逻辑的执行;下面就介绍下在unity开发中异步的处理方式
CPS

continuation-passing style的缩写,就是常见的回调方法;采用委托注册事件,来实现逻辑异步的效果
协程Coroutine

协程是unity利用枚举器配合引擎的gameloop来实现,协程的方式开发中应该经常用到,配合WaitForSeconds等unity内置的YieldInstruction,或者自定义的YieldInstruction,来实现异步的效果
为了和下面的async/await做对比,我们来写一个案例,代码如下:
using System.Collections;
using System.Linq;
using UnityEngine;

public class DataController : MonoBehaviour
{
    readonly string USERS_URL = "https://jsonplaceholder.typicode.com/users";
    readonly string TODOS_URL = "https://jsonplaceholder.typicode.com/todos";

    IEnumerator FetchData()
    {
        Todo[] todos;
        User[] users;
        
        // USERS
        var www = new WWW(USERS_URL);
        yield return www;
        if (!string.IsNullOrEmpty(www.error))
        {
            Debug.Log("An error occurred");
            yield break;
        }

        var json = www.text;
        try
        {
            var userRaws = JsonHelper.getJsonArray<UserRaw>(json);
            users = userRaws.Select(userRaw => new User(userRaw)).ToArray();
        }
        catch
        {
            Debug.Log("An error occurred");
            yield break;
        }

        // TODOS
        www = new WWW(TODOS_URL);
        yield return www;
        if (!string.IsNullOrEmpty(www.error))
        {
            Debug.Log("An error occurred");
            yield break;
        }

        json = www.text;
        try
        {
            var todoRaws = JsonHelper.getJsonArray<TodoRaw>(json);
            todos = todoRaws.Select(todoRaw => new Todo(todoRaw)).ToArray();
        }
        catch
        {
            Debug.Log("An error occurred");
            yield break;
        }

        // OUTPUT
        foreach (User user in users)
        {
            Debug.Log(user.Name);
        }

        foreach (Todo todo in todos)
        {
            Debug.Log(todo.Title);
        }
    }

    void Start()
    {
        StartCoroutine(FetchData());
    }
}
使用协程可以通过类似同步编程的方式来实现异步逻辑,但是存在一些弊端:
1、不能用try-catch来包裹yield,因此捕获异常需要手动来处理,并且内部的异常时,调试也无法看到调用堆栈
2、协程没有返回值,需要在协程内来处理异步获取的数据,导致协程代码段过大
3、获取user数据和todo数据是不同步的,先完成user数据拉取,成功后拉取todo
async/await

c#提供的异步编程能力,如果关键字无法使用,在unity中使用需要设置API为.net4.x。
这种方式是比较推荐使用的,先来看下上面使用协程的案例,使用async/await的实现方式
using System;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
public class DataAsyncController : MonoBehaviour
{
    readonly string USERS_URL = "https://jsonplaceholder.typicode.com/users";
    readonly string TODOS_URL = "https://jsonplaceholder.typicode.com/todos";
    async Task<User[]> FetchUsers()
    {
        var www = await new WWW(USERS_URL);
        if (!string.IsNullOrEmpty(www.error))
        {
            throw new Exception();
        }
        var json = www.text;
        var userRaws = JsonHelper.getJsonArray<UserRaw>(json);
        return userRaws.Select(userRaw => new User(userRaw)).ToArray();
    }
    async Task<Todo[]> FetchTodos()
    {
        var www = await new WWW(TODOS_URL);
        if (!string.IsNullOrEmpty(www.error))
        {
            throw new Exception();
        }
        var json = www.text;
        var todosRaws = JsonHelper.getJsonArray<TodoRaw>(json);
        return todosRaws.Select(todoRaw => new Todo(todoRaw)).ToArray();
    }
    async void Start()
    {
        try
        {
            var users = await FetchUsers();
            var todos = await FetchTodos();
            foreach (User user in users)
            {
                Debug.Log(user.Name);
            }
            foreach (Todo todo in todos)
            {
                Debug.Log(todo.Title);
            }
        }
        catch
        {
            Debug.Log("An error occurred");
        }
    }
}
结构比协程的方式要清晰很多,并且异步方法可以有返回值,并且可以直接await内置的异步api,这部分功能是可以自己扩展定义的,下面具体说下实现的原理。
async/await关键字会在编译时生成一个状态机类和一个异步方法处理类AsyncVoidMethodBuilder,类似处理流程的伪代码如下
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
    //等待结束直接返回异步结果,也可以没有返回值
    outcome = awaiter.GetResult();
}
else
{
    //挂起异步操作
    SuspendTheFunction();

    Action continuation = () => {
        //恢复
        ResumeTheFunction();
        // Remove 'outcome =' if `GetResult` returns void
        outcome = awaiter.GetResult();
    };

    var cnc = awaiter as ICriticalNotifyCompletion;
    if (cnc != null)
    {
        cnc.UnsafeOnCompleted(continuation);
    }
    else
    {
        awaiter.OnCompleted(continuation);
    }
}
为了更好的理解其原理,推荐看一下生成的IL代码,参考链接https://www.jacksondunstan.com/articles/4918
await后面的对象可以是task,也可以是自定义的类包含GetAwaiter()方法的实现,GetAwaiter方法会返回awaiter对象,该对象需要实现IsCompleted { get; }属性,OnCompleted(Action continuation)方法,GetResult()方法,具体接口如下
    interface IAwaitable<TResult>
    {
        IAwaiter<TResult> GetAwaiter();
    }

    interface IAwaiter<TResult>
    {
        bool IsCompleted { get; }
        void OnCompleted(Action continuation);
        TResult GetResult();
    }
不带返回值的可以定义为void,实现这两个接口就可以自定义awaiter,建议可以自己尝试下输出log,便于理解流程
unity的YieldInstruction适配await,推荐看下这个链接How to use Async-Await instead of coroutines in Unity3d 2017
具体的实现过程以WaitForSeconds为例,代码如下:
// GetAwaiter
// 适配WaitForSeconds类的GetAwaiter方法,通过GetAwaiterReturnVoid返回其Awaiter对象
public static SimpleCoroutineAwaiter GetAwaiter(this WaitForSeconds instruction)
{
    return GetAwaiterReturnVoid(instruction);
}
// GetAwaiterReturnVoid
// 创建和返回Awaiter: SimpleCoroutineAwaiter
// 并在Unity主线程执行InstructionWrappers.ReturnVoid(awaiter, instruction)
static SimpleCoroutineAwaiter GetAwaiterReturnVoid(object instruction)
{
    var awaiter = new SimpleCoroutineAwaiter();
    RunOnUnityScheduler(() => AsyncCoroutineRunner.Instance.StartCoroutine(
        InstructionWrappers.ReturnVoid(awaiter, instruction)));
    return awaiter;
}
// InstructionWrappers.ReturnVoid
// 这里其实已经在Unity主线程,所以这里本质是将await最终换回了yield,由Unity来驱动WaitForSeconds的完成
// 只不过yield完成之后,通过awaiter.Complete回到Awaiter.OnCompleted流程去
public static IEnumerator ReturnVoid(
            SimpleCoroutineAwaiter awaiter, object instruction)
{
    // For simple instructions we assume that they don't throw exceptions
    yield return instruction;
    awaiter.Complete(null);
}

// 确保Action在Unity主线程上运行
// SyncContextUtil.UnitySynchronizationContext在插件Install的时候就初始化好了
// 如果发现当前已经在Unity主线程,就直接执行Action,无需自己Post自己
static void RunOnUnityScheduler(Action action)
{
    if (SynchronizationContext.Current == SyncContextUtil.UnitySynchronizationContext)
    {
        action();
    }
    else
    {
        SyncContextUtil.UnitySynchronizationContext.Post(_ => action(), null);
    }
}

// 真正的Awaiter,它是无返回值的,对应还有一个SimpleCoroutineAwaiter<T>版本
// 它的实现比较简单,就是适配接口,记录委托回调(_continuation),并在Compele()任务完成时,通过RunOnUnityScheduler封送委托回调
public class SimpleCoroutineAwaiter : INotifyCompletion
{
    bool _isDone;
    Exception _exception;
    Action _continuation;

    public bool IsCompleted
    {
        get { return _isDone; }
    }

    public void GetResult()
    {
        Assert(_isDone);

        if (_exception != null)
        {
            ExceptionDispatchInfo.Capture(_exception).Throw();
        }
    }

    public void Complete(Exception e)
    {
        Assert(!_isDone);

        _isDone = true;
        _exception = e;

        // Always trigger the continuation on the unity thread when awaiting on unity yield
        // instructions
        if (_continuation != null)
        {
            RunOnUnityScheduler(_continuation);
        }
    }

    void INotifyCompletion.OnCompleted(Action continuation)
    {
        Assert(_continuation == null);
        Assert(!_isDone);

        _continuation = continuation;
    }
}
可以看到通过方法扩展,给waiteforseconds添加了GetAwaiter方法,然后自定义awaiter来控制异步的结束条件
参考链接:https://john-tucker.medium.com/unity-leveling-up-with-async-await-tasks-2a7971df9c57
https://wudaijun.com/2021/11/c-sharp-unity-async-programing/
最后推荐看下github上提供的工具库:https://github.com/Cysharp/UniTask 为unity开发的async/await工具库
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-22 09:52 , Processed in 0.064851 second(s), 22 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表