Unity异步扩展实践(三)——以UniTask库为参考

前言

这一章我们来讨论如何让 STask 变得更酷,让我们可以像下面这样实现一些功能:

await STask.Delay(1000); // 等待1秒

await STask.NextFrame(); // 等待下一帧

await STask.WhenAny(task1, task2); // 等待其中一个任务完成

await STask.WhenAll(task1, task2); // 等待所有任务完成

await SceneManager.LoadSceneAsync("Scene"); // 等待场景加载完毕

await AssetBundle.LoadFromFileAsync("Asset"); // 等待AB包加载完毕

上面列举的接口能被大致分为两类:

  1. 需要 STask 自己实现的功能。
  2. 在 Unity 接口基础上改造,让它能被 await 关键字等待。

后者相对来说比较简单,我们只需实现对应的 awaiter 即可。比如 SceneManager.LoadSceneAsync() 方法返回的对象是 AsyncOperation ,我们就这样做:

public static AsyncOperationAwaiter GetAwaiter(this AsyncOperation asyncOperation)
{
	return new AsyncOperationAwaiter(asyncOperation);
}

public struct AsyncOperationAwaiter : ICriticalNotifyCompletion
{
    public AsyncOperationAwaiter(AsyncOperation asyncOperation);
    
    // awaiter logic
    public bool IsCompleted;
    public void GetResult();
    public void OnCompleted(Action continuation);
    public void UnsafeOnCompleted(Action continuation);
}

内部实现依靠的是 AsyncOperation 对象,完全没有跟 STask 有关的逻辑,因此这一块代码可以随便复制粘贴到任何 Unity 项目。

而第一类功能就稍微复杂一些,具体体现在我们的实现方式上。当我们想在 Unity 中实现“让程序等待一段时间,再执行下面的代码,并且等待时不阻塞当前线程”的功能时,会考虑哪些方法?

  • 计时器加回调。
  • 在协程中等待,然后执行代码。
  • 使用 Task.Delay() 接口。

小编最开始想到的是上面三种方法,虽然它们都能达到目的,但各自都有缺点,比如使用麻烦,有不必要的线程切换开销。那如果我们想做到既使用简单,又性能优秀,就不得不介绍 Unity 的 PlayerLoop 系统了。

PlayerLoopSystem

Unity 的生命周期相信大家都很熟悉了,在2018版本后,Unity 开放了生命周期接口(先看文档嗷,这是链接),我们可以通过 PlayerLoopSystem 来修改生命周期:

public class PlayerLoopTest
{
    public struct MyUpdate { }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    private static void Init()
    {
        var mySystem = new PlayerLoopSystem
        {
            subSystemList = new PlayerLoopSystem[]
            {
                new PlayerLoopSystem
                {
                    updateDelegate = CustomUpdate,
                    type = typeof(MyUpdate),
                }
            }
        };

        PlayerLoop.SetPlayerLoop(mySystem);
    }

    private static void CustomUpdate()
    {
        Debug.Log("my update.");
    }
}

通过上面代码,我们 Unity 的生命周期事件就只剩下了 CustomUpdate() 。如果我们想在原有生命周期事件的基础上进行添加,可以这样做(伪代码):

public class PlayerLoopTest
{
    public struct MyUpdate { }
    
    private static void Init()
    {
        PlayerLoopSystem playerLoop = PlayerLoop.GetCurrentPlayerLoop();
        PlayerLoopSystem[] copyList = playerLoop.subSystemList.ToArrar();
        int index = FindLoopSystemIndex(copyList, typeof(PlayerLoopType.Update));
        PlayerLoopSystem[] source = copyList[index];
        PlayerLoopSystem[] dest = new PlayerLoopSystem[source.Length + 1];
        Array.Copy(source, 0, dest, 0, source.Length);
        
        PlayerLoopSystem MyUpdate = new PlayerLoopSystem
        {
            type = typeof(MyUpdate),
            updateDelegate = CustomUpdate,
        }
        
        dest[dest.Length - 1] = MyUpdate;
        
        playerLoop.subSystemList = copyList;
        PlayerLoop.SetPlayerLoop(playerLoop);
    }
    
    private static int FindLoopSystemIndex(PlayerLoopSystem[] playerLoopList, Type systemType)
    {
        for (int i = 0; i < playerLoopList.Length; ++i)
        {
            if (playerLoopList[i].type == systemType)
            {
                return i;
            }
        }

        throw new Exception("PlayerLoopSystem 未找到,type:" + systemType.FullName);
    }

    private static void CustomUpdate()
    {
        Debug.Log("my update.");
    }
}

