尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UE5.8升级后Ctrl+Z致自定义编辑器工具失效?两套修复方案

UE5.8升级后Ctrl+Z致自定义编辑器工具失效?两套修复方案 1. 升级第一天我的编辑器工具集体失联UE5 项目从 5.7 升到 5.8 的那天我做的第一件事不是跑场景而是把项目里自研的那套批量资产处理工具打开挨个点一遍。结果大约三个小时后问题出现了——工具按钮第一次点击正常但只要在编辑器里按一次 CtrlZ 撤销再点同一个按钮整个功能就像冻结了一样面板里的预览列表清零部分写进关卡的数据也没了怎么点都没有响应。这不是偶发抖动而是稳定复现。更诡异的是只有我项目里那套编辑器工具中招引擎自带的 Place Actors、Move Actor、Duplicate 这些操作完全正常。当时项目正好卡在提测节点十几个策划和关卡美术都在用这套工具改关卡突然集体失灵压力瞬间拉满。我查了一圈项目代码第一反应是是不是我代码里有什么静态变量被引擎升级后的 GC 策略给清了。排查了半小时没头绪最后才发现触发条件极其简单任何一次 Undo 操作都会让工具进入半死不活的状态。于是问题从功能失效 bug升级成了Undo 相关的兼容性 bug这时候我才意识到5.8 这次升级对 Undo 系统的改动远比 Release Notes 里写的那两三行要深。这篇文章想做的就是把这条排查路径和两套补丁方案完整记录下来。如果你也在 5.8 上跑了自定义编辑器工具、Editor Utility Widget、批量修改关卡数据的插件升级后遇到某些操作只要碰过 CtrlZ 就失效的怪问题那这篇文章应该能帮你省下至少一整天的排查时间。1.1 复现路径两次点击之间多了一次 CtrlZ先给出我当时整理的复现路径方便你对照自己的项目判断是否属于同一类问题打开自定义编辑器工具面板选择一个或多个 Actor。点击工具按钮执行批量操作比如批量重命名、批量修改属性、批量生成组件。操作结果正常显示面板刷新无误。回到主编辑器按 CtrlZ 撤销刚才的操作。立刻再打开工具面板或者直接再点一次按钮。第 5 步必然失灵。表现分两种轻则按钮没反应面板数据停留在上一次操作后的残留状态重则编辑器日志刷出大量Attempted to access destroyed object或者Transactional object is not in the transaction buffer之类的警告工具写进关卡的数据直接丢一半。我一开始以为是工具面板的 Refresh 函数没被调用于是在面板刷新逻辑里加了一堆日志。结果发现更离谱Refresh 函数确实被调了但里面遍历到的对象列表是空的或者拿到的是被标记为 PendingKill 的旧对象指针。团队的编辑器工具是之前从 5.3 一路升上来的中间没出过这种幺蛾子所以 5.8 一定改了什么底层行为。1.2 影响范围哪些工具最容易中招复盘之后我发现不是所有项目工具都中招踩雷的工具集中在三类第一类是依赖属性修改回执的。工具在修改 Actor 属性后会立刻读取属性值刷新 UI或者把属性值缓存到内存变量里。一旦 Undo 发生属性值回滚到旧值但缓存还停留在新值UI 和实际数据就对不上了。第二类是批量操作后还要继续做后续步骤的。比如批量改名之后自动更新引用列表批量加组件之后自动连接管线这类串联逻辑。Undo 只回滚了第一个步骤后续步骤产生的数据残留导致工具认为流程还没结束于是拒绝再次执行。第三类是直接用延迟回调或 Tick 轮询做状态同步的。升级后 Undo 发生时某些对象的销毁和重建顺序变了延迟回调在下一帧访问到的可能是悬垂指针。如果你的工具在 5.7 及之前版本都正常升到 5.8 后才出现CtrlZ 后抽风的现象大概率属于这三类之一。接下来我讲排查过程重点在怎么快速定位根因。2. 排查过程把锅一步步甩给 Undo2.1 用排除法缩小范围面对这类升级后回归的问题我习惯先做三组对照实验排除掉最常见的干扰项第一组换个新项目把工具插件装上什么都不改直接跑。如果新项目也出问题说明不是项目数据造成的脏状态而是引擎或插件本身的兼容性问题。我这边新项目确实复现了所以进入下一步。第二组把工具里所有涉及修改后立即读取的逻辑临时注释掉改成纯手动的静态数据写入。改完之后工具不报错了但功能也等于没做——这等于确认了问题就出在Undo 回滚后读取数据这条链路上。第三组在对照组里关闭引擎的 Undo 功能编辑器偏好设置里把事务缓冲调成 0再执行点击工具-撤销-再点击的完整流程。没有 Undo 介入时工具一切正常这就把嫌疑彻底钉死在 Undo 系统上。三组实验做完结论只有一个5.8 的 Undo 行为变化与我工具里对属性被回滚后应该立刻同步刷新的假设产生了冲突。2.2 根因5.8 把撤销回调的时序改了接下来的重点就是搞清楚 5.8 的 Undo 到底改了什么。我翻了两天源码和引擎升级日志概括成两点第一点PostEditUndo 的派发时机变了。在 5.7 及更早版本CtrlZ 触发事务回滚后引擎会同步地、立即地对发生变化的 Actor 和 Component 调用 PostEditUndo()然后属性面板、视口刷新统统一气呵成。这时候你的代码如果依赖PostEditUndo 已经执行完数据已经回到旧状态是可以放心读的。但在 5.8 里为了保证大型场景撤销时不卡主线程引擎把这一步改成了先恢复底层内存数据再在帧末统一派发 PostEditUndo。也就是说你按下 CtrlZ 那个瞬间对象的底层数据已经变了但 PostEditUndo 回调还没跑到要等当前帧结束才补上。这个改动对引擎自带的系统没什么影响因为引擎内部工具大多不依赖立即拿到回滚结果。但自定义工具就不一样了很多编辑器工具都是修改数据-读回数据-刷新 UI一气呵成。升级后数据和 UI 之间多了半帧的窗口期等于在状态流转路径上埋了一个雷。第二点事务回滚时对象的重新注册顺序变了。5.8 在回滚 Actor 属性时会把组件重新挂载的步骤延后到 PostEditUndo 之后。如果你的某个工具在 Undo 后立刻访问该 Actor 的 SceneComponent 或 UserWidget 组件拿到的是一个尚未完成重新注册的状态。此时组件索引还在但内部数据不对。这解释了为什么我的工具里遍历 Actor 列表拿组件属性再刷 UI的逻辑会得到空列表。2.3 为什么引擎自带功能没坏偏偏自定义工具坏了引擎自带功能没坏不是因为它更高级而是因为它跟引擎内部共享了同一套状态流。比如内置的 Delete Actor它在把 Actor 标记为待删除时同时更新了视口、大纲、细节面板等多个系统。这些系统之间通过统一的委托分发顺序来保证一致性。而第三方工具往往是自己管自己自己存一份缓存、自己刷 UI、自己维护状态机。一旦引擎改了分发时序第三方工具的缓存就成了毫无根据的脏数据。这就引出一个很重要的开发习惯编辑器工具里不要存从引擎对象上拷贝出来的长期缓存尤其是不要存指针数组、属性快照、组件引用这一类。每一次使用前都重新从引擎获取表面上会损失一点性能但换来的是对引擎升级的强健壮性。我在这次踩坑里尝到了不遵守这个原则的代价。3. 补丁一全局监听 Undo 事件失效后强制刷新3.1 应急方案的核心思路既然问题出在Undo 发生后工具状态没被正确刷新最直接的补丁就是在引擎层面监听 Undo/Redo 广播一旦发生就主动把所有自定义工具的状态强制重建一遍。这个思路类似看门狗设计。工具的固有刷新逻辑不用大改我只需要额外加一个全局的兜底机制当它检测到 Undo 事件后不管工具自身有没有收到通知都强制把所有工具缓存清掉重新从关卡里拉取最新数据。这样即使 PostEditUndo 的时序变了工具也不会长时间停留在脏状态里。补丁一适合用在以下场景工具逻辑本身不复杂只是读完引擎数据后缓存一份或者你短期内没时间把整个工具重构一遍需要立刻止住线上故障。它的优点是改动量小、见效快缺点是只解决状态不一致引发的失效不解决事务系统本身没记录你自定义状态的深层问题那个要用补丁二。3.2 实现一个编辑器模块级别的 Undo 监听器我在项目里写了一个独立的小模块专门用于挂接 Undo 监听。核心代码如下// MyToolUndoListener.h #pragma once #include CoreMinimal.h #include Modules/ModuleInterface.h class FMyToolUndoListenerModule : public IModuleInterface { public: virtual void StartupModule() override; virtual void ShutdownModule() override; private: static void HandleUndo(); static void HandleRedo(); static void FlushToolStates(); static FDelegateHandle UndoHandle; static FDelegateHandle RedoHandle; static bool bPendingFlush; };// MyToolUndoListener.cpp #include MyToolUndoListener.h #include Editor.h #include Editor/EditorEngine.h FDelegateHandle FMyToolUndoListenerModule::UndoHandle; FDelegateHandle FMyToolUndoListenerModule::RedoHandle; bool FMyToolUndoListenerModule::bPendingFlush false; void FMyToolUndoListenerModule::StartupModule() { // 5.8 里 PostEditUndo 变成帧末派发 // 所以我们这里的刷新动作也必须延迟到帧末 // 等引擎自己的回滚逻辑全部跑完再开始读数据。 UndoHandle FEditorDelegates::OnUndo.AddStatic(FMyToolUndoListenerModule::HandleUndo); RedoHandle FEditorDelegates::OnRedo.AddStatic(FMyToolUndoListenerModule::HandleRedo); } void FMyToolUndoListenerModule::ShutdownModule() { FEditorDelegates::OnUndo.Remove(UndoHandle); FEditorDelegates::OnRedo.Remove(RedoHandle); } void FMyToolUndoListenerModule::HandleUndo() { if (bPendingFlush) { return; } bPendingFlush true; // 用编辑器所在的 World 的定时器注册到下一 Tick if (UWorld* World GEditor-GetEditorWorldContext().World()) { World-GetTimerManager().SetTimerForNextTick( FTimerDelegate::CreateStatic(FMyToolUndoListenerModule::FlushToolStates) ); } else { // 编辑器世界拿不到时的兜底直接在下一帧早期处理 FlushToolStates(); } } void FMyToolUndoListenerModule::FlushToolStates() { bPendingFlush false; // 遍历所有自定义工具设置对象强制重建缓存并刷新 UI for (TObjectIteratorUMyToolSettings It; It; It) { It-RebuildAll(); It-RefreshUI(); } }这段代码的思路是OnUndo 事件触发时我什么都不做只是挂一个下一帧的定时器。等到下一帧开始时5.8 引擎内部该回滚的、该派发 PostEditUndo 的、该重建组件的全部执行完了此时再去读取关卡数据就是干净、与回滚后状态一致的。这就避开了 5.8 那个半帧窗口期。3.3 为什么强调延迟到下一帧这是补丁一里最容易踩歪的地方。有同事一开始图省事直接在 HandleUndo 里同步调用 FlushToolStates结果不仅没修好反而把问题扩大成按一次 CtrlZ 直接崩溃。原因就是OnUndo 广播发出时引擎的事务回滚只完成了底层数据恢复组件重新注册、属性面板刷新、PostEditUndo 派发全都没做。这时候你去读 Actor 的组件拿到的就是注册了一半的对象。所以说5.8 升级后凡是跟 Undo 沾边的工具代码最好都养成延迟到帧末或下一帧再处理的肌肉记忆。核心原因是引擎为了性能优化把原本同步完成的一整条状态恢复流程拆成了多阶段。你对阶段顺序的任何假设都可能因为引擎版本升级而失效。3.4 补丁一的注意事项补丁一能救急但有几个坑必须提前规避一个坑是监听器重复注册。如果你在多个模块里都加了 OnUndo 监听并且都调用了同样的刷新函数那么一次 CtrlZ 可能导致你工具的状态被刷新两次。第二次刷新时可能引发 UI 闪烁甚至死循环。我建议在 FlushToolStates 里加一个防重入标记就是上面代码里的 bPendingFlush确保同一帧只执行一次。另一个坑是不要在 Undo 回调用例里新建 Actor 或修改任何对象属性。如果你刷新逻辑里触发了 Modfy 或新建操作会直接往事务缓冲里塞新记录污染 Undo 历史。用户按一次 CtrlZ结果刷新代码又偷偷动了一次数据下次撤销就会出现撤一步跳两步的体验。实在需要在刷新时为对象设置值请把操作包在 FScopedTransaction 里并主动调用 Modify()让它成为一条独立事务。最后一个坑是性能。如果你的工具要遍历场景里几千个 Actor 来重建缓存Undo 频率高的时候会明显卡顿。我的建议是重建操作尽量按需懒加载不要一股脑全量刷新。比如只有被选中的 Actor 需要刷新 UI 时只刷新选区相关数据不要遍历整个关卡。4. 补丁二把功能状态装进事务系统4.1 治本思路让 Undo 认识你的状态补丁一解决的是引擎数据变了我跟着刷新。但如果你的工具状态根本没有进入 Undo 系统那么 Undo 时引擎根本不知道要回滚你那份状态刷新多少次也没用。很多复杂的编辑器工具失效本质上是因为工具内部维护了一套独立数据结构比如用于批量处理的临时列表、操作队列、流程图数据这些数据结构只存在内存里没打上 RF_Transactional 标记也没在事务里 SaveObject。那好要做就做彻底把工具的核心状态变成一个 UObject 子类挂上 Transactional 标记然后在每次操作前调用 GEditor-Trans-SaveObject 保存快照。这样 Undo 时引擎会把工具状态连同场景对象一起回滚天然规避所有时序问题。4.2 实现状态对象 事务保存下面是一个简化的批量重命名工具状态示例UCLASS(Transient, Transactional) class UMyToolStateObject : public UObject { GENERATED_BODY() public: // 保存操作前的 Actor 和旧名字Undo 时用来恢复 UPROPERTY() TMapTWeakObjectPtrAActor, FString OriginalLabels; // 操作后需要更新哪些 UI 面板 UPROPERTY() TArrayTObjectPtrUUserWidget TargetPanelsToRefresh; };然后是工具主逻辑void UMyToolFunctionLibrary::ApplyBulkRenameWithUndo( const TArrayAActor* InActors, const FString NewPrefix) { // 1. 先取或创建一个工具专属状态对象 UMyToolStateObject* StateObj GetMyToolStateObject(); StateObj-SetFlags(RF_Transactional); // 2. 开启一个作用域事务 const FScopedTransaction Transaction( LOCTEXT(BulkRenameWithUndo, Bulk Rename Actors With Undo)); // 3. 关键一步把状态对象注册进事务。 // 从这里开始之后对 StateObj 的任何改动都会被 Undo 捕获。 GEditor-Trans-SaveObject(StateObj); // 4. 修改状态先记录旧名字再执行批量改名 StateObj-OriginalLabels.Reset(); for (AActor* Actor : InActors) { if (!Actor) { continue; } // 引擎对象本身也要记录否则 Undo 不知道这个 Actor 需要回滚 Actor-Modify(); // 把旧名字写进状态对象这一步已经在事务保护内 StateObj-OriginalLabels.Add( TWeakObjectPtrAActor(Actor), Actor-GetActorLabel()); Actor-SetActorLabel(NewPrefix Actor-GetActorLabel()); } // 5. 状态也保存需要刷新的 UI 面板列表 StateObj-TargetPanelsToRefresh GetToolOpenPanels(); }当你按下 CtrlZ 时引擎会同时回滚两块数据一块是 Actor 本身的属性SetActorLabel 之前的状态另一块是我们自定义的 StateObj 内容OriginalLabels 回到空表或旧值。因为两块数据在同一个事务里被保存回滚是一个整体动作不会出现Actor 回去了、工具状态还留在执行后的撕裂状态。4.3 状态对象的使用要点这里有几个经验点值得单独拿出来说。第一状态对象必须放在临时包里并且加 Transient 标记不要让它被保存进关卡或资产包里。你也不希望用户撤两下项目里多出一个编辑器工具状态资产。建议用 NewObject 创建时指定 GetTransientPackage()同时挂载一个 GKey 单例或者放在工具模块根对象下。第二SaveObject 的调用时机必须在所有修改动作之前。很多人写代码习惯先改数据再保存事务这是反的。事务保存的是一份快照Undo 时要用这份快照把对象恢复到保存时的状态。所以必须确保保存时对象处于修改前的状态。第三Map 里的 Key 尽量用 TWeakObjectPtr不要用裸指针。Undo 回滚过程中部分对象可能被销毁重建裸指针会变成悬垂指针而 TWeakObjectPtr 能安全判断对象是否还活着。这是因为 TWeakObjectPtr 在对象销毁时会被自动置空而裸指针不会。4.4 方案对比补丁一还是补丁二我把两个方案的取舍整理成一个对照表方便你根据不同场景做判断维度补丁一全局监听 刷新补丁二自定义事务状态改动量小一个模块即可中需要引入状态对象解决深度解决刷不到数据的时序问题解决状态根本没进事务的根本问题对历史项目兼容性不用改工具主体逻辑需要把工具状态重构为 UObject性能影响Undo 后会全量重建缓存可能卡顿回滚由引擎事务系统管理性能可控适用场景工具逻辑简单只是缓存失效工具内部有复杂状态机或独立数据结构如果你时间紧张或者工具代码只涉及读取-展示用补丁一就够了。如果你的工具做的是带流程的批量处理比如批量导表、批量生成关卡子关卡、批量连线那强烈建议上补丁二否则后续还会遇到状态不一致的边角问题。5. 实测效果与实战避坑5.1 补丁前后对比补丁一上线后我在原有复现路径上连续测试了 50 次点击工具-撤销-再点击故障复现率从 100% 降到 0。工具面板的预览列表在撤销后也能正确回退到上一状态不再出现列表空白或按钮没反应。补丁二上线后我用批量重命名工具的完整流程做回归覆盖了改名-撤销-改名-重做-再改五种操作组合全部正常。最直观的变化是撤销后工具面板的上一次操作记录这一栏从残留状态变成了干净的空状态。这说明状态对象确实被完整回滚了而不是被外部刷新逻辑临时糊弄过去。还有一个值得记录的数字加补丁一之前每次 Undo 后如果工具里存在几千个 Actor 的缓存场景会卡 50 到 80 毫秒加了延迟到下一帧的逻辑后这个耗时被分摊到帧末编辑器体感上几乎没有卡顿。5.2 踩坑记录回调重复、对象悬垂、性能损耗踩过三次坑每次都有代表性第一次重复刷新。我在 FlushToolStates 里忘了加 bPendingFlush 防重入结果一次 Undo 同时触发了 OnUndo 和 OnRedo 两个监听因为你撤销完立刻再重做或者撤销过程中引擎内部广播两次工具面板被强制刷新三遍最后一遍直接把用户正在编辑的临时输入框内容清空了。解决方式很简单就是上文代码里的那个标记位但这个问题在多层架构里很容易被忽略。第二次对象悬垂。补丁一的刷新逻辑里我原本直接用裸指针缓存了 TArrayAActor*Undo 后旧 Actor 被标记 PendingKill我在下一帧遍历时访问这个数组编辑器直接崩了。改成 TWeakObjectPtr 之后遍历时先 IsValid() 判断悬浮指针问题消失。这里要提醒一下即使延迟到下一帧也不能保证对象一定还活着WeakObjectPtr 是必须的。第三次性能损耗。补丁一刚上线那两天团队反馈撤销后有明显卡顿。排查后发现是我把全量刷新写得太狠了每次 Undo 都会重新遍历整个关卡所有 Actor再重建所有工具的预览列表。优化方式是把刷新范围缩小到当前工具 UI 可见的数据集并且只在数据真的变化时才刷新 UI用脏标记做跳过。5.3 常见问题速查表问题现象可能原因解决建议Undo 后面板数据空白工具缓存里存了裸指针或垂悬对象改用 TWeakObjectPtr访问前做有效性判断Undo 后工具按钮点击无反应工具自身状态没进入事务系统将状态对象标记 RF_Transactional 并调用 SaveObjectUndo 后 UI 闪烁或被重复刷新多个模块重复注册 OnUndo 监听增加防重入标记确保同一帧只刷新一次Undo 后编辑器卡顿明显刷新逻辑全量遍历关卡对象缩小刷新范围按需加载必要时用脏标记跳过按一次 CtrlZ 撤了两步刷新逻辑里偷偷修改了对象属性刷新代码不要产生新事务必要修改用 FScopedTransaction 包起来PostEditUndo 里访问组件异常5.8 组件重新挂载延后到帧末不在 PostEditUndo 同步访问延迟到下一帧再读我个人的体会是5.8 这次升级给 UE5 编辑器扩展开发者提了个醒编辑器工具不只是用 UI 包装一下引擎功能它跟引擎的撤销系统、对象生命周期、帧末派发机制紧密耦合。越复杂的工具越应该尽早把自己的状态接入引擎事务系统而不是靠外部刷新去找补。最后再分享一个小技巧升级大版本后别急着把老项目里的工具全部跑一遍先用自动化脚本把操作-撤销-重做-再操作这个循环在核心工具上压测几十遍。这次要不是压测得早等策划和关卡美术全面开跑再发现 Undo 失效那可真就是灾难了。
返回列表