Unity序列化系統

轉載至https://blog.uwa4d.com/archives/2025.html

作者Github:https://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.”

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

  • 1 文件的保存/讀取,包括Scene、Asset、AssetBundle,以及自定義的ScriptableObject等。

  • 2 Inspector窗口

  • 3 編輯器重加載腳本腳本

  • 4 Prefab

  • 5 Instantiation

三、序列化規則

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

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

image

· 字段規則

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

public class MyClass {
    public string s;
}

[Serializable]
public class MyClassSerializable {
    public float f1;
    [NonSerialized]public float f2;
    private int i1;
    [SerializeField]private int i2;
}

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

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

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

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


image

可以看到,之前由于字段為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 {
    public int i1;
    public int i2;
}

public class ScriptableObjectWindow : EditorWindow {
    public MyScriptableObject m;
    void OnEnable() {
        if(m == null)
            m = CreateInstance<MyScriptableObject>();
    }
}

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

image

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

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

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

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

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

image

3. 普通類對象的序列化

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

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

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

  • 不支持空引用
    在例子4里,我們定義了兩個普通類:MyObject和MyNestedObject:
[Serializable]
public class MyNestedObject {
    public int data;
}

[Serializable]
public class MyObject {
    public MyNestedObject obj2;
}

public class NullReferenceWindow : EditorWindow {
    public MyObject obj;

    void OnEnable() {
        if(obj == null) {
            obj = new MyObject();
        }
    }
}

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


image

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

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

來看下面的鏈表類

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

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

  • 不支持多態

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

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

[Serializable]
public class Animal {
    public virtual string Species { get { return "Animal"; } }
}

[Serializable]
public class Cat : Animal {
    public override string Species { get { return "Cat"; } }
}

[Serializable]
public class Dog : Animal {
    public override string Species { get { return "Dog"; } }
}

[Serializable]
public class Giraffe : Animal {
    public override string Species { get { return "Giraffe"; } }
}

[Serializable]
public class Zoo {
    public List<Animal> animals = new List<Animal>();
}

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


image

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

五、自定義序列化

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

答案是自定義序列化。Unity為我們提供了ISerializationCallbackReceiver接口,允許我們在序列化前后對數據進行操作。它并不能讓系統直接處理你的復雜數據結構,但它給了你機會讓你把數據"加工"成為系統能支持的形式。
1. 多態對象序列化
還記得我們例5的動物園嗎,由于系統不支持多態對象造成了數據丟失,現在我們嘗試通過自定義序列化來修正這個問題。 在例子6中,我們重新定義了Zoo類讓它支持自定義序列化。

[Serializable]
public class Zoo : ISerializationCallbackReceiver
{
    [NonSerialized]
    public List<Animal> animals = new List<Animal>();

    [SerializeField]
    private List<Cat> catList;
    [SerializeField]
    private List<Dog> dogList;
    [SerializeField]
    private List<Giraffe> giraffeList;
    [SerializeField]
    private List<int> indexList;

    public void OnBeforeSerialize() {
        catList = new List<Cat>();
        dogList = new List<Dog>();
        giraffeList = new List<Giraffe>();
        indexList = new List<int>();

        for(int i = 0; i < animals.Count; i++) {
            var type = animals[i].GetType();
            if(type == typeof(Cat)) {
                indexList.Add(0);
                indexList.Add(catList.Count);
                catList.Add((Cat)animals[i]);
            }
            if(type == typeof(Dog)) {
                indexList.Add(1);
                indexList.Add(dogList.Count);
                dogList.Add((Dog)animals[i]);
            }
            if(type == typeof(Giraffe)) {
                indexList.Add(2);
                indexList.Add(giraffeList.Count);
                giraffeList.Add((Giraffe)animals[i]);
            }
        }
    }

    public void OnAfterDeserialize() {
        animals.Clear();
        for(int i = 0; i < indexList.Count; i += 2) {
            switch(indexList[i]) {
            case 0:
                animals.Add(catList[indexList[i + 1]]);
                break;
            case 1:
                animals.Add(dogList[indexList[i + 1]]);
                break;
            case 2:
                animals.Add(giraffeList[indexList[i + 1]]);
                break;
            }
        }

        indexList = null;
        catList = null;
        dogList = null;
        giraffeList = null;
    }
}

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


image

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

[Serializable]
public class Info {
    public float number1;
    public int number2;
}

[Serializable]
public class InfoBook : ISerializationCallbackReceiver
{
    [NonSerialized]
    public Dictionary<int, Info> dict = new Dictionary<int, Info>();

    [SerializeField]
    private List<int> keyList;
    [SerializeField]
    private List<Info> valueList;

    public void OnBeforeSerialize() {
        keyList = new List<int>();
        valueList = new List<Info>();
        var e = dict.GetEnumerator();
        while(e.MoveNext()) {
            keyList.Add(e.Current.Key);
            valueList.Add(e.Current.Value);
        }
    }

    public void OnAfterDeserialize() {
        dict.Clear();

        for(int i = 0; i < keyList.Count; i++) {
            dict[keyList[i]] = valueList[i];
        }

        keyList = null;
        valueList = null;
    }
}

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

image
六、參考

[1] Unity Manual - Script Serialization

[2] Unity Manual - Custom Serialization

[3] Serialization in-depth with Tim Cooper

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容