Unity插件開發基礎—淺談序列化系統

原文鏈接:Unity插件開發基礎-淺談序列化系統 - UWA Blog

這是侑虎科技第296篇原創文章,感謝作者Jintiao供稿,歡迎轉發分享,未經作者授權請勿轉載。當然,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群:465082844)

作者Github:github.com/jintiao


一、前言

在使用Unity進行遊戲開發過程中,離不開各式各樣插件的使用。然而儘管現成的插件非常多,有時我們還是需要自己動手去製作一些插件。進入插件開發的世界,就避免不了和序列化系統打交道。

可以說Unity編輯器很大程度上是建立在序列化系統之上的,一般來說,編輯器不會去直接操作遊戲對象,需要與遊戲對象交互時,先觸發序列化系統對遊戲對象進行序列化,生成序列化數據,然後編輯器對序列化數據進行操作,最後序列化系統根據修改過的序列化數據生成新的遊戲對象。

就算不需要與遊戲對象交互,編輯器本身也會不斷地對所有編輯器窗口觸發序列化。如果在製作插件時沒有正確地處理序列化甚至忽略序列化系統的存在,做出來的插件很可能會不穩定經常報錯,導致數據丟失等後果。

下面的例子展示的是我們新接觸插件開發時最常遇到的一種異常情況:插件本來運行地好好地,點了一下播放後插件就發瘋地不斷報錯,某個(些)對象莫名被置空了:

如果你曾經遇到過這種情況,而且不明白為什麼,這篇文章應該能解答你的疑惑。


二、序列化是什麼

根據Unity的官方定義,序列化就是將數據結構或對象狀態轉換成可供Unity保存和隨後重建的自動化處理過程。

「Serialization is the automatic process of transforming data structures or object states into a format that Unity can store and reconstruct later.」n

很多引擎功能會自動觸發序列化,比如

  • 文件的保存/讀取,包括Scene、Asset、AssetBundle,以及自定義的ScriptableObject等。
  • Inspector窗口
  • 編輯器重載入腳本腳本
  • Prefab
  • Instantiation

三、序列化規則

既然序列化是一個自動化的過程,那我們能做什麼呢,是不是只能坐在一邊看系統自己表演呢?並不是,序列化確實是一個自動化過程,但引擎並不是完美人工智慧,系統的功能受到序列化規則的限制。我們能做的,是通過規則告訴系統,哪些數據需要序列化,哪些數據不需要序列化。

序列化規則簡單來說有兩點,一是類型規則,系統據此判斷能不能對對象進行序列化;二是欄位規則,系統據此判斷該不該對對象進行序列化。當對象同時滿足類型規則和欄位規則時,系統就會對該對象進行序列化。

  • 類型規則

  • 欄位規則

我們通過例子1來具體講解一下。我們定義了兩個類,一個叫MyClass,另一個叫MyClassSerializable

public class MyClass {n public string s;n}nn[Serializable]npublic class MyClassSerializable {n public float f1;n [NonSerialized]public float f2;n private int i1;n [SerializeField]private int i2;n}n

接下來我們定義一個插件類SerializationRuleWindow

public class SerializationRuleWindow : EditorWindow {n public MyClass m1;n public MyClassSerializable s1;n private MyClassSerializable s2;n}n

點擊編輯器菜單"Window -> Serialization Test -> Test 1 - Serialization Rule"打開插件窗口,可以看到窗口中顯示著所有對象當前的值,並且可以通過滾動條修改各個對象的值。一切看起來很美好,接下來我們退出編輯器再重新打開,看看插件窗口會出現什麼變化:

可以看到,s1的兩個成員f1和i2保存了原來的值,其它成員都被清零了,我們來具體分析一下為什麼會是這樣。

編輯器退出前會對所有打開的窗口進行序列化並保存序列化數據到硬碟。在重啟編輯器後,序列化系統讀入序列化數據,重新生成對應的窗口對象。在對我們的插件對象SerializationRuleWindow進行序列化時,只有滿足序列化規則的對象的值得以保存,不滿足規則的對象則被序列化系統忽略。

我們來仔細看一下規則判定的情況。

首先看public MyClass m1,它的類型是MyClass,屬於「沒有標記[Serializable]屬性的類」,不滿足類型規則;它的欄位是public,滿足欄位規則;系統要求兩條規則同時滿足的對象才能序列化,於是它被跳過了。

