UE和unlua对于同一个对象的协同处理

UE × UnLua 内存协同机制:两套 GC 怎么共处?

零、问题来自哪里

UE 和 Lua 是两个完全独立的运行时,各自带着一套内存管理规则:

  • UE 这边UObject 由引擎的 GC 管。一个对象只要还能被某个 UPROPERTY()、根集(Root Set)或 FGCObject 引用到,就活着;一旦没有任何引用,下一轮 GarbageCollect 就会把它干掉。
  • Lua 这边 :所有 tableuserdatafunctionstring 由 Lua 的 GC 管。只要 Lua 端还能找到引用(通过栈、上值、注册表等),它就活着;找不到了,Lua 自己会回收。

矛盾就出现在两个世界共享同一个 UObject 的时候:

复制代码
            ┌──────────────┐                  ┌──────────────┐
            │  UE 世界      │                  │  Lua 世界     │
            │              │   绑定关系       │              │
            │APlayerCharacter◄────────────────►│  INSTANCE 表 │
            │   (UObject)   │                  │  (含 userdata)│
            └──────────────┘                  └──────────────┘
                  ↑                                    ↑
              UE GC 管这里                       Lua GC 管这里

如果两边各自为政、不通气,会出两种事故:

  1. Lua 端拿着一个野指针 :UE 已经把 UObject 删了,但 Lua 里那个 INSTANCE 表还在,它里面 .Object 字段指向的内存已经是无效的,再去用就崩。
  2. UE 端意外把对象回收掉了:明明 Lua 里还在拿着这个对象,但 UE 那边没人持有它的强引用,下一轮 GC 就给收走了,Lua 后续访问就崩。

UnLua 的内存管理本质上就是在解决这两件事。它的整体思路可以用一句话概括:

Lua 持有 UObject 时,默认走"弱引用 + UE 删除回调"的模式;只有特定情形(比如 Lua 创建 Delegate 代理)才升级为强引用挂到 UE 的 GC 根集上。

下面我们一层层把这个机制拆开看。


一、整体流程:一次 Lua 拿到 UObject 时发生的事

为了不让概念太空,我们先用一个具体的例子作引子。

假设 C++ 端创建了一个 APlayerCharacter,这个类绑定了 PlayerCharacter.lua 模块。在 Lua 里我们写:

lua 复制代码
function M:OnHurt(Damage)
    print(self:GetName(), self.Health)
end

self 是什么?它是 UnLua 在绑定时建的一个 Lua INSTANCE 表 ,表里有个字段 .Object 指向 APlayerCharacter 对应的 userdata,这个 userdata 内部包着一个 TWeakObjectPtr<UObject>

整条生命周期链路大致是这样的:

复制代码
① C++ 新建 APlayerCharacter
        ↓
② UnLua 监听器收到 NotifyUObjectCreated
        ↓
③ TryBind 检查类是否实现 IUnLuaInterface
        ↓
④ FObjectRegistry::Bind 建立 INSTANCE 表 + userdata
   userdata 内部 = TWeakObjectPtr<APlayerCharacter>
        ↓
⑤ 在 Lua 注册表的弱值表中保存 (Object 指针 → INSTANCE 表)
   同时 luaL_ref 拿到一个引用 ID 存进 ObjectRefs
        ↓
   ┌────────── 此后 Lua 和 UE 都能访问到 APlayerCharacter ─────────┐
   │                                                              │
   │  Lua 改字段、调函数 → 通过 userdata.Get() 拿到 UObject*        │
   │  UE 删除 UObject  → 通过 NotifyUObjectDeleted 通知 UnLua      │
   │                                                              │
   └──────────────────────────────────────────────────────────────┘
        ↓
⑥ UE 销毁 APlayerCharacter
        ↓
⑦ FLuaEnv::NotifyUObjectDeleted → ObjectRegistry::Unbind
        ↓
⑧ 释放 luaL_ref、把 userdata 里的 TWeakObjectPtr 主动 Reset 为空
        ↓
⑨ Lua 后续访问该 userdata 时 IsReleasedPtr 检查会拦截,报错而不是崩溃

接下来我们把这个流程里的几个关键部位放大讲。


二、Lua 端怎么"指向"一个 UObject?------ userdata + 弱引用指针

这一步是整套机制的物理基础。Lua 不能直接存一个 C++ 裸指针,它需要包装成 userdata。UnLua 的关键决策是:包装成 TWeakObjectPtr,而不是裸指针,也不是 TStrongObjectPtr

来看实际代码(LuaCore.cpp):

cpp 复制代码
void* NewUserdataWithWeakObjectPtr( lua_State* L, UObject* Object )
{
    auto Size = sizeof(TWeakObjectPtr<UObject>);
    void* Userdata = NewUserdataWithDesc( L, Size,
        ( BIT_VARIANT_TAG | BIT_UOBJECT_WEAK_PTR ), 0 );
    new(Userdata) TWeakObjectPtr<UObject>( Object );
    return Userdata;
}

每当 UE 这边要把一个 UObject 推到 Lua 栈上时,最终都会走到 PushObjectCore,里面调的就是上面这个 NewUserdataWithWeakObjectPtr

cpp 复制代码
void PushObjectCore(lua_State *L, UObjectBaseUtility *Object)
{
    // ... 取元表名 ...
    NewUserdataWithWeakObjectPtr(L, (UObject*)Object);  // 关键这一行
    TryToSetMetatable(L, TCHAR_TO_UTF8(*MetatableName));
}

为什么是弱引用?这有两层考虑:

  • 避免 Lua 反过来"绑架" UE :如果 userdata 里塞的是强引用(比如往 GC 根集里挂),那么只要 Lua 还引用这个 userdata,UObject 就永远死不了。这会让 UE 的对象生命周期变得非常难控制------明明在 C++ 里调了 Destroy,但因为 Lua 还在持有,对象就是不走 GC,行为上很反直觉。
  • 保留"已失效"的信号TWeakObjectPtrGet() 在对象被销毁后会返回 nullptr。这就给了 Lua 端一次"检查再用"的机会,而不是直接踩到野指针。

备注(合理推论):选择 TWeakObjectPtr 而不是裸指针的另一个好处是,UE 的 TWeakObjectPtr 自带"序列号校验",能识别指针被复用的情况------同一段内存可能先后是两个不同 UObject,弱指针能准确判断"我指的那个还在不在"。

每个 userdata 还带一个变体标签 (variant tag) ,里面有 BIT_UOBJECT_WEAK_PTRBIT_TWOLEVEL_PTRBIT_RELEASED_TAG 这些位(LuaCore.cppGetUserdataFast),后面"加固防御"那一节会用到。


三、绑定时的"对象映射表"------ 让 Lua 端的"替身"唯一

光把 userdata 推到栈上还不够。如果同一个 UObject 每次访问都新建一个 INSTANCE 表,那不仅浪费内存,更要命的是:你在 Lua 里给 self.SomeFlag = true 之后,下次拿到的 self 就丢了这个 flag------明显不合理。

UnLua 的做法是在 Lua 注册表里维护两张表,由 FObjectRegistry 负责(ObjectRegistry.cpp):

  • UnLua_ObjectMap:键是 UObject 指针(lightuserdata),值是对应的 INSTANCE 表。这张表是弱值表
  • ObjectRefs(C++ 端的 TMap<UObject*, int32>):记录每个 UObject 在 Lua 注册表里通过 luaL_ref 拿到的引用 ID。

构造函数里就把这两张弱值表建好了:

cpp 复制代码
FObjectRegistry::FObjectRegistry(FLuaEnv* Env) : Env(Env)
{
    const auto L = Env->GetMainState();
    lua_pushstring(L, REGISTRY_KEY);                // "UnLua_ObjectMap"
    LowLevel::CreateWeakValueTable(L);              // 弱值表
    lua_rawset(L, LUA_REGISTRYINDEX);
    // ... 同样的方式建 ManualRefProxyMap ...
}

3.1 弱值表是关键设计点

为什么要用弱值表?这里要分清两个层次:

  • 弱值表是 Lua 层面的概念:表里的"值"是弱引用,如果这个值(INSTANCE 表)没有别处引用,Lua GC 就会把它收走,同时连带把这个 entry 从弱值表里抹掉。
  • 这正好对应"如果 Lua 业务代码已经不再持有这个 UObject 的 INSTANCE 表,那 UnLua 也没必要替它留着"。

但有个例外:Bind 出来的 INSTANCE 表,UnLua 用 luaL_ref 在注册表里固定了一份强引用 (存到 ObjectRefs 里)。这是为了让 C++ 端反过来调 Lua 时(比如蓝图事件覆写、Delegate 回调),随时能根据 UObject 找到对应的 Lua 实例。否则 Lua GC 一收,C++ 调过来就找不到 self 了。

cpp 复制代码
int FObjectRegistry::Bind(UObject* Object)
{
    // ... 构建 INSTANCE 表,串好三层元表链 ...
    lua_pushvalue(L, -1);
    const auto Ret = luaL_ref(L, LUA_REGISTRYINDEX);  // 强引用,防止 Lua GC
    ObjectRefs.Add(Object, Ret);
    // ...
}

这里要留意一个不对称:Bind 上来的对象,Lua 端这一份是强引用;但 C++ 端这边并没有对 UObject 加 GC 根。也就是说,如果 UE 这边没有别人引用这个 UObject,UE GC 照样会回收它------Lua 只是"挂着一份替身",并不能阻止 C++ 端销毁它。

3.2 没 Bind 但被 Push 上来的 UObject

不是所有进 Lua 的 UObject 都会触发 Bind(比如它没实现 IUnLuaInterface)。这种情况走 Push

cpp 复制代码
void FObjectRegistry::Push(lua_State* L, UObject* Object)
{
    // 在 ObjectMap 里找
    const auto Type = lua_rawget(L, -2);
    if (Type == LUA_TNIL)
    {
        // 没找到,新建 userdata
        PushObjectCore(L, Object);
        // 写入 ObjectMap (弱值)
        ObjectRefs.Add(Object, LUA_NOREF);   // 注意:是 LUA_NOREF
    }
    // ...
}

注意 LUA_NOREF------意味着 UnLua 不在注册表里强引用它 。这种 userdata 完全交由 Lua GC 管:业务代码不再用它,Lua 自然就把它清了,弱值表里那一项也跟着没了。ObjectRefs 里这条记录虽然还在,但等到 UE 删除 UObject 时会被一起清理(见下一节)。


四、UE 销毁 UObject 时:怎么避免 Lua 端的野指针

这是整套机制最容易出事的地方。UnLua 的应对方式是主动监听 + 主动失效化

4.1 监听 UE 的删除事件

FLuaEnv 自己继承了 FUObjectArray::FUObjectDeleteListener

cpp 复制代码
class UNLUA_API FLuaEnv
    : public FUObjectArray::FUObjectDeleteListener
{
    virtual void NotifyUObjectDeleted(const UObjectBase* ObjectBase, int32 Index) override;
    // ...
};

UE 的对象数组在销毁任一 UObject 时都会广播 NotifyUObjectDeleted。UnLua 收到通知后,做了一连串清理:

cpp 复制代码
void FLuaEnv::NotifyUObjectDeleted(const UObjectBase* ObjectBase, int32 Index)
{
    UObject* Object = (UObject*)ObjectBase;
    PropertyRegistry->NotifyUObjectDeleted(Object);
    FunctionRegistry->NotifyUObjectDeleted(Object);
    if (Manager) Manager->NotifyUObjectDeleted(Object);
    ObjectRegistry->NotifyUObjectDeleted(Object);
    ClassRegistry->NotifyUObjectDeleted(Object);
    EnumRegistry->NotifyUObjectDeleted(Object);
    // ...
}

我们重点看 ObjectRegistry::NotifyUObjectDeleted,它实际就是调 Unbind

cpp 复制代码
void FObjectRegistry::Unbind(UObject* Object)
{
    int32 Ref;
    if (!ObjectRefs.RemoveAndCopyValue(Object, Ref)) return;

    // 1) 把 ObjectMap 里这个对象对应的项摘掉,并把 INSTANCE 表压栈
    RemoveFromObjectMapAndPushToStack(Object);

    if (Ref == LUA_NOREF)
    {
        // 走 Push 路径上来的,没有 INSTANCE 表,只有 userdata
        // 直接把 TWeakObjectPtr 主动 Reset 掉
        ((TWeakObjectPtr<UObject>*)Userdata)->Reset();
        return;
    }

    // 2) 释放注册表里的强引用 ref
    luaL_unref(L, LUA_REGISTRYINDEX, Ref);

    // 3) 把 INSTANCE.Object 指向的 userdata 里的弱指针也 Reset
    lua_pushstring(L, "Object");
    lua_rawget(L, -2);
    void* Userdata = GetUserdataFast(L, -1, nullptr, &bWeakObject);
    ((TWeakObjectPtr<UObject>*)Userdata)->Reset();
}

