摘要:函数的异步调用是编程过程中一种常见的调用方式,虽然虚幻引擎拥有强大的蓝图系统,但是在处理异步调用时,现有的蓝图节点却常常束手无策。本教程旨讲解在ue4中设计并实现异步蓝图节点的全流程,以实现具有计时、可取消延迟Delay、同时处理N个对象的异步调用等多个功能的异步蓝图节点SetTimer为例,逐一解析异蓝图节点从需求、设计、实现、应用各个过程。

关键字:虚幻引擎;蓝图;异步;定时器

Design and Implementation of Asynchronous Blueprint Node

Abstract:Asynchronous calling of functions is a common calling way in the programming process. Although Unreal Engine has a powerful blueprint system, the existing blueprint nodes are often at a loss for handling asynchronous call processes. This tutorial aims to explain the whole process of designing and implementing asynchronous blueprint nodes in ue4.Taking the custom asynchronous blueprint node SetTimer, which has the functions of timing, delay, and asynchronous call of N objects at the same time, as an example. Analyze each process of asynchronous blueprint nodes from requirements, design, implementation, and application one by one.

Key words: Unreal Engine; Blueprint; Async; Timer

1. Introduction

使用ue4开发时,常常会遇到异步调用情形;在上一个函数A执行结束,才触发下一个函数B,且函数A是一个多帧执行的持续过程,比如移动到目标点。若函数A在执行时被中止,则函数B不执行,换言之函数B应该是可被取消的延迟调用。对于单个对象的上述情形,使用蓝图系统中Set Timer By Event节点不难解决;若是同时处理N个不同类型对象的异步调用,则内置的蓝图节点难以应对。

利用ue4蓝图系统中内置节点完成简单异步调用包含以下方式:

  1. Delay:等待一定时间,再执行下一个函数;缺点: Delay未结束前,Delay之后的逻辑被阻塞;且无法取消Delay后面的事件;
  2. Timeline:Timeline功能较为完善,包含Play、Stop、Reverse等功能;缺点:只能在Actor及其派生类中使用,无法在Object、UMG等其他类蓝图中使用;无法动态添加;
  3. SetTimerByEvent / SetTimerByFunctionName:支持绑定事件,支持Pause、Unpause、Clear,计时等功能;缺点:无法同时处理多个对象的异步过程。
  4. Tick:Tick时,执行多帧持续性的逻辑;满足结束条件后,调用回调事件逻辑;缺点:需要独立封装。

利用ue4 c++中实现异步蓝图函数包含以下方式:

  1. 使用UFunction宏中的Latent关键字,标识该蓝图函数为Latent function(潜伏事件),在函数体内创建一个FPendingLatentAction派生类的实例并初始化,异步操作的主要过程均在派生类实例上完成。内置蓝图节点的Delay、MoveComponentTo等均采用这类方法。
  2. 继承UBlueprintAsyncActionBase类,重写派生类的函数;内置蓝图节点DownloadImage节点属于此类。
  3. Tick和蓝图Tick一样,需要单独封装。

ue4的异步与非异步的蓝图节点在外观上有一个明显的不同,就是异步蓝图节点右上角存时钟标记;使用时,异步蓝图节点可以在EventGraph/Macros中使用,但是无法在蓝图函数(FunctionGraph)中使用。由于异步节点被调用时,可能同时存在多个任务,所以与一般的蓝图节点只有一对输入输出执行流引脚不同,异步节点可能拥有多个执行流引脚;一对执行流引脚用于继续执行主逻辑,其他执行流引脚用于处理异步过程结束后的返回结果,这样而不至于阻塞程序主要逻辑的执行。

图1. 异步和非异步蓝图节点;

2. Design

由上一小节不难得出结论:[需求] 若要同时处理N个对象的异步调用,引擎内置的蓝图节点是难以满足需求的,因此设计并实现一个具有这样功能的蓝图节点是十分必要的。Set Timer By Event支持Pause、Unpause、Clear等功能,也可以等效地被封装成一个可取消的Delay节点;但是由于SetTimerByEevent节点的Delegate是一个无参数的委托,因此在处理N个对象的时候,无法确定是哪一个对象完成了Delay过程。[设计] 假设我们把Set Timer By Event节点的左侧输入引脚Event,移到节点右侧,变成一个输出执行流引脚Completed;并且在执行Completed引脚后的函数时,可以同时返回TimerHandle,那么就可以根据TimerHandle取得与之对应的Object,然后调用该Object的函数,完成同时处理N个对象的异步调用**。**在经过以上调整,新SetTimer节点的引脚应该是如下结构。

图2. SetTimer节点的引脚;

在同时处理N个对象的异步调用时,每个对象的异步过程执行时间和间隔都可以是独立的,使用以下蓝图节点处理N个对象异步过程。

同时处理N个对象的异步调用简易方案;

3. Source code

**[实现]**源代码如下所示,在去除注释和空行,余下不过70+行,其中特别需要注意的是GC过程。首先贴出源代码,随后解释代码中各个部分的作用。

//.h 文件

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Engine/EngineTypes.h"
#include "Async_SetTimer.generated.h"


// Declare General Log Category, header file .h
DECLARE_LOG_CATEGORY_EXTERN(LogAsyncAction, Log, All);


/**
 *
 */
UCLASS(meta = (HideThen = true))
class ASYNCSYSTEM_API UAsync_SetTimer : public UBlueprintAsyncActionBase
{
	// Delcear delegate for finished delay
	DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FTimerHandleDelegate, FTimerHandle, TimerHandle);
	GENERATED_BODY()

public:

	UAsync_SetTimer();

	~UAsync_SetTimer();

public:

	/**
	 * Set a timer to execute Completed delegate. Setting an existing timer will reset that timer with updated parameters.
	 * @param WorldContextObject		The world context.
	 * @param Time						How long to wait before executing the delegate, in seconds. Setting a timer to <= 0 seconds will clear it if it is set.
	 * @param bLooping					True to keep executing the delegate every Time seconds, false to execute delegate only once.
	 * @param InitialStartDelay			Initial delay passed to the timer manager, in seconds.
	 * @param InitialStartDelayVariance	Use this to add some variance to when the timer starts in lieu of doing a random range on the InitialStartDelay input, in seconds.
	 * @return							The timer handle to pass to other timer functions to manipulate this timer.
	 */
	UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", WorldContext = "WorldContextObject", DisplayName = "Set Timer", ScriptName = "SetTimerDelegate", AdvancedDisplay = "InitialStartDelay, InitialStartDelayVariance"), Category = "Utilities|Time")
		static UAsync_SetTimer* SetTimer(const UObject* WorldContextObject, float Time, bool bLooping, float InitialStartDelay = 0.f, float InitialStartDelayVariance = 0.f);

public: // Generate exec out pin

	/** Generate Exec Outpin, named Then */
	UPROPERTY(BlueprintAssignable)
		FTimerHandleDelegate Then;

	/** Generate Exec Outpin, named Completed */
	UPROPERTY(BlueprintAssignable)
		FTimerHandleDelegate Completed;

private: // UBlueprintAsyncActionBase interface

	virtual void Activate() override;
	//~ END UBlueprintAsyncActionBase interface

	/** UFunction for Delegate.BindUFunction; */
	UFUNCTION()
		void CompletedEvent();

	/** Based on UKismetSystemLibrary::K2_SetTimerDelegate() */
	FTimerHandle SetTimerDelegate(FTimerDynamicDelegate Delegate, float Time, bool bLooping, float InitialStartDelay = 0.f, float InitialStartDelayVariance = 0.f);

	/** Action for garbage class instance */
	void PreGarbageCollect();

private:
	const UObject* WorldContextObject;
	FTimerHandle TimerHandle;

	UWorld*  World;
};

//.cpp 文件

// Fill out your copyright notice in the Description page of Project Settings.


#include "Async_SetTimer.h"
#include "Kismet/KismetSystemLibrary.h"


//Declare General Log Category, source file .cpp
DEFINE_LOG_CATEGORY(LogAsyncAction);




UAsync_SetTimer::UAsync_SetTimer()
{
	WorldContextObject = nullptr;

	//if (HasAnyFlags(RF_ClassDefaultObject) == false)
	//{
	//	AddToRoot();
	//}
	// Helper message to track object instance
	UE_LOG(LogAsyncAction, Log, TEXT("UAsync_SetTimer::UAsync_SetTimer(): Async_SetTimer object [%s] is being created."), *this->GetName());
}


UAsync_SetTimer::~UAsync_SetTimer()
{
	// Helper message to track object instance
	UE_LOG(LogAsyncAction, Log, TEXT("UAsync_SetTimer::~UAsync_SetTimer(): Async_SetTimer object is being deleted."));
}


UAsync_SetTimer* UAsync_SetTimer::SetTimer(const UObject* WorldContextObject, float Time, bool bLooping, float InitialStartDelay /*= 0.f*/, float InitialStartDelayVariance /*= 0.f*/)
{
	if (!WorldContextObject)
	{
		FFrame::KismetExecutionMessage(TEXT("Invalid WorldContextObject. Cannot execute Set Timer."), ELogVerbosity::Error);
		return nullptr;
	}
	/** Based on UKismetSystemLibrary::K2_SetTimer() */
	InitialStartDelay += FMath::RandRange(-InitialStartDelayVariance, InitialStartDelayVariance);
	if (Time <= 0.f || ((Time + InitialStartDelay) - InitialStartDelayVariance) < 0.f)
	{
		FFrame::KismetExecutionMessage(TEXT("SetTimer passed a negative or zero time.  The associated timer may fail to fire!  If using InitialStartDelayVariance, be sure it is smaller than (Time + InitialStartDelay)."), ELogVerbosity::Warning);
		return nullptr;
	}

	UAsync_SetTimer* AsyncNode = NewObject<UAsync_SetTimer>();
	AsyncNode->WorldContextObject = WorldContextObject;

	FTimerDynamicDelegate Delegate;
	Delegate.BindUFunction(AsyncNode, FName("CompletedEvent"));
	AsyncNode->TimerHandle = AsyncNode->SetTimerDelegate(Delegate, Time, bLooping, InitialStartDelay, InitialStartDelayVariance);

	//  Call to globally register this object with a game instance, it will not be destroyed until SetReadyToDestroy is called
	AsyncNode->RegisterWithGameInstance((UObject*)WorldContextObject);

	FCoreUObjectDelegates::GetPreGarbageCollectDelegate().AddUObject(AsyncNode, &UAsync_SetTimer::PreGarbageCollect);

	return AsyncNode;
}


/** Based on UKismetSystemLibrary::K2_SetTimerDelegate() */
FTimerHandle UAsync_SetTimer::SetTimerDelegate(FTimerDynamicDelegate Delegate, float Time, bool bLooping, float InitialStartDelay /*= 0.f*/, float InitialStartDelayVariance /*= 0.f*/)
{
	FTimerHandle Handle;
	if (Delegate.IsBound())
	{
		World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull);
		if (World)
		{
			InitialStartDelay += FMath::RandRange(-InitialStartDelayVariance, InitialStartDelayVariance);
			if (Time <= 0.f || ((Time + InitialStartDelay) - InitialStartDelayVariance) < 0.f)
			{
				FFrame::KismetExecutionMessage(TEXT("SetTimer passed a negative or zero time.  The associated timer may fail to fire!  If using InitialStartDelayVariance, be sure it is smaller than (Time + InitialStartDelay)."), ELogVerbosity::Warning);
			}

			FTimerManager& TimerManager = World->GetTimerManager();
			Handle = TimerManager.K2_FindDynamicTimerHandle(Delegate);
			TimerManager.SetTimer(Handle, Delegate, Time, bLooping, (Time + InitialStartDelay));
		}
	}
	else
	{
		UE_LOG(LogBlueprintUserMessages, Warning,
			TEXT("SetTimer passed a bad function (%s) or object (%s)"),
			*Delegate.GetFunctionName().ToString(), *GetNameSafe(Delegate.GetUObject()));
	}

	return Handle;
}


void UAsync_SetTimer::PreGarbageCollect()
{
	if (World)
	{
		FTimerManager& TimerManager = World->GetTimerManager();
		if (!TimerManager.TimerExists(TimerHandle))
		{
			SetReadyToDestroy();
		}
	}
}

void UAsync_SetTimer::Activate()
{
	if (!WorldContextObject)
	{
		FFrame::KismetExecutionMessage(TEXT("Invalid WorldContextObject. Cannot execute Set Timer."), ELogVerbosity::Error);
		return;
	}

	// call Then delegate binding event
	Then.Broadcast(TimerHandle);
}


void UAsync_SetTimer::CompletedEvent()
{
	Completed.Broadcast(TimerHandle);
}

// Delcear delegate 
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FTimerHandleDelegate, FTimerHandle, TimerHandle);
  • 构造和析构函数:二个输出Log用于观察该对象是创建和销毁的时刻。
UAsync_SetTimer::UAsync_SetTimer()
{
	WorldContextObject = nullptr;
	//if (HasAnyFlags(RF_ClassDefaultObject) == false)
	//{
	//	AddToRoot();
	//}
	// Helper message to track object instance
	UE_LOG(LogAsyncAction, Log, TEXT("UAsync_SetTimer::UAsync_SetTimer(): Async_SetTimer object [%s] is being created."), *this->GetName());
}

