一、引子
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組件的簡化版代碼如下:
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中如果發現控制台報錯的話,我們一般會雙擊控制台中的錯誤信息,它會自動地幫我們直接定位到發生錯誤的代碼行數,首先就讓我們來雙擊操作一下,觀察下效果。雙擊以后,發現定位到了如下的這段代碼:
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控制台的堆棧情況:
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方法,因為最后的堆棧信息指向了這里:
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)這個方法:
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虛擬機的函數,它的實現如下:
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 函數被調用並且在整個腳本的生命周期中只會被調用一次,那么置空操作放在這里再合適不過了:
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...
如果覺得本篇博客對您有幫助,可以掃碼小小地鼓勵下馬三,馬三會寫出更多的好文章,支持微信和支付寶喲!

作者:馬三小伙兒
出處:https://www.cnblogs.com/msxh/p/10333558.html
請尊重別人的勞動成果,讓分享成為一種美德,歡迎轉載。另外,文章在表述和代碼方面如有不妥之處,歡迎批評指正。留下你的腳印,歡迎評論!
