在最近參與的遊戲專案中,我負責重新撰寫經過時間累積、充滿不同想法的核心元件。隨處可見複製的程式碼,元件之間交互使用,亦或是為了加新功能而繞路。可以看出每個新功能為了不影響之前的功能,正維持著微妙的平衡。對於負責重寫元件的我來說就好像是在茂密的遠古森林裡一路披荊斬棘,充滿挑戰。就想寫來記錄過程和心得。

refactor-is-like 重構元件大概是這樣的感覺吧

理解需求

要作重構還是要回歸需求,知道這些元件或這個功能的目的是什麼,這樣在重新設計時比較容易掌握方向。不過萬事起頭難,這也是最痛苦的階段,尤其是靈力不足以通靈出程式的想法的時候。

如果有文件或是之前的開發者還在的話是最好,就可以快速理解它們的作用,找到切入點。但通常不會那麼美好,可能因為趕時限沒時間作文件,或是之前的開發者不在了。這時候就只能自己看程式碼通靈了。

找出運作流程

我會先找出元件在運作流程中的位置,利用 debug 大法先看事件是如何發生。例如想知道攻擊演出是怎麼播放的,就在覺得可能發生的地方插入 Debug.Log,當演出時就可以知道那個地方有沒有被呼叫到。而且 Unity 的 Debug.Log 也會包含 call stack,可以知道發生的起點。

unity-debug-message

有 call stack 的幫助,可以知道要重構的元件在流程中的位置,與牽涉的哪些元件,例如演出播放會跟素材載入的元件有關,而發起演出請求的元件有哪些等。如果遇到非同步的程式碼就會麻煩一點,得要找出哪裡呼叫到斷點,再從斷點另一頭開始找。有這些資訊就可以拉出這次重構的界線要到哪裡,因為當牽涉到的元件很多時,一次改動太多東西反而不好掌握。

old-structure 原本的問題是類型 B 的特效生成與管理跟類型 A 完全不同,想見類型 B 的特效是後來新增的

理解程式碼

即使有文件或是有人可以問,要到能有修改想法前,還是得實際看程式碼,痛苦的開始。在拉出重構界線後,就去看目標元件內的功能是如何運作的,重點在於理解需求,也就是它是為了作什麼事情而存在。最好是能抓出元件內每個功能的作用,還有偷偷幫你做的功能,例如是在意料之外的地方做到不會重複播放特效的功能。這樣在重新設計時會有比較好的藍圖。

old-structure-functions 找出舊架構每個元件的功能和用途,紅色是有問題的使用方式

在理解程式碼的過程中,也會發現原本的功能有什麼問題。例如生成的特效物件沒有統一管理、使用的資料結構有效能問題等。當然在重新設計時,也得一併考慮解決這些問題。

reading-legacy-code

重新設計

有時候在理解功能的時候,也會有想法冒出來,所以不會等完全理解完才開始重新設計。有想法時先設計新的架構大致要長什麼樣,漸漸體悟出功能後,再逐步更新設計。

redesign-structure

重新設計是規劃元件之間要如何互動,要提供什麼樣的功能給其它元件使用,目標是讓程式能直覺好讀與方便後續維護。我習慣的設計方向如下:

單向相依性

同一個系統中,元件之間的相依性是單向的。也就是不會繞過使用的元件,再去取用後面的元件,或是元件之間的相依性不該有迴圈,這很常出現在那個元件是 static 的情況下。這樣在後續追縱程式碼時,路徑會比較單純。

keep-single-reference-direction 以這次的例子來說,應該要把類型 B 的特效管理元件合併進來

只負責一件事

每個元件只負責一件事。如果規劃出來的元件作的事情太多,就可以考慮將它切出來作成另一個元件。好處是命名更容易,可以直覺知道這個元件負責的功能。但也不用為了一個小功能而切出新元件,反而會使程式更複雜。通常在實作時會意識到這個元件太大或太小,在設計階段不用一直考慮切的好不好。

one-component-one-job 每個元件專心做它負責的事就好

seperate-large-component 像共用跟個別使用的特效的管理方式不同,就分出次元件來,EffectManager 再依照請求的特效決定(透過 flag 之類的)從哪邊取資料,EffectUser 不用管這件事

只開放需要的功能

元件之間能看到的功能是有限的,也就是作抽象化。元件只開放需要的功能給外部,讓外部要求元件去做事,而不是把元件的資料拿出來做事,或是操作元件內的流程。這樣實際運作的地方會集中在元件內,如果出現問題,也容易找到出問題的地方。如果要修改功能也不會牽一髮而動全身,只需要在元件內修改,而不影響使用這個功能的其它元件。

only-expose-necessary-functions 限制元件能看到的功能的話,假如 EffectManager 要改用不同的流程來管理特效元件,就只要改那個元件,其它元件不會有感覺的

實做新架構

在設計好重構的藍圖後,再來就是決定先從哪邊下手。先從改動比較小的部份開始修改,再來是修改有關連的元件,例如要實作的功能,需要其它元件先提供對應的功能,最後是重構目標元件。所以在重新設計階段時,就可以先列出修改任務了,而且比較好掌握有哪些任務要先做,哪些有相依性。重構目標元件的任務可能會很大,像是包含設計資料結構、資源的運作流程、有多個功能要提供等,就要再把任務細分成子任務了。

seperate-the-task

如果要改動的部份很大的話或是要重寫的話,我會先保留原本的元件或功能,然後直接寫新的功能,再逐步替換上去,最後取代舊有的元件。另外我也會保留沒有問題的 API 的使用方式。每個新功能完成後,就測試行為是不是跟原來的功能一樣,如果有搭配版控,可以方便的在新舊功能間切換。

因為最後還是要看實作去調整架構,重構也不太可能一次就到位,所以在前面的設計階段並不用設計得太完整,有主要的架構出來就可以開始實作了,再依照每個元件的功能需求個別設計。

總結

這次在專案中重構元件的過程可以整理成下圖的樣子,其實每個階段不會只做那個階段的事情,也會牽涉到其它階段。

refactor-progress

此外有筆記的好處是,在重構完成後,可以幫助撰寫文件。像是運作流程、元件關係等,這些要回顧程式碼的資訊,如果在製作過程中一邊記錄的話,會大大減少整理文件的時間。

後記

這次重構比較大的失誤是,我發現目標元件有兩個功能是比較特別的,所以就沒有詳細看它們是怎麼運作的,等做好其它功能後,回來處理。結果沒想到那兩個功能就是那麼特別,給了已經設計好的資料結構跟流程重重一拳,花了好一陣子才修改完成。當初就應該意識到這兩個功能會很難處理,不然在舊的程式碼中也不會繞過原本的架構去達成了。

另外要隨時做筆記,記錄設計想法還有要修改的元件,在下個階段才不會漏掉。我因為少記了一些要改的功能,結果在開始實作後,發現要先去改其它元件,錯估了任務的數量,造成時程預估要更長了。