通过上面的操作,我们就成功在 Update 生命周期后添加了自定义事件 CustomUpdate ,该方法每一帧都会被调用,并打印“my update.”。

回到 STask ,我们将在原有生命周期上添加14处自定义事件(2020.2版本及以上为16处),如下所示:

//Initialization
//---
//**STaskLoopRunnerYieldInitialization**
//**STaskLoopRunnerInitialization**
//PlayerUpdateTime
//DirectorSampleTime
//AsyncUploadTimeSlicedUpdate  
//SynchronizeInputs  
//SynchronizeState  
//XREarlyUpdate  
//**STaskLoopRunnerLastYieldInitialization**  
//**STaskLoopRunnerLastInitialization**  

//EarlyUpdate  
//---  
//**STaskLoopRunnerYieldEarlyUpdate**  
//**STaskLoopRunnerEarlyUpdate**  
//PollPlayerConnection  
//ProfilerStartFrame  
//GpuTimestamp  
//AnalyticsCoreStatsUpdate  
//UnityWebRequestUpdate  
//ExecuteMainThreadJobs  
//ProcessMouseInWindow  
//ClearIntermediateRenderers  
//ClearLines  
//PresentBeforeUpdate  
//ResetFrameStatsAfterPresent  
//UpdateAsyncReadbackManager  
//UpdateStreamingManager  
//UpdateTextureStreamingManager  
//UpdatePreloading  
//RendererNotifyInvisible  
//PlayerCleanupCachedData  
//UpdateMainGameViewRect  
//UpdateCanvasRectTransform  
//XRUpdate  
//UpdateInputManager  
//ProcessRemoteInput  
//*ScriptRunDelayedStartupFrame*  
//UpdateKinect  
//DeliverIosPlatformEvents  
//TangoUpdate  
//DispatchEventQueueEvents  
//PhysicsResetInterpolatedTransformPosition  
//SpriteAtlasManagerUpdate  
//PerformanceAnalyticsUpdate  
//**STaskLoopRunnerLastYieldEarlyUpdate**  
//**STaskLoopRunnerLastEarlyUpdate**  

//FixedUpdate  
//---  
//**STaskLoopRunnerYieldFixedUpdate**  
//**STaskLoopRunnerFixedUpdate**  
//ClearLines  
//NewInputFixedUpdate  
//DirectorFixedSampleTime  
//AudioFixedUpdate  
//*ScriptRunBehaviourFixedUpdate*  
//DirectorFixedUpdate  
//LegacyFixedAnimationUpdate  
//XRFixedUpdate  
//PhysicsFixedUpdate  
//Physics2DFixedUpdate  
//DirectorFixedUpdatePostPhysics  
//*ScriptRunDelayedFixedFrameRate*  
//**STaskLoopRunnerLastYieldFixedUpdate**  
//**STaskLoopRunnerLastFixedUpdate**  

//PreUpdate  
//---  
//**STaskLoopRunnerYieldPreUpdate**  
//**STaskLoopRunnerPreUpdate**  
//PhysicsUpdate  
//Physics2DUpdate  
//CheckTexFieldInput  
//IMGUISendQueuedEvents  
//NewInputUpdate  
//SendMouseEvents  
//AIUpdate  
//WindUpdate  
//UpdateVideo  
//**STaskLoopRunnerLastYieldPreUpdate**  
//**STaskLoopRunnerLastPreUpdate**  

//Update  
//---  
//**STaskLoopRunnerYieldUpdate**  
//**STaskLoopRunnerUpdate**  
//*ScriptRunBehaviourUpdate*  
//*ScriptRunDelayedDynamicFrameRate*  
//*ScriptRunDelayedTasks*  
//DirectorUpdate  
//**STaskLoopRunnerLastYieldUpdate**  
//**STaskLoopRunnerLastUpdate**  

//PreLateUpdate  
//---  
//**STaskLoopRunnerYieldPreLateUpdate**  
//**STaskLoopRunnerPreLateUpdate**  
//AIUpdatePostScript  
//DirectorUpdateAnimationBegin  
//LegacyAnimationUpdate  
//DirectorUpdateAnimationEnd  
//DirectorDeferredEvaluate  
//EndGraphicsJobsAfterScriptUpdate  
//ParticleSystemBeginUpdateAll  
//ConstraintManagerUpdate  
//*ScriptRunBehaviourLateUpdate*  
//**STaskLoopRunnerLastYieldPreLateUpdate**  
//**STaskLoopRunnerLastPreLateUpdate**  

