前言
这一章我们来讨论如何让 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包加载完毕
上面列举的接口能被大致分为两类:
- 需要 STask 自己实现的功能。
- 在 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 的接口向自定义事件中添加需要执行的代码。那么用户提供的代码有两种执行需求:
- 每一帧执行一次,每一次执行后都判断下一次是否需要继续执行。
- 只执行一次。
不同的执行需求,实现的方式也是不一样的。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 的区别还在于各自使用的线程锁:
- PlayerLoopRunner:由于不确定IPlayerLoopItem.MoveNext()的执行结果,可能会有比较多Item存在于数组中,迭代一次需要花费较多时间,因此这里用混合多线程锁(Monitor)。
- 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.