ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE5.8 Undo系统变更与自定义编辑器工具兼容修复实践

UE5.8 Undo系统变更与自定义编辑器工具兼容修复实践 项目刚从UE 5.3升到5.8那天我就接到了三个工单生存制作的批量放置工具点了Undo后毫无反应程序化关卡插件撤销一次直接崩掉编辑器还有一个旧版数据资源重命名脚本Undo后引用全部断掉。老实说我对UE5.8的Undo系统变化是有预期的但没想到会集中爆发。排查了三天中间甚至怀疑是硬件问题最后定位到全部是自定义编辑器工具没有跟上新的Undo事务模型。这篇文章把我个人的兼容补丁完整拆开讲包括5.8的事务变化、失效根因、补丁代码和替换过程中踩到的坑希望能帮到正在升级5.8或打算升级的开发者。1. 先从现场说起5.8升级后undo失效的三个典型症状1.1 项目情况与环境还原先说我的项目背景一个中型开放世界游戏原型编辑器里塞了大量自研工具包括批量资产重命名、程序化生成家具、关卡内批量摆放角色、Landscape掩码批处理。引擎升级前是UE 5.3所有工具在5.3里跑了一年没有出过大问题。升到5.8以后第一轮编译过了游戏逻辑正常但编辑器里凡是涉及“做一批修改然后按CtrlZ回退”的操作几乎全军覆没。我的验证环境不算特殊Windows 11、使用DirectX12、Visual Studio 2022编译开发版引擎。工程代码是C模块加一部分Editor Utility Blueprint。升级方式是直接拉取5.8源码在分支上重新编译然后把项目插件逐个启用。没有做全新项目迁入所以很多第三方插件和自研插件的代码是在编译期才发现问题的。需要说明的是我这里说的“补丁”不是从网上找一个安装包打上去而是针对自研工具做的一层代码兼容修复。UE5.8本身没有官方补丁专门解决“所有自定义Undo失效”因为这类失效通常是老代码用法导致的。1.2 三个工单的具体表现为了还原现场我把三个工单原样拆开第一个是批量放置工具。工具作用是在当前关卡里连续生成100个草垛模型并且随机调整旋转和缩放。5.3下可以一步步Undo每按一次撤销一个草垛。5.8下连续放置后按一次Undo会删掉最后一个草垛但再按第二次就没有反应了更糟的是已经被删除的草垛在Outliner里彻底消失但关卡视口里还能看到它的静态网格组件渲染出来。第二个是程序化关卡生成器。它会根据一个文本表在地板上生成多条路径上的灯柱。升级后每次点击Undo编辑器会在FTransaction相关的调用栈里崩溃OutputLog最后一行总是重复打印“Transaction member pushed multiple times for same object”。这个错误从字面看是同一个对象被推入了事务多次。第三个是批量资产重命名工具。在内容浏览器选一批Mesh资产脚本会统一给它们加上前缀并且刷新关卡内所有Actor上的静态网格引用。5.8下执行完改名后Undo历史里确实出现了“Rename Assets”这一条。但点击撤销后资产名称会变回来关卡内Actor对网格的引用却仍然指向旧名字这样关卡加载后就会丢失网格堆变成粉红色默认材质球。这三个问题看起来完全不同但我尝试用最简单的“单步操作”复现后发现它们的共同点非常明显工具在修改对象前后并没有完整地把这些对象纳入到5.8的事务追踪机制里。旧版编辑器默认帮忙做了很多隐藏工作5.8收紧了这个隐藏逻辑。1.3 五分钟快速判断是不是Undo链路问题如果你也遇到类似情况先别急着改造代码。我建议先做下面几个小实验确认问题确实发生在Undo层级而不是数据本身坏了手动对一个Actor执行移动然后立即CtrlZ。如果手动操作可以正常撤销说明引擎核心Undo功能没问题。在OutputLog里过滤“Transaction”日志。如果能看到“BeginTransaction”但看不到“EndTransaction”配对说明某个作用域提前返回或抛异常了。关掉一部分第三方插件再重启编辑器重新跑故障工具。如果恢复说明第三方插件和5.8的事务系统有冲突。我用这几条很快锁定引擎本身的Undo没有坏坏的是我们自定义工具和插件里旧的事务调用方式。2. 原因拆解5.8事务机制到底哪里变了2.1 UE Undo系统的基本运作逻辑要理解5.8升级带来的坑得先花两分钟把UE的Undo系统结构过一遍。UE编辑器里的Undo并不像普通文本编辑器那样记录“操作前字符串”而是基于对象快照加上属性变化的混合模型。核心类叫FTransaction它会把一组UObject标记为事务成员并在事务提交时保存所有成员对象在提交瞬间的序列化状态。当一个对象要被编辑器操作时它必须带有RF_Transactional标记。然后在每次修改前调用Modify()告诉当前事务“我接下来要变了请把当前状态存一份”。修改后引擎会在事务结束时比较对象前后状态生成Undo和Redo所需的内容。整个过程里FScopedTransaction扮演了一个非常关键的角色。它是一个栈对象构造函数里开启一个事务析构函数里提交事务。在自定义编辑器工具里最常见的正确写法是FScopedTransaction Transaction(LOCTEXT(MyAction, My Action)); Target-Modify(); // 在这里修改Target的属性这里有个顺序原则必须先让Target进入事务再修改它。如果先改了属性再调用Modify那Modify保存的其实已经是修改后的状态Undo时就找不到修改前的快照表现就是“撤销无效”或“回退不完整”。2.2 5.8升级后我踩到的两个核心变化在5.3时代很多自定义工具写的顺序其实并不规范但引擎内部会做一些兼容处理。5.8升级后我结合日志和调试至少确认了两个实质性变化第一个变化是对象快照的追踪粒度变严格了。5.8里一个对象如果想被正确记录到事务中必须在事务活跃期间显式持有RF_Transactional标记。旧代码中如果只对World或Outermost Package调用了Modify但子对象或组件自己没有标记那么子对象的变化不会被写入事务快照。这直接导致了“Actor删了但组件还留在视口里”的现象。第二个变化是事务的刷新时机变了。5.8的编辑器默认在事务提交后增加了一层“异步视口刷新”导致PostEditChangeProperty这类通知不会马上触发。旧代码如果在Modify和通知之间依赖前台调用顺序就会出现“数据已经回滚但UI仍旧显示旧值”的情况。如果把这两个变化叠加起来之前三个工单的根因就清楚了批量放置工具在生成Actor后没有对Actor和组件执行完整的Modify组件没有进入事务所以Undo只能删除Actor不能删除组件。关卡生成器在同一个事务里对不同对象调用了多次Modify且对象之间Outer关系嵌套5.8新逻辑认为这是重复推入事务直接崩溃。资源重命名工具只保存了资产本身的Modify没有把所有引用到该资产的对象也纳入事务所以Undo只恢复资产名不恢复引用关系。2.3 用增量排查锁死罪魁祸首我当时没有直接大改代码而是先写了个小工具把所有自定义操作统一包到一个日志入口里。在入口函数开始时打一行日志UE_LOG(LogTemp, Log, TEXT([UndoPatch] BeginTransaction: %s, Active? %d), *ActionName.ToString(), GEditor ? GEditor-IsTransactionActive() : 0);然后在目标对象每次Modify后打一行日志记录对象路径和对象身上当前标记了哪些标志位。跑一次复现后日志明确显示对于放置草垛的工具SpawnActor之后NewActor的ObjectFlags里根本没有RF_Transactional对于资源重命名工具目标资产虽然标记了但引用它的Level Actor没有Modify。这样就把责任精确分配到了代码行。3. 补丁实现兼容5.8的Undo事务包装层3.1 补丁总体设计思路我做的补丁不是去改引擎源码而是在项目工程里新增一个编辑器模块提供一个统一的事务包装器。所有自研工具在改造后都必须经过这个包装器去执行修改禁止在工具内部裸调Modify。模块结构如下Plugins/GameToolUndoPatch/Source/GameToolUndoPatch/ GameToolUndoPatch.Build.cs Public/ GameToolUndoPatchModule.h UndoPatchScope.h Private/ GameToolUndoPatchModule.cpp UndoPatchScope.cpp模块依赖里加上必要的编辑器模块// GameToolUndoPatch.Build.cs using UnrealBuildTool; public class GameToolUndoPatch : ModuleRules { public GameToolUndoPatch(ReadOnlyTargetRules Target) : base(Target) { PCHUsage PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, UnrealEd, EditorScriptingUtilities }); } }这个模块只负责对外提供两个东西一个FUndoPatchScope作用域类一个静态封装函数ExecuteWithUndo。我建议工具代码统一调用静态函数这样未来升级引擎时只需要改这一层不用到几十个文件里做机械替换。3.2 核心代码FUndoPatchScope作用域类FUndoPatchScope的职责很纯粹构造时开启一个事务把传入的目标对象标记为RF_Transactional并调用Modify析构时自动让FScopedTransaction提交事务。重点是它强制保证了一个安全顺序。// UndoPatchScope.h #pragma once #include CoreMinimal.h #include Misc/ScopedTransaction.h #include Editor.h class GAMETOOLUNDOPATCH_API FUndoPatchScope { public: FUndoPatchScope(UObject* Target, const FText ActionName) : Transaction(ActionName) , bValid(false) { if (Target IsValid(Target) GEditor) { Target-SetFlags(RF_Transactional); Target-Modify(); bValid true; } } ~FUndoPatchScope() { // FScopedTransaction 的析构函数会自动提交事务 } bool IsValid() const { return bValid; } private: FScopedTransaction Transaction; bool bValid; };有人会问为什么这里要显式SetFlags(RF_Transactional)因为在5.8下只调用Modify时如果对象原来不是RF_TransactionalModify并不能让对象真正进入事务。GroupFlags只标一次更稳妥。外面再包一层静态函数用来接受任意业务逻辑// UndoPatchScope.cpp #include UndoPatchScope.h template typename TFunc bool GUndoPatchExecute(UObject* Target, const FText ActionName, TFunc Function) { if (!Target || !IsValid(Target)) { UE_LOG(LogTemp, Error, TEXT(GUndoPatchExecute: 目标对象无效)); return false; } FUndoPatchScope Scope(Target, ActionName); if (!Scope.IsValid()) { UE_LOG(LogTemp, Error, TEXT(GUndoPatchExecute: 无法创建事务作用域)); return false; } Function(); return true; }这里模板函数没有任何平台相关逻辑可以放在一个全局命名空间里。工具侧的调用方式就从原来的一堆手写代码变成了非常简短的形式GUndoPatchExecute(Target, LOCTEXT(ModifyScale, Modify Scale), []() { Target-SetActorRelativeScale3D(FVector(2.0f)); });这样改造完后至少能保证目标对象在修改前被正确快照。但前面也提到某些工具会修改多个对象或生成新对象单靠一个作用域类还不够。所以我还补了一个“子对象递归纳入事务”的工具函数。3.3 补齐子对象解决Actor与组件残留问题批量放置工具的修复重点是让生成出来的Actor和它所有组件都进入同一个事务。我在补丁模块里增加了SpawnActorWithUndo函数AActor* GUndoPatchSpawnActor(UWorld* World, UClass* ActorClass, const FTransform SpawnTransform) { if (!World || !ActorClass) { return nullptr; } FActorSpawnParameters Params; Params.bNoFail true; Params.bAllowDeletion true; Params.bCreateFromScratch true; Params.ObjectFlags RF_Transactional; AActor* NewActor World-SpawnActor(ActorClass, SpawnTransform, Params); if (NewActor) { // 关键Actor本身和它所有组件都要标记并Modify NewActor-Modify(); TInlineComponentArrayUActorComponent* Components(NewActor); for (UActorComponent* Component : Components) { if (IsValid(Component)) { Component-SetFlags(RF_Transactional); Component-Modify(); } } } return NewActor; }这里有个细节很容易漏SpawnActor时传入的ObjectFlags只对Actor本身生效不会自动传播给组件。5.3下某些特殊资源类型可能没问题5.8下需要显式对组件再处理一遍。我改造完批量放置工具后连续放100个草垛再一步步Undo每个草垛和它的StaticMeshComponent都能同步消失没有再出现“幽灵网格”的情况。对于更复杂的生成器如果一次操作会创建多个Actor我建议在外面仍然包一个FUndoPatchScope再循环调用SpawnActorWithUndo保证整批生成共享同一个事务名。这样玩家按一次Undo就能撤销整批生成而不是一百次生成拆成一百个事务。3.4 资源重命名工具的引用修复资源重命名的坑在于修改的不只是资产本身还有关卡中引用资产的对象。旧代码只改了资产路径5.8则要求把所有受影响对象都标记进事务。补丁思路是在执行重命名前遍历当前世界里的Level和Actor把它们全部Modify一遍。这样事务会同时保存旧引用关系和新引用关系。下面给出我改造后的核心伪代码bool GUndoPatchRenameAsset(UObject* Asset, const FString NewName) { UWorld* World GEditor ? GEditor-GetEditorWorldContext(false).World() : nullptr; if (!World || !Asset) { return false; } bool bOk GUndoPatchExecute(Asset, LOCTEXT(RenameAsset, Rename Asset), []() { // 1. 先把所有可能引用Asset的关卡对象纳入事务 for (ULevel* Level : World-GetLevels()) { Level-Modify(); for (AActor* Actor : Level-Actors) { if (!Actor) { continue; } Actor-Modify(); TInlineComponentArrayUActorComponent* Components(Actor); for (UActorComponent* Component : Components) { if (IsValid(Component)) { Component-Modify(); } } } } // 2. 使用资产工具执行真正重命名 FAssetToolsModule AssetToolsModule FModuleManager::LoadModuleCheckedFAssetToolsModule(AssetTools); TArrayFAssetRenameData RenameData; RenameData.Emplace(Asset, NewName, Asset-GetPathName(), true); AssetToolsModule.Get().RenameAssets(RenameData); }); return bOk; }实际工程里把所有Actor和组件都Modify一遍肯定开销大但资源重命名这种低频操作完全可接受。我在自己的工具里加了一个优化先找出引用计数大于零的Asset再只保存“引用了这个Asset的Actor”减少快照数量。如果你追求性能建议用AssetRegistry查引用再针对性地打快照。不要为了性能省掉这部分因为Undo恢复引用的正确性远比那几毫秒重要。3.5 补丁的启用开关与回退策略在代码层面我用一个Build配置宏来控制补丁行为#if WITH_GAMETOOL_PATCH_ENABLED #define EXECUTE_UNDO_PATCH GUndoPatchExecute #else #define EXECUTE_UNDO_PATCH(...) (false) #endif这样一旦发现5.8后续小版本修复了兼容问题可以直接关闭宏恢复成老代码。补丁保留在项目里继续作为统一的Undo入口长期使用。我推荐你也在自己的工具里保留这种开关不要把所有逻辑强耦合在补丁函数里否则未来升级又是一轮重构。4. 修复过程中遇到的常见问题与排查技巧4.1 问题速查表我把这一轮升级里遇到的所有问题整理成了一张速查表适合你自己在团队里排查时使用。表现可能原因修复方向Undo按钮灰色历史里没有记录工具没有进入任何FScopedTransaction作用域检查是否在业务代码最外层调用事务包装按Undo后对象消失但组件仍渲染Actor的组件没有RF_Transactional标记使用组件级Modify参考SpawnActorWithUndoUndo后UI仍是旧数据事务刷新被延迟通知没有触发在事务结束后手动调用RefreshEditorsUndo后资产名回退但引用断裂只保存了资产本身没有保存引用方对象遍历引用方并Modify点击Undo直接崩溃日志有“pushed multiple times”同一对象在同一事务里被重复记录用全局Set去重避免嵌套ModifyRedo结果和Undo结果不一致修改过程拆散在多个事务中在一整个FScopedTransaction内完成所有改动4.2 坑一事务内再开事务导致栈损坏第一次改造时我在一个工具的success回调里又调用了GUndoPatchExecute外层已经有一个正在运行的事务。结果就是日志里出现两个BeginTransaction但只有一个EndTransactionUndo栈顺序全乱编辑器越来越卡最终在保存时崩掉。解决方式是在补丁模块里加一个线程局部变量作为重入保护。同一个线程同一时间只允许有一个全局事务作用域static thread_local bool bInsideUndoPatch false; template typename TFunc bool GUndoPatchExecute(UObject* Target, const FText ActionName, TFunc Function) { if (bInsideUndoPatch) { UE_LOG(LogTemp, Warning, TEXT(GUndoPatchExecute: 检测到嵌套调用忽略本次操作)); return false; } bInsideUndoPatch true; FUndoPatchScope Scope(Target, ActionName); bool bSuccess Scope.IsValid(); if (bSuccess) { Function(); } bInsideUndoPatch false; return bSuccess; }如果你的工具内部真的需要在一个事务里做两次不同阶段的改动不要用两个独立事务应该把两个阶段都装进同一个lambda里让FScopedTransaction只创建一次。4.3 坑二Modify的时机不对造成重复回滚程序化关卡生成器最初的崩溃原因是同一个Actor在生成过程中被Modify了多次而且其中一次是在组件已经初始化之后再Modify导致事务里保存了同一对象的两个快照。5.8对这种情况会抛出异常。我的解决思路是在SpawnActorWithUndo里用一个TSet记录已经Modify过的对象避免重复记录。同时把Params.bDeferConstruction设置成true等组件全部添加完成后再执行FinishSpawning从而只生成一次完整快照。如果你接手的老插件没有做去重最快的手工排查方法是在Modify前后各放一句日志比较对象路径和序号看看同一个对象是否出现在日志里两次。出现两次就说明有问题。4.4 坑三崩溃后编辑器无法恢复Undo历史还有一个比较隐蔽的问题如果在Undo过程中编辑器崩溃重启后自动加载的本地Undo历史可能处于损坏状态。你可能会看到Undo菜单非空但点击任何一条都会Crash。这不是代码问题是临时的序列化缓存坏了。遇到这个情况可以清除编辑器本地配置里的Transaction缓冲文件再重启编辑器。不过我不建议长期依赖这个办法更稳妥的做法是在编辑器启动时检测上次非正常退出标志有异常就直接禁用自动恢复Undo历史避免用户误操作。5. 升级到5.8之前可以做好的准备工作5.1 源码级扫描清单经历了这轮折腾我整理了一份适合在引擎升级前做的Undo相关代码扫描清单你可以直接拿给团队用搜索所有FScopedTransaction调用确认有没有配对。搜索所有Modify()调用看它周围三行内有没有创建过FScopedTransaction。搜索所有SpawnActor调用检查Actor和组件的RF_Transactional标记是否设置。搜索PostEditChangeProperty重载函数确认事务提交后是否依赖同步刷新。搜索“ObjectFlags RF_”这类写法确认没有把RF_Transactional意外清除。用命令行工具可以快速扫grep -r FScopedTransaction Plugins/ Source/ --include*.cpp --include*.h grep -r \bModify() Plugins/ Source/ --include*.cpp --include*.h扫描结果不一定要全改但至少要人工确认每条Modify都有事务保护。5.2 用自动化测试守护Undo功能Undo问题的可怕之处在于它只在特定资源、特定步骤下触发。升级后很难靠人工完全回归。我在项目里加了一套非常轻量级的编辑器测试脚本启动编辑器创建一个临时关卡放置一个Actor执行完修改后调用Undo再校验Actor状态。简单的测试步骤可以写成EditorUtilityBlueprint也可以写成C自动化测试。重点不是测试代码多漂亮而是要在每次引擎版本升级后在CI里跑一遍。只要跑一次就能发现之前那种“组件残留”或“引用不撤销”的问题不用等美术在编辑器里点出来。5.3 保留一份干净的Undo调试环境排查Undo问题还有一个很实用的建议在本地保留一个不启用任何第三方插件的开发构建。也就是说项目目录和引擎目录分开插件目录里只保留自研工具模块第三方商城插件全部临时禁用。这样每次排查时先在这个干净环境里复现就可以把变量缩小到“自研代码”还是“插件冲突”。我这轮修复时就是先在一个只启用GameToolUndoPatch的干净环境里复现了批量放置工具的问题再把第三方插件逐个加回去最终定位到一个旧版材质插件会修改Actor的根组件标记导致事务记录不完整。最后再分享一个小技巧我修复完这批Undo问题后给团队立了一条规矩所有编辑器工具代码里不允许再出现裸的Target-Modify()调用一律走GUndoPatchExecute或FUndoPatchScope。这条规矩看起来很死板但确实能拦住大部分升级后遗症。另外我习惯在每次升级后先拿二三十个有代表性的资产跑一遍“批量修改反复Undo/Redo”确认引用计数和编辑器内存没有异常再让其他人开始开发。UE Undo机制本身设计得很强大但它的正确使用依赖对对象标记和事务顺序的理解。希望这套兼容方案能帮你少熬几个通宵。
返回列表