標籤:

Unity手游開發札記——我們是如何使用Lua來開發大型遊戲的?(下)

上一部分的文章聊了一下我對於使用Lua的一些觀點,但是核心的內容還是在說如何彌補Lua語言在開發中的不足。一些朋友表示對於觀點不敢苟同,我也希望不贊同的朋友可以多討論,畢竟我也是屁股決定腦袋,在用多這門語言的時候總是覺得很多東西是習慣和順手的。從心態上說我也是很開放的,比如自己會去關注ET這樣純C#的框架,想看看他們是如何做的,比用Lua好在哪裡。

本來第二部分的開始計劃是想先聊聊如何劃分Lua和C#這兩種語言的職責,但我想還是直奔主題,來聊下那些可以佐證我前面拋出的兩個觀點的那些方面。

3. 讓Lua代碼更好調試

遊戲開發過程中總會寫出或者遇到各種各樣的bug,如何快速地定位和修復bug,也會對於開發效率有很大的影響。針對這點,我前面提到的一個觀點是:

使用Lua這樣的腳本語言,調試bug的效率並不低,甚至可能比C#這樣的靜態語言還要高

我想從以下幾個方面來聊聊這個點。

3.1 節省編譯時間

像Lua這樣的動態語言是解釋執行的,因此和靜態語言相比,它們雖然運行時效率比較低,但是不需要編譯的過程。這對於需要頻繁修改代碼,然後運行遊戲測試結果的的bug修復過程,本身就是優勢。比如之前做引擎C++代碼的修改,無論是使用increbuild這樣分散式的編譯工具也好,還是購買更強力的機器也好,一次編譯鏈接的時間總會需要等上那麼一會,修改了頭文件就更加痛苦……

嗯,曾經感同身受

當然,Unity的C#語言編譯通常沒有這麼誇張,可以通過將部分模塊提前編譯為dll,或者將不常用的組件(比如第三方庫)放置到Unity特殊的目錄下來加快編譯過程。然而,到項目中後期,我們的體驗是修改C#代碼之後,切換到unity總需要那麼幾秒鐘的時間來進行編譯。而修改Lua代碼後,是不需要這幾秒鐘的等待時間的,重啟遊戲是絲般順滑,哈哈。

有些朋友可能會覺得這幾秒鐘的等待無所謂,而對於一個用慣了腳本語言進行邏輯開發的人,可能會感受到這其中的差異。當然不會有人為了節約這點時間而去在Unity中集成Lua語言,它只是使用腳本語言一個順帶的福利而已。

當然,如果有朋友知道如果減少修改C#編譯時間的方法,也歡迎指教~我們項目也很需要這樣的經驗。

3.2 斷點調試支持

前文已經說了,Lua也是支持斷點調試的,有朋友評論里分享了他們的調試方法:

感謝hhy分享的調試方法

我們團隊內部的是使用VS Code+luaide來進行斷點調試的。使用過程中偶爾遇到過一些變數值顯示不正確的異常情況,但整體上基本可以滿足斷點調試的需求。

在調試方面,個人體驗Lua的確不如C#這樣的代碼方便,需要自己集成調試插件,然後啟動調試的時候還要有額外的步驟,而當已經確認要使用Lua之後,儘早讓團隊的成員學習和熟悉斷點調試的方法和工具,可以節省掉不少調試的時間。

3.3 基於Reload的調試方法

想像一下這樣的調試過程——

運行遊戲過程中,你發現一些問題,根據經驗和代碼邏輯你大概定位到問題原因,在IDE中修改一些代碼,或者添加一些log,按下Ctrl+S保存代碼,遊戲中的邏輯自動被替換為修改後的代碼邏輯,不需要重新啟動遊戲,在遊戲中重新觸發相關的邏輯,就可以看到新的log輸出,或者驗證你的修改的代碼是否已經修復了之前的問題。

這是我在網易時,團隊內已經在大範圍地使用的調試方法,而基於這種方法,很多同學都懶得去學習和部署斷點調試的工具。

越是大型的遊戲,啟動越慢,我們團隊也做了一些事情來加快編輯器下的遊戲啟動時間,比如:

  • 編輯器下關閉閃屏過程;
  • 提供自動登錄、自動創建角色等功能;
  • 提供遊戲全局加速、角色速度加速等GM指令;
  • ……

這些工作都是為了提高整個程序團隊乃至整個遊戲研發團隊的工作效率,因為重新啟動遊戲是一件在遊戲開發中太過頻繁的操作。而當一個程序需要嘗試重現並修復一個bug的時候,可能需要多次這樣的過程,而這也是基於log調試最為令人詬病的地方——你可能無法一次就精準地知道要在哪些地方添加log,還要根據log的輸出結果調整log的位置或者輸出的信息內容,如果這一過程都需要不斷地重啟遊戲,那調試效率之低可以想像。

