量子计算9 发表于 2022-3-8 08:43

[Unreal] Timer + Lambda---如何优雅的避(制)免(造)麻烦 ...

一、前言

    在这篇文章中,我想要分享一下在UE中使用Timer+Lambda组合的一些案例,通过分析这些案例,找到问题的原因,提出解决问题的方法。
    如果读者对Lambda还不甚了解的话,请参考文章末尾的两篇文章。
    如果想直接看结论,请跳到文章结尾总结部分。
    本文使用的引擎版本为源码4.26.0。
<hr/>二、一些调试技巧

    测试与Timer相关的问题时,有一些很好用的技巧:
1. %p以16进制打印指针指向的地址,按编译器位数长短(32位/64位)输出地址,不够的补零。用来打印内存地址。
    例如:
float* MyFloat = new float(0);
    UE_Log(LogTemp,Warning,TEXT(" Adress is %p"), MyFloat );//打印MyFloat指针所指向的地址
2. 控制台命令: Stat Game
    注意最下方的Counters条目中的 TimerManager Heap size 显示的数量就是当前正在“运行”的Timer的数量。
<hr/>三、案例

-------------------案例No.1-------------------

-停不下来的Timer-用BindWeakLambda替代BindLambda-

案例No.1函数通过timer定时,3秒后,执行Lambda函数并打印Log。但在调用函数0.2s后DestroyActor自身。
//.h
        UPROPERTY()
        FTimerHandle TimerHandle;   //handle声明在.h上

        UFUNCTION(BlueprintCallable)
                void TestFunc();

//.cpp
void ALambdaTestActor::TestFunc()
{
        auto Lambda = []()
        {
                UE_LOG(LogTemp,Warning,TEXT("Lambda!"))
        };

        FTimerDelegate TimerDelegate;
        TimerDelegate.BindLambda(Lambda);//执行BindLambda
        GetWorldTimerManager().SetTimer(MyTimerHandle, TimerDelegate, 3.0, true, -1.0f); //3秒后执行Timer的委托,并且loop
}



3s后, LogTemp: Warning: Lambda! 被打印出来了,并且还在每隔3秒打印。我们的Timer并没有随着ActorDestroy而停止!

-------------------案例No.1分析-------------------
①TimerManager执行TimerDelegate的基本原理
    想要弄清楚问题,就要先了解一下TimerMananger是怎么执行我们的TimerDelegate的。
    让我们将目光投向在TimerManager.cpp中的 void FTimerManager::Tick(float DeltaTime) 这个函数上。顾名思义,TimerManager的Tick函数,TimerManager核心所在。我们可以简单的认为它做了三件事情:
第一 是累加内部时间,每过一tick,timer的内部时间就要累计一次(InternalTime += DeltaTimer),可以理解为一个参照用的时间。

第二 是While循环,循环条件是 while (ActiveTimerHeap.Num() > 0) 。ActiveTimerHeap是个TArray<FTimerHandle> ,是当前需要被检查的TimerHandle的数组(也就是俗称的“在跑着的Timer”,实际上是一个最小堆,最小堆结构适合高速的查找,插入,移除元素,堆顶一定是时间上最接近要执行的那个)。总之,只要其中有元素,while就会一直在运行。他的作用是持续的检查堆顶的TimerHandle和其所对应的TimerData(对应Top这个变量,下同)的数据。
    --->当InternalTime 大于Top期满时间时(InternalTime > Top->ExpireTime),就开始就执行Top中所绑定的Delegate(Top->TimerDelegate.Execute();)
    ---> Execute() 函数中会对Delegate进行检查,满足条件之后才允许执行Delegate,不满足条件不给执行Delegate
    --->(如果上一步中Delegate没有Clear掉这个Timer的话)While最后会再一次检查Top,决定是否要将TimerHandle重新压回堆(例如Loop),如果满足条件就重新压回堆内,如果不需要就移除。
    --->While结束

第三 是检查并将上一次Tick中待加入的TimerHandle在这一次Tick中压入堆内。