这里有一个看起来有点多此一举但其实非常关键的动作:主动 Reset userdata 里的 TWeakObjectPtrTWeakObjectPtr 不是已经能自动识别失效了吗?为什么还要手动 reset?

这是合理推论:手动 reset 是一道确定性的失效化操作 ,它让 Get() 立刻返回 nullptr,而不依赖于 UE 内部弱指针的序列号机制(那个机制在跨帧、跨 GC 阶段的边角情况下行为不那么直观)。它给了 UnLua 一个简单的判定标准:IsReleasedPtr(ptr) == (ptr == nullptr)

4.2 Lua 端访问已失效对象时的兜底

光 reset 还不够------Lua 业务代码不会主动去检查"我手上的 self 还活着吗"。所以 UnLua 在所有从 Lua 读 / 写属性、调函数的入口都做了 IsReleasedPtr 检查。比如读属性时(UnLuaLib.cpp):

cpp 复制代码
if (UnLua::LowLevel::IsReleasedPtr(Self))
{
    lua_pushnil(L);
    return luaL_error(L, ...
        TEXT("attempt to get ue property '%s' on released object"), ...);
}

效果是:原本会崩溃的野指针访问,现在变成一条Lua 错误。Lua 错误是可恢复的(pcall 能接住),不会带崩整个进程。这是用很小的代价换很大的稳定性。

4.3 容器里的"过期 UObject"

还有一种情况比较隐蔽:Lua 里持有一个 TArray<UObject*>,数组里某些元素被 UE 销毁了,但数组本身没有清理。Lua 拿到这个数组遍历时会怎样?

FObjectRegistry::Push 在最前面就有这道闸:

cpp 复制代码
if (!UnLua::IsUObjectValid(Object))
{
    luaL_error(L, "attempt to read invalid uobject ptr from lua, "
                  "maybe from containers like TArray.");
    return;
}

这条注释直接点明了它防的就是容器里的过期指针。这种情况下 Object 不是 nullptr(指针还在数组里躺着),但它指向的 UObject 已经被销毁了------IsUObjectValid 通过 UE 的对象数组校验拦下来。


五、Lua 销毁了它持有的 UObject userdata:UE 这边怎么知道?

刚才讲的是 UE → Lua 方向的同步。反过来呢?如果 Lua 这边把一个 userdata 给 GC 了(比如某个临时变量出作用域),UE 端要不要知道?要知道的话,怎么知道?

UnLua 的做法是给每个 userdata 挂一个 __gc 元方法。当 Lua GC 回收这个 userdata 时,会自动调它,从而把信号传回 C++ 端。

5.1 元表上的 __gc

ClassRegistry 在为每个 UClass 创建元表时会注册:

cpp 复制代码
lua_pushstring(L, "__gc");
lua_pushcfunction(L, UObject_Delete);
lua_rawset(L, -3);

UObject_Delete 的实现(LuaLib_Object.cpp):

cpp 复制代码
int32 UObject_Delete(lua_State* L)
{
    bool bTwoLvlPtr = false;
    bool bClassMetatable = false;
    bool bWeakObject = false;
    void* Userdata = GetUserdata(L, 1, &bTwoLvlPtr, &bClassMetatable, &bWeakObject);
    if (!Userdata)            return 0;
    if (bClassMetatable)      return 0;   // 元表本身被 gc,不是实例
    if (!bWeakObject)         return 0;

    UObject* Object = ((TWeakObjectPtr<UObject>*)Userdata)->Get();
    if (UnLua::LowLevel::IsReleasedPtr(Object)) return 0;

    UnLua::FLuaEnv::FindEnvChecked(L)
        .GetObjectRegistry()->NotifyUObjectLuaGC(Object);
    return 0;
}

注意它只通知一下,没有真的销毁 UObject。理由很简单:UObject 的生命周期归 UE 管,Lua 这边的 userdata 被回收只代表"Lua 不再用了",不能据此推断 UObject 也该死。

5.2 NotifyUObjectLuaGC 实际做了什么?

cpp 复制代码
void FObjectRegistry::NotifyUObjectLuaGC(UObject* Object)
{
    Env->AutoObjectReference.Remove(Object);
}

它从 AutoObjectReference 里把这个 UObject 移除------也就是取消对它的强引用。这就引出下一个话题:那这个强引用一开始是谁加的?


六、AutoObjectReferenceManualObjectReference:把对象挂上 UE GC 根

前面一直在说"Lua 默认对 UObject 是弱引用"。但有些场景必须升级为强引用,否则 UE GC 会先一步把对象收走。UnLua 用两个 FObjectReferencer 来管理这种情况(ObjectReferencer.h):

cpp 复制代码
class FObjectReferencer : public FGCObject
{
public:
    void Add(UObject* Object)    { ReferencedObjects.Add(Object); }
    void Remove(UObject* Object) { ReferencedObjects.Remove(Object); }

    virtual void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObjects(ReferencedObjects);
    }
private:
    TSet<UObject*> ReferencedObjects;
};

它继承自 FGCObject------这意味着 UE 的 GC 在 mark 阶段会调 AddReferencedObjects,把这个集合里所有对象当成"被引用",从而不会被回收。

FLuaEnv 持有两个实例:

cpp 复制代码
FObjectReferencer AutoObjectReference;     // "UnLua_AutoReference"
FObjectReferencer ManualObjectReference;   // "UnLua_ManualReference"

6.1 AutoObjectReference ------ 主要给 Delegate Handler 用

在 UnLua 这份代码里,AutoObjectReference.Add 的调用点全部位于 DelegateRegistry.cpp

cpp 复制代码
const auto Handler = CreateHandler(Ref, Info.Owner.Get(), SelfObject);
Handler->BindTo(Delegate);
Env->AutoObjectReference.Add(Handler);

这里要做一个事实层面的纠正(代码事实):在某些版本的 UnLua 文档里会说"Lua 里 NewObject 的对象会被 AutoObjectReference 自动接管",但就这份 UnLua 代码而言,Global_NewObject 里只是调了 PushUObject 推到 Lua 栈,并没有调用 AutoObjectReference.Add 。也就是说,Lua 里 UE.NewObject(...) 出来的 UObject,如果 C++ 那边没人把它接住(比如赋给某个 UPROPERTY()),它会被 UE GC 当作无主对象回收掉。这是使用时容易踩坑的地方。

回到 Delegate 这条主线。为什么 Delegate Handler 必须挂 GC 根?因为 ULuaDelegateHandler 是 UnLua 用 NewObject 临时建的代理对象,UE 端没有任何 UPROPERTY() 持有它。如果不主动加根,下一次 GC 就会把它收掉,结果挂在原 Delegate 上的回调就成了无效引用。

挂根之后什么时候摘?两条路径:

  1. Lua 端的 __gc :当 Lua 里持有这个 Handler 引用的 userdata 被 GC 时,UObject_DeleteNotifyUObjectLuaGCAutoObjectReference.Remove(Object)

  2. DelegateRegistry 自己的清理 :在 PostGarbageCollect 回调里检查 Owner 是否已失效,是的话主动 Remove:

    cpp 复制代码
    if (Pair.Key.SelfObject.IsStale())
    {
        ToRemove.Add(Pair.Key);
        Env->AutoObjectReference.Remove(Pair.Value.Get());
    }

6.2 ManualObjectReference ------ Lua 主动声明"我要握住这个对象"

这是给业务方留的一个出口:当 Lua 里就是想确保某个 UObject 不被 UE 回收,比如它要异步用一段时间、又没有合适的 C++ 持有者时,可以主动加一个手动引用。

这条路径由 FObjectRegistry::AddManualRef 实现:

cpp 复制代码
void FObjectRegistry::AddManualRef(lua_State* L, UObject* Object)
{
    // 检查 ManualRefProxyMap 里是不是已经有了
    if (lua_rawget(L, -2) == LUA_TNIL)
    {
        Env->AddManualObjectReference(Object);   // 加到 GC 根

        auto Proxy = new(...) FManualRefProxy;
        Proxy->Object = Object;                  // 内部是 TWeakObjectPtr
        luaL_getmetatable(L, "UnLuaManualRefProxy");
        lua_setmetatable(L, -2);
        // 写入 ManualRefProxyMap
    }
}

