頂上絕贊 Unity3D 免費(fèi)腳本插件大派送!

介紹一些個(gè)人覺得有意思的,免費(fèi)開源的 Unity3D Scripting Assets。

引言

現(xiàn)如今國內(nèi)國外做中低端游戲開發(fā)幾乎就只有一個(gè)選擇:Unity3D,客觀的講它應(yīng)該是歷史上最具有統(tǒng)治力的和壟斷性的游戲開發(fā)工具了。如果你在工作中用過 Unity3D,對(duì)它的感覺肯定是愛恨交加難以言喻。但有一點(diǎn)是絕對(duì)沒法否認(rèn)的,Unity3D 是第一個(gè)做到了活躍的面向開發(fā)者的市場(chǎng),即它的 Asset Store,你可以相對(duì)方便和廉價(jià)的找到各樣的開發(fā)工具并輕易的集成到你的游戲里。不過只要有買賣就有一句充滿中華人民智慧的俗語 "便宜沒好貨,好貨不便宜" 隨時(shí)警醒著你。但是軟件開發(fā)就是這么一個(gè)神奇的領(lǐng)域,很多超棒的東西它還真的就是免費(fèi)開源的,先不管程序員的這種浪漫主義情節(jié)是否會(huì)將這個(gè)行業(yè)推入無底深淵,這里介紹幾款我認(rèn)為非常棒的 Unity3D Asset。這幾個(gè)都是偏向編程開發(fā)的庫,有些我在白天的工作中用過,有些說實(shí)話只是折騰過一陣但沒有用在實(shí)際項(xiàng)目中。但因?yàn)檫@里一共列出的東西也不多,所以這里我會(huì)給出很詳細(xì)的理由以及相關(guān)的一些東西,希望能對(duì)讀者您的工作和生活有所幫助。

FullSerializer

FullSerializer on GitHub
FullInspector on Asset Store (這個(gè)是收費(fèi)的)

現(xiàn)如今應(yīng)該是沒有什么項(xiàng)目不用跟 JSON 打交道。如果你只是有一兩個(gè)簡單的 JSON 配置需要讀取那么其實(shí) Unity3D 5.3 以上的版本自帶了 JsonUtility 可以解決一部分問題,或者你也可以去找其他常見的 MiniJSON/SimpleJSON 都可以。但是如果你的游戲中有大量的數(shù)值使用 JSON 存儲(chǔ),那 FullSerializer 應(yīng)該是當(dāng)前最棒的解決方案,下文里簡稱 FS。

這里還是舉一個(gè) Shape 的玩具例子,我們有一個(gè) BaseShape 的基類,以及 Rect 這個(gè)具體的形狀類型,代碼如下:

[Serializable]
abstract class BaseShape {
    public string Name;
}

[Serializable]
class Rect : BaseShape {
    public float Width;
    public float Height;
}

對(duì)應(yīng) Rect 我有一個(gè)這樣子的 JSON:

// my awesome rect definition
{
    "Name" : "AwesomeRect"
    "Width" : 2,
    "Height" : 3
}

那么我要把這段 JSON 反序列化成一個(gè) Rect instance 的最蠢的辦法,應(yīng)該是類似這樣的:

var jsonNode = XXXJson.Load("path/to/myrect.json")
var rect = new Rect();
rect.Name = jsonNode["Name"].AsString;
rect.Width = jsonNode["Width"].AsFloat;
rect.Height = jsonNode["Height"].AsFloat;

這個(gè)流程具體的說就是通過某個(gè) JSON 庫我們手動(dòng)的把這個(gè) JSON 讀進(jìn)來,手動(dòng)創(chuàng)建一個(gè) Rect instance 然后按照名字一個(gè)個(gè)把數(shù)據(jù)填進(jìn)去。如果你經(jīng)常寫這樣的東西,那么你需要知道科技已經(jīng)發(fā)展到程序員不需要手動(dòng)寫這種反序列化代碼的時(shí)代了。在 FS 里你只要指定被反序列化的類型就 ok 了,等效于上面的代碼的是:

var serializer = new fsSerializer();

fsData data;
var rect = null;

fsJsonParser.Parse(jsonStr, out data); //  Parse JSON
serializer.TryDeserialize<Rect>(data, ref rect);  //  And deserialize

雖然在這個(gè)例子里面沒有顯得優(yōu)勢(shì)很大,但當(dāng)結(jié)構(gòu)比較復(fù)雜的時(shí)候這樣的方式明顯簡單很多,你也不用去手寫那種重復(fù)又容易出錯(cuò)的代碼。事實(shí)上這種方式應(yīng)該才是當(dāng)下比較科學(xué)的方式,而且上面提到的 Unity3D 新版本自帶的 JsonUtility 也僅提供類似這樣的用法。講起來 C# 里流行的 JSON.Net 和 .Net 自帶的 XML 序列化也支持也都支持類似的功能。

FS 比起上面提到的這些東西的優(yōu)勢(shì)在于,其是完全針對(duì) Unity3D 環(huán)境開發(fā)的一個(gè)庫,對(duì) Unity 本身的一些限制和移動(dòng)平臺(tái)適配都做的很好,如果你用過 JSON.Net 或者需要它的某些功能,F(xiàn)S 功能上也能比較好的替代,你也不用去用那種不知道誰改過一點(diǎn)的 Unity 適配版的 JSON.Net 了。

