编辑器模块卸载:解绑委托还不够,注册工厂也必须撤销

自动关联目录:编辑器模块卸载:解绑委托还不够,注册工厂也必须撤销

核心判断:编辑器模块把回调注册进更长寿命的全局 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 中不存在任何能调用该模块实例或代码的令牌。