用一段伪代码来表示大概是这样的:
void FTimerManager::Tick(float DeltaTime)
{
    InternalTime += DeltaTimer //累计内部时间
   
    While(ActiveTimerHeap.Num() > 0)
    {
      当InternalTime 大于Top期满时间时(InternalTime > Top->ExpireTime),就开始就执行Top中所绑定的Delegate(Top->TimerDelegate.Execute();)
      Execute() 函数中会对Delegate进行检查,满足条件之后才允许执行Delegate,不满足条件不给执行Delegate
      (如果上一步中Delegate没有Clear掉这个Timer的话)While最后会再一次检查Top,决定是否要将TimerHandle重新压回堆(例如Loop),如果满足条件就重新压回堆内,如果不需要就移除。
    }
   
    检查并将上一次Tick中待加入的TimerHandle在这一次Tick中压入堆内。
   
}

②TimerManager执行Delegate前的检查
--Execute() 函数中会对Delegate进行检查,满足条件之后才允许执行Delegate,不满足条件不给执行Delegate--
    让我们回到问题上,我们的Delegate能在ActorDestroy之后还能执行,那肯定就是决定他还能不能继续执行的判断有问题。上面的字是这个部分的重点,既然在TimerManager中Delegate要通过Execute()函数来执行,那来看看Execute()函数中有些什么。
//TimerManager.h38行
inline void Execute()
{
    if (FuncDelegate.IsBound())//执行先决条件
    {
      ...
      FuncDelegate.Execute(); //执行委托
    }
    else if(FuncDynDelegate.IsBound())
    {
      ...
    }
    else if ( FuncCallback )
    {
      ...
    }
}
只要满足这三个中的任意一个,就能执行Delegate
    FuncDelegate就是我们使用的FTimerDelegate,后两个不需要关注。接着来看FuncDelegate.IsBound(),到底检查了些什么东西.
/**
* Checks to see if the user object bound to this delegate is still valid.
*
* @return True if the user object is still valid and it's safe to execute the function call.
*/检查绑定到此委托的用户对象是否仍然Valid,如果用户对象仍然Valid则返回真,可以安全的执行函数调用。
FORCEINLINE bool IsBound( ) const
{
        IDelegateInstance* Ptr = Super::GetDelegateInstanceProtected();//通过接口获取委托实例的指针

        return Ptr && Ptr->IsSafeToExecute();//根据DelegateInstance的类型执行IsSafeToExecute()函数,类型不同,执行的函数内容也不同
}
    通过代码看到,最后能不能执行的决定在IsSafeToExecute()函数上。问题就出在这个函数里了。由于是通过接口获取delegateInstance指针,我们实际上是不知道这个delegateInstance的类型是什么的,也就是说,执行IsSafeToExecute()函数时,delegateInstance的类型不同,执行的函数内容也不同,得到的结果也不相同。下方列出所有delegateInstance类型和其对应的IsSafeToExecute()的返回结果:
TBaseFunctorDelegateInstance<>                        return true; //直接true
TBaseRawMethodDelegateInstance<>                return true;
TBaseSPMethodDelegateInstance<>                        return UserObject.IsValid();
TBaseStaticDelegateInstance<>                        return true;
TBaseUFunctionDelegateInstance<>                return UserObjectPtr.IsValid();
TBaseUObjectMethodDelegateInstance<>                return !!UserObject.Get();
TWeakBaseFunctorDelegateInstance<>                return ContextObject.IsValid();
    那么我们的委托实例属于上面的哪一个呢?当我们执行 TimerDelegate.BindLambda(Lambda) 时,最后会执行CreateLambda这个函数,它最终返回的是一个TBaseFunctorDelegateInstance!代码如下:
/**
* Static: Creates a C++ lambda delegate
* technically this works for any functor types, but lambdas are the primary use case
*/
template<typename FunctorType, typename... VarTypes>
UE_NODISCARD inline static TDelegate<RetValType(ParamTypes...), UserPolicy> CreateLambda(FunctorType&& InFunctor, VarTypes... Vars)
{
        TDelegate<RetValType(ParamTypes...), UserPolicy> Result;
        TBaseFunctorDelegateInstance<FuncType, UserPolicy, typename TRemoveReference<FunctorType>::Type, VarTypes...>::Create(Result, Forward<FunctorType>(InFunctor), Vars...);
        return Result;
}
    情况很清楚了,TBaseFunctorDelegateInstance 中的 IsSafeToExecute() 将直接返回 true,也就是说delegate的执行前检查将直接允许我们执行delegate。他不会检查我们的用户对象是否还存在(而且实际上CreateLambda函数也没有绑定用户对象到委托上),这就造成了即使destroy了Actor,本应“不允许”执行的委托还是能被执行的原因。只能说,我们使用了不恰当的方法。

