Unity 編輯器擴展和序列化踩坑記(一)
來自專欄 windsmoon 的遊戲相關4 人贊了文章
本篇文章未經作者本人授權,禁止任何形式的轉載,謝謝!如果在第三方閱讀發現公式等格式有問題,請到個人博客地址或知乎地址閱讀。
windsmoon:Unity 編輯器擴展和序列化踩坑記(一)Unity 編輯器擴展和序列化踩坑記(一)概要
最近兩三周在寫 Unity 的編輯器擴展,主要是給項目開發一個地圖編輯器,期間遇到了一些坑,所以有了開個系列記錄一下的想法。這是第一篇,但我希望不要再有第二篇了。
我目前使用的版本是 Unity 2017.1.3.f1。
問題與解決方案
數據序列化
寫編輯器擴展,如果需要保存數據到文件,序列化數據是必不可少的。我目前用過兩個方案,一個是利用 Unity 自身的序列化系統配合 ScriptableObject,一個是利用 C# 的序列化 API ,這兩種方式各有優劣。
C# 序列化
C# 序列化方便快捷,但是在 Unity 中不一定好用。如果需要用 C# 的序列化,class 或者 struct 必須加上 Serializable 這個 Attribute,然而可能大部分你想序列化的 C# 內置 class 或者 struct,都沒加這個 Attribute,例如 VectorX 系列,還有 ScriptableObject。之前需要用 C# 序列化的時候,Vector2 和 Vector3 等我都自己實現了一個可序列化版本。
C# 序列化在 Unity 中還有一個問題,就是不如內置序列化系統方便,因為 Unity 的序列化幾乎每時每刻都在進行,雖然有相應介面,但你必須確保在必須要序列化的時機進行序列化,以免數據被清空。
Unity 內置序列化
Unity 內置序列化系統以及一些配套組件(例如 ScriptableObject 等)在 Unity 中能保證序列化和反序列化的數據正確性,並且幾乎不需要關心序列化的時機,只需要指明哪些數據需要序列化即可。一般來說,在會被 Unity 進行序列化的內置類中,public 欄位和被 SerializeField 這個 Attribute 修飾的欄位會被序列化,例如 MonoBehaviour 中的欄位,ScriptableObject 也是,其他自定義類我沒驗證過,但是 private 的我都加了 SerializeField 這個標籤沒有問題,public 的也可以正確序列化。
但是不是所有類型都能序列化的!在常用的需要序列化的類中,Dictionary 和二維數組以及其他有嵌套關係的容器(目前看來是這樣,不能保證所有這種容器一定不能被序列化)不能被序列化。好在都有解決辦法。
序列化 Dictionary
雖然 Dictionary 不能被 Unity 序列化,但是 List 是可以的,我們可以把 Dictionary 的 keys 和 values 保存在兩個 List 里,這樣就可以序列化和反序列化字典了。注意我不是說要用兩個 List 完全代替 Dictionary 這個數據結構,這樣就失去了 Dictionary 的數據結構特性了,我們只需要在 Unity 進行序列化和反序列化的時候把 Dictionary 挪到兩個 List 里和從 兩個 List 里復原 Dictionary 就行了。Unity 進行序列化和反序列化的時機是可以知道的,關鍵在於一個 interface。
using UnityEngine.Scripting;namespace UnityEngine{ [RequiredByNativeCode] public interface ISerializationCallbackReceiver { /// <summary> /// <para>Implement this method to receive a callback before Unity serializes your object.</para> /// </summary> void OnBeforeSerialize (); /// <summary> /// <para>Implement this method to receive a callback after Unity deserializes your object.</para> /// </summary> void OnAfterDeserialize (); }}
這個 interface 有兩個方法,OnBeforeSerialize 在將要序列化的時候執行,OnAfterDeserialize 在反序列化完成後執行。
所以我們只需要在 OnBeforeSerialize 中把 keys 和 values 保存到兩個 List 里,在 OnAfterDeserialize 中根據兩個 List 中的數據重建一個 Dictionary 就行了。
public void OnBeforeSerialize(){ keyList.Clear(); valueList.Clear(); foreach (var pair in dataDict) { keyList.Add(pair.Key); valueList.Add(pair.Value); }}public void OnAfterDeserialize(){ dataDict.Clear(); for (int i = 0; i < keyList.Count; ++i) { dataDict[keyList[i]] = valueList[i]; }}
要注意的是這個 interface 目前不支持 struct 。
嵌套容器
如果是類似這樣的數據,
Dictionary<int, X[]>
則裡面的 X[] 是不能被序列化的,但是如果你將 X[] 寫在一個類里。
public class XWrapper{ [SerializeField] private X[] Xs;}
外部容器保存這個 XWrapper ,就可以正確序列化數組中的數據了。所以如果你在使用嵌套容器的時候出現問題,可以考慮這種方法。
內存數據清空
寫編輯器代碼的時候,會有一些情況導致內存中的數據被清空,我判斷內存數據被清空重置的方式是觀察靜態構造函數在控制台的輸出。目前我遇到的情況有:
- 編譯代碼:代碼修改後 Unity 會進行重新編譯,並把內存清空重置,重新載入代碼。例如一個沒有被序列化的變數被賦值以後,再修改代碼,編譯完成後這個變數的值就為 null 或者默認的值類型數據了。我在某個類的靜態構造函數內輸出一段字元串到控制台,當重新編譯代碼的時候,訪問這個類,控制台會進行輸出,但之後再次訪問就不會,這說明代碼沒有被重新載入,但是當更改代碼並進行編譯後,第一次訪問這個類依然會有輸出,這足以說明問題。這裡有一個需要注意的點是,修改代碼後,並不會立即進行編譯。一般來說,會等一會兒才開始編譯,且代碼編譯的時候會有卡頓,並且編譯完成後控制台里也會輸出各種警告(如果有的話),這時候就說明編譯完成了。也就是說你必須等待一會兒才行,如果你改完代碼後立即回到編輯器里執行你寫的代碼,這時候實際上還是之前的代碼。有一個小技巧是可以改完代碼點運行,運行前會進行代碼編譯,運行開始就說明是新的代碼了,這樣做的好處是你可以確保代碼是新的代碼,但必須注意運行也會把沒序列化的數據清除。
- 運行:點擊運行按鈕的時候,會進行數據的重置,我在某個 MonoBehaviour 的 Awake 方法里訪問上面提到的類,每次都會有輸出。但是運行還需要注意的是,Unity 點擊運行會把沒有進行序列化的數據丟掉,這時退出運行,之前在編輯器狀態下設置好的數據就沒了。序列化的數據,這裡是指 MonoBehaviour 中訪問修飾符為 public 的變數和擁有 SerializeField 這個 Attribute 的變數。
退出運行不會進行內存重置,但很重要 ,因為退出也相當於載入了場景,會執行 Awake 等相關函數,如果你有一些初始化函數在這時候執行,你需要注意它是否會影響到你。
ScriptableObject
這個類看似很簡單,但是第一次使用卻花費了我很長時間來與其搏鬥。這個類主要用來進行數據交互,它不需要掛在 GameObject 上,可以作為單獨的數據存儲來使用。在寫編輯器擴展的時候使用它,有不少地方需要注意。
ScriptableObject 與 Asset
ScriptableObject 可以保存為 Asset,而且實際使用時發現,如果你不保存成 Asset,相關的引用就會在某些時候變為空。例如使用 ScriptableObject.CreateInstance 創建 一個 ScriptableObject 後,把它保存在容器里,然後函數結束。當你下次再用,就會發現這個引用變為 null 了。如果 ScriptableObject.CreateInstance 之後把這個 ScriptableObject 保存為 Asset,則沒有問題。
刪除 ScriptablObject Asset
如果使用諸如 AssetDatabase.LoadAsset<> 之類的函數把 ScriptableObject Asset 載入進來,然後保存在一個 MonoBehaviour 的變數里,當你刪除這個 Asset 時,這個變數也為空了。也就是說,如果是直接載入這個 ScriptableObject Asset,當 Asset 被刪除,載入進來的對象也沒了(我原本以為掛在物體上的數據還會保留 )。
這個不注意可能會有一些致命的問題,例如你有個 ScriptableObject Asset,裡面可能還有很多子 ScriptableObject Asset,策劃想要用你的編輯器繼續做上次沒完成的工作,於是把上次保存好的 ScriptableObject Asset 載入進來進行操作,操作完畢後進行保存,可能會直接選擇上次的路徑,代碼里可能會使用 AssetDatabase.CreateAsset 等方法,這實際上會覆蓋原先的資源,也就是說之前的資源被刪掉了。這時問題就來了,資源被刪除後,Scene 中的數據也沒了,這時保存代碼執行,自然什麼也保存不到。
解決方案是載入後使用 Instantiate<> 去複製出一個 ScriptableObject,之後都操作這個複製出來的,或者乾脆用 ScriptableObject.CreateInstance 創建一個新的(要注意上面提到的問題,最好創建完後立即使用或者掛在物體上)。
枚舉
保存數據的時候,儘可能不要用枚舉作為 key ,因為一旦枚舉因為一些原因,數值有變化,數據可能取不出來。可以考慮轉成字元串。參考了下 C# 的源碼,枚舉在進行 GetHashCode 的時候,貌似是用它的數值進行操作的。
結語
這次暫時寫這麼多,再次希望不要有第二篇。以上如果有朋友知道更準確的原因,可以留言。感謝大家閱讀。
推薦閱讀:
※什麼是VPN?全方位講解VPN是什麼意思?
※三國混戰 會獵遊戲機
※[GTD]時間管理健身房 兩個下一步清單 - Linux技術討論/軟體/遊戲下載區 - 手...
※PS4與Switch對比評測 | 遊戲機選購指南