從零開始獨立游戲開發(fā)學(xué)習(xí)筆記33--Unity學(xué)習(xí)筆記16-Junior Programmer(下)

書接上回。
1. 場景流管理和數(shù)據(jù)管理
1.1 設(shè)置版本控制
突然插入一個版本管理的教程,和本節(jié)講的東西無關(guān),但是順便看一下吧。版本控制對于個人來講也是比較重要的一件事。
這里使用 git + github 進行管理。教程用的是 github desktop。我就直接用命令行了。
如果對 git 不熟練的話,可以參考我很久很久以前寫的這篇文章。
1.1.1 使用
非常簡單。Unity 的每個項目都是一個完整的文件夾。直接把這個文件夾作為 repo 根目錄即可。
1.1.2 Unity 相關(guān)
Unity 本身并沒有什么專門為 github 做的東西,畢竟有自己的 plasticSCM。要方便使用的話去找插件吧。 Github 有一個專門給 Unity 做的插件。但是評分巨低無比,看了一下,說這工具很久不維護,現(xiàn)在版本已經(jīng)基本不能用了。
1.2 創(chuàng)建場景流
這一節(jié)是全文字,沒有視頻,太棒了。我已經(jīng)受夠了好幾個 3 分鐘的視頻就為了講一件事了。
導(dǎo)入項目。試玩一下 menu,可以發(fā)現(xiàn) bug。就是根本沒法從菜單進入游戲。那么我們先把一些基本功能像是進入游戲和離開游戲做好吧。
1.2.1 開始游戲
canvas 上有一個腳本,在這個腳本里添加一個 StartNew() 函數(shù)。用于加載游戲場景。綁上 Start 按鈕。
1.2.2 離開游戲
依然在同一個腳本里添加函數(shù),Application.Quit() 來離開游戲。綁上 Quit 按鈕。
不過,就像我們已經(jīng)知道的一樣,這個只適用于成品游戲。測試的時候并沒用。
1.2.2.1 在測試環(huán)境的時候退出 play mode
但是還沒完,我們想讓點擊的時候,能夠退出 play mode。
退出 play mode 并不是難點,有專門的方法。不過問題在于,我們?nèi)绾巫層螒蛑雷约涸谀姆N環(huán)境下?畢竟你寫了個退出 play mode 的方法,成品游戲里不就尷尬了嗎?
1.2.2.2 條件編譯
這個時候就要用到條件編譯了。這個方式可以讓你在不同編譯環(huán)境下運行不同的代碼。
我們的 Exit 函數(shù)里這么寫:

語法還是簡單的,一看大概也能猜出來。
不用擔(dān)心這些代碼會影響生產(chǎn)環(huán)境的性能,這些 # 開頭的是給編譯器看的。編譯器看到就會知道哪些代碼需要編譯,哪些不用。在生產(chǎn)環(huán)境里不會多一層條件判斷的。
此外,要使用 EditorApplication 需要引入新的 namespace:UnityEditor。但是需要注意,我們要給這個引入也加上條件編譯:

這不是可選,是必須這么做,因為 build 環(huán)境下沒有 UnityEditor 的 namespace。到時候花半天編譯沒法用就難過了。
1.2.2.3 從 main 回到 menu
簡單。
1.3 場景變化中的數(shù)據(jù)持久化
困擾我很久的一個問題,在這里解決。如何在場景變化中保留一些數(shù)據(jù)。
1.3.1 保持顏色
我們想讓在 menu 里選的顏色能夠帶到 main 里,影響一些物體的顏色。
為了做數(shù)據(jù)持久化,我們需要用到以下兩個新東西:
DontDestroyOnLoad:這個方法可以讓某個 object 在更換場景的時候不被銷毀。
靜態(tài)類和靜態(tài)成員。
1.3.2 單例模式(best practice 版)
之前在?M_Studio 的小狐貍教程中講過單例模式。但是那是個非常不靠譜的單例模式。這回講的是單例模式的完整體。
1.3.2.1 基礎(chǔ)代碼
先創(chuàng)建一個 GameManager 空物體,掛載一個 GameManager 腳本。寫上如下之前已經(jīng)講過的代碼:

這個代碼問題很多,首先第一個,切換場景后就沒了。即使新場景也放一個,但那已經(jīng)不是它了。因此數(shù)據(jù)沒法持久化。
1.3.2.2 保留到第二個場景
于是我們剛剛提到的 DontDestroyOnLoad 不就來了嗎:

注意參數(shù)是 gameObject,不是 this。this 是這個腳本組件實例,gameObject 才是場景上的那個 GameManager 物體。
但是這并不是最終版,它仍然有巨大問題。
我們?nèi)y試一下現(xiàn)在的游戲,我們點 start,可以在 Hierarchy 里看到有一個專門的 DontDestroyOnLoad 的 Scene,里面可以看到 gameManager 還保留著,很好。但是我們再回到菜單看看。咦?怎么現(xiàn)在有兩個 GameManager 了?
原因:因為我們把 GameManager 從 menu scene 帶往 main scene ,然后我們帶著這個 GameManegr 回到 menu 的時候。menu 的物體又被全部加載(也就是實例化)一遍,包括一個新的 GameManager。畢竟 DontDestroyOnLoad 只是告訴 unity 不要在換場景的時候銷毀該物體。其他的表現(xiàn)和別的物體是一樣的。當(dāng)你加載某個場景的時候,就會實例化全部的 object。那這個自然也會被再次加載一遍。
ps. 在 DontDestroyOnLoad 的 hierarchy 里還可以看到一個 [Debug Updater] ,這是 Unity 自己用于 debug 的物體,不用管。
1.3.2.3 不再生成新的 GameManegr
不過,這個問題其實也很好解決。
首先我們知道兩件事:
靜態(tài)屬性的特點是,和實例無關(guān),所有地方共享同一個值。
因此,再次回到 menu 的時候,GameManage 的 Instance 已經(jīng)是有值的。
因此,我們對 Awake 里的代碼進行一些修改:

第一次實例化的時候,Instance 為 null,因此不會執(zhí)行 if 語句里的內(nèi)容。
第二次實例化的時候,Instance 因為是共享的靜態(tài)屬性,已經(jīng)有值了。于是執(zhí)行 if 語句里的內(nèi)容。銷毀自身。
發(fā)現(xiàn)沒有,我們其實沒辦法阻止到一個新場景的時候所有物體會被重新實例化一遍的過程。但我們可以在一開始就把該物體銷毀掉。Awake 作為最早的生命周期函數(shù)正好適合這個作用,避免其它副作用。
1.3.2.4 數(shù)據(jù)持久化
我們已經(jīng)有了一個會在所有場景中保持不變的物體。那數(shù)據(jù)持久化就變得相當(dāng)簡單了,只要把想持久化的數(shù)據(jù)存在這個物體上就可以了。
以前我還會在單例類里把一些其他屬性也寫成靜態(tài)。但是其實沒有必要,單例模式的類已經(jīng)可以代替靜態(tài)類了,反正都是同一個實例,怕什么。而且靜態(tài)和非靜態(tài)混用,在 GameManegr 類里面寫代碼會很難受,一會兒寫 this.xxx,一會兒寫 GameManegr.xxx 真的很容易混淆。不如好好享受單例模式的快樂,類里面全用非靜態(tài)的,類外面全用 GameManegr.Instance.xxx 就行了。
1.4 不同 session 中的持久化(存檔)
剛剛的持久化是在游戲中的持久化。還有一種持久化是在玩家離開游戲,下次玩的時候還存在的數(shù)據(jù),也就是存檔。
1.4.1 持久化過程
跨越游戲進程的持久化。需要一個文件來儲存數(shù)據(jù)。
但是除了儲存的行為以外,我們還要考慮到,游戲過程的數(shù)據(jù)是很復(fù)雜的。我們肯定不能這么直接存。我們需要將其轉(zhuǎn)化為易于儲存的數(shù)據(jù)形式。這一過程叫做序列化。反之,當(dāng)我們讀取數(shù)據(jù)的時候,將其轉(zhuǎn)化為游戲狀態(tài)信息的過程就叫做反序列化。
下圖便是描述了這種形式:

1.4.2 持久化數(shù)據(jù)形式
上圖看到其實中間的數(shù)據(jù)形式可以是很多種。畢竟我們有那么多儲存數(shù)據(jù)的格式。json,xml,數(shù)據(jù)庫,普通的文本文件,excel,等等等等。
以上說的這些全都可以用,事實上,以上這些也確實被用在各種游戲中過。
不過,在本教程中,我們使用 JSON 格式。原因無他,簡單,好用。
1.4.3 JsonUtility
Unity 自帶一個 JsonUtility 類來幫助我們使用 JSON。
1.4.3.1 序列化
假如我們有這么一個 object 信息需要序列化:
[Serializable]public class PlayerData {
? ?public int level; ? ?public Vector3 position; ? ?public string playerName;
}
那么,假如在游戲過程中,玩家就很有可能產(chǎn)生這樣的數(shù)據(jù):
_myPlayerData.level = 1;
_myPlayerData.position = new Vector3(1.0f, 2.0f, 3.0f);
_myPlayerData.playerName = "Joe";
那么我們可以用?JsonUtililty.ToJson(_myPlayerdata)
?來對其進行序列化,序列化的結(jié)果是這樣的(注意:方法返回的是個字符串):
{ ? ?"level": 1,
? ?"position": { "x" : 3.0, "y" : 4.4, "z" : 2.3 },
? ?"playerName": “John”
}
這里重點看第二個,因為 json 并沒有 Vector3 這樣的類型。
問題來了,明明我代碼里都沒有寫 x,y,z,這幾個字母哪來的呢? 答案是 Vector3 類的定義里:
// 極度簡化后的 Vector3 類定義[Serializable]public class Vector3 {
? ?public float x;
? ?public float y; ? ?public float z;
}
也就是說,當(dāng)遇到復(fù)雜的結(jié)構(gòu)(自定義類)的時候,就會去該類里找成員,還是挺智能的。
關(guān)于 JsonUtility 支持哪些類型請參考文檔。
JsonUtility 會自動忽略不能序列化的數(shù)據(jù),因此當(dāng)你沒看到一些屬性的時候,除了看一下有沒有 bug 以外。也要看一下是不是不支持這種類型。這種類型是否是 Serializable 的。
1.4.3.2 反序列化
反序列化用的方法叫做?JsonUtility.FromJson<T>
,接受一個 json string 作為參數(shù),泛型里指定反序列化之后的對象類型。
要把類名給出來,這也很合理,畢竟上面的 json 結(jié)構(gòu)可以對應(yīng)無數(shù)個類定義。
PlayerData _mySavePlayerData = JsonUtility.FromJson<PlayerData>(MySaveDataJsonString);
1.4.4 實踐
回到練習(xí)中。我們用 JsonUtility 試著做一個存檔出來看看。
我們在 GameManage 里添加一個新的類(仍然在 GameManager 的范圍里),里面存一個 TeamColor 變量如下:

看到后一定會有問題:
為啥不直接序列化 GameManager,又來個新的,上面已經(jīng)有了 TeamColor,新的類還要重新寫一遍。
那么,其實用 GameManager 是可以的,不過繼承了 MonoBehaviour,本身又有一大堆屬性方法啥的。JsonUtility 讀取的時候會稍微性能多耗損一點。用一個簡單的類來儲存就會比較快一些。
當(dāng)然了,這么點性能只是九牛一毛,最重要的是,這樣的話,也容易控制哪些需要儲存哪些不需要。以后想知道哪些數(shù)據(jù)被儲存了,一眼就能看出來也方便查找。
為什么是 private
public 也行,不過一般不會在其他地方用到存檔數(shù)據(jù)類。這樣作用域比較安全。
1.4.4.1 儲存方法
有了這個類,我們可以寫一個方法來儲存數(shù)據(jù)了,比如我們寫這么一個方法來儲存顏色:

File.WriteAllText 的使用需要添加命名空間 System.IO。
JsonUtility.ToJson 返回的是一個字符串。
Application.persistentDataPath 在不同的平臺上不一樣。具體路徑可以查看文檔。但總之不在項目文件里。PC 端在那個 AppData 一大堆里。
SaveColor 因為和 SaveData 在同一個類里,因此可以訪問 SavaData 類。這樣作用域比較安全。
1.4.4.2 加載方法
同理,寫一下加載方法:

先判斷文件是否存在。
1.4.4.3 綁定使用
首先我們要在游戲開始的時候加載存檔,我們選擇在 GameManager 的 Awake 函數(shù)里加載:

這里只是加載存檔了,我們還要把數(shù)據(jù)顯示到屏幕上。在 menu 的 Start 函數(shù)里把剛剛讀取的顏色填充上。
在退出的時候,存儲顏色。于是在 Exit 函數(shù)里使用剛剛寫的 SaveColor 方法。
游玩,選擇顏色,退出后。再進游戲,可以看到之前的顏色保留下來了!

也可以順便給 save color 等按鈕也加上功能。
2. 編程 tips 以及性能優(yōu)化
前面部分講一些 oop 的特性沒什么好說的。
后面有很多小 tips 倒是挺有趣的:
2.1 變量安全問題
2.1.1 安全的單例模式
之前的單例模式,雖然在使用上已經(jīng)是完整的。但是有一個安全問題,那就是可以在別的地方設(shè)置值,如果有人(雖然沒有這樣的人)在其他腳本文件里獲取這個 Instance 變量,然后進行賦值,這樣整個游戲就壞掉了。為了提高代碼的健壯性,也為了防止自己誤操作,可以改成如下聲明:
public static GameManager Instance {get; private set};
以上代碼表示,我是 public 字段,你們都可以訪問,但是想要賦值則只能在類內(nèi)部賦值。
2.1.2 安全的數(shù)值,或其他類型
像是有些數(shù)值字段如物品數(shù),在真實情況下不能小于 0,以及別的一些有真實含義限制的類型。為了防止別人,或者自己的誤操作或者在什么地方不小心賦值錯誤的時候,能夠迅速找到原因??梢越o變量添加 setter 來驗證。
2.2 使用 Profiler 進行性能分析和優(yōu)化
打開之前的叉車項目。有一個 Optimization 文件夾里有一個 scene。進入 play 模式可以發(fā)現(xiàn)會在屏幕上渲染超多叉車。很卡。

右上角的 stats 打開可以看到各種負載情況。
現(xiàn)在幀數(shù)相當(dāng)?shù)停? 幀。
當(dāng)然,只要把叉車數(shù)量減少。很容易就可以增加幀數(shù)。但是,假如我們就是想要 2000 個叉車出現(xiàn)在屏幕上還能 60 幀運行怎么辦?
那么,我們就要找出性能瓶頸了。
2.2.1 使用 Profiler 獲取數(shù)據(jù)信息
打開 Window -> Analuysis -> Profiler 窗口。 打開 Profiler 的錄制按鈕(默認應(yīng)該是開著的,就那個紅點)。然后進入游戲模式,便可以看到 Profiler 正在努力收集數(shù)據(jù)。差不多后,我們退出游戲模式??梢钥吹揭惶追浅T敿毜男阅軋D:

上半部分可以拉下來,有很多信息的使用情況。

本教程里,我們只看 CPU 信息。
2.2.1.1 CPU 信息分析
先看上部分的信息。

左邊顯示了不同顏色代表的操作。右邊的圖案則是顯示整個時間段里,不同操作花費的時間。
取消勾選,右邊就不會顯示了。因為有很多顏色相同,很難判斷這個藍色到底是 Scripts 還是 Animation??梢栽囍∠渲幸粋€,看右邊的變化就知道是哪個了。
可以看到,目前占大頭的是藍色的 Scripts(別當(dāng)成背景了),然后是綠色的 Others。其他顏色幾乎看不見。
那么,我們就大概知道 Scripts 中有大問題。之后我們會去找出問題。
2.2.1.2 下半部分的詳細信息
上面的圖表中有一個白色條條,表示當(dāng)前幀。而下半部分顯示的就是這一幀的信息。
拉動條上顯示著當(dāng)前這一幀花的全部時間:

可以看到這一幀花了 147ms。如果我我們想要達到 60 幀,那么我們需要把這個速度降到 1000/60 = 16 ms 左右。
2.2.1.3 Timeline 分析
Timeline 就是下面這每一幀的信息。左邊可以調(diào)整 Timeline 還是 Hierarchy 形式。現(xiàn)在我們就選擇比較直觀的 Timeline 形式。
2.2.1.4 第一行
先看第一行??赡苷б豢春孟裰挥幸粋€綠色的 PlayerLoop(截圖紅框標識了),但實際上后面還斷斷續(xù)續(xù)地跟著一些灰色的 EditorLoop(截圖紫框標識了)。

PlayerLoop 就是發(fā)生在游戲中的所有事情。EditorLoop 則是發(fā)生在編輯器中的事情。由于最后玩家玩的時候已經(jīng)沒有編輯器了,因此可以忽略掉這些 EditorLoop。
2.2.1.5 其它行
PlayerLoop 下面這幾行則是表示發(fā)生在應(yīng)用中的所有事,因此你看不到 EditorLoop。
下面這幾行是排序過的,從上到下越來越少。當(dāng)然你也可以說 PlayerLoop 在第一行是因為它作為一個綜合的,肯定是最長的。
顏色和上半部分是對應(yīng)的,藍色是 Scripts,綠色是 Others
2.2.1.6 藍色行
剛剛的圖中,那里有一個長長的藍色行,說明這一幀中有很大的 Script 開銷。(當(dāng)然了,我們早在之前大圖中就發(fā)現(xiàn)了所有幀中 Script 開銷都很大)
點擊這個藍色條,會告訴你這開銷是在哪里。我們點一下發(fā)現(xiàn)是在 OptimUnit 里的 Update 方法。這是教程里的物體上掛載的方法。

這里會顯示一個總體的值(截圖下方 115.46ms),并告訴我們有 2000 個 instances。以及某個 instance 的花費時間(上面的 0.062ms)。點擊這個 bar 的不同地方可以看到不同單位花費的時間,不過這個信息目前對我們沒用。
以上問題當(dāng)然是合理的,畢竟我們場景中就是有 2000 個物體。每個物體上都掛載著 OptimUnit。自然每一幀都會一共調(diào)用 2000 次。
那么,知道了是哪個方法當(dāng)然還不夠,畢竟誰不知道 Update 花銷大,大部分邏輯都在這里面,我們還想知道到底是哪一行代碼開銷這么大。
2.2.2 使用 Profiler 尋找代碼中開銷大的地方
去往 OptimUnit 的 Update 方法中看一下,大概可以分為 4 個部分:

于是我們要對每個部分設(shè)定一個采樣器,可以看一下第一部分的使用例:

一個開始采樣,和一個結(jié)束采樣。開始采樣里有一個字符串參數(shù),這是為了在 Profiler 顯示,用于區(qū)分不同的采樣。
就這樣依次把每個部分包住。
萬事俱備后,我們再到 Profiler 里錄制一次:

可以看到多出了新的一個藍色行,選中可以看到名字正是我們當(dāng)時寫的采樣字符串。
不過有個問題,我們并不能看到這 4 個采樣的分布情況,雖然鼠標點中的地方會顯示是哪個,我們可以在條上多點幾下,看下哪個出現(xiàn)的頻率高。不過這顯然不是正規(guī)做法,也不嚴謹。
正確的方法是,點截圖左邊紅框標識的 Timeline,之前我們用不上 Hierarchy,現(xiàn)在可以用上了:

一眼就看出來,3:move 很顯然是花銷最大的地方,遠超其它。
2.2.2.1 優(yōu)化代碼
我們看一下 Move 的代碼:

好家伙,擱這萃香玩元宇宙————糊弄鬼呢。
立刻把這個 for loop 取消掉,再打開 play mode 看一看,世界立刻就輕松了:
