UE5 蓝图组件重建导致的细节面板缓存失效问题

发布于 14 天前  10 次阅读


UE5 蓝图组件重建导致的细节面板缓存失效问题

近期项目内遇到了一个 Bug,实现的一个 ActorComponent 中,一些 Struct 的特性无法正常运作。
很多系统类也有同样的问题,本文以 FDataTableRowHandle 为例,记录于此。

一句话总结:当一个 Struct 注册了自定义样式,并在样式类中监听了属性变更的回调,属性变更时蓝图组件会先重建,再触发属性变更的回调,此时因为蓝图组件已经完成了重建,旧的组件实则已经失效了,所以在样式类中缓存的句柄、数据等信息也都是失效的,因而会引发异常。

测试示例:

UTestComponent: 继承 UActorComponent 的 C++ 或蓝图组件,类中定义一个 FDataTableRowHandle 类型的成员。
ATestActor: Actor BP,在蓝图类中为其 Add 一个 UTestComponent 组件。

此时,当我们在场景中创建该 TestActor 的实例后,在细节面板中对 DataTableRowHandle 元素操作时,会发现切换 DataTable 时并不会将 RowName 置空,这与正常的 DataTableRowHandle 的表现并不一致。

问题跟踪:

查看代码可以看到:

void FDataTableCustomizationLayout::CustomizeChildren(TSharedRef<class IPropertyHandle> InStructPropertyHandle, class IDetailChildrenBuilder& StructBuilder, IPropertyTypeCustomizationUtils& StructCustomizationUtils)
{
    /** Get all the existing property handles */
    DataTablePropertyHandle = InStructPropertyHandle->GetChildHandle("DataTable");
...
    if (DataTablePropertyHandle->IsValidHandle() && RowNamePropertyHandle->IsValidHandle())
    {
        /** Setup Change callback */
        FSimpleDelegate OnDataTableChangedDelegate = FSimpleDelegate::CreateSP(this, &FDataTableCustomizationLayout::OnDataTableChanged);
        DataTablePropertyHandle->SetOnPropertyValueChanged(OnDataTableChangedDelegate);
...
    }
}

在 DataTableRowHandle 的样式类中,对 DataTable Handle 注册了属性变更的回调:

void FDataTableCustomizationLayout::OnDataTableChanged()
{
    UDataTable* CurrentTable;
    FName OldName;

    // Clear name on table change if no longer valid
    if (GetCurrentValue(CurrentTable, OldName))
    {
        if (!CurrentTable || !CurrentTable->FindRowUnchecked(OldName))
        {
            RowNamePropertyHandle->SetValue(FName());
        }
    }
}

回调中若检测到当前表格中不存在当前的 RowName 命名的行时,则会置空 RowName。

GetCurrentValue 方法中就是通过缓存的句柄获取了 DataTable 与 RowName 的值,并做了一些容错。

bool FDataTableCustomizationLayout::GetCurrentValue(UDataTable*& OutDataTable, FName& OutName) const
{
    if (RowNamePropertyHandle.IsValid() && RowNamePropertyHandle->IsValidHandle() && DataTablePropertyHandle.IsValid() && DataTablePropertyHandle->IsValidHandle())
    {
        // If either handle is multiple value or failure, fail
        UObject* SourceDataTable = nullptr;
        if (DataTablePropertyHandle->GetValue(SourceDataTable) == FPropertyAccess::Success)
        {
            OutDataTable = Cast<UDataTable>(SourceDataTable);

            if (RowNamePropertyHandle->GetValue(OutName) == FPropertyAccess::Success)
            {
                return true;
            }
        }
    }
    return false;
}

当修改表格时,断点可以发现,缓存的 Handle 虽然看起来都是有效的,但是 Get 返回的结果都是 Fail,实则均失效了,因而无法正常执行。
这里的 Handle 因为是完全失效的,SetValue 也不会生效到具体的句柄属性上,赋值也是无效的。

