介紹一種基於Mono的Unity熱更新方案


《介紹一種基於Mono的Unity熱更新方案》

熱更新是Unity3D開發總也繞不過去的話題,甚至影響到了開發語言,程序架構、人員配置,不可謂不重要。文章開頭先從一些大家都熟知的東西帶入。熱更新目前有很多成熟的方案,筆者很早前因為工作需要了解了一些信息,大體分幾個流派

  • Lua流派/CSharp轉Lua流派
  • CSharp流派
  • JS/TS流派

各個流派均有成熟的框架,優劣勢在此不再展開,選擇時往往是結合自己團隊的情況來取舍。從方向上看,筆者更看好Lua流派,Lua天生就作為腳本語言設計,集成到游戲引擎中作為邏輯腳本似乎是一件很合理的事情。筆者對Lua不是很熟悉,也曾因此在工作面試中被鄙視,從個人喜好上,還是喜歡CSharp這門語言多一點,當然這個喜好也是建立在特定環境下的,語言層面的優劣在此也不再展開。在聊新的方案前,先從頭聊一些熱更新方面的知識做引子。

熱更新的重災區是在iOS系統,因為一些眾所周知的原因,Unity最初的Mono運行時在iOS平台下只能以full AOT模式運行,這樣就無法實現熱更新了。這里也引出了運行模式的概念,大家熟知的有:

  • JIT運行

    Mono,V8等引擎默認運行模式,這種模式是可以動態Load代碼的,也就是可以更新代碼邏輯。但是在iOS系統上是被禁止的。

  • AOT運行

    提前編譯成本機代碼,運行效率可以比肩原生代碼,Unity的Mono引擎在iOS系統上即以此模式運行,但是不能更新代碼。

  • Interpreter運行

    即解釋器執行,顧名思義,腳本語言以此方式運行,並沒有生成本地機器碼,目前各個熱更流派均是以此方式實現熱更新,代價是效率低一些。

Lua天生是以解釋器運行的,具有體積小,集成靈活等特性作為大家的首選腳本,也發展出了jit模式來解決其他平台上的性能問題,有xLua,toLua等成熟框架。

CSharp也發展出了ILRuntime框架來支持解釋模式,從而實現了CSharp熱更新。

那么JS/TS呢,筆者以前以為V8引擎一直有解釋器的,不然iOS上的Chrome是怎么運行的呢?帶着這個問題查了下才發現V8確實加了解釋器,並不是很久以前。所以現在JS/TS流派也發展出了成熟的框架比如Puerts。

那么Mono呢?再繼續查了下,也有。Mono的解釋器命運就比較曲折了,從Mono第一個版本便有,再到后來光榮退場,然后重新出山,當然是肩負了使命的。既然再加回來當然還要再進一步,AOT和Interpreter都可以在iOS上運行,如果可以讓熱點代碼跑在AOT,容易變更的代碼跑在Interpreter,兩部分代碼不需要關心自己的運行時不是更好嗎?再繼續查了下,也有,Mono內部已經實現了兩套運行模式的交互部分,在aot編譯時提前生成了交互代碼,運行時的代碼可以無感知的相互調用,並且完善度已經相當高。

  • Mixed-Mode Execution

    Mono支持的一種運行模式,混合了AOT和Interpreter,在執行沒有AOT的程序集時,自動將程序集切換到Interpreter內執行,所以支持動態Load代碼。

事情在朝着好的方向發展,似乎一切都比較合理。從mono的提交日志看,2018年開始充斥着大量的[interp]模塊提交,幾乎占到了總提交量的1/3,mono的這個模塊發展很快。反觀Unity官方mono,恰好停留在[interp]模塊加入前,便不再合並mono主干。具體原因我們不再此猜測,只是這樣就需要我們自己動手了。

既然運行時已經支持,剩下的工作就是集成到UnityEngine內與Il2cpp親密無間。在此之前我們先以Unity的默認執行框架做引子,以下為筆者個人理解,不正確的地方請以官方為准。

1號通道最初是通過Mono的Internal call來實現的,Il2cpp同樣使用此方式來實現(C)到(A)的請求。

2號通道是UnityEngine通過Il2cpp 然后invoke上層接口來實現回調。

0號通道我們先稱之為magic,實現一些定制特性,我們先忽略。

