UE5 项目从 5.4 一路升级到 5.8,编译过了、烘焙过了,正打算庆祝的时候,美术在编辑器里按了一下 Ctrl+Z。场景里上百个装饰物直接回到默认位置,材质球全白,蓝图引用断了一片。这种“升级之后 Undo 把功能 Undo 没了”的问题,我 5.8 上踩了整整三天。这篇文章不是官方文档翻译,是一个项目自救记录:把 5.8 事务系统给编辑器工具带来的变化讲清楚,再给出一套能直接参考的补丁代码和排查思路。适合维护过编辑器工具、写过 EditorUtility 插件、或者被 Ctrl+Z 坑过的 UE 开发者。
1. 先搞清楚 5.8 的 Undo 到底改了什么
很多同学一遇到问题就直奔源码,我建议先别急。Undo 在 5.8 里的改动不是界面上的变化,而是底层FTransaction的序列化路径变了。你直接去翻引擎源码容易陷进去,因为问题往往不在引擎本体,而在你自己工具里那些“假设 Undo 只回滚属性”的写法。
1.1 Undo 不是简单的 Ctrl+Z:说说事务系统的最小构成
UE 的 Undo 本质上是一个“快照系统”:每次你在编辑器里做一笔修改,引擎会创建一个FTransaction,它把被修改对象的关键属性在修改前和修改后的状态记录下来。Undo 的时候,引擎把“修改前”的状态恢复回去;Redo 的时候,把“修改后”的状态重新应用。这个机制听起来很直觉,但 5.8 对事务记录的底层引用方式做了比较大的手术。
最核心的变化是内部的对象引用从“直接存对象指针”变成了“存对象路径/稳定句柄”,再在恢复时做路径解析。用生活类比来说,旧版本像是在便签上直接写“冰箱里第三层那瓶酱油”,而新版本写的是“厨房-冰箱-上层储物区-生抽-保质期2025”。后者更抗搬动,但在恢复的时候,它不再保证“那瓶酱油还是同一个瓶子”,而只保证“那个位置有一瓶符合条件的液体”。
这带来了一个副作用:如果你在工具里缓存了旧指针、旧句柄,或者你的工具逻辑依赖“对象在 Undo 过程中物理地址不变”,那么 5.8 的 Undo 就会让你的缓存失效。而这恰恰是升级后“功能失效”最主要的来源——属性本身回滚了,但你额外记录的缓存、生成物、临时对象没有跟着正确恢复。
1.2 升级之后最容易炸的三种典型故障
我这边重点排查了三种故障模式,基本覆盖了大多数“Undo 导致功能失效”的现场。
第一类是“属性回滚了,但副作用没回滚”。典型表现是:你的工具修改了一堆 Actor 的BodySetup碰撞属性,Undo 后数值确实恢复了,但场景里那些你生成的预览 Mesh、临时材质、辅助线全部残留,或者跟恢复后的数据完全不匹配。原因是这些“副作用”根本不是 UObject 属性,事务系统根本不知道它们的存在。
第二类是“Undo 后引用变成无效 Handle”。常见于编辑器工具里用TWeakObjectPtr或者普通指针缓存了某一个 Component、SubObject。5.8 的事务系统在恢复时可能会把对象重新序列化、重新创建,老地址变成悬垂指针。你后来再访问它,不崩溃是运气,崩溃是常态。
第三类是“撤销导致组件重建,蓝图连线断裂”。5.8 对组件树的跟踪比以前更激进,一些对组件做的运行时增删操作会被事务系统捕获并回滚,回滚过程中组件的内部FName或索引变化,导致蓝图里绑定的变量引用失效。常见的就是“撤销一次操作后,蓝图变量引用变成 None”。
1.3 先做一张判断表,别急着打补丁
拿到“Undo 后功能失效”的问题,我建议按下面的表格走一遍,先定位再动手。
| 故障现象 | 最可能原因 | 快速验证方法 |
|---|---|---|
| 属性恢复但 UI/预览不刷新 | 工具缓存未纳入事务系统 | 撤销后手动移动一下摄像机或按 F5 强制刷新 |
| 撤销后指针访问崩溃 | 缓存了对象原始指针 | 打开控制台,查看崩溃栈是否在事务恢复后的清理阶段 |
| 组件失效、蓝图引用变 None | 组件在恢复时被重建 | 检查PostEditUndo是否被调用,且是否在PostEditUndo里重建引用 |
| Undo 变成一次无操作 | Modify()没调或者被跳过 | 在操作函数里打断点,确认是否真的调用了Actor->Modify() |
| 只有特定工具失效 | 工具没处理PostTransacted事件 | 单测该工具单独步骤,排除引擎全局问题 |
如果你的问题能对号入座到前三种,那本文下面的补丁思路基本适用;如果只是第四种,那可能不是 5.8 的锅,是你升级后某些调用点没走到。
2. 补丁方案选型:核心是“适配 Undo 生命周期”,不是“对抗”
很多博客一上来就教你怎么重写引擎源码,我不建议这么干。5.8 的 Undo 变化是有迹可循的,它要的是让我们的工具代码去适配新生命周期。乱改引擎源码,下次升级 5.9 的时候又是一地鸡毛。
2.1 四个最容易踩的误区
先说我踩过的四个坑,希望大家别重复。
第一个误区:默认是引擎 bug,上来就改源码。5.8 的 Undo 重构确实有潜在兼容问题,但绝大多数编辑器工具失效的原因是自己的工具没有正确接入事务机制。你真去改引擎源码,不光编译时间长,而且你无法保证局改不会影响项目其他功能,后期合并引擎大版本也全是冲突。
第二个误区:给所有工具统一加一层“撤销保护”。比如粗暴地用一个全局锁保证工具运行时禁止 Undo,或者干脆在操作结束前把Transaction全部吞掉。这会让 Undo 不可用,美术和策划会直接骂人,这种“补丁”是负优化。
第三个误区:只改数据不处理序列化。有些人把属性更新了、缓存数据修正了,就以为补丁结束了,其实 5.8 的失效问题多半出在“UObject 的序列化路径”和“普通实例缓存”之间。你手里那堆编辑器运行时缓存,如果不加UPROPERTY()或者不放在事务感知的容器里,Undo 后照样麻爪。
第四个误区:没有回归测试就交付。这种补丁最怕“修好了工具 A,弄坏了工具 B”。编辑器 Undo 是一个全局机制,你的改动很可能影响其他工具的撤销栈。至少要准备 3~5 个典型操作,建一组回归用例,让程序在测试场景里反复执行“操作-A-撤销-A-重做-A”来验证。
2.2 正确姿势:把你的工具逻辑挂到 Undo 生命周期回调上
UObject 本身提供了一组虚函数,专门用来响应 Undo 流程,这是打补丁的第一选择。
PostEditUndo()就是最常用的一个。当你的对象被 Undo 或者 Redo 之后,引擎会调用它,你可以在这里重建预览物、刷新编辑器状态、重新计算缓存。注意,这个方法在“属性恢复完成”之后调用,所以你可以安全地读取已经恢复完成的属性。
还有个PreEditUndo(),它在属性恢复之前调用,适合保存一些事务系统不感知的临时状态。比如你工具里有一个TMap作为运行时缓存,没有存到属性里,就可以在PreEditUndo()里把它快照出来,然后在PostEditUndo()里恢复。
如果是纯 Editor 模块里的工具类,不继承自 Actor,那就要用全局事件来订阅:FEditorTransactionSubsystem::OnObjectTransacted。这个事件在每次对象事务完成后触发,你可以在回调里把指定对象的界面表现刷新一遍。它比虚函数更灵活,但要特别注意性能,不要在回调里做重操作。
2.3 什么时候才需要“自定义事务补丁”
不是所有问题都要上回调,有些问题适合直接用FScopedTransaction把操作包起来,让引擎接管属性快照。如果你的工具只是修改了一批 Actor 的属性,没有额外生成对象、没有缓存、没有依赖复杂运行时状态,那直接在修改前对相关对象调用Modify()、并把修改包在事务范围内就够了。
但如果你的工具涉及“生成临时预览物”或“维护独立的数据缓存”,那FScopedTransaction就不够用了。因为事务系统只能回滚 UObject 属性,它不可能知道你生成的那一排预览球形体该在什么时机消失。这种情况必须用“PostEditUndo 重建”或“OnObjectTransacted 回调”来做二次同步。
只有一种情况我建议你重新审视:如果你的失效原因是“Undo 回滚时,引擎无法正确恢复自定义资源里的某些序列化数据”,那需要在资源的序列化函数上加版本控制,给旧数据做兼容迁移。这属于引擎侧适配,但也不等于改源码,而是利用引擎提供的PostSerialize、CustomVersion机制做数据迁移补丁。
3. 实操记录:一个碰撞体批量修正工具的补丁全过程
光说理论没有说服力,我拿项目里一个真实的“批量碰撞体修正工具”来演示整个补丁流程。这个工具在 5.8 升级后第一个中招,非常有代表性。
3.1 工具功能与失效表现
这个工具叫 “Batch Collision Fixer”,作用是批量修正场景里 StaticMeshActor 的碰撞复杂度:根据每个 Actor 的 Mesh 面数,自动设置简单碰撞/复杂碰撞规则,并用临时生成的线框预览体把结果可视化出来。
在编辑器里,美术选中一批 Actor,点按钮,工具遍历每个 Actor,写BodySetup相关属性,然后在场景里放置一个EditorMeshActor作为预览。这个预览 Actor 不是实际资产,只是临时的可视化辅助,美术确认没问题后可以一键删除。
升级 5.8 后表现很诡异:点“应用修改”没问题,但一按 Ctrl+Z,所有被修改的 Actor 的碰撞规则确实回到了修改前,可场景里的预览线框全部残留在原地,而且跟 Actor 的新状态完全对不上。再按一次 Redo,预览线框又跑偏了。美术每次撤销都以为自己回到了上一步,实际上被一堆残留线框干扰得没法干活。
3.2 第一次失败尝试:简单在 Undo 前删除预览
我一开始的补丁思路很朴素:既然预览物残留,那我就在PreEditUndo里把所有预览物删掉,在PostEditUndo里重新生成。这听起来对,实际运行却发现两个新问题。
一是删除预览物的操作本身又被记录进了事务系统,导致一轮 Undo 触发新的 Undo 记录,编辑器 Undo 栈变得非常混乱。二是在PostEditUndo里重新生成预览物时,我需要读取 Actor 的最新属性,但这个工具的预览物生成逻辑依赖工具自己维护的一份内部缓存,这份缓存没有随事务恢复,所以新生成的预览物依然用的是旧数据。
说白了,第一版补丁治标不治本。缓存不解决,预览物永远对不上。
3.3 最终补丁:让预览物从“有状态”变成“无状态”
最终我采取的策略是:彻底干掉工具的持久缓存,让预览物完全依赖 Actor 的当前属性实时生成。具体做法是重写PostEditUndo,在撤销/重做完成后,直接根据 Actor 当前碰撞属性重新生成预览物。这里不依赖任何历史缓存,所以天然跟 Undo 状态一致。
代码核心片段如下。先看工具类的定义:
// BatchCollisionFixerTool.h UCLASS() class UBatchCollisionFixerTool : public UEditorUtilityObject { GENERATED_BODY() public: UFUNCTION(CallInEditor) void ApplyCollisionRules(); virtual void PreEditUndo() override; virtual void PostEditUndo() override; private: void RebuildPreviewForActor(AActor* Actor); void DestroyStalePreviews(); UPROPERTY() TArray<TWeakObjectPtr<AActor>> PreviewActors; };然后是PreEditUndo和PostEditUndo的实现:
void UBatchCollisionFixerTool::PreEditUndo() { Super::PreEditUndo(); // 在属性被回滚之前,先把场景里所有预览体标记为待销毁 // 注意:这里不能直接 Destroy,否则会引发新的事务记录 DestroyStalePreviews(); } void UBatchCollisionFixerTool::PostEditUndo() { Super::PostEditUndo(); // 属性恢复完成后,根据最新状态重新生成预览 for (TWeakObjectPtr<AActor> WeakActor : SelectedActors) { if (AActor* Actor = WeakActor.Get()) { RebuildPreviewForActor(Actor); } } }这里有个很关键的点:DestroyStalePreviews()不能直接用Actor->Destroy(),因为销毁 Actor 本身是一个编辑器操作,会被 Undo 系统记录。你需要先把预览物从当前事务上下文中剥离,最稳妥的做法是把预览物设置成“非事务追踪”状态,或者干脆用不参与事务的编辑器辅助子系统来管理。
另外,PostEditUndo里重新生成预览物时,一定不要用那种带有“增量状态”的代码路径。我的工具原来为了性能,只在碰撞属性变化时做局部更新,现在直接改成全量重建。预览物的数量一般不会很多,全量重建的消耗可以忽略,但逻辑正确性提升了一个量级。
3.4 补丁里的几个关键参数与处理顺序
这套补丁看似简单,真正落地时有几个顺序和参数问题值得单独拿出来说。
第一个是SelectedActors本身也得纳入事务感知。我原来用一个普通的TArray<AActor*>成员来存选中列表,Undo 之后这个指针数组很可能全军覆没。解决办法是把它声明为UPROPERTY()并用TArray<TWeakObjectPtr<AActor>>,这样在 Undo 恢复时,这个数组里的弱引用本身也是可恢复的,而且不会因为对象被 GC 而悬垂。
第二个是销毁预览物的时机必须放在PreEditUndo里。如果你放在PostEditUndo里销毁,那么你销毁动作会导致一次新的Modify,又给 Undo 栈塞进一条记录。放在PreEditUndo里,属性还没开始回滚,对象结构还没发生剧变,可以安全清理。
第三个是预览物生成以后要跟工具类绑定生命周期。我用PreviewActors数组管理所有预览物,每次重建前先遍历销毁旧的。如果不在数组里记录,你会在场景里发现大量“孤儿”预览体,时间久的项目里可能有几十个。
看下RebuildPreviewForActor的简化实现:
void UBatchCollisionFixerTool::RebuildPreviewForActor(AActor* Actor) { // 生成一个简单的线框盒表示碰撞范围 UStaticMeshComponent* PreviewMesh = NewObject<UStaticMeshComponent>(Actor); PreviewMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision); PreviewMesh->SetVisibility(true); PreviewMesh->SetHiddenInGame(true); UMaterialInterface* WireframeMaterial = LoadObject<UMaterialInterface>(nullptr, TEXT("/Engine/EditorMeshes/DebugMeshMaterial.DebugMeshMaterial")); PreviewMesh->SetOverlayMaterial(WireframeMaterial); FBoxSphereBounds Bounds = Actor->GetRootComponent()->Bounds; PreviewMesh->SetWorldScale3D(Bounds.BoxExtent); // 挂到 Actor 下面但不参与移动、不变换,只做显示 // 关键:把一个生命周期跟工具绑定的标记挂上去 }这段代码不是我项目里的完整版本,但把核心思路表达清楚了:预览物不再由工具持有的缓存数据驱动,而是由 Actor 当前属性实时推导。Undo 之后 Actor 属性变了,PostEditUndo里重新生成,预览物自然就是正确的。
3.5 验证补丁是否有效的完整清单
补丁交付前,我建议按下面这个清单回归一遍:
| 操作 | 预期结果 | 实际表现 |
|---|---|---|
| 选中一批 Actor,点应用 | 预览体出现,属性更新 | Open |
| Ctrl+Z 一次 | 属性恢复,预览体消失 | Pass |
| Ctrl+Z 一次,再 Ctrl+Y | 属性重新更新,预览体正确出现 | Pass |
| 连续 Ctrl+Z 多次 | 每次撤销都干净,无残留预览体 | Pass |
| 工具控件点击过程中快速 Ctrl+Z | 不崩溃,不产生多余事务记录 | Pass |
我这边第一次打完补丁,连续 Ctrl+Z 三次之后就发现预览体数量不对,排查了一个小时才发现是DestroyStalePreviews()里用了Actor->Destroy()导致事务污染。改成PreviewMesh->DetachFromComponent加延迟销毁之后,问题彻底消失。
4. 常见问题与排查技巧实录
打完补丁不代表所有症状都会消失,这里把我在处理 5.8 Undo 问题时最常碰到的几个坑和排查技巧记录下来,算是通用的“避坑指南”。
4.1 撤销时偶发崩溃,多半是 GC 和悬垂指针
场景表现:不是每次撤销都崩,而是“做很多次操作后撤销才崩”。这通常不是逻辑问题,而是指针悬垂。Undo 恢复时,旧对象可能被标记待回收,而你缓存里的指针没有感知。
我建议统一排查手法:把所有编辑器工具的成员缓存,只要是对象引用,一律改成TWeakObjectPtr,不要用裸指针。访问前先.Get()判空,这能挡掉 80% 的崩溃。另外在PostEditUndo里重建缓存之前,先强制调用CollectGarbage(RF_NoFlags, true)来复现问题,如果复现了,说明问题确凿是悬垂指针。
4.2 数组属性被 Undo 后 UI 不刷新
场景表现:你的工具修改了一个TArray<FName>之类的属性,Undo 后数据确实恢复,但编辑器面板上显示的项数还是旧的,或者 ListView 不更新。
这是 5.8 里很常见的“数据恢复但通知没有发出去”的情况。属性值本身被事务系统恢复了,但依赖这个数组做 UI 展示的委托没有触发。解决办法是在PostEditUndo里手动调用一次OnDataChanged.Broadcast()或者对应的FPropertyChangedEvent。
注意不要放在PostEditChangeProperty里死等,因为 Undo 流程不一定会触发它。正确做法是直接在PostEditUndo末尾主动刷新 UI。
4.3 SubObject 被 Undo 后连接丢失
场景表现:你的 Actor 动态创建了一个子对象并保存在属性里,Undo 之后属性指针变 None,蓝图组件引用也失效。
问题出在动态子对象没有正确的 Outer 或没有标记为事务感知。你需要保证子对象创建时NewObject<UMySubObject>(Actor, TEXT("SubName")),而不是NewObject<UMySubObject>(GetTransientPackage(), TEXT("SubName"))。前者会把子对象纳入 Actor 的包和事务管理,后者就是纯粹的临时对象,Undo 系统根不感知。
另外我强烈建议在创建动态子对象之后马上调用Actor->Modify(),并给子对象也手动调用一次Modify(),确保其初始状态被事务系统记录。这样前者后续对子对象的修改才能被完整回滚。
4.4 快速排查的几板斧
如果上面的具体场景还是没法定位,可以用几个土办法快速收敛:
一是在PostEditUndo函数开头加日志,打印对象名、对象标记、当前属性值。很多时候你以为是 Undo 有问题,其实是PostEditUndo压根没被调。
二是对比升级前后的 Undo 栈行为。在 5.4 和 5.8 里分别执行同一个操作,打印FTransaction的日志级别,看变化的记录内容差在哪里。
三是把工具里所有“非 UObject 状态”列一张清单。凡是没有UPROPERTY()标记、没有挂在 UObject 生命周期上的成员变量,全部列出来,一个个判断它是否需要在 Undo 后重建。这一步做完,绝大多数问题的范围就缩小了。
5. 补丁之外的建议:给编辑器工具做一个兼容层
这次修复让我意识到,5.8 的 Undo 改动并不是最后一次。如果项目在持续迭代、引擎版本会不断升级,我建议给编辑器工具层做一个轻量兼容层,把“事务处理”相关逻辑集中封装,避免每个工具都单独写一套补救逻辑。
这个兼容层可以是一个UEditorToolBase基类,基类里统一处理PreEditUndo、PostEditUndo、OnObjectTransacted订阅,并提供几个虚函数给子类按需重写:OnToolPreUndo()、OnToolPostUndo()、OnToolPostRedo()。
在此基础上,把所有工具内部的缓存都统一用TWeakObjectPtr和UPROPERTY()管理。缓存数据的快照与恢复交给基类完成,子类只关心“状态恢复后要刷新什么”。这样,下次引擎 Undo 再变,你只需要改基类一个文件,而不是满项目找工具。
我在项目里已经按这个思路做了重构,目前 12 个编辑器工具全部接入。这次 5.8 升级暴露出来的问题,大多在新基类里一次解决,新开发的工具也默认继承这套机制,少踩很多坑。
最后说一个很多人容易忽略的小技巧:凡是和 Undo 相关的代码,一定要在“启用时间回退”的编辑器模拟环境下测试。你在普通编辑器环境下跑过一百遍没问题,不代表 Undo 过程中没问题。打开World Partition、打开External Package,在这些更激进的对象加载模式下面跑一次完整撤销流程,比任何代码审查都更能暴露问题。我这次就是在一个World Partition测试关卡里复现了 5.8 的失败场景,才真正定位到根因。