上一部分的文章聊了一下我對於使用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操作,程式碼就被更新了。
需要說明的是,這個流程看起來並不複雜,但是也經歷過幾個步驟的演化過程:
首先在最初的時候,只提供了reload模組的功能,IDE中修改了程式碼之後,需要手動在遊戲內透過GM指令或者Telnet上去的Shell控制檯執行Reload操作;
後來引入了rpyc和Tango這樣的跨程序通訊的模組之後,在外部製作了一個簡單的ui工具,可以記錄之前操作過的指令,方便快速reload;
最後才引入了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介面,就可以做到:
直接輸入程式碼輸出和修改遊戲內的資料;
可以直接指定遊戲內的一個table獲取程式碼,然後檢視其所有內容。
透過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的應用流程
更加具體地可以描述為:
程式發現要修復的bug,編寫特殊的Hotfix程式碼進行修復,測試通過後上傳到svn伺服器;
透過釋出指令,將svn上更新後的Hotfix程式碼同步到伺服器上;
伺服器發現Hotfix程式碼有更新,則將其壓縮序列化後透過socket傳送給所有線上的客戶端,同時帶上字串的MD5值供客戶端驗證;
客戶端收到Hofix訊息之後,首先反序列化資料得到程式碼內容,校驗MD5值之後,如果和本地已經執行過的Hotfix的MD5值不同,則執行替換邏輯,並記錄當前已經執行過Hotfix的MD5值,如果相同則不再執行;
客戶端連線伺服器的時候會主動請求一次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
end
end
local
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
end
end
if
(
not
IsGLDeclared
(
“HotfixClassFunction”
))
or
(
not
HotfixClassFunction
)
then
GLDeclare
(
“HotfixClassFunction”
,
__HotfixClassFunction
)
end
給出一段簡單的Hotfix程式碼示例如下:
—— hotfix
local
function
WorldEntity_destroy
(
self
)
—— New Code Here
end
—— 替換函式
local
function
ReplaceFunc
(
)
HotfixClassFunction
(
WorldEntity
,
“destroy”
,
WorldEntity_destroy
)
end
—— 替換資料
local
function
ReplaceData
(
)
ResDungeon
[
1011
][
‘member_num’
]
=
1
ResDungeon
[
1012
][
‘member_num’
]
=
1
end
ReplaceFunc
()
ReplaceData
()
注意:如果你像我們一樣在戰鬥中使用了幀同步的方式,對於戰鬥中邏輯或者資料的Hotfix一定要非常小心,一場戰鬥中的玩家,無論是開始就進入的還是在斷線重連上的時候,都
必須使用同樣的Hotfix程式碼
,否則不同客戶端幀同步計算的結果就不同了。
4。4 小結
如果你們團隊在外放前擁有更長的測試周期,擁有更加專業的團隊成員,可以儘量減少線上問題出現的機率,那大家都會非常開心,也就可能不會需要我們正在使用的這種Hofix修復線上問題的方法。如果你們在使用Lua或者其他的指令碼語言,具有動態reload程式碼的特性,你也可以實現一下這種fix方式,以備不時之需。當然,還是建議對於這套東西多加測試,更加希望所有團隊都不需要修復這麼緊急的線上問題~
無痛的人流可能並不存在,但是對於玩家無感的bug修復,是可以存在的。
5. 分享經驗,而不爭辯好壞
回頭來看,寫這篇文章最初的心態帶著那麼一點點為Lua“正名”的意思,想告訴大家其實Lua雖然的確有些難用的地方,但是經過一些工具構建,加上對於動態語言特性的善用,可以做很多事情,在某些方面甚至可以比靜態語言做得更好。
回頭看這個想法有些可笑,其實一個語言真正好用與否,完全取決於用的人。團隊的歷史經驗、團隊的整體實力、所要做的遊戲型別等等不同,都會有完全不同的結論。無論什麼樣的框架,什麼樣的語言,什麼樣的技術,都只有最合適的,沒有最好的。又或者換個角度,只有經過專案和團隊的磨礪,技術這把雙刃劍,朝向問題的那面才能更鋒利,朝向自己的那面才會更加圓潤。
而我能做的,是把我覺得我們專案中對於Lua的使用較好的部分分享給你,如果你必須使用Lua,我希望你能用得舒服一點,如果你依然覺得它是“狗屎”,那就選擇合適你的。
之前我習慣使用Python,出來創業之後要使用Lua開始還有些膽怯,花了一個多月的時間和團隊一起構建上述的這些基礎框架、除錯工具以及維護流程,並用一個專案的時間來改進磨礪它們。現在,一年多之後,我覺得我們把它用得挺順手了。但我依然告訴自己要保持開放的態度,下一個專案,我們可能繼續使用Lua,也可能會嘗試ILRuntime,又或者其他什麼新鮮的東西。我相信,對於之前經驗的總結、工具的改進讓我們走得更穩,對於不熟悉的技術的學習和討論讓我們跑得更快。
對於技術,放寬心態,分享經驗心得而不爭辯孰好孰壞,這是我現在的態度。期望讀者可以跟我分享你們的經驗~
2018年3月18日夜 於杭州家中