腳本語言提供的Reload功能,可以幫我們實現無需重啟進程就可以更新代碼的效果。在我們工程中使用了Tango這個非常古老的庫來做進程間的跨Lua虛擬機訪問,它的底層也是基於Socket來實現的,整個更新流程的結構示意圖如下:

基於Tango的代碼自動Reload結構示意圖

在這個流程中,需要自己開發一個簡單的IDE插件,或者把IDE的快捷鍵映射到本地的一個執行程序上。在需要reload的時候,獲取要reload的文件,比如是當前IDE打開的文件,然後通過Tango的客戶端嘗試連接本地的Tango伺服器,如果連接成功,就將reload的請求發送過去,遊戲進程中開啟的Tango伺服器收到請求之後,執行reload操作,代碼就被更新了。

需要說明的是,這個流程看起來並不複雜,但是也經歷過幾個步驟的演化過程:

  1. 首先在最初的時候,只提供了reload模塊的功能,IDE中修改了代碼之後,需要手動在遊戲內通過GM指令或者Telnet上去的Shell控制台執行Reload操作;
  2. 後來引入了rpyc和Tango這樣的跨進程通訊的模塊之後,在外部製作了一個簡單的ui工具,可以記錄之前操作過的指令,方便快速reload;
  3. 最後才引入了IDE插件,將reload功能直接集成到保存操作中,實現自動Reload。

Tango雖然遠沒有Python的rpyc好用,經過簡單的改造之後也基本滿足我們的需求。如果了解有更好用庫的朋友非常歡迎推薦~具體的實現細節不做過多的討論,這裡只聊一下reload的實現。

在Python中原生就有reload函數,Lua中的實現要通過loadfile或者loadstring這樣的函數來實現。當然,你有可以暴力地刪除掉原來已經require過的模塊,然後重新require它,但這可能只能夠正確處理非常少的情況,畢竟其他模塊可能已經保留的對於原模塊的引用。一個完備的reload過程需要保留之前模塊中的上下文數據,只替換對應的邏輯和需要添加的數據內容,這樣才能能夠保證進程不重啟的條件下,下次執行的正確性。

這裡截取部分代碼來說明這個流程的複雜性和基本原理:

-- Reload.lua-- 並沒有給出完整代碼,僅供參考 local Old = _ImportModule[PathFile] if Old and not Reload then return Old end -- 先loadfile再clear環境 local func, err = loadfile(PathFile) if not func then logerror(func, err) return func, err end -- 第一次載入,不存在更新的問題 if not Old then _ImportModule[PathFile] = {} local New = _ImportModule[PathFile] -- 設置原始環境 setmetatable(New, {__index = _G}) local ret = setfenv(func, New)() _ImportResult[PathFile] = ret return New end -- 先緩存原來的舊內容 local OldCache = {} for k,v in pairs(Old) do OldCache[k] = v Old[k] = nil end -- 使用原來的module作為fenv,可以保證之前的引用可以更新到 local ret = setfenv(func, Old)() _ImportResult[PathFile] = ret -- 更新以後的模塊, 裡面的table的reference將不再有效,需要還原. local New = Old -- 還原table(copy by value) for k,v in pairs(OldCache) do local TmpNewData = New[k] -- 默認不更新 New[k] = v if TmpNewData then if type(v) == "table" then if type(TmpNewData) == "table" then -- 如果是一個class則需要全部更新,其他則可能只是一些數據,不需要更新 if v.__IsClass then local mt = getmetatable(v) if rawget(v,"__IsClass") then -- 是class要更新其mt local old_mt = v.mt local index = old_mt.__index ReplaceTbl(v, TmpNewData) v.mt = old_mt old_mt.__index = index end end local mt = getmetatable(TmpNewData) if mt then setmetatable(v, mt) end end -- 函數段必須用新的 elseif type(v) == "function" then New[k] = TmpNewData end end end

整個Reload的過程中需要考慮的內容比較多,但是即便如此,對於那些比如local func = xxx.foo這樣被緩存的函數,依然可能存在更新不到的情況,對於某些被緩存在閉包中的函數,也有類似的問題。

相比於客戶端調試用的Reload邏輯,如果是使用Lua語言實現邏輯的服務端,當需要Refresh邏輯的時候,需要更加完備的更新考慮,所以如果對這塊感興趣的朋友,可以找一些開源的Lua服務端框架來看下,看看是否有可以參考的代碼。而如果僅僅是調試使用,則可以使用相對少的精力實現最為核心的基礎功能,做到對於大部分函數的重新載入就夠用了。