③TimerManager执行Delegate后的检查
    While最后会再一次检查Top,决定是否要将TimerHandle重新压回堆(例如Loop),如果满足条件就重新压回堆内,如果不需要就移除。
    事情还没有结束,上面只是解释了为什么还能执行的原因,而没有解释为什么还能继续Loop的原因。上面的字是这个部分的重点,让我们来看一下While循环中最后再检查一次Top的代码:
// test to ensure it didn't get cleared during execution
if (Top)//Top就是堆顶的FTimerData,可以理解为当前正在执行的Timer
{
    // if timer requires a delegate, make sure it's still validly bound (i.e. the delegate's object didn't get deleted or something)
    if (Top->bLoop && (!Top->bRequiresDelegate || Top->TimerDelegate.IsBound()))//注意这一句,如果if为真,timer就会重新被压回堆中,如果不满足,就会被移除掉
    {
      // Put this timer back on the heap
      Top->ExpireTime += CallCount * Top->Rate;
              Top->Status = ETimerStatus::Active;
      ActiveTimerHeap.HeapPush(CurrentlyExecutingTimer, FTimerHeapOrder(Timers));
    }
    else
   {
      RemoveTimer(CurrentlyExecutingTimer); //如果不符合条件,就移除TimerHandle
   }

    CurrentlyExecutingTimer.Invalidate();
}
    注意这里的 if (Top->bLoop && (!Top->bRequiresDelegate || Top->TimerDelegate.IsBound()))   如果为真,就将TimerHandle重新压回堆中,否则移除。注意这里的Top->TimerDelegate.IsBound() 它实际上执行了
inline bool IsBound() const
{
    return ( FuncDelegate.IsBound() || FuncDynDelegate.IsBound() || FuncCallback );
}
    是不是很眼熟,没错,我们的TBaseFunctorDelegateInstance在这里又会直接返回为真。
    那么最终我们的TimerData(Top)在if判断中的情况就是:
      Top->bLoop                                        为真
      !Top->bRequiresDelegate                为假
      Top->TimerDelegate.IsBound()        为真
    最终的结果为真,于是timer又重新回到了堆中,而不是被移除。
    至此,使用BindLambda导致Timer无法在Actor被Destroy后无法停止的原因已经全部揭晓:
-BindLambda生成的TBaseFunctorDelegateInstance执行IsBound()时, 结果均为true-

-------------------案例No.1解决-------------------
    使用BindWeakLambda而不是使用BindLambda。
    来看一下使用BindWeakLambda生成的委托类型的是什么:
/**
* Static: Creates a weak object C++ lambda delegate
* technically this works for any functor types, but lambdas are the primary use case
*/
template<typename UserClass, typename FunctorType, typename... VarTypes>
UE_NODISCARD inline static TDelegate<RetValType(ParamTypes...), UserPolicy> CreateWeakLambda(UserClass* InUserObject, FunctorType&& InFunctor, VarTypes... Vars)
{
    TDelegate<RetValType(ParamTypes...), UserPolicy> Result;
    TWeakBaseFunctorDelegateInstance<UserClass, FuncType, UserPolicy, typename TRemoveReference<FunctorType>::Type, VarTypes...>::Create(Result, InUserObject, Forward<FunctorType>(InFunctor), Vars...);
    return Result;
}
是 TWeakBaseFunctorDelegateInstance
    它将会以TWeakObjectPtr的形式绑定用户对象,从而使得TimerManager执行delegate时将会以 return ContextObject.IsValid() 作为判断依据,得到“正确”的结果。
    前面没有提到的是,如果你想要通过执行GetWorldTimerManager.ClearAllTimersForObject()来清除某个Object上所有Timer的话,也是要求delegate上绑定有用户对象的。用BindLambda的方式并没有绑定用户对象,这个方法也会无效。
<hr/>-------------------案例No.2-------------------

-引用捕获造成的空悬-

案例No.2Lambda按引用捕获函数参数中的StaticMeshComponent,启动timer定时,2秒后,执行Lambda,将SMC隐藏。
//.h
    UPROPERTY()
      FTimerHandle MyTimerHandle;   //handle声明在.h上

    UFUNCTION(BlueprintCallable)
      void DelayHideMesh(UStaticMeshComponent* SMC);