笔者在查找此问题时,发现若是直接在场景中的蓝图实例对象中 Add 一个同样的 TestComponent,表现则正常。
因而推测可能与蓝图中 ActorComponent 的重建有关。

蓝图重建:

UE 中的 ActorComponent 中存在一个成员 EComponentCreationMethod CreationMethod,记录了组件实例如何被创建的:

UENUM()
enum class EComponentCreationMethod : uint8
{
    /** A component that is part of a native class. */
    Native,
    /** A component that is created from a template defined in the Components section of the Blueprint. */
    SimpleConstructionScript,
    /**A dynamically created component, either from the UserConstructionScript or from a Add Component node in a Blueprint event graph. */
    UserConstructionScript,
    /** A component added to a single Actor instance via the Component section of the Actor's details panel. */
    Instance,
};

注释挺明确,几种枚举分别指:

  • Native:在 C++ 构造中使用 CreateDefaultSubobject 形式注册的组件;
  • SimpleConstructionScript:在蓝图类中添加的组件;
  • UserConstructionScript:在 Construct 脚本或蓝图 Event Graph 中添加的组件;
  • Instance:在场景中的蓝图实例中 Add 的组件。

查看 AActor::RerunConstructionScripts() 方法,可以注意到,对于 ConstructScript 类型的组件或 ConstructScript 组件的子组件,会被删除并重建:

bool UActorComponent::IsCreatedByConstructionScript() const
{
    return ((CreationMethod == EComponentCreationMethod::SimpleConstructionScript) || (CreationMethod == EComponentCreationMethod::UserConstructionScript));
}
void AActor::RerunConstructionScripts()
{
...

    CurrentTransactionAnnotation = FActorTransactionAnnotation::Create(this, false); // 数据缓存
    /* 该类内存在成员 FComponentInstanceDataCache ComponentInstanceData; 来完成组件的数据迁移 */
...
        auto GetComponentAddedByConstructionScript = [](UActorComponent* Component) -> UActorComponent* // 获取 ConstructScript 组件的方法
        {
            while (Component)
            {
                if (Component->IsCreatedByConstructionScript())
                {
                    return Component;
                }

                Component = Component->GetTypedOuter<UActorComponent>();
            }

            return nullptr;
        };
...
        for (UActorComponent* Component : PreviouslyAttachedComponents)
        {
            if (UActorComponent* CSAddedComponent = GetComponentAddedByConstructionScript(Component))
            {
...
                InstancedChild->DetachFromComponent(FDetachmentTransformRules::KeepRelativeTransform); // 清理旧组件
...
            }
        }
...
    DestroyConstructedComponents(); // 清理旧组件
...
    const bool bErrorFree = ExecuteConstruction(OldTransform, &OldTransformRotationCache, InstanceDataCache, bIsDefaultTransform); // 构造新组件,完成数据迁移
...
        if (GEditor && (OldToNewComponentMapping.Num() > 0))
        {
            GEditor->NotifyToolsOfObjectReplacement(OldToNewComponentMapping); // 通知 Object 更换
        }
...
}

(对于 Native 类型但具有 RF_DefaultSubObject 并且组件原型为其类 CDO 的情况也会被移除,本文不讨论)

重建时,系统会创建一个 FComponentInstanceDataCache 对象来完成数据迁移(FActorTransactionAnnotation 的成员),在这个类中会调用 ActorComponent::GetComponentInstanceData() 来获取具体要使用的迁移组件数据的对象,并将组件数据序列化保存到此对象中。

而后,旧的组件会被 DetachFromComponent,调用 DestroyComponent,并重命名添加 TRASH_ 前缀。

最后,在 ExecuteConstruction 方法中会完成新组件的构造,并在合适的时机基于第一步中的 FComponentInstanceDataCache 对象,调用 ApplyToActor 方法来应用缓存的旧组件数据。

