Hugo Future Imperfect Slim

L-Lawliet's Blog

记录游戏开发的点点滴滴

UnityEvent引起的内存泄漏

UnityEvent引起的内存泄漏

Lawliet

1 分钟

Colourful

一个由UnityEvent缓存机制引起的内存泄漏问题

前言

笔者在开发项目时,发现在xLua Dispose时总是会有DelegateBridge没有移除的情况,而排查了一轮,发现就算是一个简单界面也会出现这种情况。 后来经过一轮排查,发现Button的点击事件触发了,就会出现无法移除DelegateBridgeBase,然后翻看了xLua的issue(xLua#139)发现很早就有这个问题,而且也有人提供了解决方案。

问题原因

首先这个问题归根到底是由UnityEvent的缓存机制导致:

Unity在设计UnityEvent时,为其加了缓存机制,也就是上一次调用过的Calls会缓存起来,然后在增加/删除callback时,对缓存设置脏标记。然后在下一次触发事件时,在有变动时才会重新生成Calls

//这里截取Unity 2020.3.x版本UnityEvent.CS <InvokableCallList>类的片段
//与Issue中的版本代码有点差异,但机制大致相同
public void RemoveListener(object targetObj, MethodInfo method)
{
    var toRemove = new List<BaseInvokableCall>();
    for (int index = 0; index < m_RuntimeCalls.Count; index++)
    {
        if (m_RuntimeCalls[index].Find(targetObj, method))
            toRemove.Add(m_RuntimeCalls[index]);
    }
    m_RuntimeCalls.RemoveAll(toRemove.Contains);
    m_NeedsUpdate = true;
}

...
public List<BaseInvokableCall> PrepareInvoke()
{
    if (m_NeedsUpdate)
    {
        m_ExecutingCalls.Clear();
        m_ExecutingCalls.AddRange(m_PersistentCalls);
        m_ExecutingCalls.AddRange(m_RuntimeCalls);
        m_NeedsUpdate = false;
    }

    return m_ExecutingCalls;
}

那么问题就来,由于在每次触发(Invoke)事件前,才会重新生成Calls,就算之前已经对callback进行了Remove了,只要没有调用,缓存还会保留已经移除的函数。

问题危害

正常流程

在xLua框架中,lua需要监听C#的事件,需要把function(lua)设置到LUA_REGISTRYINDEX,并且把引用给到C#,C#再生成Delegate,然后把Delegate和引用封装到DelegateBridge(C#)对象中。 这样,只要把此Delegate绑定到对应的事件中,当事件触发后,就会调用此Delegate,再由DelegateBridge根据引用获取并调用function(lua)

由于DelegateBridge只以弱引用的方式保存,所以当移除事件后,Delegate只与对应的DelegateBridge有引用关系,所以在下一轮GC即可销毁掉DelegateBridge,从而接触对应引用的function(lua)(将functionLUA_REGISTRYINDEX中释放掉)。

这个时候,lua gc就可以把function以及对应upvalue销毁(假设没有任何其他对象引用)。

泄漏情况

上述都是function(lua)Delegate绑定和释放的正常流程。而在UnityEvent内存泄漏的情况下,又会变得怎样呢?

这里我们加入比较常见的情景:

local item = {}
item.name = "name"
item.button = .... --获取Button对象

item.button.onClick:AddListener(function ()
    print(item.name)
end)

...--一顿操作

item.button.onClick:RemoveAllListeners()

根据前文,如果我们在移除前触发了点击事件,那么UnityEvent就会缓存了Delegate,从而保留了DelegateBridge。这个时候唯一办法就是等button释放掉,顺带把UnityEvent也释放,这样DelegateBridge才能给GC回收。

但是,我们button却给lua的table对象引用了,而这个table又是闭包函数的upvalue值,而最糟糕的是,这个闭包函数却给DelegateBridge引用了(通过LUA_REGISTRYINDEX)。所以,这个table以及button(userdata)都无法给GC回收。

那么现在就形成了一个死结,而这个死结只要有任何一处解开就可以完全解开了,但现在处处都无法解开。

解决方案

调用Invoke

根据xLua#139,可以在RemoveAllListener之后,手动调用一次Invoke,这样就可以清除掉Calls

但此方法有个问题,假设ButtonInspector界面上绑定了持久化事件,就会多触发一次事件,可能会有意想不到的bug出现。所以不建议使用

反射

根据xLua#139,其实可以通过反射去释放掉UnityEvent中的缓存。


private static MethodInfo prepareInvoke;

public static void ReleaseUnusedListeners(this UnityEventBase unityEventBase)
{
    if (prepareInvoke == null)
    {
        BindingFlags flag = BindingFlags.Instance | BindingFlags.NonPublic;
        Type type = unityEventBase.GetType();
        prepareInvoke = type.GetMethod("PrepareInvoke", flag);
    }

    prepareInvoke.Invoke(unityEventBase, null);
}

...
item.button.onClick:RemoveAllListeners()
item.button.onClick:ReleaseUnusedListeners()

笔者在issue基础上进行了优化。 此方案副作用小,只要不忘记调用,就可以释放掉对应事件。笔者也是采用这套方案。 这里要注意的是,如果是采用Generate的方式,需要增加ButtonClickedEvent等参数的接口,不然xlua会找不到方法。

升级版本

UnityEvent这个内存泄漏的问题,在Unity 2021.2.x版本就已经修复了(吐槽:这个bug在2017年前就已经存在了)。

Scripting: Fixed a memory leak happening when removing listeners from a UnityEvent that is never raised afterwards. (1303095) 所以可以通过升级版进行修复。

public void RemoveListener(object targetObj, MethodInfo method)
{
    var toRemove = new List<BaseInvokableCall>();
    for (int index = 0; index < m_RuntimeCalls.Count; index++)
    {
        if (m_RuntimeCalls[index].Find(targetObj, method))
            toRemove.Add(m_RuntimeCalls[index]);
    }
    m_RuntimeCalls.RemoveAll(toRemove.Contains);

    // removals are done synchronously to avoid leaks
    var newExecutingCalls = new List<BaseInvokableCall>(m_PersistentCalls.Count + m_RuntimeCalls.Count);
    newExecutingCalls.AddRange(m_PersistentCalls);
    newExecutingCalls.AddRange(m_RuntimeCalls);
    m_ExecutingCalls = newExecutingCalls;
    m_NeedsUpdate = false;
}

说些什么

评论

还沒有留言。

最新文章

Colourful

HLSL

分类

关于

记录游戏开发的点点滴滴