在功能上,F(xiàn)S 各方面也做的很全,本身它就支持 Unity 絕大多數(shù)內(nèi)部類型的序列化,同時(shí)也完美適配了泛型,C# 容器類像 Dictionary<> 這種東西的序列化。 如果你非常確定你只是需要把 JSON 讀進(jìn)來作為樹狀數(shù)據(jù)結(jié)構(gòu)來用的話,那 FS 里面的 fsJsonParser 可以把 JSON 解析成 fsData,功能上跟 MiniJSON 這一類東西幾乎一樣。

簡單易用的多態(tài)反序列化

下面再介紹幾個(gè)比較炫酷的功能。接上面 BaseShape 的例子,我們又加了一個(gè) Circle 的類:

[Serializable]
class Rect : BaseShape {
    public float Width;
    public float Height;
}

[Serializable]
class Circle : BaseShape {
    public float Radius;
}

對(duì)應(yīng)的我們有這樣一個(gè) JSON 文件:

[
    {
        "Name" : "MyAwesomeSquare",
        "Width" : 10,
        "Height" : 10,
    },
    {
        "Name" : "MyUnitCircle",
        "Radius" : 1,
    }
]

在最理想的情況下,我們其實(shí)希望它可以被反序列化成一個(gè) BaseShape[] 數(shù)組,并且數(shù)組中的成員是正確的派生類,拿上面例子來講就是被反序列化為 [<Rect>, <Circle>] 這樣。

這種情況雖然手動(dòng)處理也可以做,但隨著派生類的類型增多你的反序列化代碼也要對(duì)應(yīng)修改,就顯得很蠢。FS 在這里有一個(gè)非常簡單的辦法可以搞定這個(gè)問題:增加一個(gè) $type 域來提示類型。對(duì)上面的 JSON 稍做修改:

[
    {
        "$type" : "Rect", // <------ 標(biāo)記這是一個(gè) `Rect`
        "Name" : "MyAwesomeSquare",
        "Width" : 10,
        "Height" : 10,
    },
    {
        "$type" : "Circle", // <------ 標(biāo)記這是一個(gè) `Circle`
        "Name" : "MyUnitCircle",
        "Radius" : 1,
    }
]

反序列化的代碼幾乎沒有任何修改:

var data = fsJsonParser.Parse(txt.text);
var serializer = new fsSerializer();

BaseShape[] arr = null;
var result = serializer.TryDeserialize(data, ref arr);

Debug.Assert(result.Succeeded);
Debug.Assert(arr[0].GetType() == typeof(Rect));
Debug.Assert(arr[1].GetType() == typeof(Circle));

仔細(xì)想想的話,你應(yīng)該能體會(huì)到這個(gè)功能非常酷,它會(huì)允許你隨意的定義多態(tài)的數(shù)據(jù)結(jié)構(gòu),不用把所有可能出現(xiàn)的參數(shù)碾成平的一整列,你甚至還可以輕松的定義和讀取復(fù)雜的樹狀結(jié)構(gòu)。

這里有一點(diǎn)額外需要提的就是 $type 里面需要寫完整的,包括命名空間的全名,如果你覺得這樣不夠帥氣的話可能需要自己 patch 一下 FS。

支持自定義的讀寫邏輯

在多態(tài)支持之外,F(xiàn)S 還提供了像序列化前后回調(diào),特殊類型轉(zhuǎn)換處理的手動(dòng)處理的東西。這里也舉一個(gè)例子,比如我們想把 UnityEngine.Color 讀寫都用 HTML/CSS 里面的那種 "#RRGGBBAA" 字符串表示,下面是一個(gè)完整的例子。相關(guān)的文檔可以參閱[ FS 項(xiàng)目頁面]上的文檔。

public class ColorConverter : fsDirectConverter
{
    public override Type ModelType { get { return typeof(Color); } }

    public override object CreateInstance(fsData data, Type storageType)
    {
        return new Color();
    }

    public override fsResult TryDeserialize(fsData data, ref object instance, Type storageType)
    {
        if (data.IsString == false) return fsResult.Fail("expect string in " + data);

        Color color;
        bool parsed = ColorUtility.TryParseHtmlString(data.AsString, out color);
        if (!parsed) return fsResult.Fail("failed to parse color " + data.AsString);

        instance = color;
        return fsResult.Success;
    }

    public override fsResult TrySerialize(object instance, out fsData serialized, Type storageType)
    {
        serialized = new fsData("#" + ColorUtility.ToHtmlStringRGBA((Color)instance));
        return fsResult.Success;
    }
}

private void ColorConvererTest()
{
    var serializer = new fsSerializer();
    serializer.AddConverter(new ColorConverter());  // <-- 注冊(cè) Converter

    fsData colorData;
    serializer.TrySerialize<Color>(Color.red, out colorData);
    Debug.Assert(colorData.IsString && colorData.AsString == "#FF0000FF"); // `Color` 會(huì)被序列化成 "#RRGGBBAA" 的格式

    Color readColor = new Color();
    serializer.TryDeserialize<Color>(colorData, ref readColor);
    Debug.Assert(readColor == Color.red);   // 同樣能從這個(gè)格式正確的反序列化成 `Color`

    return;
}