// 代码有修正,只保留流程
bool AActor::ExecuteConstruction(const FTransform& Transform, const FRotationConversionCache* TransformRotationCache, const FComponentInstanceDataCache* InstanceDataCache, bool bIsDefaultTransform, ESpawnActorScaleMethod TransformScaleMethod)
{
...
    for (int32 i = ParentBPClassStack.Num() - 1; i >= 0; i--)
    {
...
        SCS->ExecuteScriptOnActor(this, NativeSceneComponents, Transform, TransformRotationCache, bIsDefaultTransform, TransformScaleMethod);
...
        UBlueprintGeneratedClass::CreateComponentsForActor(CurrentBPGClass, this);
    }
...
    RegisterAllComponents();
...
    USimpleConstructionScript::RegisterInstancedComponent(ActorComponent);
...
    InstanceDataCache->ApplyToActor(this, ECacheApplyPhase::PostSimpleConstructionScript);
...
    ProcessUserConstructionScript();
...
    InstanceDataCache->ApplyToActor(this, ECacheApplyPhase::PostUserConstructionScript);
...
}

执行完组件的重建后,会对重建的数据进行收集,而后调用 GEditor->NotifyToolsOfObjectReplacement(OldToNewComponentMapping) 来广播通知发生了 Object 的替换。

解决方案

跟踪完蓝图重建的流程,到这里问题已经很明确了,ActorComponent 发生了重建,导致在细节面板自定义的样式类中缓存的句柄均是旧组件的属性句柄,自然无法完成读取或写入。
跟下流程确认下具体的时机:

/* Super 这里其实就是 UActorComponent */
旧组件成员的样式类对象中 SetOnPropertyValuePreChange 的回调函数
旧组件的 Super::PostEditChangeChainProperty(PropertyChangedEvent) 前的逻辑
旧组件的 Super::PostEditChangeChainProperty(PropertyChangedEvent)
AActor::RerunConstructionScripts()
GEditor->NotifyToolsOfObjectReplacement(OldToNewComponentMapping);
新组件成员的样式类对象的 IPropertyTypeCustomization::CustomizeHeader()
新组件成员的样式类对象的 IPropertyTypeCustomization::CustomizeChildren()
旧组件的 Super::PostEditChangeChainProperty(PropertyChangedEvent) 后的逻辑
旧组件成员的样式类对象中 SetOnPropertyValueChangedWithData 的回调函数
旧组件成员的样式类对象中 SetOnPropertyValueChanged 的回调函数

SetOnPropertyValueChanged 的回调在组件替换完成后才会被调用,因而也就导致了回调中使用缓存的 Handle 或数据其实均是无效的,即使缓存了一些看似有效的指针(如保存了 ActorComponent 的指针),也只是可以访问,IsValid 的结果肯定是 False 的,因为旧组件其实已经在 DestroyComponent 过程中被 MarkAsGarbage 了,对指针进行的操作也只会修改到旧的组件上,而不会影响到真正有作用的对象上。在上述流程里也可以看到,细节面板的对象甚至在回调之前就已经发生了替换。

SetOnPropertyValueChangedWithData 的时机也在重建后,入参的 FPropertyChangedEvent 存储的也都是旧信息,同样无法使用。

通过上述流程,也可以看出,若是重写了 PostEditChangeChainProperty 方法,我们在调用 Super::PostEditChangeChainProperty(PropertyChangedEvent) 之后对组件数据的修改也将不会生效,实测也确实如此。

void UTestComponent::PostEditChangeChainProperty(struct FPropertyChangedChainEvent& PropertyChangedEvent)
{
    TestFloat = 222.222f;
    Super::PostEditChangeChainProperty(PropertyChangedEvent);
    TestFloat = 333.333f; // 结果始终会是 222.222f
}