//PostLateUpdate  
//---  
//**STaskLoopRunnerYieldPostLateUpdate**  
//**STaskLoopRunnerPostLateUpdate**  
//PlayerSendFrameStarted  
//DirectorLateUpdate  
//*ScriptRunDelayedDynamicFrameRate*  
//PhysicsSkinnedClothBeginUpdate  
//UpdateRectTransform  
//UpdateCanvasRectTransform  
//PlayerUpdateCanvases  
//UpdateAudio  
//VFXUpdate  
//ParticleSystemEndUpdateAll  
//EndGraphicsJobsAfterScriptLateUpdate  
//UpdateCustomRenderTextures  
//UpdateAllRenderers  
//EnlightenRuntimeUpdate  
//UpdateAllSkinnedMeshes  
//ProcessWebSendMessages  
//SortingGroupsUpdate  
//UpdateVideoTextures  
//UpdateVideo  
//DirectorRenderImage  
//PlayerEmitCanvasGeometry  
//PhysicsSkinnedClothFinishUpdate  
//FinishFrameRendering  
//BatchModeUpdate  
//PlayerSendFrameComplete  
//UpdateCaptureScreenshot  
//PresentAfterDraw  
//ClearImmediateRenderers  
//PlayerSendFramePostPresent  
//UpdateResolution  
//InputEndFrame  
//TriggerEndOfFrameCallbacks  
//GUIClearEvents  
//ShaderHandleErrors  
//ResetInputAxis  
//ThreadedLoadingDebug  
//ProfilerSynchronizeStats  
//MemoryFrameMaintenance  
//ExecuteGameCenterCallbacks  
//ProfilerEndFrame  
//**STaskLoopRunnerLastYieldPostLateUpdate**  
//**STaskLoopRunnerLastPostLateUpdate**

其中前后加了两颗星星的为自定义事件,一颗星星的是比较重要的自带事件。

聪明的小伙伴可能已经发现,我们每一处插入的事件都分为了两种类型——普通类型和 Yield 类型。

为什么要定义两种类型?

当我们插入自定义事件后,这些事件方法每一帧都会被调用。而 STask 的 PlayerLoopSystem 考虑到通用性,不能仅仅只为了 STask.Delay() 这种接口服务,它要支持用户通过 PlayerLoopSystem 的接口向自定义事件中添加需要执行的代码。那么用户提供的代码有两种执行需求:

  1. 每一帧执行一次,每一次执行后都判断下一次是否需要继续执行。
  2. 只执行一次。

不同的执行需求,实现的方式也是不一样的。STask 的 PlayerLoopSystem 有两个迭代功能的实现小帮手,分别是 PlayerLoopRunner 和 ContinuationQueue .

PlayerLoopRunner

PlayerLoopRunner 用于处理需要多次迭代的代码。为了判断每次迭代后,下一次是否需要继续迭代,它需要配合接口 IPlayerLoopItem 接口工作。

/// <summary>
/// PlayerLoopSystem的迭代对象
/// 提供<see cref="MoveNext"/>方法供PlayerLoopSystem迭代,返回 false 时迭代结束
/// (类似IEnumerator)
/// </summary>
/// <seealso href="https://learn.microsoft.com/zh-cn/dotnet/api/system.collections.ienumerator"/>IEnumerator参考
public interface IPlayerLoopItem
{
    bool MoveNext();
}

被迭代的对象需要实现该接口,并将待执行代码放进 MoveNext() 方法中,当该方法返回 true ,表示下次继续迭代,否则结束迭代。

ContinuationQueue

ContinuationQueue 用于处理只需要迭代一次的代码。它的实现比较简单,每次迭代后就把回调方法从队列中排除出去。

它与 PlayerLoopRunner 的区别还在于各自使用的线程锁:

  1. PlayerLoopRunner:由于不确定IPlayerLoopItem.MoveNext()的执行结果,可能会有比较多Item存在于数组中,迭代一次需要花费较多时间,因此这里用混合多线程锁(Monitor)。
  2. ContinuationQueue:迭代对象为委托,由于只执行一次,迭代数组中的元素一般来说比较少,这里用自旋线程锁(SpinLock),且多用在Yield、线程切换上。

PlayerLoopHelper

STask 的 PlayerLoopSystem 的大部分操作都通过 PlayerLoopHelper 类来实现。当我们需要向 PlaerLoopRunner 或 ContinuationQueue 注册迭代方法时,就可以使用下面两个接口:

public static void AddAction(PlayerLoopTiming timing, IPlayerLoopItem action)
{
    PlayerLoopRunner runner = runners[(int)timing];
    if (runner == null)
    {
        ThrowInvalidLoopTiming(timing);
    }
    runner.AddAction(action);
}

public static void AddContinuation(PlayerLoopTiming timing, Action continuation)
{
    ContinuationQueue queue = yielders[(int)timing];
    if (queue == null)
    {
        ThrowInvalidLoopTiming(timing);
    }
    queue.Enqueue(continuation);
}