我個(gè)人認(rèn)為 FS 應(yīng)該算是解決了 JSON 讀取上的絕大部分需求,講夸張點(diǎn)界限就是你的想象力了,但使用上我感覺有幾點(diǎn)需要注意一下。首先,聰明的你肯定發(fā)現(xiàn)了 FS 會(huì)大量運(yùn)用反射,其平臺(tái)兼容性問題倒是基本解決了,但效率上肯定還是有點(diǎn)問題,起碼不適合每一幀都調(diào)。FS 有提供 AOT 代碼生成的東西,但我個(gè)人并沒有用過不太好評(píng)價(jià)。還有就是如果你要使用它的 DLL 版本,需要注意 DLL 之間引起的一些類型查找的問題,有可能你從代碼版切換到 DLL 版就爆炸了,需要提前注意一下。

那么你應(yīng)該用 FullSerializer 嗎?

如果給我選的話我會(huì)用,按上面說的 FS 應(yīng)該是當(dāng)前 Unity 生態(tài)中最棒的 JSON 庫。當(dāng)然如果你根本沒有 JSON 讀寫需求的話那這個(gè)的確意義不大。

FullInspector

FS 的作者有一個(gè)收費(fèi)的插件叫做 FullInspector,廣告語是 "Inspect Everything",吹的就是他擴(kuò)展了 Unity3D Inspector 的功能到可以支持顯示任意類型的數(shù)據(jù)。

結(jié)果功能上它還真的做到了,你需要花很少的操作就可以讓你 MonoBehavior 里面的 Dictionary,其他非 MonoBehavior 的類型成員,上面提到的 BaseShape[] 數(shù)組,各種各樣的東西都可以在 Inspector 里面被正確顯示出來,同時(shí)你還可以在運(yùn)行時(shí)修改他們。先不管你需不需要在 Prefab 上存這么多東西,能夠?qū)崟r(shí)的看到所有的數(shù)據(jù)對(duì)于開發(fā)和調(diào)試真的是有巨大的幫助。如果你經(jīng)常點(diǎn)開 Unity 做的游戲的目錄里看他們用了什么插件的話,你會(huì)發(fā)現(xiàn) FullInspector 在外國游戲里非常常見,比如 Enter the Gungeon 和 Firewatch 兩者都用了。

UniRx

UniRx on GitHub

如果說快速排序這樣的算法是順序編程,那么這里簡單的把 "點(diǎn)擊按鈕彈出一個(gè)對(duì)話框,發(fā)送一個(gè)網(wǎng)絡(luò)包等待受到回復(fù)后彈出一個(gè)對(duì)話框" 這樣的邏輯代碼統(tǒng)稱為 “異步編程”。那么什么水平的程序員能輕松順利寫出沒有 bug 的異步編程代碼呢?

我覺得答案是沒有人能做到。因?yàn)檫@個(gè)叼東西實(shí)在是太特么麻煩了.. 比如點(diǎn)一個(gè)框彈出對(duì)話框,然后點(diǎn)擊對(duì)話框再彈出一個(gè)對(duì)話框,比如發(fā)送兩個(gè)包等待兩個(gè)都收到回復(fù),再加上其中任意一步可能會(huì)出錯(cuò),需要做異常處理和恢復(fù),逐漸開發(fā)者的意識(shí)就不太跟的上了迷一樣的邏輯了。正是因?yàn)楫惒骄幊屉y,所以最頂層的程序員一直在設(shè)計(jì)方案來簡化這個(gè)問題,從回調(diào)函數(shù)(以及回調(diào)函數(shù)地獄)開始出發(fā),到有 JS 里面的 promise/future, Unity3D 里面自帶的 coroutine 和 Python 里的 generator, 高級(jí)版本 C# 里的 async/await 等等。而 Reactive Programming (響應(yīng)式編程) 是當(dāng)下比較火的一個(gè)新的流行概念,其概念本身很模糊以至于各個(gè)語言里面有完全不一樣的定義。其中比較流行的一塊是微軟從 C# 的 Reactive Extension 庫后面推出來的 ReactiveX。它描述了一套統(tǒng)一名詞,各個(gè)語言的實(shí)現(xiàn)里公用著這些概念。這里的說的 UniRx 可以說是 ReactiveX C# 版在 Unity3D 里可用的 port。所以后面概念上的東西這里會(huì)用其簡稱 Rx 來指代。

當(dāng)你花點(diǎn)時(shí)間稍微了解一下 Rx 以后,你會(huì)發(fā)現(xiàn)它就是那種程序員夢(mèng)中的技術(shù)方案,它非常有野心的把多線程,網(wǎng)絡(luò)編程,UI 編程的問題都抽象成了同一個(gè)模型,核心只有兩三個(gè)簡單概念,然后需要新的功能只要無腦的實(shí)現(xiàn)一個(gè)接口取個(gè)名字,就可以橫向擴(kuò)展并跟之前的功能一起拼接出新的效果。

舉個(gè)例子,Rx 最為核心的兩個(gè)接口 IObserver/IObservable 負(fù)責(zé)了定義整個(gè)消息訂閱,取消,傳輸,異常處理和狀態(tài)同步,對(duì)應(yīng)的代碼只有 10 行整:

public interface IObservable<T>
{
    IDisposable Subscribe(IObserver<T> observer);
}
public interface IObserver<T>
{
    void OnCompleted();
    void OnError(Exception error);
    void OnNext(T value);
}

我感覺都可以想象到設(shè)計(jì)者自豪的表情,就是這么炫酷。因?yàn)?Rx 抽象的東西非常多,學(xué)習(xí)起來其實(shí)是有一些成本的,這里也不可能幾段話講清楚,所以下面會(huì)舉幾個(gè)玩具例子供感受一下它的功能,再列舉一些我認(rèn)為它比較重要的一些思想。

UniRx 基本功能

Rx 的核心思想是 "所有的異步事件都是由事件的組成的 Stream"。以一個(gè)游戲里面的 Player 單位為例,它有兩個(gè)屬性 HPSP,這里用到了 UniRx 里面的 ReactiveProperty

using UnityEngine;
using UniRx;

public class MyPlayer : MonoBehaviour {
    public ReactiveProperty<int> HP;
    public ReactiveProperty<int> SP;

    void Awake() {
        HP = new ReactiveProperty<int>(100);
        SP = new ReactiveProperty<int>(100);
    }

    void OnDestroy() {
        HP.Dispose();
        SP.Dispose();
    }
}

注意我們需要手動(dòng)創(chuàng)建這兩個(gè) ReactiveProperty,在自身被銷毀的時(shí)候還要手動(dòng)調(diào)用 Dispose() 來結(jié)束這個(gè) stream。不過其實(shí)你就算用 C# 的 event 來做的話這些事情其實(shí)也要手動(dòng)做,所以其實(shí)差別不大。

對(duì)應(yīng)這兩個(gè)屬性,我們兩個(gè) UI 組件用來顯示 PlayerHP, SP 值,比起每一幀都去更新,我們更希望在有變化的時(shí)候去更新顯示。用 Rx 的語言,ReactiveProperty 是一個(gè)事件流 (Observable Stream),它代表的是 HP 值的變化事件,我們需要做的是 訂閱(Subsribe) HP 的 Stream,在收到事件的時(shí)候更新 UI 顯示,對(duì)應(yīng)代碼如下:

using UnityEngine;
using UniRx;

public class MyPlayer : MonoBehaviour {
    public ReactiveProperty<int> HP;
    public ReactiveProperty<int> SP;

    public UnityEngine.UI.Text HPDisplay;
    public UnityEngine.UI.Text SPDisplay;

    void Awake() {
        HP = new ReactiveProperty<int>(100);
        SP = new ReactiveProperty<int>(100);

        HP.Subscribe(x => HPDisplay.text = x.ToString());   // <-- Subscribe HP 變動(dòng)
        SP.Subscribe(x => SPDisplay.text = x.ToString());   // <-- Subscribe SP 變動(dòng)
    }

    void OnDestroy() {
        HP.Dispose();
        SP.Dispose();

        return;
    }
}

這里其實(shí)跟常見的回調(diào)其實(shí)看起來差不多,但有一點(diǎn)很酷的是雖然你在 HP 設(shè)定了初始值之后才進(jìn)行的 Subscribe,但初始值會(huì)正確的設(shè)置上去。

到這里看起來其實(shí)還沒有什么特別酷的東西,所以我們用 Rx 的方法來創(chuàng)建一個(gè) isDead 屬性,這個(gè)會(huì)在 HP 值小于等于 0 的時(shí)候被設(shè)置為 true

public class MyPlayer : MonoBehaviour {
    public ReactiveProperty<int> HP;
    public ReactiveProperty<int> SP;
    public ReactiveProperty<bool> IsDead;   // <--- 定義 IsDead Field

    public UnityEngine.UI.Text HPDisplay;
    public UnityEngine.UI.Text SPDisplay;

    void Awake()
    {
        HP = new ReactiveProperty<int>(100);
        SP = new ReactiveProperty<int>(100);

        IsDead = HP.Select(x => x <= 0).ToReactiveProperty(); // <-- 通過 Select Operator 來創(chuàng)建 IsDead

        HP.Subscribe(x => HPDisplay.text = x.ToString());
        SP.Subscribe(x => SPDisplay.text = x.ToString());
    }

    void OnDestroy()
    {
        HP.Dispose();
        SP.Dispose();
    }
}

Rx 中的 Operator(操作符) 可以用來將 Stream 進(jìn)行變換成為你想要的東西,這個(gè)跟 C# LINQ 的概念很像,區(qū)別在于你處理的東西是動(dòng)態(tài)的一個(gè)事件流。這里用到的 Select Operator 跟 LINQ 里的 Select 幾乎一樣,就是對(duì)單個(gè) Stream 進(jìn)行變換后生成新的 Stream,它跟手動(dòng)定義的 Stream 在使用上基本上是一樣的。

為了強(qiáng)行演示一下更高級(jí)的功能,我們創(chuàng)建一個(gè)新的 Observable IsCritical,在 HP 和 SP 都小于 10 的時(shí)候會(huì)被變?yōu)闉?true,同時(shí)我們將訂閱它并在第一次發(fā)生的時(shí)候打印 Log。

void MakeIsCritical()
{
    var isCritical = HP
        .CombineLatest(SP, (hp, sp) => Tuple.Create(hp, sp))
        .Select(x => x.Item1 <= 10 && x.Item2 <= 10); // <- 注意這里都不需要把 isCritical 作為 MyPlayer 的 field

    IDisposable unsubHandle = null;
    unsubHandle = isCritical.Subscribe(x => {
        if (x) {
            Debug.Log("is critical");
            unsubHandle.Dispose();  //  <-- 取消自己的訂閱
        }
    });
}

這里 isCriticalCombineLatest 來將 HPSP 合并到一起,在任意一個(gè)值產(chǎn)生變化的時(shí)候產(chǎn)生一個(gè)新的消息,之后還是用 Select 來判斷是否符合目標(biāo)值。需要注意的是 isCritical 是一個(gè)局部變量,但它的生命周期會(huì)長于這個(gè)函數(shù)調(diào)用,至少跟 HPSP 一樣長。在訂閱的地方,我們記錄下了 Subscribe 調(diào)用的返回值,這個(gè)是唯一可以用來取消這個(gè)訂閱的 handle。這一部分寫的不是很科學(xué),主要還是為了演示 Rx 的功能。

Rx 相關(guān)概念

上面例子里的東西只是 UniRx 功能中很小的一部分,這里再歸納一下我認(rèn)為 Rx 其中幾個(gè)重要的東西:

  1. 所有異步的東西都是 Observable,上面例子里的 HP 值變化,到用戶點(diǎn)擊屏幕,到網(wǎng)絡(luò)收發(fā)包等等
    如果你按照 Rx 的概念來做的話這些東西都應(yīng)該是 Observable Stream。UniRx 也提供了各種方法來將像
    UI 的回調(diào)和一些 Unity3D 的 Event 來簡單的轉(zhuǎn)換成 Observable
  2. Rx 跟狀態(tài)機(jī)某種意義上是互斥的,即我感覺 Rx 的一個(gè)目的就是為了省去狀態(tài)的處理,如果你需要一個(gè)狀態(tài)你應(yīng)該做的是用相關(guān)的東西組合出一個(gè)新的 Observable
  3. 使用 Rx 并不需要寫新的 class,作為用戶除非你要寫新的 Operator,否則是不需要寫新的 class 的,你看例子就可以看出來 Rx 的例子里大量使用了匿名函數(shù),用戶代碼幾乎沒有說需要 subclass 某一個(gè)基類的情況。

如果你對(duì) Rx 感興趣的話,光靠上面這點(diǎn)介紹其實(shí)絕對(duì)是不夠的,這里推薦一些相關(guān)資源:

  1. Intro To Rx
    這是一個(gè)介紹最初 C# 版 Rx 的文檔,內(nèi)容比較多但講的非常清楚。
  2. ReactiveX Operators Reference
    跨語言的 Rx Operator 文檔,當(dāng)你需要一個(gè)什么 Operator 的話最好在這里先找。
  3. The introduction to Reactive Programming you've been missing
    這其實(shí)是一個(gè) RxJS 的教程,但有一個(gè)比較完整而且高級(jí)一點(diǎn)的例子,這也體現(xiàn)了 Rx 的概念真的是跨語言跨框架的。

那么你應(yīng)該用 UniRx 嗎?

如果給我選的話我傾向于不用在工作的項(xiàng)目里面,因?yàn)?Rx 概念比較多上手有點(diǎn)難,最理想的情況是你需要把所有的東西都轉(zhuǎn)成 Rx 的寫法,但游戲開發(fā)里面奇奇怪怪的東西太多了,不知道是不是真的可以處理所有的情況。另一方面是 Rx 里面這些 Observable 的生命周期感覺不太可控,讓人稍微有點(diǎn)害怕。

但如果你已經(jīng)決定了需要一個(gè)啥 Data Binding 或者 MVVM 啥的東西弄上去做 UI 的話,UniRx 應(yīng)該算是一個(gè)比較好的選擇,起碼他真的是科學(xué)家研究出來的一個(gè)方案,在其他領(lǐng)域的確有應(yīng)用。還是按上面講的,了解一下 Rx 對(duì)當(dāng)前最潮的 Reactive Programming 起碼能有個(gè)概念,幫助你選擇這方面的技術(shù)方案。

FlatBuffers

FlatBuffers on GitHub

這里提示一下,這一整段其實(shí)算是一個(gè)關(guān)于技術(shù)方案調(diào)研的一個(gè)例子,聰明的你需要自己客觀的做出判斷。

如果你的游戲涉及到網(wǎng)絡(luò)通訊的話,你必須面臨的一個(gè)問題就是找一個(gè) Wire Format 的方案,即通過網(wǎng)絡(luò)傳輸?shù)陌淖x寫。這其實(shí)也是一個(gè)序列化的問題,但比起上面講的 FullSerializer 這種讀寫復(fù)雜 JSON 的,Wire Format 需要解決的問題是跨平臺(tái)跨語言以及快速的序列化,還有包體大小壓縮這些問題。注意我們這里把需要的解決方案限定在實(shí)時(shí)網(wǎng)絡(luò)用 Wire Format 的序列化這個(gè)層面上,評(píng)價(jià)面向這個(gè)東西方案的標(biāo)準(zhǔn)我這邊簡單概括為:

  1. 有跨變成語言的實(shí)現(xiàn),并且跨語言之間序列化的內(nèi)容都能讀取
  2. 對(duì)效率有一定要求,因?yàn)樽x寫相對(duì)頻繁。
  3. 讀寫代碼需要相對(duì)簡單,能比較容易的添加和修改包格式。
  4. API 使用起來要比較簡潔。
  5. 序列化后的數(shù)據(jù)要能比較小,要支持可選的域 (optional fields)。

現(xiàn)在 Unity3D 環(huán)境下這方面常見的解決方案是 protobuf.net,這是一個(gè)非常保險(xiǎn)的選擇,因?yàn)闋t石就是用的這個(gè)。拋開 protobuf 這個(gè)協(xié)議來說的話,我個(gè)人在初次調(diào)研 protobuf.net 這個(gè)實(shí)現(xiàn)的時(shí)候發(fā)現(xiàn)有幾個(gè)問題:

  1. 對(duì) GC 不夠友好,雖然反序列化方法里面你可以傳入一個(gè)已經(jīng)預(yù)先創(chuàng)建好的 instance 進(jìn)去,但像對(duì)數(shù)組和一些數(shù)據(jù)類型的讀取用的 BitConverter 會(huì)產(chǎn)生一些無法避免的 GC。
  2. 對(duì)反序列化出來的 instance,很難重用。比如我想把拿到的 buffer 讀到這同一個(gè) instance 里面,其實(shí) protobuf.net 并不太支持這種方式,主要原因也是因?yàn)槟銢]法輕松的重置一個(gè) instance 到初始值,加上 protobuf 有可選域,讀取的時(shí)候并不會(huì)寫那些沒有傳輸?shù)臄?shù)據(jù)。
  3. 本身難以擴(kuò)展和修改,這個(gè)可能是因?yàn)槲覀€(gè)人水平還不過,或者說時(shí)間預(yù)算不夠,我發(fā)現(xiàn)雖然 protobuf.net 是開源的但是我很難把我想要的功能添進(jìn)去。

在尋找替代方案的時(shí)候,發(fā)現(xiàn) Unity3D 能用的還有 SilentOrbit.Protobuf 這另外一個(gè) protobuf 的實(shí)現(xiàn),還有就是同屬于 Google 的另一個(gè)類似的解決方案 FlatBuffers

FlatBuffers 的“廣告”

你應(yīng)該知道其實(shí)不管是開源還是商業(yè)的技術(shù)產(chǎn)品都是會(huì)向開發(fā)者做市場(chǎng)操作的。如果這個(gè)事實(shí)讓你大吃一驚的話,那你可能還是太天真了哈哈... 這方面一個(gè)著名的例子就是 MongoDB,這個(gè)被宣傳到成幾乎等價(jià)于 NoSQL 的項(xiàng)目,圍繞它有著各種各樣的爆料爭吵嘲諷,其原因我個(gè)人認(rèn)為就是 MongoDB 的宣傳主力在強(qiáng)調(diào)它的好用高效速度快,但對(duì)一些敏感的邊界的技術(shù)問題則含糊而過,導(dǎo)致很多人在投入一些時(shí)間學(xué)習(xí)后大失所望由愛生恨。不過反過來講也可以理解,在介紹一個(gè)軟件項(xiàng)目的時(shí)候你肯定希望別人先知道它有多么炫酷,麻煩的地方就放到后面再說吧。對(duì)于 FlatBuffers 來說,這個(gè)還沒有到"市場(chǎng)操作"這個(gè)級(jí)別,但是它自己提供的介紹還是有一些微妙的地方。

根據(jù) FlatBuffers 的主頁面上的介紹,以及其 Benchmark,然后下下來簡單跑了一下例子,我初步歸納出來這么幾點(diǎn):

  1. 符合對(duì)實(shí)時(shí)序列化庫的期望,即跨語言的實(shí)現(xiàn),單獨(dú)的協(xié)議描述格式,可選域這些 Protobuf 支持的功能它都有。
  2. FlatBuffers 是所謂的"下一代序列化方案",即它只做序列化不做反序列化,這樣一來則省去了收到數(shù)據(jù)時(shí)的反序列化開銷。
  3. 根據(jù)它提供的 Benchmark,比起其他類似的方案效率高,數(shù)據(jù)打包后容量小,內(nèi)存開銷小,總體來說全放位領(lǐng)先其他同類方案。
  4. 同樣是生成代碼的解決方案,F(xiàn)latBuffers 運(yùn)行時(shí)的庫很小,C# 只有三四個(gè)文件加起來一兩千行。生成的 class 也相對(duì)比較簡單。
  5. 有著名用戶,比如 cocos 的序列化格式全部都是用的這個(gè) (確認(rèn)過 cocos 3.0 后的確是的)。

這里先提前聲明一下上面列出的這幾點(diǎn)全部都是客觀正確的事實(shí)。我估計(jì)你看到這里應(yīng)該在想臥槽這個(gè)這個(gè)太刁了為什么還沒有全世界的程序員都在用這個(gè)。但殘酷的現(xiàn)實(shí)是 FlatBuffers 有一些特性會(huì)導(dǎo)致它并不一定符合你的應(yīng)用場(chǎng)景,下面會(huì)逐條列舉。

FlatBuffers 微妙的地方

最開始的一點(diǎn),我總覺得 FlatBuffers 并不是一個(gè)面向?qū)崟r(shí)網(wǎng)絡(luò)序列化這個(gè)問題來設(shè)計(jì)的一個(gè)解決方案,當(dāng)你仔細(xì)閱讀文檔的時(shí)候你會(huì)發(fā)現(xiàn)它雖然跟 protobuf 和其他 JSON 庫來做比較,但它并沒有提到"網(wǎng)絡(luò)"這個(gè)關(guān)鍵字。這里有一個(gè)比較關(guān)鍵的原因是它的包格式中根據(jù)設(shè)計(jì)就有很多留空的 padding,以及其 vtable 的設(shè)計(jì)。

比如你有這樣的一個(gè)協(xié)議格式:

table DamageData {
    damageA:float;
    damageB:float;
    damageC:float;
    /* D - W 省略,反正總共有 26 個(gè)  */
    damageX:float;
    damageY:float;
    damageZ:float;
}

FlatBuffers 中每一個(gè) field 都是可選的,即你顯示的在序列化的時(shí)候添加了它才會(huì)被寫入包中。對(duì)于這個(gè)問題 protobuf 中是用 tag 來實(shí)現(xiàn)的,比如對(duì)于 damageZ 它需要定義其 tag26,然后寫入的時(shí)候?qū)懙氖?[26,<damageZ>] 這樣前綴 tag 后面寫數(shù)據(jù)的做法。那么 FlatBuffers 里面 field 不需要手動(dòng)指定 tag,還仍然支持無限制的 field 添加,它到底是用的什么魔法來做到這個(gè)的呢?其答案就是 vtable,這個(gè)東西對(duì) DamageData 的例子來講,它每一個(gè)包都會(huì)固定寫 26 個(gè) 2byte 的 ushort,其中內(nèi)容就是每一個(gè) field 的偏移。舉一個(gè)例子比如我們一個(gè) DamageData 里面只有一個(gè) damageA = 10,那么不考慮對(duì)其, FlatBuffers 序列化后的包大小是 26 * 2 + 4 == 56bytes,在考慮對(duì)其以后可能會(huì)到 70bytes 左右,對(duì)比來說 protobuf 的情況下這個(gè)包大概是 1 + 4 = 5bytes,這個(gè)已經(jīng)是夸張到數(shù)量級(jí)的差距。

那么你說為什么它的 Benchmark 的結(jié)果里包大小比 protobuf 還小呢?原因是 protobuf 期望的是不是所有的 field 都被寫,tag 的方案在所有域全寫的時(shí)候跟上面 vtable 的做法可能是差不多或者還要差一點(diǎn)的。但對(duì)于網(wǎng)絡(luò)傳輸?shù)挠美@個(gè)的確有點(diǎn)不能接受。其實(shí)比較適合它的應(yīng)用場(chǎng)景是序列化 10kb 以上的東西到文件存儲(chǔ),而 vtable 方面針對(duì)這個(gè)情況也會(huì)有優(yōu)化,比如所有值都完全相同的 vtable 不會(huì)被寫多份。

第二點(diǎn)就是反序列化的開銷,關(guān)于這一點(diǎn)在跟 FlatBuffers 同類型的 Cap’n Proto 的主頁上就有一個(gè)圖表,說 "寫/讀一個(gè)來回的時(shí)間是 0 微秒,比 protobuf 快無盡倍",這個(gè)可以看成對(duì)不認(rèn)真評(píng)估技術(shù)方案的開發(fā)者的嘲諷。有個(gè)比較好理解的思路就是,你可以認(rèn)為你要解決的技術(shù)問題是固定的,那不管用什么技術(shù)方案它的最低開銷和代價(jià)是固定的,不同的技術(shù)方案肯定就是在這個(gè)界限上面把開銷挪到不同的地方。

那么 FlatBuffers 讀寫的開銷到底是什么呢?其實(shí) FlatBuffers 和 Cap'n Proto 這種"下一代序列化"方案在序列化的時(shí)候跟傳統(tǒng)方案差不多,會(huì)需要按一個(gè)特定的規(guī)則來把數(shù)據(jù)打包成某種特殊的格式,差別就在反序列化的地方,相比于 protobuf 會(huì)把所有數(shù)據(jù)按規(guī)則反序列化成一個(gè)當(dāng)前語言環(huán)境下的 object,F(xiàn)latBuffers 這種也會(huì)提供一個(gè) object 來讀取數(shù)據(jù),但它并不會(huì)把整段的數(shù)據(jù)完全反序列化,而是當(dāng)你每用到一個(gè) field 的值的時(shí)候,回去定位這個(gè)數(shù)據(jù)在下面 buffer 中的偏移,然后現(xiàn)場(chǎng)去讀這個(gè)值。簡單講就是反序列化的開銷從 object 創(chuàng)建的地方放到了每一次 object 屬性調(diào)用的地方。這個(gè)變動(dòng)帶來了兩個(gè)相關(guān)的影響:

  1. FlatBuffer 反序列化出的 object 不太容易被修改,F(xiàn)latBuffers 自己也只是提供了有限的修改功能。
  2. 調(diào)用處的開銷意味著你寫代碼的時(shí)候,在頻繁訪問的時(shí)候正確的做法是把值拷貝一份,不然就會(huì)有效率問題。

第三點(diǎn)其實(shí)就是 FlatBuffers 的 API,在 protobuf.net 的情況下你用它生成的代碼的話,其實(shí)也是用一個(gè)普通的 POCO,你還是可以像一個(gè)普通的 object 一樣來使用它。但 FlatBuffers 提供的 API,在序列化的時(shí)候你其實(shí)是并沒有一個(gè) object 可以拿來用的,你需要用它生成的一系列靜態(tài)方法來把一個(gè)個(gè)屬性寫到緩存里,以官方 Sample 里的例子:

// Serialize the FlatBuffer data.
var name = builder.CreateString("Orc");
var treasure =  new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
var inv = Monster.CreateInventoryVector(builder, treasure);
var weapons = Monster.CreateWeaponsVector(builder, weaps);
var pos = Vec3.CreateVec3(builder, 1.0f, 2.0f, 3.0f);

Monster.StartMonster(builder);
Monster.AddPos(builder, pos);
Monster.AddHp(builder, (short)300);
Monster.AddName(builder, name);
Monster.AddInventory(builder, inv);
Monster.AddColor(builder, Color.Red);
Monster.AddWeapons(builder, weapons);
Monster.AddEquippedType(builder, Equipment.Weapon);
Monster.AddEquipped(builder, weaps[1].Value);
var orc = Monster.EndMonster(builder);

這段例子里雖然沒有顯示提到,但 FlatBuffer 的調(diào)用順序是有眼哥要求的。比如 string 類型的 field 需要在 table 創(chuàng)建前先調(diào)用,不然就會(huì)報(bào)錯(cuò);比如 struct 必須在 table "Start" 后寫入,不然就會(huì)報(bào)錯(cuò),反正需要注意的地方非常非常多,就算是用過一陣子以后還是很容易出錯(cuò)。一個(gè)更重要的問題是在邏輯上你并不再是把一個(gè) object 序列化/反序列化,因?yàn)槟阋阉拿恳豁?xiàng)都手動(dòng)寫入,而且之后讀出來的東西也不再是原始的類型,你拿到的是一個(gè) FlatBuffer 的 Table/Struct,加上上面提到的它們不太適合被修改,這個(gè)對(duì)于有些用例非常的致命。

那么你應(yīng)該用 FlatBuffers 嗎?

如果你看到這里覺得我次奧 FlatBuffers 真是蠢爆了,那其實(shí)事實(shí)也并不是這樣,有些用例下加上一些處理它其實(shí)是非常棒的一個(gè)技術(shù)方案。

  1. 因?yàn)?FlatBuffer 反序列化后的東西其實(shí)是指向一段數(shù)據(jù) buffer 的 Table/Struct,你可以非常容易的重置里面的內(nèi)容,這樣激進(jìn)的緩存就比較可能,你可以每一個(gè) Table object 都只創(chuàng)建一份,每次都用它來指向不同的 buffer 就可以了。相比起來一個(gè) POCO 的內(nèi)容很難完美重置。
  2. FlatBuffer 生成的文件和運(yùn)行時(shí)庫都比較簡單,你可以很輕易的做修改,再加上其靜態(tài)的序列化方法,很適合用代碼生成來輔助序列化你邏輯代碼里用到的類型。
  3. 其序列化包比較大的問題其實(shí)是有現(xiàn)成解決方案的,Cap'n Proto 和 sproto 中都描述了一種 "zero packing" 的方法,可以大幅減少數(shù)據(jù)中連續(xù)的 0 所占的空間,而且效率上問題也不大。
  4. 有些情況下,"Wire Format" 對(duì)應(yīng)的類型并不一定適合在邏輯代碼中用,而 FlatBuffers 很好的把這個(gè)邊界區(qū)分開來了 - 因?yàn)槟阈蛄谢臅r(shí)候連 object 都沒得用。
  5. 像上面提到的,如果你的用例里面需要讀寫的東西特別大,或者說讀的時(shí)候很多屬性不會(huì)訪問到,那 FlatBuffers 就很棒了,因?yàn)樗娴目欤矣幸粋€(gè)確定的標(biāo)準(zhǔn),比自己亂寫的序列化格式還是要好很多。

所以是否應(yīng)該用這個(gè)難題,最終還是得具體的開發(fā)者根據(jù)具體情況來進(jìn)行調(diào)研,這個(gè)道理應(yīng)該還是不會(huì)變的。只是要小心有意無意的廣告,多花時(shí)間嘗試和比較才是硬道理。

最后

這里額外還有兩個(gè)沒法撐夠長篇幅的東西

  1. Dotween,感覺應(yīng)該是 tween 庫里面比較好的選擇。
  2. Unity Test Tools,官方的測(cè)試工具集,其實(shí) 5.3 里面已經(jīng)集成了 Editor Test Runner。

最后其實(shí)如果你插件用的多的話應(yīng)該就有感覺,其實(shí) Unity 項(xiàng)目里大量引入第三方代碼并不一定是個(gè)好主意,需要承擔(dān)風(fēng)險(xiǎn)除了 bug 以外還有其不支持新 Unity 這些等等等等。但說實(shí)話現(xiàn)在做個(gè) Unity 游戲什么插件都不用幾乎是不可能的,所以花時(shí)間評(píng)估真的是非常重要。希望這里列出的幾個(gè) Assets 和對(duì)應(yīng)的介紹能夠幫到你。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容