知道了问题的原因,不考虑引擎修正可以有如下解决办法:

  1. 针对本篇问题,没有太好的方案,若是有一些有联动需求的结构体(如改了 AProperty 同步计算 BProperty 之类的),可以考虑在绑定回调的前提下,再在 CustomizeHeader 或 CustomizeChildren 执行时执行一下刷新,因为重建后新对象会再次执行样式的自定义逻辑;
  2. FCoreUObjectDelegates::OnObjectPropertyChanged 代理的回调会在 ActorComponent 重建之前回调,所以也可以使用此代理,另外注意这里只是 ActorComponent 的属性变更导致的重建会在重建之前回调,对于 Actor 属性变更则会在重建之后回调(这里并没有什么问题,因为 Actor 上的组件被替换了,之前只注意到了时机不一致)
  3. 如果是 PostEditChangeChainProperty 或其它的一些问题,可以根据调用的流程找到合适的时机解决相关的问题即可,同时,重建过程中会将 GIsReconstructingBlueprintInstances 标记为 True,也可以依赖此标志完成一些逻辑的控制;
  4. 组件数据相关问题也可以考虑自定义 FActorComponentInstanceData,实现 FActorComponentInstanceData::ApplyToComponent 方法,在 UActorComponent::GetComponentInstanceData() 中返回自定义类对象即可。

下面简单看下蓝图对象重建后细节面板的重建流程,以期有没有更优质的引擎内解决方案。

细节面板的重建

void FPropertyEditorModule::StartupModule()
{
    StructOnScopePropertyOwner = nullptr;

    FCoreUObjectDelegates::OnObjectsReplaced.AddRaw(this, &FPropertyEditorModule::ReplaceViewedObjects);
...
}

FPropertyEditorModule 的 StartupModule 中,注册了 OnObjectsReplaced 的回调:

void FPropertyEditorModule::ReplaceViewedObjects( const TMap<UObject*, UObject*>& OldToNewObjectMap )
{
    // Replace objects from detail views
    for( int32 ViewIndex = 0; ViewIndex < AllDetailViews.Num(); ++ViewIndex )
    {
        if( AllDetailViews[ ViewIndex ].IsValid() )
        {
            TSharedPtr<SDetailsView> DetailViewPin = AllDetailViews[ ViewIndex ].Pin();

            DetailViewPin->ReplaceObjects( OldToNewObjectMap );
        }
    }

    // Replace objects from single views
    for( int32 ViewIndex = 0; ViewIndex < AllSinglePropertyViews.Num(); ++ViewIndex )
    {
        if( AllSinglePropertyViews[ ViewIndex ].IsValid() )
        {
            TSharedPtr<SSingleProperty> SinglePropPin = AllSinglePropertyViews[ ViewIndex ].Pin();

            SinglePropPin->ReplaceObjects( OldToNewObjectMap );
        }
    }
}

回调里,会遍历所有的 DetailsView,转调 SDetailsView::ReplaceObjects 方法:

void SDetailsView::ReplaceObjects(const TMap<UObject*, UObject*>& OldToNewObjectMap)
{
    TArray<UObject*> NewObjectList;
    NewObjectList.Reserve(UnfilteredSelectedObjects.Num());
    TArray<TWeakObjectPtr<UObject>> NewUnfilteredSelectedObjects;
    NewUnfilteredSelectedObjects.Reserve(UnfilteredSelectedObjects.Num());

    bool bNeedRefresh = false;
    for (const TWeakObjectPtr<UObject>& Object : UnfilteredSelectedObjects)
    {
        // We could be replacing an object that has already been garbage collected, so look up the object using the raw pointer.
        UObject* Replacement = OldToNewObjectMap.FindRef(Object.GetEvenIfUnreachable());
        if (Replacement)
        {
            NewObjectList.Add(Replacement);
            NewUnfilteredSelectedObjects.Add(Replacement);
            bNeedRefresh = true;
        }
        else if (Object.IsValid())
        {
            NewObjectList.Add(Object.Get());
            NewUnfilteredSelectedObjects.Add(Object);
        }
        else
        {
            bNeedRefresh = true;
        }
    }

    if (bNeedRefresh)
    {
        UnfilteredSelectedObjects = MoveTemp(NewUnfilteredSelectedObjects);
        SetObjectArrayPrivate(NewObjectList);
    }
}

方法中会遍历所有当前细节面板 Set 的对象,然后查找每个对象是否在入参 OldToNewObjectMap 中,如果能找到,就说明发生了重建,会将新 Object 收集到新的数组中,未找到直接添加遍历的 Object,最后执行 SetObjectArrayPrivate 传入新数组,完成 Objects 对象的替换,这个方法就是平时对 DetailsView 调用 SetObjects 最终会调用到的实现,不再跟踪。

同时可以注意遍历时到使用 WeakPtr 访问 UObject 指针时使用的是 GetEvenIfUnreachable,因为需要替换的对象已经被 MarkAsGarbage 了,直接 Get 不传参的话拿到的会是 nullptr。

细节面板的重建这边粒度没有特别细,因为是以 Object 为单位的处理,所以也不太方便考虑手动触发旧 Handle 绑定的代理,或触发新样式类回调等。
细节面板重建这边没有特别好的方案,就只能考虑在流程上处理了。

引擎解决方案

细节面板提交后,会触发 FPropertyValueImpl::ImportText:

FPropertyAccess::Result FPropertyValueImpl::ImportText( const TArray<FObjectBaseAddress>& InObjects, const TArray<FString>& InValues, FPropertyNode* InPropertyNode, EPropertyValueSetFlags::Type Flags )
{
...
            InPropertyNode->NotifyPreChange(NodeProperty, NotifyHook);
...
            FPropertyTextUtilities::TextToPropertyHelper(*NewValue, InPropertyNode, NodeProperty, Cur, PortFlags);
...
            InPropertyNode->NotifyPostChange( ChangeEvent, NotifyHook );
...
}

FPropertyNode::NotifyPostChange:

void FPropertyNode::NotifyPostChange( FPropertyChangedEvent& InPropertyChangedEvent, class FNotifyHook* InNotifyHook )
{
...
                    if (PropertyChain->Num() == 0)
                    {
                        Object->PostEditChangeProperty(ChangedEvent);
                    }
                    else
                    {
                        FPropertyChangedChainEvent ChainEvent(*PropertyChain, ChangedEvent);
                        ChainEvent.ObjectIteratorIndex = CurrentObjectIndex;

                        Object->PostEditChangeChainProperty(ChainEvent);
                    }
...
    BroadcastPropertyChangedDelegates(InPropertyChangedEvent);
    BroadcastPropertyChangedDelegates();
...
}

BroadcastPropertyChangedDelegates 触发的即是 PropertyHandle 中的 OnPropertyValueChanged 代理,
Object->PostEditChangeChainProperty(ChainEvent) 触发的是 UObject 的 PostEditChangeChainProperty。

根据上文可知在 UActorComponent::PostEditChangeChainProperty 中会触发蓝图的 RerunConstructionScripts 重建流程,所以其实只要将两句 PropertyNode 的回调代理,迁移到 Object 的 PostEditChangeChainProperty 之前执行,就可以解决此问题了,简单测试如此确实可解决篇首问题。但是这部分代码持续很久了,并且修改顺序也的确会导致原 Object 的 PostEditChangeChainProperty 与 PropertyHandle 回调的执行顺序变更,影响会比较大,不推荐如此修正。

考虑项目稳定,减少异常发生的可能性,最好还是新加一个代理,在 Object->PostEditChangeChainProperty 之前广播,对于解决此问题,影响会更小一些,也会更稳定一些。

最后还是提交了一个修改顺序的 PR,第一总体测试下来没有太大问题,回调中的再次修改等也都没有异常,第二是因为引擎中很多 Struct 存在该问题,若是新加一个代理,有问题的结构体也需要同步更正代理,还需要整体过一遍,有些麻烦,所以先提个 PR,开发人员看到后也可以沟通下有没有更优质的解决方案。

相关文章:

[Possible bug] Changing blueprint actor’s component properties reconstructs all of its components

UE 5: Pitfalls of UActorComponent::PostEditChangePropertyponent