不僅僅針對Lua語言,在使用任何語言進行遊戲開發的過程中,善用語言的特性,在加上不斷改進的心,就可以做出很多提升團隊效率的工具。

3.4 Lua內存數據的查看和修改

在開發和調試過程中,經常會遇到需要查看內存數據的需求,一方面Unity在編輯器模式下提供了非常便利的場景數據查看的方式,在設備上也可以集成之前推薦過很多次的插件Hdg Remote Debug,另外一方面C#和Lua的內存通過斷點調試工具來進行查看。

在我們遊戲的開發中,基於Tango製作了另外的內存查看和修改工具。原理非常簡單,基於Tango跨Lua進程的特性,配合一個基於pyQT的gui界面,就可以做到:

  1. 直接輸入代碼輸出和修改遊戲內的數據;
  2. 可以直接指定遊戲內的一個table獲取代碼,然後查看其所有內容。
  3. 通過ip訪問可以直接連接移動設備進行操作。

我們在用的工具截圖如下:

Lua內存查看和修改工具截圖

截圖中左側是內存對象的逐層展示功能,可以看到當前內存中通過代碼獲取的某個table中的具體信息,比如QA要驗證角色數值計算的正確性,就會使用這個工具來進行查看。右側更多的提供給程序,用於執行一些代碼和邏輯,直接查看遊戲進程中的Lua數據,並可以進行實時的修改。同時右側的功能還有一個單純的Shell版本,基於iLua可以做補全等操作,方便很多。

3.5 小結

上述的這些工具的開發部署的確會花費團隊一些時間和精力,但是有了這些工具,不斷根據需求進行完善和改進,可以讓程序團隊可以更加高效地進行錯誤的調試和修復,提高整個團隊的工作效率。

4. 更快修複線上問題

對於線上問題的修復,Patch的部分其實很多項目大同小異,不過這裡面細節也有很多,有時間的時候可以整理和分享一下我們在這部分做的工作。這篇文章的線上問題修復我們來著重聊聊Hotfix

前文描述觀點的時候已經說了Hotfix可以實現的效果,我其實不知道業內使用這一方式進行線上問題修復的普及程度是怎麼樣的。在網易的時候因為大家都用腳本,Hotfix是遊戲上線的標配功能,出來創業之後,大家在聊熱更新什麼的,從來不會單獨提這塊,所以我並不知道這種修復方式在行業內是正在被廣泛應用呢,還是並不常用的一種方式。

4.1 Hotfix的優勢

現在手游開發的周期越來越短,開發速度要求越來越快,往往會在上線之後遇到一些影響了玩家體驗或者阻礙了玩家流程的客戶端bug需要線上修復,這時候修改代碼製作patch然後放出去,對於已經在線的玩家,如果是強制patch的方式,要把玩家踢掉線讓其重啟客戶端或者到Patch更新界面去進行Patch的下載操作,這其實對於玩家的體驗非常不好。而Hotfix的優勢正是在玩家無感知的情況下修復緊急的bug。

4.2 基本原理

Hotfix的基本原理依然是基於動態語言的Reload功能,更加準確的說是Function Reload。下圖簡單描述了整個Hotfix的流程:

Hotfix的應用流程

更加具體地可以描述為:

  1. 程序發現要修復的bug,編寫特殊的Hotfix代碼進行修復,測試通過後上傳到svn伺服器;
  2. 通過發布指令,將svn上更新後的Hotfix代碼同步到伺服器上;
  3. 伺服器發現Hotfix代碼有更新,則將其壓縮序列化後通過socket發送給所有在線的客戶端,同時帶上字元串的MD5值供客戶端驗證;
  4. 客戶端收到Hofix消息之後,首先反序列化數據得到代碼內容,校驗MD5值之後,如果和本地已經執行過的Hotfix的MD5值不同,則執行替換邏輯,並記錄當前已經執行過Hotfix的MD5值,如果相同則不再執行;
  5. 客戶端連接伺服器的時候會主動請求一次Hofix。

4.3 實現方式

執行Hotfix執行的代碼非常簡單,基於loadstring函數即可:

local f = loadstring(GameContext.HotfixData)if f then ClientUtils.trycall(f)end

這裡的實現就沒有reload那麼複雜,但是也是有一定的限制,比如local的函數或者在閉包內的函數依然很難做正確的hotfix,需要編寫特殊的Hotfix代碼。

而如果使用了類似於我們這樣複雜的Class結構,有大量Function的緩存的話,需要額外的處理函數來保證這些緩存的函數對象被正確替換,比如針對我前文提供的Class方式,需要這樣的代碼來執行Class級別的函數替換:

-- 類的繼承關係數據,用於處理Hotfix等邏輯。-- 數據形式:key為ClassType,value為繼承自它的子類列表。local __InheritRelationship = {}local function __getInheritChildren( classType, output ) if output[classType] then return else output[classType] = true if __InheritRelationship[classType] then for index, childType in pairs(__InheritRelationship[classType]) do __getInheritChildren(childType, output) end end endendlocal function __HotfixClassFunction(classType, funcName, newFunc) local classVtbl = __ClassTypeList[classType] if classVtbl and funcName and newFunc then local preFunc = classVtbl[funcName] classVtbl[funcName] = newFunc local children = {} __getInheritChildren(classType, children) for replaceClass, value in pairs(children) do local vtbl = __ClassTypeList[replaceClass] if rawget(vtbl, funcName) == preFunc then vtbl[funcName] = newFunc end if replaceClass ~= classType then local super = replaceClass.super if rawget(super, funcName) == preFunc then super[funcName] = newFunc end end end endendif (not IsGLDeclared("HotfixClassFunction")) or(not HotfixClassFunction) then GLDeclare("HotfixClassFunction", __HotfixClassFunction)end

給出一段簡單的Hotfix代碼示例如下:

-- hotfixlocal function WorldEntity_destroy(self) -- New Code Hereend-- 替換函數local function ReplaceFunc( ) HotfixClassFunction( WorldEntity, "destroy", WorldEntity_destroy)end-- 替換數據local function ReplaceData( ) ResDungeon[1011][member_num] = 1 ResDungeon[1012][member_num] = 1endReplaceFunc()ReplaceData()

注意:如果你像我們一樣在戰鬥中使用了幀同步的方式,對於戰鬥中邏輯或者數據的Hotfix一定要非常小心,一場戰鬥中的玩家,無論是開始就進入的還是在斷線重連上的時候,都必須使用同樣的Hotfix代碼,否則不同客戶端幀同步計算的結果就不同了。

4.4 小結

如果你們團隊在外放前擁有更長的測試周期,擁有更加專業的團隊成員,可以盡量減少線上問題出現的概率,那大家都會非常開心,也就可能不會需要我們正在使用的這種Hofix修複線上問題的方法。如果你們在使用Lua或者其他的腳本語言,具有動態reload代碼的特性,你也可以實現一下這種fix方式,以備不時之需。當然,還是建議對於這套東西多加測試,更加希望所有團隊都不需要修復這麼緊急的線上問題~

無痛的人流可能並不存在,但是對於玩家無感的bug修復,是可以存在的。

5. 分享經驗,而不爭辯好壞

回頭來看,寫這篇文章最初的心態帶著那麼一點點為Lua「正名」的意思,想告訴大家其實Lua雖然的確有些難用的地方,但是經過一些工具構建,加上對於動態語言特性的善用,可以做很多事情,在某些方面甚至可以比靜態語言做得更好。

回頭看這個想法有些可笑,其實一個語言真正好用與否,完全取決於用的人。團隊的歷史經驗、團隊的整體實力、所要做的遊戲類型等等不同,都會有完全不同的結論。無論什麼樣的框架,什麼樣的語言,什麼樣的技術,都只有最合適的,沒有最好的。又或者換個角度,只有經過項目和團隊的磨礪,技術這把雙刃劍,朝向問題的那面才能更鋒利,朝向自己的那面才會更加圓潤。

而我能做的,是把我覺得我們項目中對於Lua的使用較好的部分分享給你,如果你必須使用Lua,我希望你能用得舒服一點,如果你依然覺得它是「狗屎」,那就選擇合適你的。

之前我習慣使用Python,出來創業之後要使用Lua開始還有些膽怯,花了一個多月的時間和團隊一起構建上述的這些基礎框架、調試工具以及維護流程,並用一個項目的時間來改進磨礪它們。現在,一年多之後,我覺得我們把它用得挺順手了。但我依然告訴自己要保持開放的態度,下一個項目,我們可能繼續使用Lua,也可能會嘗試ILRuntime,又或者其他什麼新鮮的東西。我相信,對於之前經驗的總結、工具的改進讓我們走得更穩,對於不熟悉的技術的學習和討論讓我們跑得更快。

對於技術,放寬心態,分享經驗心得而不爭辯孰好孰壞,這是我現在的態度。期望讀者可以跟我分享你們的經驗~

2018年3月18日夜 於杭州家中


推薦閱讀:

為什麼很多編程語言用 end 作為區塊結束符,而放棄花括弧?
陰陽師肝不動了,試試Lua吧
用 Nginx + Lua(OpenResty) 開發高性能 Web 應用
公式計算機的另一種實現思路
Lua 為什麼數組下標從 1 開始?

TAG:Lua |