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;
}
评论
还沒有留言。