接下來看public MyClassSerializable s1,它的類型是MyClassSerializable,屬於標記了[Serializable]屬性的類,滿足類型規則;它的欄位是public,滿足欄位規則;s1同時滿足類型規則和欄位規則,系統需要對它進行序列化操作。

序列化是一個遞歸過程,對s1進行序列化意味著要對s1的所有類成員對象進行序列化判斷。所以現在輪到s1中的成員進行規則判斷了。

public float f1,類型float是c#原生數據類型,滿足類型規則;欄位是public,滿足欄位規則;判斷通過。

[NonSerialized]public float f2,欄位被標記了[NonSerialized],不滿足欄位規則。

private int i1,欄位是private,不滿足欄位規則。

[SerializeField]private int i2,類型int是c#原生數據類型,滿足類型規則;欄位被標記了[SerializeField],滿足欄位規則;判斷通過。

所以s1中f1和i2通過了規則判斷,f2和i1沒有通過。所以圖中s1.f1和s1.i2保留了原來的值。

最後我們看private MyClassSerializable s2,這時相信我們都能輕易看出來,private不滿足欄位規則,s2被跳過。


四、跨過序列化的坑

上一節我們通過例子1了解了序列化的規則,我們發現我們好像已經掌握了序列化系統的秘密。但!是!別高興太早,這個世界並不是我們想像的這麼簡單,現在是時候讓我們來面對系統複雜的一面了。

1. 熱重載(hot-reloading)

對腳本進行修改可以即時編譯,不需要重啟編輯器就看看到效果,這是Unity編輯器的一個令人稱讚的機制。你有沒有想過它是怎麼實現的呢?答案就是熱重載。

當編輯器檢測到代碼環境發生變化(腳本被修改、點擊播放)時,會對所有現存的編輯器窗口進行熱重載序列化。等待環境恢復(編譯完成、轉換到播放狀態)時,編輯器根據之前序列化的值對編輯器窗口進行恢復。

熱重載序列化與標準序列化的不同點是,在進行熱重載序列化時,欄位規則被忽略,只要被處理對象滿足類型規則,那麼就對其進行序列化。

我們可以通過運行之前講解序列化規則時的例子1來對比熱重載序列化與標準序列化的區別。

記得上一節我們是通過退出重啟編輯器觸發的標準序列化,現在我們通過點擊播放觸發熱重載序列化,運行結果如下。

可以看到,之前由於欄位為private的s1.i1以及s2都進行了序列化。同時我們也注意到標記了[NonSerialized]的s1.f2和s2.f2、沒有標記[Serializable]的m1依然被跳過了。

2. 引擎對象的序列化

我們把UnityEngine.Object及其派生類(比如MonoBehaviour和ScriptableObject)稱為Unity引擎對象,它們屬於引擎內部資源,在序列化時和其他普通類對象的處理機制上有著較大的區別。

引擎對象特有的序列化規則如下:

  • 引擎對象需要單獨進行序列化。
  • 如果別的對象保存著引擎對象的引用,這個對象序列化時只會序列化引擎對象的引用,而不是引擎對象本身。
  • 引擎對象的類名必須和文件名完全一致。

對於插件開發,我們最可能接觸到的引擎對象就是ScriptableObject,我們通過例子2來講解ScriptableObject的序列化。

我們新定義一個編輯器窗口ScriptableObjectWindow,和一個繼承自ScriptableObject的類

public class MyScriptableObject : ScriptableObject {n public int i1;n public int i2;n}nnpublic class ScriptableObjectWindow : EditorWindow {n public MyScriptableObject m;n void OnEnable() {n if(m == null)n m = CreateInstance<MyScriptableObject>();n }n}n

我們把m的欄位設為public確保系統會對它進行序列化。我們來看運行結果:

可以看到,m的InstanceId在熱重載後發生了變化,這意味著原來m所引用的對象丟失了,ScriptableObjectWindow只能重新生成一個新的MyScripatable對象給m賦值。

回看第二條規則,我們知道ScriptableObjectWindow序列化時只會保存m對象的引用。在編輯器狀態變化後,m所引用的引擎對象被gc釋放掉了(序列化後ScriptableObjectWindow被銷毀,引擎對象沒有別的引用了)。所以編輯器在重建ScriptableObjectWindow時,發現m是個無效引用,於是將m置空。

