一、引子
RT,本篇博客记录的是马三的一次解决 LuaFunction has been disposed 的bug的全过程,事情还要从马三的自研框架 ColaFrameWork 说起。最近,马三在业余时间维护了一款基于Unity的客户端自研框架,起名叫 ColaFrameWork ,寓意是希望写代码能像喝小可乐一样享受和轻松。为了在Lua层可以监听到UI事件,马三制作了UGUIEventListener、UGUIDragEventListenner和UGUIMsgHandler等这样几个UI组件,其中 UGUIEventListener和UGUIDragEventListenner这种Listener组件实现了IPointerDownHandler、IPointerClickHandler和ISubmitHandler这样的UGUI IEventSystemHandler UI事件接口,并且实现了接口定义的方法,然后在 UGUIEventListener中暴露出来一些 onClick、onDrag、onSubmit这种委托字段出来。在UI实例化的时候,代码会把这些监听器的脚本动态地绑定到UI预制体上面,然后再将Lua层的onClick、onDrag等这些方法动态地与Listener暴露出来的委托字段进行绑定。这样,当我们触发了UI的事件的时候,就会执行Listener中预先实现了相关接口的方法,而我们又在这些方法中调用了我们的委托,接着在通过lua虚拟机触发Lua层的function,从而实现了Lua层对UI事件的监听,之后我们也就可以很方便地在Lua层进行业务逻辑的开发了。
大概地工作原理就先讲到这里,毕竟我们这篇博客主要是记录如何解决 LuaFunction has been disposed这个bug的,知道一些基本的东西就OK了,关于UGUIEventListener、UGUIDragEventListenner和UGUIMsgHandler等这样几个UI组件的一些细节和实现原理等相关内容,马三会在后续的博客中进行进一步的讲解。同时马三也有计划待 ColaFrameWork 框架大概成型和稳定以后,将整个框架按照流程与模块进行分篇地讲解与解读,形成一系列的博客供大家交流学习,好的稍微有点扯远了,我们言归正传,说说这个bug。
上面的组件在实际使用中会偶现 LuaFunction has been disposed 这个bug,它经常出现于我们在UnityEditor中停止运行游戏的时候,虽然看起来没有影响游戏的正常运行,但是毕竟这个error信息在控制台看着也很讨厌,而且为了我们框架的稳定性也应该及时地解决到这个bug。在经过进一步地测试以后,马三发现了在只点击UI上面的button组件之后,再执行关闭游戏并不会出现这个报错信息,而当在我们点击或者使用了InputFiled组件之后,再关闭游戏则会100%地重现出这个问题。知道了如何复现问题,就好办了,下一步我们着手分析一下这个问题是如何出现的,并且尝试干掉它。
上面说的UGUIEventListener组件的简化版代码如下:
代码语言:javascript复制 1 public class UGUIEventListener : MonoBehaviour,
2 IMoveHandler,
3 IPointerDownHandler, IPointerUpHandler,
4 IPointerEnterHandler, IPointerExitHandler,
5 ISelectHandler, IDeselectHandler, IPointerClickHandler,
6 ISubmitHandler, ICancelHandler
7 {
8 void Start()
9 {
10
11 }
12 public delegate void UIEventHandler(GameObject obj);
13 public UIEventHandler onClick;
14 public virtual void OnPointerClick(PointerEventData eventData)
15 {
16 if (CheckNeedHideEvent())
17 {
18 return;
19 }
20 if (null != onEvent)
21 {
22 this.onEvent("onClick");
23 }
24 if (this.onClick != null)
25 {
26 this.onClick(gameObject);
27 }
28 }
29 }
出现报错信息时的控制台截图:
二、分析异常出现的原因
一般来说在Unity中如果发现控制台报错的话,我们一般会双击控制台中的错误信息,它会自动地帮我们直接定位到发生错误的代码行数,首先就让我们来双击操作一下,观察下效果。双击以后,发现定位到了如下的这段代码:
代码语言:javascript复制 1 public virtual int BeginPCall()
2 {
3 if (luaState == null)
4 {
5 throw new LuaException("LuaFunction has been disposed");
6 }
7
8 stack.Push(new FuncData(oldTop, stackPos));
9 oldTop = luaState.BeginPCall(reference);
10 stackPos = -1;
11 argCount = 0;
12 return oldTop;
13 }
可以观察到error信息就是第5行的那个抛出异常操作触发的,通过观察上下文我们可以大概地知道是因为luaState这个Lua虚拟机被销毁了,但是程序由于某些未知的原因仍然调用了某个或者某些LuaFunction所引起的。让我们再观察一下上图中Unity控制台的堆栈情况:
代码语言:javascript复制LuaException: LuaFunction has been disposed
LuaInterface.LuaFunction:BeginPCall() (at Assets/3rd/ToLua/Core/LuaFunction.cs:73)
System_Action_string_Event:Call(String) (at Assets/Scripts/Generate/DelegateFactory.cs:1364)
UGUIEventListener:OnDeselect(BaseEventData) (at Assets/Scripts/UIBase/UIEventListeners/UGUIEventListener.cs:239)
UnityEngine.EventSystems.EventSystem:OnDisable()
可以看到这个调用是由UGUIEventListener.cs的239行的代码触发的,让我们继续看看UGUIEventListener.cs的239行代码做了什么操作:
在第239行我们尝试调用了 onEvent 这个委托,但是按道理我们在游戏退出的时候并没有操作UI,应该不会触发到这个方法才对啊。按照以前的基本套路,我们可以尝试着在这里下个断点观察一下调用堆栈,这样就能知道是什么触发这个方法的了并且还可以观察一下局部变量的值与状态。但是马三发现当游戏退出运行的时候,这个断点是并不生效的,根本断不住,因为当游戏停止运行的时候,我们所Attach得进程也就结束了,所以VS并不会在这个断点停住。但是操蛋的是,这个bug只有在游戏退出运行的时候才会出现,简直陷入了僵局,怎么办呢?别急我们继续看Unity控制台打印出来的调用堆栈的最后一行:UnityEngine.EventSystems.EventSystem:OnDisable(),由此我们可以得知是Unity底层的EventSystem:OnDisable()触发的这段代码。
看来不阅读分析一下UGUI的源代码是不行了,幸好Unity官方将大部分的UGUI代码进行了开源操作,我们可以很方便地阅读,以便深入地了解UGUI的运行机理,遇到问题时也可以更好地定位源头,UGUI源代码的传送门。首先我们定位到 EventSystem的OnDisable方法,因为最后的堆栈信息指向了这里:
代码语言:javascript复制 1 protected override void OnDisable()
2 {
3 if (m_CurrentInputModule != null)
4 {
5 m_CurrentInputModule.DeactivateModule();
6 m_CurrentInputModule = null;
7 }
8
9 m_EventSystems.Remove(this);
10
11 base.OnDisable();
12 }
在EventSystem的OnDisable方法中,调用了m_CurrentInputModule.DeactivateModule()这个方法,它是 BaseInputModule 这个基类中的一个虚方法,继承自它的子类负责了重写。我们所处的平台是PC平台,因此使用的是 StandaloneInputModule 这个子类,找到它的 DeactivateModule 方法,内容很简单就是两行,先调用了基类的方法,然后执行了ClearSelection这个方法:
继续观察一下 ClearSelection 这个方法的实现,发现最后关键代码主要是调用了eventSystem.SetSelectedGameObject(null, baseEventData)这个方法:
代码语言:javascript复制 1 protected void ClearSelection()
2 {
3 var baseEventData = GetBaseEventData();
4
5 foreach (var pointer in m_PointerData.Values)
6 {
7 // clear all selection
8 HandlePointerExitAndEnter(pointer, null);
9 }
10
11 m_PointerData.Clear();
12 eventSystem.SetSelectedGameObject(null, baseEventData);
13 }
继续分析 SetSelectedGameObject 这段代码:
终于看到点苗头了,问题就出现在125行这里,让我们再看看 ExecuteEvents.deselectHandler 这个委托到底是何方神圣?
在上面的 ExecuteEvents.deselectHandler 实现代码中,我们看到了熟悉的 OnDeselect ,我们的错误调用就是由这里直接发起的,本质上来讲它会在Unity MonoBehavior脚本的生命周期函数 OnDisable中触发。
看完了UGUI 的源码之后,让我们再来分析一下ToLua的源码,看看Lua虚拟机是在何时被销毁的,在ToLua框架中,LuaClient是一个非常重要的类,它掌管着Lua虚拟机的创建、启动和销毁,我们可以在这里找到我们想要的答案:
其中LuaClient的Destroy方法,就是负责销毁Lua虚拟机的函数,它的实现如下:
代码语言:javascript复制 1 public virtual void Destroy()
2 {
3 if (luaState != null)
4 {
5 #if UNITY_5_4_OR_NEWER
6 SceneManager.sceneLoaded -= OnSceneLoaded;
7 #endif
8 luaState.Call("OnApplicationQuit", false);
9 DetachProfiler();
10 LuaState state = luaState;
11 luaState = null;
12
13 if (levelLoaded != null)
14 {
15 levelLoaded.Dispose();
16 levelLoaded = null;
17 }
18
19 if (loop != null)
20 {
21 loop.Destroy();
22 loop = null;
23 }
24
25 state.Dispose();
26 Instance = null;
27 }
28 }
可以看到在这个方法中,ToLua对Lua虚拟机进行了Dispose释放的骚操作,然后将虚拟机引用重新置空,如果执行完这步以后,我们再通过 luaState.BeginPCall 去尝试调用一个LuaFunction的话就会出现上文中的 LuaFunction has been disposed 的异常了。我们继续往下看,观察一下这个销毁的方法是在游戏中的哪个生命周期被调用的:
可以看到分别是在重写过MonoBehavior的OnDestroy和OnApplicationQuit函数中调用的,这两个函数处在整个MonoBehavior脚本的哪个声明周期呢?是时候祭出我们珍藏已久的了Unity MonoBehavior脚本执行顺序和生命周期图了:
通过观察上图,我们知道了,首先会执行脚本中的 OnApplicationQuit 然后再执行 OnDisable 最后执行脚本的OnDestroy函数。经过这一系列还不算太复杂地分析与追踪,我们终于理清了如下的这么一个bug出现的机制和流程:
- 在游戏退出的时候,根据Unity脚本函数的生命周期,首先触发了 LuaClinet的 OnApplicationQuit 函数,Lua虚拟机在此处被销毁,引用被置空;
- 紧接着执行了脚本的OnDisable函数,触发了EventSystem 的 OnDisable() 函数;
- 该函数执行了 BaseInputModule 及其子类的 DeactivateModule() 方法;
- 在 StandaloneInputModule 这个子类对 DeactivateModule() 方法的实现中,调用了 ClearSelection() 方法;
- ClearSelection 方法中调用了 EventSystem 的 SetSelectedGameObject(),这个方法用于触发激活/非激活 GameObject的选中状态;
- SetSelectedGameObject中会执行我们UGUIEventListener的OnSelect和OnDeselect这两个函数;
- UGUIEventListener 中的 OnSelect 和 OnDeselect函数会尝试调用绑定过LuaFunction的委托;
- 通过 luaState.BeginPCall 去尝试调用一个LuaFunction的时候,发现 LuaState 已经被提前释放掉了,所以就会抛出 “LuaFunction has been disposed”的异常了
三、解决bug
在理清了bug出现的机制后,只要对症下药,就不难解决问题了。上文中分析出来最根本的原因其实就是调用时机的问题,UGUI的源码我们是最好不要去随便改的,能改得只有我们自己的工程代码。其实只要在执行 UGUIEventListener 的那些回调之前,将UGUIEventListener 中绑定LuaFunction的那些委托执行置空操作就可以了,通过再次观察Unity MonoBehavior脚本生命周期图,我们发现了 OnApplicationQuit 函数先于OnDisable 函数被调用并且在整个脚本的生命周期中只会被调用一次,那么置空操作放在这里再合适不过了:
代码语言:javascript复制 1 public virtual void OnApplicationQuit()
2 {
3 this.onClick = null;
4 this.onDown = null;
5 this.onUp = null;
6 this.onDownDetail = null;
7 this.onUpDetail = null; ;
8 this.onDrag = null;
9 this.onExit = null;
10 this.onDrop = null;
11 this.onSelect = null;
12 this.onDeSelect = null;
13 this.onMove = null;
14 this.onBeginDrag = null;
15 this.onEndDrag = null;
16 this.onEnter = null;
17 this.onSubmit = null;
18 this.onScroll = null;
19 this.onCancel = null;
20 this.onUpdateSelected = null;
21 this.onInitializePotentialDrag = null;
22 this.onEvent = null;
23 }
添加了上面的置空步骤以后,我们再次按照bug复现的流程进行多次测试,发现不会抛出 “LuaFunction has been disposed” 的异常了。
四、总结
在本篇博客中,大家跟着马三一起经历了出现bug、寻找复现bug的步骤、通过调试和分析源码定位问题出现的位置和原因、根据分析对症下药解决bug 的一整套流程,可以说在实际的Unity游戏开发工作中,大部分的bug修复流程都与上述类似。在遇到我们没有见过的疑难bug的时候,首先千万不要慌张,不妨抽根烟或者喝杯小可乐压压惊,之后再从断点调试和分析运行原理入手定能解决大多数的bug。
惊现Bug不要慌
断点调试来帮忙
理性分析看源码
写好程序奔小康
解决完了Bug,马三心里美滋滋,哼着自己瞎编的打油诗,又开始写起了下一个Bug...