UAsync_SetTimer::~UAsync_SetTimer()
{
	// Helper message to track object instance
	UE_LOG(LogAsyncAction, Log, TEXT("UAsync_SetTimer::~UAsync_SetTimer(): Async_SetTimer object is being deleted."));
}
  • 声明Exec Pin**:**这一部分声明委托变量的个数将会决定此后异步蓝图节点执行流引脚的个数,以下声明将增加2个执行流。由于默认的执行流then输出引脚执行时并不能返回参数,在本例中几乎无任何作用,故使用UCLASS(meta =(HideThen =true))宏隐藏了默认的then执行流。如下图所示,若不使用该说明符将存在3个输出执行流引脚。
public: // Generate exec out pin

	/** Generate Exec Outpin, named Then */
	UPROPERTY(BlueprintAssignable)
		FTimerHandleDelegate Then;

	/** Generate Exec Outpin, named Completed */
	UPROPERTY(BlueprintAssignable)
		FTimerHandleDelegate Completed;
图3. 注意观察三个输出执行流引脚的提示tooltip;

  • 声明异步节点**:**此处声明了该蓝图节点,BlueprintInternalUseOnly ="true",隐藏自动生成的非异步蓝图节点,没有这个说明符将蓝图中看到二个同名节点。此函数的返回值必须是该派生类类型的指针,该返回值参数不会出现在蓝图节中,函数体主要用于输入参数检验、创建派生类对象以及赋值。
UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true", WorldContext = "WorldContextObject", DisplayName = "Set Timer", ScriptName = "SetTimerDelegate", AdvancedDisplay = "InitialStartDelay, InitialStartDelayVariance"), Category = "Utilities|Time")
	static UAsync_SetTimer* SetTimer(const UObject* WorldContextObject, float Time, bool 
图4. 声明函数时,不使用BlueprintInternalUseOnly ="true";说明符;

  • Activate()函数:基类UBlueprintAsyncActionBase的接口,在函数体内部触发委托绑定的事件。
void UAsync_SetTimer::Activate()
{
	if (!WorldContextObject)
	{
		FFrame::KismetExecutionMessage(TEXT("Invalid WorldContextObject. Cannot execute Set Timer."), ELogVerbosity::Error);
		return;
	}

	// call Then delegate binding event
	Then.Broadcast(TimerHandle);
}

GC管理(特别重要**)**:UBlueprintAsyncActionBase是UObject派生类,若不进行对象管理,则会自动被回收;

生成后,调用RegisterWithGameInstance(),防止自动回收

//  Call to globally register this object with a game instance, it will not be destroyed until SetReadyToDestroy is called
AsyncNode->RegisterWithGameInstance((UObject*)WorldContextObject);

结束时,调用Clear and Invalidate Timer by Handle节点后,该实例对象的TimerHandle被重置。UAsync_SetTimer::PreGarbageCollect()在系统每次进行GC前时被触发,检查UAsync_SetTimer实例的TimerHandle是否有效;如无效,则调用SetReadyToDestroy(),并在GC时,回收该实例对象。

void UAsync_SetTimer::PreGarbageCollect()
{
	if (World)
	{
		FTimerManager& TimerManager = World->GetTimerManager();
		if (!TimerManager.TimerExists(TimerHandle))
		{
			SetReadyToDestroy();
		}
	}
}

4. Usage

上述代码编译成功后,可在蓝图图表中找到SetTimer节点,下面列举异步节点SetTimer的3种功能:[应用]

(1) 用作计时器,和内置的SetTimerByEvent相比,无需新定义一个函数;下面蓝图示例可用于计算二次调用间隔的时间。

SetTimer用作计时器;

(2) 等效于一个非阻塞、可随时取消的Delay节点;Then引脚可以继续执行主逻辑,TimerHandle用来控制Completed引脚后连接函数的暂停、取消暂停、清空等操作。

SetTimer用作可取消的Delay;

(3) 同时处理N个对象的异步调用;简单测试执行异步节点SetTimer消耗,下面蓝图代码随机生成100个StaticMeshActor,以不同的时间间隔让Actor自转,观察fps(Cmd: Stat UnitGraph, Game Frame曲线(Cmd: Stat UnitGraph)等参数几乎没明显变化,表明该节点耗损很小 。(提示:先要关闭测试StaticMeshActor的模拟物理,碰撞,Shadow等与SetTimer测试无关的属性)

SetTimer 同时处理N个对象的异步调用;

5. Conclusion

本文以实现异步节点SetTimer为例,着重介绍使用UBlueprintAsyncActionBase派生类实现异步蓝图节点的全流程,其中特别需要注意的GC过程。扩展:(1)该SetTimer节点还可以进一步扩展,限制Completed引脚执行的次数;(2)UBlueprintAsyncActionBase派生类,如果同时继承FTickableGameObject就可以使输出执行流Tickable。