如果要在Unity項目中實現Mono 的Mixed-Mode Execution,我們需要在此系統內再加入一個Mono runtime,同時綁定上述三條通道,這里先說下我們的第一種綁定方案(icall綁定):

  • 針對1號通道,Mono原生即支持Internal call(我們簡稱icall),那么在Mono中直接執行unity assembly,然后將icall調用直接指向(A)即是最直接的方式。
  • 針對2號通道需要在(B)層做些手腳,通過查代碼我們發現Il2cpp的Method實際是一個函數指針,那么查找到需要回調的函數,並使指針指向我們的實現,然后再Invoke Mono內的相同函數即實現了hook功能,即實現了UnityEngine的回調。

通過以上兩種方式我們在自己的Mono runtime內綁定了大部分的Unity功能。為什么是大部分呢,這里可以實現icall綁定的前提是所有icall綁定傳遞的對象只有一份內存,並且是在(A)內,UnityEngine.Object即是此目的。Unity當然不會就此收手,magic就無用武之地了,除此還有其他一些特性,最麻煩的是0號magic通道,比如MonoBehaviour、Coroutine、傳遞給(A)一個.Net 的Stream等等。這里因為Unity做了一些特殊處理,具體實現我們不得而知,即使勉強實現了也無法保證以后兼容性,所以我們使用了Wrapper的方式。這里有兩種實現方式,后面再詳細介紹。

我們再來看看加上Mono runtime后的結構:

如上面所說,我們需要綁定(H)內的icall指向(A)即新增了通道5-3,同時需要hook(B)內的函數指針實現回調,即新增了通道4-5。

同時為了處理magic情況,我們提供兩種方案,一種是手動在(F)內實現綁定接口(Unity的icall綁定大部分也是這種無規則的手動實現,所以給我們的自動綁定帶來了很多麻煩)這種方案上層用戶(I/K)完全無感知,只是因為這部分是由c/cpp實現,對部分團隊並不友好。所以我們新增了通道6,也就是我們的第二種綁定方案(Adapter綁定):

Adapter是指在(D)中指定一些需要在(I/K)內使用的程序集,在構建時為這些程序生成兩個Adapter程序集,分別位於(E)和(J),這樣當用戶(I/K)調用(D)內的程序接口時會自動通過通道6調用,調用方無感知,同時通道6是雙向的,即同時支持調用與回調。

另外指出的是(H)是直接使用的UnityEngine.*.dll,只需重新綁定icall即可,(F)/(E)/(J)內的綁定代碼均由代碼生成器生成,即除非需要手動實現icall綁定,通道3/4/5/6均自動生成。

兩種方案是否給框架增加了復雜性呢,其實在開發過程中,為了保持簡潔筆者在這兩種方案中反復切換了多次,每個單獨方案都能實現絕大部分的功能,但是總會讓一小部分特定的問題復雜化。比如我們全部使用Adapter綁定可以完成需求嗎?其實是可以的,但是碰到Unity使用runtime來支持的特性,單純的從CSharp層來實現復雜度會大大增加,或者需要用戶修改程序,而且后續功能的兼容和擴展性會低很多。兩種方式一起用,雖然給綁定生成器帶來了復雜性,用戶使用反而簡單一些,所以保留了兩種綁定方案。

至於用戶的程序集是在(K)內執行還是在(I)內執行,用戶可以自己根據實際需求來配置,綁定生成器會在構建時自動觸發,根據配置生成不同的工程,然后將此工程以pod庫的形式提供給主項目集成。主項目需要在podfile中引用后執行

pod install

即可鏈接成最終執行項目,以上即是筆者本次介紹的方案,詳細使用細節請移步這里。此方案支持iOS平台下Assembly.Load接口。Android平台建議直接使用Unity的Mono運行時,同樣支持Assembly.Load接口,這樣在架構上不需改動。


此方案其實構思已久,期間做了不少可行性測試,一直因抽不出時間拖着未實現,最終也因2020年這個年終閑的時間長了些才得空實現了出來,其間縫合多個程序邊界並實現自動化的復雜度還是超出了預期,總算最終走通了,因為感覺到自己可以調配的精力非常有限,也深知獨立開發很難使這個框架完善,所以決定開源出來,也順便取了個名字:PureScript,起碼保持從用戶角度看來是一個簡潔、單純的腳本框架。

如果大家有興趣,后面再補充詳細實現細節,目前項目已經開源。對此方案有興趣的同學歡迎提交PR或者Star。

添加個視頻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM