天天看點

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

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

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

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

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

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

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

根據Unity的官方定義,序列化就是将資料結構或對象狀态轉換成可供Unity儲存和随後重建的自動化處理過程。

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

檔案的儲存/讀取,包括Scene、Asset、AssetBundle,以及自定義的ScriptableObject等。

Inspector視窗

編輯器重加載腳本腳本

Prefab

Instantiation

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

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

類型規則

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

字段規則

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

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

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

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

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

可以看到,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來對比熱重載序列化與标準序列化的差別。

記得上一節我們是通過退出重新開機編輯器觸發的标準序列化,現在我們通過點選播放觸發熱重載序列化,運作結果如下。

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

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

2. 引擎對象的序列化

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

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

引擎對象需要單獨進行序列化。

如果别的對象儲存着引擎對象的引用,這個對象序列化時隻會序列化引擎對象的引用,而不是引擎對象本身。

引擎對象的類名必須和檔案名完全一緻。

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

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

我們把m的字段設為public確定系統會對它進行序列化。我們來看運作結果:

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

可以看到,m的InstanceId在熱重載後發生了變化,這意味着原來m所引用的對象丢失了,ScriptableObjectWindow隻能重新生成一個新的MyScripatable對象給m指派。

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

那麼,如何避免m引用失效呢?很簡單,将m儲存到硬碟就行了。對于引擎對象的引用,Unity不光能找到已經加載的記憶體對象,還能在對象未加載時找到它對應的檔案進行自動加載。在例子3,我們在建立

MyScriptableObject對象的同時将其儲存到硬碟,確定其永久有效。

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

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

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

3. 普通類對象的序列化

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

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

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

不支援空引用

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

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

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

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

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

來看下面的連結清單類

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

不支援多态

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

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

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

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

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

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

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

1. 多态對象序列化

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

我們為Zoo添加了ISerializationCallbackReceiver接口,在序列化之前,系統會調用OnBeforeSerialize,我們在這裡把List一分為三:List、List,以及List。新生成的三個連結清單用于序列化,避免多态的問題。在反序列化之後,系統調用OnAfterDeserialize,我們又把三個連結清單合為一個供使用者使用。我們來看這樣的處理能否解決問題

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

2. Dictionary容器序列化

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

和之前的處理相似,我們在序列化之前,将Dictionary中的Key和Value分别儲存到兩個List中,然後在反序列化之後重新生成Dictionary資料,運作結果如下:

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

原文出處:侑虎科技

本文作者:admin

轉載請與作者聯系,同時請務必标明文章原始出處和原文連結及本聲明。