与 PlayerLoopSystem 相关的代码比较多,小编就不在这里贴源码了,感兴趣的小伙伴可以前往仓库地址自行翻阅,代码都做了比较详细的注释喔。

STask.Delay

要实现“让程序在这行代码等待一段时间”的功能,我们需要累加每一帧的间隔时间,并判断从开始到这一帧的时间之和是否达到了预设的等待时间,因此我们需要借助 PlayerLoopHelper.AddAction 接口。

public static STask Delay(int millisecondsDelay, PlayerLoopTiming delayTiming = PlayerLoopTiming.Update, CancellationToken cancellationToken = default(CancellationToken))
{
	TimeSpan delayTimeSpan = TimeSpan.FromMilliseconds(millisecondsDelay);
	return new STask(DelayPromise.Create(delayTimeSpan, delayTiming, cancellationToken, out short token), token);
}

private sealed class DelayPromise : ISTaskSource, IPlayerLoopItem, ITaskPoolNode<DelayPromise>
{
    private static TaskPool<DelayPromise> pool;
    private DelayPromise nextNode;
    public ref DelayPromise NextNode => ref this.nextNode;

    static DelayPromise()
    {
        TaskPool.RegisterSizeGetter(typeof(DelayPromise), () => pool.Size);
    }

    private int initialFrame;
    private float delayTimeSpan;
    private float elapsed;
    private CancellationToken cancellationToken;

    STaskCompletionSourceCore<object> core;

    private DelayPromise() { }

    public static ISTaskSource Create(TimeSpan delayTimeSpan, PlayerLoopTiming timing, CancellationToken cancellationToken, out short token)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            return AutoResetSTaskCompletionSource.CreateFromCanceled(cancellationToken, out token);
        }

        if(!pool.TryPop(out DelayPromise result))
        {
            result = new DelayPromise();
        }

        result.elapsed = 0.0f;
        result.delayTimeSpan = (float)delayTimeSpan.TotalSeconds;
        result.cancellationToken = cancellationToken;
        result.initialFrame = PlayerLoopHelper.IsMainThread ? Time.frameCount : -1;

        PlayerLoopHelper.AddAction(timing, result);

        token = result.core.Version;
        return result;
    }

    public void GetResult(short token)
    {
        try
        {
            this.core.GetResult(token);
        }
        finally
        {
            this.TryReturn();
        }
    }

    public STaskStatus GetStatus(short token)
    {
        return this.core.GetStatus(token);
    }

    public STaskStatus UnsafeGetStatus()
    {
        return this.core.UnsafeGetStatus();
    }

    public void OnCompleted(Action<object> continuation, object state, short token)
    {
        this.core.OnCompleted(continuation, state, token);
    }

    public bool MoveNext()
    {
        if(this.cancellationToken.IsCancellationRequested)
        {
            this.core.TrySetCanceled(this.cancellationToken);
            return false;
        }

        if (this.elapsed == 0.0f)//刚开始
        {
            if (this.initialFrame == Time.frameCount)
            {
                return true;
            }
        }

        this.elapsed += Time.deltaTime;
        if (this.elapsed >= this.delayTimeSpan)
        {
            this.core.TrySetResult(null);
            return false;
        }

        return true;
    }

    private bool TryReturn()
    {
        this.core.Reset();
        this.delayTimeSpan = default;
        this.elapsed = default;
        this.cancellationToken = default;
        return pool.TryPush(this);
    }
}

重点是 DelayPromise 这个家伙,在它身上我们看到了三个接口:

  • ITaskPoolNode:使 DelayPromise 池化。
  • ISTaskSource:实现 STask 的相关逻辑。
  • IPlayerLoopItem:实现迭代逻辑。

除此之外,我们还有个老朋友 STaskCompletionSourceCore ,它来配合 ISTaskSource 接口的相关方法,处理异步相关的事情,这一点跟 AsyncSTask 类很相似。

总结

与上一章的内容相比,STask 的扩展比较简单,唯一麻烦的是前期的准备工作比较多。在理解了扩展的原理后,后面的工作就一泻千里(?了,其他功能的扩展,小伙伴们可以查阅源码来学习。

到此,Unity 异步扩展实践的内容将告一段落。其实小编也没有深入了解全部的 UniTask 代码,它的其他功能,比如 linq 异步,与第三方插件(DoTween)的适配我都还没有了解过,等到以后需要用到的时候再继续吧,哈哈O(∩_∩)O。

最后祝小伙伴们学习愉快,Do what you do best.

上一篇
下一篇