它返回一个 FManualRefProxy 给 Lua。业务持有这个 proxy 期间,UObject 就活着 ;一旦 Lua 把 proxy 丢了,proxy 被 GC,会触发 ReleaseManualRef

cpp 复制代码
static int ReleaseManualRef(lua_State* L)
{
    auto Proxy = (FManualRefProxy*)lua_touserdata(L, 1);
    auto Object = Proxy->Object.Get();
    // 检查 ManualRefProxyMap 里这个 entry 是否已经空
    if (lua_rawget(L, -2) == LUA_TNIL)
        Env.RemoveManualObjectReference(Object);
    return 0;
}

这套设计很优雅的地方在于:Lua 业务方不用记得"什么时候释放强引用",只要不再持有 proxy,Lua GC 自然会触发 __gc,强引用就被自动撤掉

6.3 业务层的入口:UnLua.Ref / UnLua.Unref

上一节讲的 AddManualRef / RemoveManualRef 是 C++ 端的 API。Lua 业务方不会直接调它们------UnLua 在 UnLuaLib.cpp 里把它包了一层,注册成两个 Lua 全局函数:UnLua.RefUnLua.Unref,这是业务真正用到的入口。

6.3.1 源码上长什么样

实现极其简单,就是把调用透传到 FObjectRegistry

cpp 复制代码
static int Ref(lua_State* L)
{
    const auto Object = GetUObject(L, -1);
    if (!Object)
        return luaL_error(L, "invalid UObject");

    const auto& Env = FLuaEnv::FindEnvChecked(L);
    Env.GetObjectRegistry()->AddManualRef(L, Object);
    return 1;   // 返回 1 个值:FManualRefProxy userdata
}

static int Unref(lua_State* L)
{
    const auto Object = GetUObject(L, -1);
    if (!Object)
        return luaL_error(L, "invalid UObject");

    const auto& Env = FLuaEnv::FindEnvChecked(L);
    Env.GetObjectRegistry()->RemoveManualRef(Object);
    return 0;
}

对应的 Lua 签名(来自 Content/IntelliSense/UnLua.lua):

lua 复制代码
---Add or find a manual reference to specified UObject. This prevents the object from UE GC.
---@param Object UObject
---@return userdata @Proxy object of the reference.
---    The reference of UObject will be removed when proxy object deleted by lua GC.
function UnLua.Ref(Object) end

---Force remove all manual reference to specified UObject.
---@param Object UObject
function UnLua.Unref(Object) end
6.3.2 用法语义

可以这样理解:

  • UnLua.Ref(obj) ------ "把这个对象挂到 UE GC 根上,并给我一个 proxy 拿着"。只要 proxy 没死,对象就不会被 UE GC 回收。
  • UnLua.Unref(obj) ------ "立即、强制把这个手动引用摘掉",不管 Lua 端 proxy 还在不在。

两者一个柔和一个刚性。多数业务场景用 Ref + 让 proxy 自然死亡的 RAII 模式就够了;只有特殊情况(比如想立刻让对象可以被回收,不等 Lua GC)才显式调 Unref

6.3.3 典型业务场景

场景一:异步资源加载完成后立即保活

加载完成回调里拿到的 UObject,C++ 端往往没有强引用------如果不立即 Ref 就可能被下一轮 GC 收掉:

lua 复制代码
function ResObject:OnSuccess(Request, Object)
    self.LoadedAsset    = Object
    self.LoadedAssetRef = UnLua.Ref(Object)   -- 立即保活
end

场景二:UE.NewObject 创建的对象需要保活

前面提到过,UE.NewObject 出来的对象 UnLua 不会自动加 GC 根,业务必须自己接住:

lua 复制代码
local Obj = UE.NewObject(SomeClass, Owner)
self.ObjRef = UnLua.Ref(Obj)   -- 不加这一行的话,下次 GC 时 Obj 可能就没了

场景三:跨帧/跨异步流程持有 UObject

lua 复制代码
-- 拿到一个对象,但要在几秒后才真正用
local target = GetSomeUObject()
self.targetRef = UnLua.Ref(target)

DelayManager:DelaySeconds(5, function()
    if UE.UObject.IsValid(self.targetRef) then
        -- ... 安全使用 target ...
    end
    self.targetRef = nil   -- 丢掉 proxy,Lua GC 之后会自动 Unref
end)

注意一个细节:就算调了 UnLua.Ref,使用前依然要 IsValid 检查Ref 只能防住"UE GC 主动收回"这条路径,对于"对象被显式 Destroy"、"关卡切换强制销毁"这些场景挡不住------它们会绕过 GC 直接让对象失效。

场景四:显式释放(用 Unref 而不是依赖 proxy GC)

lua 复制代码
function SomeView:OnDestruct()
    if self.PhotoTextureRef and UE.UObject.IsValid(self.PhotoTextureRef) then
        UnLua.Unref(self.PhotoTextureRef)   -- 立刻摘掉,不等 Lua GC
    end
    self.PhotoTextureRef = nil
end

UI 控件销毁时常用这种模式:业务上很明确"现在就要让 UE 可以回收它",不依赖 Lua GC 的时机。

6.3.4 易混点:proxy 是什么、能拿来干什么

UnLua.Ref(obj) 返回的不是 obj 本身,而是一个 FManualRefProxy userdata。这一点在用法上有几个隐含约束:

  • proxy 不能当 UObject 使用------它只是个"持有凭证",调不了 UObject 的方法。如果要用对象,得继续用原来那个 obj 变量。
  • 多次调 UnLua.Ref(同一个 obj) 不会重复加根AddManualRef 内部先在 ManualRefProxyMap 里查,已存在的话直接返回原 proxy。
  • proxy 的生死直接决定保活的生死 :把 proxy 字段置 nil 是最常见的"主动释放"写法,Lua GC 触发 proxy 的 __gc 时会自动调 ReleaseManualRef

6.4 两个 Referencer 的对比

维度 AutoObjectReference ManualObjectReference
谁来加 UnLua 内部(主要是 Delegate) 业务代码显式调用 UnLua.Ref / AddManualRef
谁来减 Lua 的 __gc + DelegateRegistry 周期性清理 proxy 被 Lua GC 时自动减,或显式 UnLua.Unref
用途 临时代理对象的保活 业务级别的"我要这个对象别死"
业务感知 透明 需要持有返回的 proxy

七、加固防御:当多套机制对不齐时

理想状态下前面几节已经能覆盖大部分情况,但实际工程里总会有边角问题。UnLua 还加了一些额外防御:

7.1 Released 标记位

GetUserdataFast 在拆 userdata 时会检查 BIT_RELEASED_TAG

cpp 复制代码
if (UserdataDesc->tag & BIT_RELEASED_TAG)
    Userdata = nullptr;

这个 tag 在 LuaDanglingCheck 里被设置------这是一套额外加的悬空指针防护,在某些场景把 userdata 主动标记为已释放,让所有读取都退化成 nullptr。

这是经验推测:LuaDanglingCheck 的命名和实现表明它是在标准 UnLua 之上的扩展机制,目的是抢在 __gc 之前就让 userdata "失效",避免某些异步路径下 Lua 还能拿到旧引用。

7.2 Lua 环境销毁时的清理

FLuaEnv::~FLuaEnv 会调:

cpp 复制代码
AutoObjectReference.Clear();
ManualObjectReference.Clear();

这两步会把所有挂在 GC 根上的 UObject 全部释放,让它们在下一轮 UE GC 时被正常回收。同时 lua_close(L) 会触发所有 userdata 的 __gc,但因为 Env 已经在销毁中,那些 NotifyUObjectLuaGC 的副作用其实也无所谓了。


八、一张图把整套机制串起来

复制代码
                    ┌────────────────────────────────────┐
                    │            FLuaEnv                 │
                    │                                    │
                    │  ┌──────────────────────────────┐  │
                    │  │   AutoObjectReference        │  │  → 挂 UE GC 根
                    │  │   (主要给 Delegate Handler)   │  │     (FGCObject)
                    │  └──────────────────────────────┘  │
                    │  ┌──────────────────────────────┐  │
                    │  │   ManualObjectReference      │  │  → 挂 UE GC 根
                    │  │   (业务主动 AddManualRef)     │  │     (FGCObject)
                    │  └──────────────────────────────┘  │
                    │  ┌──────────────────────────────┐  │
                    │  │   FObjectRegistry            │  │
                    │  │   ObjectRefs (UObject* → ref)│  │
                    │  │   UnLua_ObjectMap (弱值表)    │  │
                    │  └──────────────────────────────┘  │
                    └────────────────────────────────────┘
                                    │
                    ┌───────────────┼───────────────┐
                    ▼               ▼               ▼
               UE 销毁回调      Lua __gc        业务主动调 AddManualRef
       NotifyUObjectDeleted   UObject_Delete       │
                    │               │              │
                    ▼               ▼              ▼
              主动 Reset      AutoRef.Remove    挂 / 摘 ManualRef
              userdata 弱指针
                    │
                    ▼
            后续 IsReleasedPtr
            兜底拦截

