编辑器模块卸载:解绑委托还不够,注册工厂也必须撤销
自动关联目录:编辑器模块卸载:解绑委托还不够,注册工厂也必须撤销
核心判断:编辑器模块把回调注册进更长寿命的全局 Registry 后,Registry 就持有了对模块实例或模块代码的调用能力。
ShutdownModule必须撤销每一项注册,匿名 Lambda 和静态函数指针同样不能例外。
一、业务冲突:工具要深度接入编辑器,又必须支持动态卸载
资源检查、批量修复和蓝图定制通常要接入菜单、命令、保存事件、资产编辑器打开事件与属性定制 Registry。注册越分散,工具能力越丰富;但编辑器模块可以热重载、禁用或在引擎退出前卸载,任何遗留回调都可能在代码段已经卸载后被调用。
二、实际实现:已经有所有权意识,但清理账本不完整
工程启动时创建命令列表,用 Raw Delegate 映射工具动作;注册菜单启动回调和包保存事件;资产编辑器打开时,还用捕获 this 的 Lambda 判断资产类型,并向另一个编辑器模块注册属性定制工厂。
关闭路径做对了不少事:包保存事件按对象 RemoveAll,菜单启动回调与 Owner 都显式注销,命令和样式也释放。这说明实现已经在按模块寿命清理全局副作用。
缺口集中在两处:资产编辑器事件使用 AddLambda 却没有保存返回句柄,关闭时无法定点移除;由该回调注册进去的定制工厂也没有对称注销。前者可能调用已经销毁的模块实例,后者即使不依赖实例,仍可能跳到已经卸载的模块代码。
三、为什么 AddLambda 比 AddUObject 更危险
AddUObject 至少能在 UObject 失效后跳过调用;模块类通常不是 UObject,捕获 this 的普通 Lambda 没有自动失效机制。RemoveAll(this) 也不能删除一个没有 UObject/Raw 绑定身份的匿名 Lambda。静态函数没有悬空实例,却仍属于动态模块的代码地址,模块卸载后同样不可调用。
所以安全条件不是“Lambda 没访问成员”,而是“长寿命 Registry 已失去调用本模块任何代码的能力”。
四、目标设计:注册即获得令牌,关闭按逆序回滚
class EditorFeatureModule final : public IModuleInterface {
FDelegateHandle assetOpenedHandle;
bool customizationRegistered = false;
void StartupModule() override {
RegisterCommands();
RegisterMenusWithOwner(this);
packageSavedHandle = PackageSaved.AddRaw(this, &Self::OnPackageSaved);
assetOpenedHandle = AssetEditors.OnOpened().AddRaw(this, &Self::OnAssetOpened);
}
void OnAssetOpened(UObject* asset) {
if (!ShouldCustomize(asset) || customizationRegistered) return;
BlueprintEditor.RegisterCustomization(PropertyType, MakeFactory());
customizationRegistered = true;
}
void ShutdownModule() override {
if (auto* editor = FindLoadedBlueprintEditor(); editor && customizationRegistered)
editor->UnregisterCustomization(PropertyType);
AssetEditors.OnOpened().Remove(assetOpenedHandle);
PackageSaved.Remove(packageSavedHandle);
UnregisterMenusAndCommands();
}
};更稳妥的做法是维护 RegistrationLedger:每次注册同时压入一个幂等撤销函数,Shutdown 逆序执行。这样菜单 Owner、Delegate Handle、Tab、Extender、Property Customization、Ticker 和 Console Command 都进入同一份资源账本,不依赖开发者记忆。
五、取舍与验证
保存句柄和撤销令牌会增加少量样板代码,但比编辑器随机崩溃、重复菜单和定制叠加便宜得多。对一次性进程模块也应保持对称清理,因为自动化测试、Live Coding 和插件重载会主动打破“进程只启动一次”的假设。
测试连续执行 Load -> 打开资产 -> Unload -> 再打开资产 -> Reload,并统计每个 Registry 的订阅数。关闭后触发保存、菜单重建和资产打开,不得进入旧回调;重载后每项注册必须恰好一份。核心不变量是:模块完成 Shutdown 后,外部 Registry 中不存在任何能调用该模块实例或代码的令牌。