UnityEvent引起的内存泄漏
UnityEvent引起的内存泄漏

一个由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)(将function从LUA_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。
但此方法有个问题,假设
Button在Inspector界面上绑定了持久化事件,就会多触发一次事件,可能会有意想不到的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;
}