九、总结:怎么管理两个世界共享的对象?

回到最初的问题:UE 和 Lua 各有各的 GC,怎么协同?UnLua 给出的答案可以拆成五条原则:

  1. 默认弱引用 :Lua 持有 UObject 时用 TWeakObjectPtr userdata,不强行延寿。这避免了"Lua 反向绑架 UE GC"。
  2. 删除事件强同步 :注册 UE 的 FUObjectDeleteListener,UE 一旦销毁对象,立刻 Reset Lua 端的弱指针并清理映射表。Lua 不会拿到野指针。
  3. 入口处兜底 :所有从 Lua 访问 UObject 属性 / 函数的入口都加 IsReleasedPtr 检查,把潜在崩溃降级成 Lua 错误。
  4. 必要时升级强引用 :通过 AutoObjectReference(UnLua 内部用)和 ManualObjectReference(业务可用)两个 FGCObject,在确实需要保活时把对象挂到 UE GC 根集。
  5. Lua GC 信号反向同步 :每个 userdata 挂 __gc 元方法,Lua GC 触发时通知 C++ 端撤销保活引用。配合 proxy + 弱值表,让"业务释放引用 → 强引用自动撤销"这条链路全自动。

本质上 UnLua 选择了一种**"UE 主导生命周期 + Lua 端做投影"**的设计:UObject 还是由 UE 管,Lua 只在自己这边维护一份会自动失效的"映像",并通过显式机制(ManualRef)允许业务在必要时反向影响 UE 的生命周期。这种不对称的设计虽然牺牲了一些"对称的优雅",但避免了两套 GC 互相牵制可能产生的引用环和不可预测行为,是一个相当务实的工程取舍。

对应到日常开发,有几条经验性的注意事项:

  • Lua 里 UE.NewObject 出来的对象 ------就这份 UnLua 代码而言,UnLua 不会自动给它加根(这一点在不同版本的 UnLua 之间有差异),最好立即把它赋给某个 UPROPERTY() 字段、或者用 UnLua.Ref 显式保活,否则下一轮 UE GC 就没了。
  • Lua 里订阅 Delegate ------不用担心 Handler 被回收,UnLua 自己会用 AutoObjectReference 兜住。
  • 跨帧异步使用 UObject ------必要时显式 UnLua.Ref(obj) 把 proxy 存起来,用完释放(最简单:proxy 字段置 nil,让 Lua GC 自然撤引用;或显式 UnLua.Unref(obj))。
  • 看到 attempt to xxx on released object 这类报错------别慌,这是 UnLua 的兜底机制在工作,意味着你拿了一个已经被 UE 销毁的 UObject,去顺着调用栈查谁持有了过期引用即可。
相关推荐
归真仙人1 天前
【UE】Lightmass可执行文件已经过时
ue5·游戏引擎·ue4·虚幻·unreal engine
DoomGT2 天前
Design - 一些免费图标网站
ue5·ue4·虚幻·虚幻引擎·unreal engine
晴夏。2 天前
UE5 GASP技术浅析
ue5·gasp
邪修king2 天前
UE5 TA 核心修炼:材质与纹理艺术全解 —— 从 PBR 理论到工业级材质实战
c++·后端·游戏·ue5·材质
SCLchuck3 天前
UE5 地形材质UV
ue5·材质·uv
UTwelve4 天前
【UE】材质与半透明 - 01. 基于Masked遮罩的抖动半透明 DitherMask
ue5·材质·虚幻引擎·着色器
晴夏。4 天前
UE5 motion warping 运动扭曲的用途
运维·ue5
蓝图大法4 天前
ue5 血条 渲染方形的分辨率 血条缩放的问题
ue5