那麼,如何避免m引用失效呢?很簡單,將m保存到硬碟就行了。對於引擎對象的引用,Unity不光能找到已經載入的內存對象,還能在對象未載入時找到它對應的文件進行自動載入。在例子3,我們在創建MyScriptableObject對象的同時將其保存到硬碟,確保其永久有效。

public class SavedScriptableObjectWindow : EditorWindow {n public MyScriptableObject m;n void OnEnable() {n if(m == null) { // 注意只在新開窗口時 m 才會為 nulln string path = "Assets/Editor/Test3-SavedScriptableObject/SaveData.asset";n // 先嘗試從硬碟中讀取assetn m = AssetDatabase.LoadAssetAtPath<MyScriptableObject>(path);n if(m == null) { // 當asset不存在時創建並保存n m = CreateInstance<MyScriptableObject>();n AssetDatabase.CreateAsset(m, path);n AssetDatabase.SaveAssets();n AssetDatabase.Refresh();n }n }n }n}n

運行,我們可以看到m引用的對象再也不會丟失了。

最後簡單說一下第三條規則,類名與文件名相同這是Unity的硬性規定,比如MyScriptableObject對應的文件名必須是MyScriptableObject.cs。如果你發現編輯器在啟動時,而且只在啟動時報序列化錯誤,很大可能是因為類名和文件名不同所導致的。

3. 普通類對象的序列化

由於每個ScriptableObject對象都需要單獨保存,如果插件使用了多個ScriptableObject對象,保存這些對象意味著多個文件,而大量的零碎文件意味著讀取速度會變慢。

如果你在考慮這個問題,不妨將目光轉向普通類。和引擎對象不一樣,普通類對象是按值存儲的,所以我們可以將所有的普通類對象混在一起保存成單一文件。

然而按值序列化也有自己的問題,我們下面一一進行說明。

  • 不支持空引用

在例子4里,我們定義了兩個普通類:MyObject和MyNestedObject:

[Serializable]npublic class MyNestedObject {n public int data;n}nn[Serializable]npublic class MyObject {n public MyNestedObject obj2;n}nnpublic class NullReferenceWindow : EditorWindow {n public MyObject obj;nn void OnEnable() {n if(obj == null) {n obj = new MyObject();n }n }n}n

可以看到,我們讓MyObject保存一個MyNestedObject的引用,但不去初始化它,初次運行的時候我們知道它是一個空引用。我們來看看經過序列化後會有什麼變化:

哈,系統幫我們生成了一個MyNestedObject對象!

通過測試我們知道,當系統對普通類對象進行序列化時,會自動給空引用生成對象。在我們的測試例子里,這個功能好像沒有帶來負面影響。但是在特定情況下會導致序列化失敗,比如說帶有同類的引用。

來看下面的鏈表類

[Serializable]npublic class MyListNode {n public int data;n public MyListNode next;n}n

這在我們的代碼中很常見,也能正常運行,因為next最終會為空,意味著我們的鏈表是有盡頭的。但是到了序列化系統里,回想一下,對啊序列化系統不允許有空引用,系統會幫我們無限地把這個鏈錶鏈下去!當然,實際上系統檢測到這種情況會主動終止序列化,但這意味著我們的類無法正常地進行序列化了。

  • 不支持多態

普通類序列化的另一個問題是不支持多態對象。在編碼中我們使用一個基類引用指向一個派生類對象,這是很正常的設計。然而這種設計在序列化中卻無法正常運作。

來看例子5,首先我們定義了一系列的類代表不同的動物

[Serializable]npublic class Animal {n public virtual string Species { get { return "Animal"; } }n}nn[Serializable]npublic class Cat : Animal {n public override string Species { get { return "Cat"; } }n}nn[Serializable]npublic class Dog : Animal {n public override string Species { get { return "Dog"; } }n}nn[Serializable]npublic class Giraffe : Animal {n public override string Species { get { return "Giraffe"; } }n}nn[Serializable]npublic class Zoo {n public List<Animal> animals = new List<Animal>();n}n

在Zoo類中,我們使用List來記錄動物園中的所有動物。我們來看看序列化系統會怎麼對待我們的動物

可以看到,序列化之後我們的貓狗都被放跑了,這可不是我們想要的結果。


五、自定義序列化

如之前所說,序列化功能有著各種各樣的限制,而我們的項目需求千變萬化,實際用到的數據結構只會比本文的例子複雜百倍。如何讓這些更複雜的數據結構和序列化系統友好地合作呢?

答案是自定義序列化。Unity為我們提供了ISerializationCallbackReceiver介面,允許我們在序列化前後對數據進行操作。它並不能讓系統直接處理你的複雜數據結構,但它給了你機會讓你把數據"加工"成為系統能支持的形式。

1. 多態對象序列化

還記得我們例5的動物園嗎,由於系統不支持多態對象造成了數據丟失,現在我們嘗試通過自定義序列化來修正這個問題。 在例子6中,我們重新定義了Zoo類讓它支持自定義序列化。

[Serializable]npublic class Zoo : ISerializationCallbackReceivern{n [NonSerialized]n public List<Animal> animals = new List<Animal>();nn [SerializeField]n private List<Cat> catList;n [SerializeField]n private List<Dog> dogList;n [SerializeField]n private List<Giraffe> giraffeList;n [SerializeField]n private List<int> indexList;nn public void OnBeforeSerialize() {n catList = new List<Cat>();n dogList = new List<Dog>();n giraffeList = new List<Giraffe>();n indexList = new List<int>();nn for(int i = 0; i < animals.Count; i++) {n var type = animals[i].GetType();n if(type == typeof(Cat)) {n indexList.Add(0);n indexList.Add(catList.Count);n catList.Add((Cat)animals[i]);n }n if(type == typeof(Dog)) {n indexList.Add(1);n indexList.Add(dogList.Count);n dogList.Add((Dog)animals[i]);n }n if(type == typeof(Giraffe)) {n indexList.Add(2);n indexList.Add(giraffeList.Count);n giraffeList.Add((Giraffe)animals[i]);n }n }n }nn public void OnAfterDeserialize() {n animals.Clear();n for(int i = 0; i < indexList.Count; i += 2) {n switch(indexList[i]) {n case 0:n animals.Add(catList[indexList[i + 1]]);n break;n case 1:n animals.Add(dogList[indexList[i + 1]]);n break;n case 2:n animals.Add(giraffeList[indexList[i + 1]]);n break;n }n }nn indexList = null;n catList = null;n dogList = null;n giraffeList = null;n }n}n

我們為Zoo添加了ISerializationCallbackReceiver介面,在序列化之前,系統會調用OnBeforeSerialize,我們在這裡把List一分為三:List、List,以及List。新生成的三個鏈表用於序列化,避免多態的問題。在反序列化之後,系統調用OnAfterDeserialize,我們又把三個鏈表合為一個供用戶使用。我們來看這樣的處理能否解決問題

2. Dictionary容器序列化

在實踐中,Dictionary容器也是經常使用的容器類。系統不支持Dictionary容器的序列化給我們造成了不便,我們也可以通過自定義序列化來解決,我們通過下文的例7來說明。

[Serializable]npublic class Info {n public float number1;n public int number2;n}nn[Serializable]npublic class InfoBook : ISerializationCallbackReceivern{n [NonSerialized]n public Dictionary<int, Info> dict = new Dictionary<int, Info>();nn [SerializeField]n private List<int> keyList;n [SerializeField]n private List<Info> valueList;nn public void OnBeforeSerialize() {n keyList = new List<int>();n valueList = new List<Info>();n var e = dict.GetEnumerator();n while(e.MoveNext()) {n keyList.Add(e.Current.Key);n valueList.Add(e.Current.Value);n }n }nn public void OnAfterDeserialize() {n dict.Clear();nn for(int i = 0; i < keyList.Count; i++) {n dict[keyList[i]] = valueList[i];n }nn keyList = null;n valueList = null;n }n}n

和之前的處理相似,我們在序列化之前,將Dictionary中的Key和Value分別保存到兩個List中,然後在反序列化之後重新生成Dictionary數據,運行結果如下:


六、參考

[1] Unity Manual - Script Serialization

[2] Unity Manual - Custom Serialization

[3] Serialization in-depth with Tim Cooper


文末,感謝Jintiao的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群:465082844)。

也歡迎大家來積极參与U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!

推薦閱讀:

這個牛的碎片化效果是怎麼做出來的?
【辦公軟體】- Office Tab,在微軟 Office 2003-2013 和 2016 的一個標籤視窗中打開多個文檔
Excel快速填充與自定義格式的合體工具#Transform Data by Example
Vue.js 插件開發詳解
最好用的 IntelliJ 插件 Top 10

TAG:插件 | unity | Android开发 |