//.cpp
void AC_TimerTestActor::DelayHideMesh(UStaticMeshComponent* SMC)
{
    auto Lambda = [&SMC]()//lambda按引用捕获函数参数里类型是UStaticMeshComponent的指针
    {
      SMC->SetHiddenInGame(true);
    };

    FTimerDelegate MyTimerDelegate;
    MyTimerDelegate.BindWeakLambda(this,Lambda); //别忘了用上我们刚刚提到的BindWeakLambda
    GetWorldTimerManager().SetTimer(MyTimerHandle, MyTimerDelegate, 2.0, false, -1.0f); //2秒后执行Timer
}



~~结果就是2秒后妥妥的崩溃~~。

----------------------------------------案例No.2分析----------------------------------------
    其实这是一个非常容易出现的指针悬空的错误,我们来分析一下崩溃的原因。这时候我们的UE_Log+%p 就派上用场了,给我们的代码加上log,并且开始调试模式.


调试模式+断点后,列出所有信息,是这样的:
Mesh = 0x000001e2c4a6d600 (Name=Cube)                                       
Lambda外,SMC指针指向的地址是                        000001E2C4A6D600                       
Lambda外,SMC指针自身的地址是                        0000005FEA5783F8                         
--2秒后,开始运行Lambda--
Mesh = 0x000000003f800000 (Name=???)               
Lambda内,引用捕获的SMC指针指向的地址是                000000003F800000//原本指向Mesh的地址改变了,指向了非目标区域。对这个地址进行操作造成崩溃。                       
Lambda内,引用捕获的SMC指针自身的地址是                0000005FEA5783F8//由于是引用捕获,Lamdbda内打印的指针其自身的地址和外部的一致。                       
    通过内存地址我们可以看到,这是一个很明显的空悬。虽然指针自身的内存位置还是那个位置,但是指向的位置(指针的值)发生了改变。这是因为lambda使用 引用捕获 来捕获函数形参,而函数参数作为局部变量在该函数生命周期结束后,其生命周期也一起结束,其内容遭到释放,进而导致了空悬。当Lambda内对这个地址进行操作时,导致了崩溃!
“按引用捕获会导致闭包中包含了对某个局部变量或者形参的引用,变量或形参只在定义lambda的作用域中可用。如果该lambda创建的闭包生命周期超过了局部变量或者形参的生命周期,那么闭包中的引用将会变成悬空引用。”---Effective Modern C++ 条款31

-----------------------------------案例No.2(不完美的)解决-----------------------------------
    将引用捕获改成 按值捕获,让lambda闭包知道它要单独申请一块地址,在闭包内专门存储指向StaticMeshComponent的地址。
auto Lambda = () //lambda按值捕获函数参数里的指针变量Mesh
    这次没有崩溃!一切正常运行!看看运行的内存地址:
Lambda外,指针指向的地址是         000002518AC13200
Lambda外,指针变量自身的地址是         000000CB67F78048
--2秒后,开始运行Lambda--
Lambda内,指针指向的地址是        000002518AC13200//这次和原始地址一致了,接着针对此地址进行操作是没问题的。
Lambda内,指针变量自身的地址是        00000251DD7488F8//由于是Lambda闭包内单独申请的地址,肯定是和外部形参的地址是不一样的,这个没有问题。    可为什么这不是完美的解决方案呢,请看下一个案例。
<hr/>-------------------案例No.3-------------------

-按值捕获也有坑-使用TWeakObjectPtr判断捕获的UObject有效性-

案例No.3我们改造一下上面的代码。以每秒1次的频率loop执行lambda。如果SMC isValid就执行打印。在蓝图中,先执行函数,2秒后destroy UStaticMeshComponent本体。
//.cpp
void AC_TimerTestActor::DelayHideMesh(UStaticMeshComponent* SMC)
{
    auto Lambda = ()//lambda按值捕获UObject
    {
      if(isValid(SMC))//判断UObject的有效性 ,然后才对指针进行操作
      {
            UE_LOG(LogTemp, Warning, TEXT("SMC is still valid"));
      }
    };
    FTimerDelegate MyTimerDelegate;
    MyTimerDelegate.BindWeakLambda(this,Lambda); //别忘了用上我们刚刚提到的BindWeakLambda
    GetWorldTimerManager().SetTimer(MyTimerHandle, MyTimerDelegate, 1.0, true, -1.0f); //每秒执行1次Timer
}



“SMC is still valid”开始只打印一次了,中间停止打印,但是过了60多秒后又会继续打印, 看来isValid“失灵了”! 如果这时候去操作SMC,就有潜在崩溃的危险,按值捕获UObject,也有坑!

-------------------案例No.3分析-------------------
    将代码改造一下,再次打印内存:
if(isValid(SMC))
{
    UE_LOG(LogTemp, Warning, TEXT("SMC is still valid"));
    UE_LOG(LogTemp, Warning, TEXT("the SMC points to %p"),SMC); //打印SMC指向的地址
    UE_LOG(LogTemp, Warning, TEXT("the SMC pointer at %p"),&SMC);//打印SMC自身的地址
    FString TempString = SMC->GetFName().ToString();
    UE_LOG(LogTemp, Warning, TEXT("the logical name of SMC is %s"), *TempString); //打印SMC的logical Name
}
打印结果是
LogTemp: Warning: SMC is still valid
LogTemp: Warning: the SMC points to 0000029BBD6AF900
LogTemp: Warning: the SMC pointer at 0000029BB3D44C90
LogTemp: Warning: the logical name of SMC is Cube
---大约1分钟过后---
LogTemp: Warning: SMC is still valid
LogTemp: Warning: the SMC points to 0000029BBD6AF900
LogTemp: Warning: the SMC pointer at 0000029BB3D44C90
LogTemp: Warning: the logical name of SMC is None
    可以看到,Lambda闭包内SMC指针的地址和指针指向的地址都没有发生改变,但是logical name发生了改变,变成了None,指针指向了一个无效的对象,这是空悬的状态。
    那么isValid()是怎么判断我们对象的有效性的呢?看看他的代码:
/**
* Test validity of object
*
* @param        Test                        The object to test
* @return        Return true if the object is usable: non-null and not pending kill
*/
FORCEINLINE bool IsValid(const UObject *Test)
{
    return Test && !Test->IsPendingKill();
}    代码很简单只要同时满足两点,就返回true:

[*]此UObject指针指向的内存地址非零(非零就行不在乎有效性)
[*]此UObject不被标记为PendingKill ( UObjectBase的EObjectFlags不为PendingKill 就行)
    在UE中,一个继承于UObject的实例对象被destroy后将会进入PendingKill的状态,此时使用isValid()能检测到此UObject的确处于PendingKill的状态,故而能正常返回 false,因为此时UObject实际上还是存在在内存中的(要不就检测不了了....)。处于PendingKill状态的UObject将会在61秒后(版本4.26.0的默认时间)会被GC释放掉,对象原本占用的内存位置也被释放。
    如果此时再继续对此地址进行isValid的检查,传入isValid()函数的参数仍然是Lambda闭包里捕获的那个SMC。那么此时条件1可以为真,因为这个地址本身就存储在SMC指针里,他不为0。条件2极大概率上也可以满足,因为此时被检测的内存位置上已经是另一个UObject(内存替换),他的标记也极大概率上不是pendingkill。(笔者在这里推测此地址应该处于ObjObject这个数组里,此数组里存放着所有活着的FUObjectItem)
    也许你会考虑再加上使用 isValidLowLevel() 进行判断,它会先在在全局表中索引到值检查是否为空,最后检查是否与原UObject相同。但是这种方式和上面条件2的情况相同,很有可能内存被替换而通过检查,所以还是存在问题。
    当我们按值捕获时,仍然要关心捕获对象的声明周期!为了避免空悬的发生,我们需要使用一种比isValid()和isValidLowLevel()更可靠的方法去判断UObject的有效性。

-------------------案例No.3解决-------------------
使用TWeakObjectPtr来捕获UObject对象。
    修改后的代码如下:
auto Lambda = ()//使用初始化捕获,通过弱对象指针将我们的SMC“弱♂子”化。WeakSMC的类型由编译器自动推导,一切交给语法糖~
{
    if(WeakSMC.IsValid())//使用弱对象指针的IsValid()方法来判断UObject的有效性
    {
      UE_LOG(LogTemp, Warning, TEXT("SMC is still valid"));
    }   
};
    WeakSMC = TWeakObjectPtr<UStaticMeshComponent>(SMC)语法看起来有点怪,但它实际上是C++14中的新特性“初始化捕获”,也被称为广义捕获(generalized capture)。

[*]等号左边的变量是声明在“闭包类” 里面的,它的类型由编译器自动推导;
[*]等号右边的表达式,其作用域就是当前定义lambda的作用域,可以引用局部变量或者实参。
    WeakObjectPtr不会对原UObject对象造成任何引用,原对象的死活与WeakObjectPtr无关,但是WeakObjectPtr能通过其内部实现的方法准却判断对象是否存在和获取原对象的指针。大致原理如下。
    前置知识:
UObjectBase                         //UObject的基类,没有比他更基的了。我们生成U开头的东西时实际上都在生成它。
FUObjectArray GUObjectArray ;        //UE里所有的UObjectBase都在这个Array里进行管理。里面还有好几个数组作为成员在进一步的对FUObjectItem进行分类管理。
FUObjectItem ObjectItem;        //FUObjectItem是FUObjectArray体系中的最小单位,我们的UObjectBase*就封装在里面(不是裸泳的哦)
TUObjectArray ObjObjects;        //谜一样的名字,它是存放所有活着的对象的数组。它作为成员存在于FUObjectArray 中.   (注:TUObjectArray 实际上是 FChunkedFixedUObjectArraytypedef的 )
    WeakObjectPtr通过两个int32属性成员就能建立与UObject的“连接”,弱指针创建的时候会通过构造函数获取这两个值:
int32 ObjectIndex;
    //UObjectBase中有属性int32 InternalIndex ,该值在UObjectBase 生成的时候分配,被释放的时候回收。也就是说只有该UObjectBase还活着的时候这个index是唯一的,注意这点。
    //WeakObjectPtr构造的时候会去获取UObjectBase这个数值。
    //有了这个index,就可以去索引 GUObjectArray找到你的UObjectBase。

int32 ObjectSerialNumber;
    //一个序列号。由class FThreadSafeCounter专门负责生产。他是线程安全的。使用InterLockedIncrement来进行计数,只会增大不会减小。
    //WeakObjectPtr构造时会请求一个这个序列号。请求过程是WeakObjectPtr--->FUObjectArray---> FUObjectItem--->是否已有序列号,有就返回没有就生成。
      这个序列号生成后会给到我们的WeakObjectPtr和FUObjectItem。给到后者的原因是,当此FUObjectItem对应的UObject又在某处被另一个WeakObjectPtr包裹时,FUObjectItem可以直接给出已有的序列号给WeakObjectPtr。
    //这个序列号几乎是唯一的,除非int32被用完。(当序列号超过默认的1000时,将会发出报警Log,但是似乎并不影响序列号的生成;在development模式下,执行WeakObjectPtr.IsValid()时,若序列号大于1000并且ObjectIndex>=0时会触发断言)    好了,我们再整理一下,InternalObjectIndex是UObjectBase生成出来就自带的,WeakObjectPtr构造时会获取这个值;ObjectSerialNumber只有WeakObjectPtr构造时才会要求生成获得,值同时也会存到FUObjectItem里。

当我们使用WeakObjectPtr.IsValid()时,其判断过程是这样的:
    1.通过ObjectIndex去GUObjectArray里的数组ObjObjects里寻找FUObjectItem (注意前面有讲过ObjObjects这个数组是存放所有活着的对象的数组)。如果没有找到则直接为假。
    2.将自身的ObjectSerialNumber 和返回的 FUObjectItem 的 SerialNumber 对比。如果对比一致,则返回真,否则假。

<hr/>四、总结


[*]和Timer结合使用时,使用BindWeakLambda ,而不是BindLambda。
[*]如果一个由lambda创建的闭包的生命期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。
[*]在延迟执行的Lambda中考虑使用TWeakObjectPtr来捕获UObject。
[*]避免使用隐式捕获([=] 和 [&])。出于易读的目的和易维护的目的。
[*]考虑好捕获对象的生命周期。不论是引用捕获还是取值捕获。
[*]延迟执行的Lambda是危险的。你需要为此做一些额外保险的操作,在决定这么做之前,考虑好花费额外的功夫是否值得。过于复杂的情况下可以考虑放弃Lambda。


参考文章:

Baste 发表于 2022-3-8 08:51

擦...我DNA动了

XGundam05 发表于 2022-3-8 08:57

此lambda非彼λ[大笑]

xiangtingsl 发表于 2022-3-8 09:05

懂的懂的[大哭]同UE4使用者,学习了,谢谢分享

LiteralliJeff 发表于 2022-3-8 09:15

感谢dalao分享[赞同]

Zephus 发表于 2022-3-8 09:20

开发这么多年的经验就是 要想稳定 还是不要用timer+lamda
页: [1]
查看完整版本: [Unreal] Timer + Lambda---如何优雅的避(制)免(造)麻烦 ...