顶上绝赞 Unity3D 免费脚本插件大派送!

介绍一些个人觉得有意思的,免费开源的 Unity3D Scripting Assets。

发布于 2016.9.6

引言

现如今国内国外做中低端游戏开发几乎就只有一个选择:Unity3D,客观的讲它应该是历史上最具有统治力的和垄断性的游戏开发工具了。如果你在工作中用过 Unity3D,对它的感觉肯定是爱恨交加难以言喻。但有一点是绝对没法否认的,Unity3D 是第一个做到了活跃的面向开发者的市场,即它的 Asset Store,你可以相对方便和廉价的找到各样的开发工具并轻易的集成到你的游戏里。不过只要有买卖就有一句充满中华人民智慧的俗语 "便宜没好货,好货不便宜" 随时警醒着你。但是软件开发就是这么一个神奇的领域,很多超棒的东西它还真的就是免费开源的,先不管程序员的这种浪漫主义情节是否会将这个行业推入无底深渊,这里介绍几款我认为非常棒的 Unity3D Asset。这几个都是偏向编程开发的库,有些我在白天的工作中用过,有些说实话只是折腾过一阵但没有用在实际项目中。但因为这里一共列出的东西也不多,所以这里我会给出很详细的理由以及相关的一些东西,希望能对读者您的工作和生活有所帮助。

FullSerializer

FullSerializer on GitHub
FullInspector on Asset Store (这个是收费的)

现如今应该是没有什么项目不用跟 JSON 打交道。如果你只是有一两个简单的 JSON 配置需要读取那么其实 Unity3D 5.3 以上的版本自带了 JsonUtility 可以解决一部分问题,或者你也可以去找其他常见的 MiniJSON/SimpleJSON 都可以。但是如果你的游戏中有大量的数值使用 JSON 存储,那 FullSerializer 应该是当前最棒的解决方案,下文里简称 FS。

这里还是举一个 Shape 的玩具例子,我们有一个 BaseShape 的基类,以及 Rect 这个具体的形状类型,代码如下:

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

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

对应 Rect 我有一个这样子的 JSON:

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

那么我要把这段 JSON 反序列化成一个 Rect instance 的最蠢的办法,应该是类似这样的:

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;

这个流程具体的说就是通过某个 JSON 库我们手动的把这个 JSON 读进来,手动创建一个 Rect instance 然后按照名字一个个把数据填进去。如果你经常写这样的东西,那么你需要知道科技已经发展到程序员不需要手动写这种反序列化代码的时代了。在 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

虽然在这个例子里面没有显得优势很大,但当结构比较复杂的时候这样的方式明显简单很多,你也不用去手写那种重复又容易出错的代码。事实上这种方式应该才是当下比较科学的方式,而且上面提到的 Unity3D 新版本自带的 JsonUtility 也仅提供类似这样的用法。讲起来 C# 里流行的 JSON.Net 和 .Net 自带的 XML 序列化也支持也都支持类似的功能。

FS 比起上面提到的这些东西的优势在于,其是完全针对 Unity3D 环境开发的一个库,对 Unity 本身的一些限制和移动平台适配都做的很好,如果你用过 JSON.Net 或者需要它的某些功能,FS 功能上也能比较好的替代,你也不用去用那种不知道谁改过一点的 Unity 适配版的 JSON.Net 了。

在功能上,FS 各方面也做的很全,本身它就支持 Unity 绝大多数内部类型的序列化,同时也完美适配了泛型,C# 容器类像 Dictionary<> 这种东西的序列化。 如果你非常确定你只是需要把 JSON 读进来作为树状数据结构来用的话,那 FS 里面的 fsJsonParser 可以把 JSON 解析成 fsData,功能上跟 MiniJSON 这一类东西几乎一样。

简单易用的多态反序列化

下面再介绍几个比较炫酷的功能。接上面 BaseShape 的例子,我们又加了一个 Circle 的类:

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

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

对应的我们有这样一个 JSON 文件:

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

在最理想的情况下,我们其实希望它可以被反序列化成一个 BaseShape[] 数组,并且数组中的成员是正确的派生类,拿上面例子来讲就是被反序列化为 [<Rect>, <Circle>] 这样。

这种情况虽然手动处理也可以做,但随着派生类的类型增多你的反序列化代码也要对应修改,就显得很蠢。FS 在这里有一个非常简单的办法可以搞定这个问题:增加一个 $type 域来提示类型。对上面的 JSON 稍做修改:

[
    {
        "$type" : "Rect", // <------ 标记这是一个 `Rect`
        "Name" : "MyAwesomeSquare",
        "Width" : 10,
        "Height" : 10,
    },
    {
        "$type" : "Circle", // <------ 标记这是一个 `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));

仔细想想的话,你应该能体会到这个功能非常酷,它会允许你随意的定义多态的数据结构,不用把所有可能出现的参数碾成平的一整列,你甚至还可以轻松的定义和读取复杂的树状结构。

这里有一点额外需要提的就是 $type 里面需要写完整的,包括命名空间的全名,如果你觉得这样不够帅气的话可能需要自己 patch 一下 FS。

支持自定义的读写逻辑

在多态支持之外,FS 还提供了像序列化前后回调,特殊类型转换处理的手动处理的东西。这里也举一个例子,比如我们想把 UnityEngine.Color 读写都用 HTML/CSS 里面的那种 "#RRGGBBAA" 字符串表示,下面是一个完整的例子。相关的文档可以参阅[ FS 项目页面]上的文档。

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());  // <-- 注册 Converter

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

    Color readColor = new Color();
    serializer.TryDeserialize<Color>(colorData, ref readColor);
    Debug.Assert(readColor == Color.red);   // 同样能从这个格式正确的反序列化成 `Color`

    return;
}

我个人认为 FS 应该算是解决了 JSON 读取上的绝大部分需求,讲夸张点界限就是你的想象力了,但使用上我感觉有几点需要注意一下。首先,聪明的你肯定发现了 FS 会大量运用反射,其平台兼容性问题倒是基本解决了,但效率上肯定还是有点问题,起码不适合每一帧都调。FS 有提供 AOT 代码生成的东西,但我个人并没有用过不太好评价。还有就是如果你要使用它的 DLL 版本,需要注意 DLL 之间引起的一些类型查找的问题,有可能你从代码版切换到 DLL 版就爆炸了,需要提前注意一下。

那么你应该用 FullSerializer 吗?

如果给我选的话我会用,按上面说的 FS 应该是当前 Unity 生态中最棒的 JSON 库。当然如果你根本没有 JSON 读写需求的话那这个的确意义不大。

FullInspector

FS 的作者有一个收费的插件叫做 FullInspector,广告语是 "Inspect Everything",吹的就是他扩展了 Unity3D Inspector 的功能到可以支持显示任意类型的数据。

结果功能上它还真的做到了,你需要花很少的操作就可以让你 MonoBehavior 里面的 Dictionary,其他非 MonoBehavior 的类型成员,上面提到的 BaseShape[] 数组,各种各样的东西都可以在 Inspector 里面被正确显示出来,同时你还可以在运行时修改他们。先不管你需不需要在 Prefab 上存这么多东西,能够实时的看到所有的数据对于开发和调试真的是有巨大的帮助。如果你经常点开 Unity 做的游戏的目录里看他们用了什么插件的话,你会发现 FullInspector 在外国游戏里非常常见,比如 Enter the Gungeon 和 Firewatch 两者都用了。

UniRx

UniRx on GitHub

如果说快速排序这样的算法是顺序编程,那么这里简单的把 "点击按钮弹出一个对话框,发送一个网络包等待受到回复后弹出一个对话框" 这样的逻辑代码统称为 “异步编程”。那么什么水平的程序员能轻松顺利写出没有 bug 的异步编程代码呢?

我觉得答案是没有人能做到。因为这个叼东西实在是太特么麻烦了.. 比如点一个框弹出对话框,然后点击对话框再弹出一个对话框,比如发送两个包等待两个都收到回复,再加上其中任意一步可能会出错,需要做异常处理和恢复,逐渐开发者的意识就不太跟的上了迷一样的逻辑了。正是因为异步编程难,所以最顶层的程序员一直在设计方案来简化这个问题,从回调函数(以及回调函数地狱)开始出发,到有 JS 里面的 promise/future, Unity3D 里面自带的 coroutine 和 Python 里的 generator, 高级版本 C# 里的 async/await 等等。而 Reactive Programming (响应式编程) 是当下比较火的一个新的流行概念,其概念本身很模糊以至于各个语言里面有完全不一样的定义。其中比较流行的一块是微软从 C# 的 Reactive Extension 库后面推出来的 ReactiveX。它描述了一套统一名词,各个语言的实现里公用着这些概念。这里的说的 UniRx 可以说是 ReactiveX C# 版在 Unity3D 里可用的 port。所以后面概念上的东西这里会用其简称 Rx 来指代。

当你花点时间稍微了解一下 Rx 以后,你会发现它就是那种程序员梦中的技术方案,它非常有野心的把多线程,网络编程,UI 编程的问题都抽象成了同一个模型,核心只有两三个简单概念,然后需要新的功能只要无脑的实现一个接口取个名字,就可以横向扩展并跟之前的功能一起拼接出新的效果。

举个例子,Rx 最为核心的两个接口 IObserver/IObservable 负责了定义整个消息订阅,取消,传输,异常处理和状态同步,对应的代码只有 10 行整:

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

我感觉都可以想象到设计者自豪的表情,就是这么炫酷。因为 Rx 抽象的东西非常多,学习起来其实是有一些成本的,这里也不可能几段话讲清楚,所以下面会举几个玩具例子供感受一下它的功能,再列举一些我认为它比较重要的一些思想。

UniRx 基本功能

Rx 的核心思想是 "所有的异步事件都是由事件的组成的 Stream"。以一个游戏里面的 Player 单位为例,它有两个属性 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();
    }
}

注意我们需要手动创建这两个 ReactiveProperty,在自身被销毁的时候还要手动调用 Dispose() 来结束这个 stream。不过其实你就算用 C# 的 event 来做的话这些事情其实也要手动做,所以其实差别不大。

对应这两个属性,我们两个 UI 组件用来显示 PlayerHP, SP 值,比起每一帧都去更新,我们更希望在有变化的时候去更新显示。用 Rx 的语言,ReactiveProperty 是一个事件流 (Observable Stream),它代表的是 HP 值的变化事件,我们需要做的是 订阅(Subsribe) HP 的 Stream,在收到事件的时候更新 UI 显示,对应代码如下:

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 变动
        SP.Subscribe(x => SPDisplay.text = x.ToString());   // <-- Subscribe SP 变动
    }

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

        return;
    }
}

这里其实跟常见的回调其实看起来差不多,但有一点很酷的是虽然你在 HP 设定了初始值之后才进行的 Subscribe,但初始值会正确的设置上去。

到这里看起来其实还没有什么特别酷的东西,所以我们用 Rx 的方法来创建一个 isDead 属性,这个会在 HP 值小于等于 0 的时候被设置为 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 来创建 IsDead

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

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

Rx 中的 Operator(操作符) 可以用来将 Stream 进行变换成为你想要的东西,这个跟 C# LINQ 的概念很像,区别在于你处理的东西是动态的一个事件流。这里用到的 Select Operator 跟 LINQ 里的 Select 几乎一样,就是对单个 Stream 进行变换后生成新的 Stream,它跟手动定义的 Stream 在使用上基本上是一样的。

为了强行演示一下更高级的功能,我们创建一个新的 Observable IsCritical,在 HP 和 SP 都小于 10 的时候会被变为为 true,同时我们将订阅它并在第一次发生的时候打印 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 合并到一起,在任意一个值产生变化的时候产生一个新的消息,之后还是用 Select 来判断是否符合目标值。需要注意的是 isCritical 是一个局部变量,但它的生命周期会长于这个函数调用,至少跟 HPSP 一样长。在订阅的地方,我们记录下了 Subscribe 调用的返回值,这个是唯一可以用来取消这个订阅的 handle。这一部分写的不是很科学,主要还是为了演示 Rx 的功能。

Rx 相关概念

上面例子里的东西只是 UniRx 功能中很小的一部分,这里再归纳一下我认为 Rx 其中几个重要的东西:

  1. 所有异步的东西都是 Observable,上面例子里的 HP 值变化,到用户点击屏幕,到网络收发包等等 如果你按照 Rx 的概念来做的话这些东西都应该是 Observable Stream。UniRx 也提供了各种方法来将像 UI 的回调和一些 Unity3D 的 Event 来简单的转换成 Observable
  2. Rx 跟状态机某种意义上是互斥的,即我感觉 Rx 的一个目的就是为了省去状态的处理,如果你需要一个状态你应该做的是用相关的东西组合出一个新的 Observable
  3. 使用 Rx 并不需要写新的 class,作为用户除非你要写新的 Operator,否则是不需要写新的 class 的,你看例子就可以看出来 Rx 的例子里大量使用了匿名函数,用户代码几乎没有说需要 subclass 某一个基类的情况。

如果你对 Rx 感兴趣的话,光靠上面这点介绍其实绝对是不够的,这里推荐一些相关资源:

  1. Intro To Rx
    这是一个介绍最初 C# 版 Rx 的文档,内容比较多但讲的非常清楚。
  2. ReactiveX Operators Reference
    跨语言的 Rx Operator 文档,当你需要一个什么 Operator 的话最好在这里先找。
  3. The introduction to Reactive Programming you've been missing
    这其实是一个 RxJS 的教程,但有一个比较完整而且高级一点的例子,这也体现了 Rx 的概念真的是跨语言跨框架的。

那么你应该用 UniRx 吗?

如果给我选的话我倾向于不用在工作的项目里面,因为 Rx 概念比较多上手有点难,最理想的情况是你需要把所有的东西都转成 Rx 的写法,但游戏开发里面奇奇怪怪的东西太多了,不知道是不是真的可以处理所有的情况。另一方面是 Rx 里面这些 Observable 的生命周期感觉不太可控,让人稍微有点害怕。

但如果你已经决定了需要一个啥 Data Binding 或者 MVVM 啥的东西弄上去做 UI 的话,UniRx 应该算是一个比较好的选择,起码他真的是科学家研究出来的一个方案,在其他领域的确有应用。还是按上面讲的,了解一下 Rx 对当前最潮的 Reactive Programming 起码能有个概念,帮助你选择这方面的技术方案。

FlatBuffers

FlatBuffers on GitHub

这里提示一下,这一整段其实算是一个关于技术方案调研的一个例子,聪明的你需要自己客观的做出判断。

如果你的游戏涉及到网络通讯的话,你必须面临的一个问题就是找一个 Wire Format 的方案,即通过网络传输的包的读写。这其实也是一个序列化的问题,但比起上面讲的 FullSerializer 这种读写复杂 JSON 的,Wire Format 需要解决的问题是跨平台跨语言以及快速的序列化,还有包体大小压缩这些问题。注意我们这里把需要的解决方案限定在实时网络用 Wire Format 的序列化这个层面上,评价面向这个东西方案的标准我这边简单概括为:

  1. 有跨变成语言的实现,并且跨语言之间序列化的内容都能读取
  2. 对效率有一定要求,因为读写相对频繁。
  3. 读写代码需要相对简单,能比较容易的添加和修改包格式。
  4. API 使用起来要比较简洁。
  5. 序列化后的数据要能比较小,要支持可选的域 (optional fields)。

现在 Unity3D 环境下这方面常见的解决方案是 protobuf.net,这是一个非常保险的选择,因为炉石就是用的这个。抛开 protobuf 这个协议来说的话,我个人在初次调研 protobuf.net 这个实现的时候发现有几个问题:

  1. 对 GC 不够友好,虽然反序列化方法里面你可以传入一个已经预先创建好的 instance 进去,但像对数组和一些数据类型的读取用的 BitConverter 会产生一些无法避免的 GC。
  2. 对反序列化出来的 instance,很难重用。比如我想把拿到的 buffer 读到这同一个 instance 里面,其实 protobuf.net 并不太支持这种方式,主要原因也是因为你没法轻松的重置一个 instance 到初始值,加上 protobuf 有可选域,读取的时候并不会写那些没有传输的数据。
  3. 本身难以扩展和修改,这个可能是因为我个人水平还不过,或者说时间预算不够,我发现虽然 protobuf.net 是开源的但是我很难把我想要的功能添进去。

在寻找替代方案的时候,发现 Unity3D 能用的还有 SilentOrbit.Protobuf 这另外一个 protobuf 的实现,还有就是同属于 Google 的另一个类似的解决方案 FlatBuffers

FlatBuffers 的“广告”

你应该知道其实不管是开源还是商业的技术产品都是会向开发者做市场操作的。如果这个事实让你大吃一惊的话,那你可能还是太天真了哈哈... 这方面一个著名的例子就是 MongoDB,这个被宣传到成几乎等价于 NoSQL 的项目,围绕它有着各种各样的爆料争吵嘲讽,其原因我个人认为就是 MongoDB 的宣传主力在强调它的好用高效速度快,但对一些敏感的边界的技术问题则含糊而过,导致很多人在投入一些时间学习后大失所望由爱生恨。不过反过来讲也可以理解,在介绍一个软件项目的时候你肯定希望别人先知道它有多么炫酷,麻烦的地方就放到后面再说吧。对于 FlatBuffers 来说,这个还没有到"市场操作"这个级别,但是它自己提供的介绍还是有一些微妙的地方。

根据 FlatBuffers 的主页面上的介绍,以及其 Benchmark,然后下下来简单跑了一下例子,我初步归纳出来这么几点:

  1. 符合对实时序列化库的期望,即跨语言的实现,单独的协议描述格式,可选域这些 Protobuf 支持的功能它都有。
  2. FlatBuffers 是所谓的"下一代序列化方案",即它只做序列化不做反序列化,这样一来则省去了收到数据时的反序列化开销。
  3. 根据它提供的 Benchmark,比起其他类似的方案效率高,数据打包后容量小,内存开销小,总体来说全放位领先其他同类方案。
  4. 同样是生成代码的解决方案,FlatBuffers 运行时的库很小,C# 只有三四个文件加起来一两千行。生成的 class 也相对比较简单。
  5. 有著名用户,比如 cocos 的序列化格式全部都是用的这个 (确认过 cocos 3.0 后的确是的)。

这里先提前声明一下上面列出的这几点全部都是客观正确的事实。我估计你看到这里应该在想卧槽这个这个太刁了为什么还没有全世界的程序员都在用这个。但残酷的现实是 FlatBuffers 有一些特性会导致它并不一定符合你的应用场景,下面会逐条列举。

FlatBuffers 微妙的地方

最开始的一点,我总觉得 FlatBuffers 并不是一个面向实时网络序列化这个问题来设计的一个解决方案,当你仔细阅读文档的时候你会发现它虽然跟 protobuf 和其他 JSON 库来做比较,但它并没有提到"网络"这个关键字。这里有一个比较关键的原因是它的包格式中根据设计就有很多留空的 padding,以及其 vtable 的设计。

比如你有这样的一个协议格式:

table DamageData {
    damageA:float;
    damageB:float;
    damageC:float;
    /* D - W 省略,反正总共有 26 个  */
    damageX:float;
    damageY:float;
    damageZ:float;
}

FlatBuffers 中每一个 field 都是可选的,即你显示的在序列化的时候添加了它才会被写入包中。对于这个问题 protobuf 中是用 tag 来实现的,比如对于 damageZ 它需要定义其 tag26,然后写入的时候写的是 [26,<damageZ>] 这样前缀 tag 后面写数据的做法。那么 FlatBuffers 里面 field 不需要手动指定 tag,还仍然支持无限制的 field 添加,它到底是用的什么魔法来做到这个的呢?其答案就是 vtable,这个东西对 DamageData 的例子来讲,它每一个包都会固定写 26 个 2byte 的 ushort,其中内容就是每一个 field 的偏移。举一个例子比如我们一个 DamageData 里面只有一个 damageA = 10,那么不考虑对其, FlatBuffers 序列化后的包大小是 26 * 2 + 4 == 56bytes,在考虑对其以后可能会到 70bytes 左右,对比来说 protobuf 的情况下这个包大概是 1 + 4 = 5bytes,这个已经是夸张到数量级的差距。

那么你说为什么它的 Benchmark 的结果里包大小比 protobuf 还小呢?原因是 protobuf 期望的是不是所有的 field 都被写,tag 的方案在所有域全写的时候跟上面 vtable 的做法可能是差不多或者还要差一点的。但对于网络传输的用例这个的确有点不能接受。其实比较适合它的应用场景是序列化 10kb 以上的东西到文件存储,而 vtable 方面针对这个情况也会有优化,比如所有值都完全相同的 vtable 不会被写多份。

第二点就是反序列化的开销,关于这一点在跟 FlatBuffers 同类型的 Cap’n Proto 的主页上就有一个图表,说 "写/读一个来回的时间是 0 微秒,比 protobuf 快无尽倍",这个可以看成对不认真评估技术方案的开发者的嘲讽。有个比较好理解的思路就是,你可以认为你要解决的技术问题是固定的,那不管用什么技术方案它的最低开销和代价是固定的,不同的技术方案肯定就是在这个界限上面把开销挪到不同的地方。

那么 FlatBuffers 读写的开销到底是什么呢?其实 FlatBuffers 和 Cap'n Proto 这种"下一代序列化"方案在序列化的时候跟传统方案差不多,会需要按一个特定的规则来把数据打包成某种特殊的格式,差别就在反序列化的地方,相比于 protobuf 会把所有数据按规则反序列化成一个当前语言环境下的 object,FlatBuffers 这种也会提供一个 object 来读取数据,但它并不会把整段的数据完全反序列化,而是当你每用到一个 field 的值的时候,回去定位这个数据在下面 buffer 中的偏移,然后现场去读这个值。简单讲就是反序列化的开销从 object 创建的地方放到了每一次 object 属性调用的地方。这个变动带来了两个相关的影响:

  1. FlatBuffer 反序列化出的 object 不太容易被修改,FlatBuffers 自己也只是提供了有限的修改功能。
  2. 调用处的开销意味着你写代码的时候,在频繁访问的时候正确的做法是把值拷贝一份,不然就会有效率问题。

第三点其实就是 FlatBuffers 的 API,在 protobuf.net 的情况下你用它生成的代码的话,其实也是用一个普通的 POCO,你还是可以像一个普通的 object 一样来使用它。但 FlatBuffers 提供的 API,在序列化的时候你其实是并没有一个 object 可以拿来用的,你需要用它生成的一系列静态方法来把一个个属性写到缓存里,以官方 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 的调用顺序是有眼哥要求的。比如 string 类型的 field 需要在 table 创建前先调用,不然就会报错;比如 struct 必须在 table "Start" 后写入,不然就会报错,反正需要注意的地方非常非常多,就算是用过一阵子以后还是很容易出错。一个更重要的问题是在逻辑上你并不再是把一个 object 序列化/反序列化,因为你要把他的每一项都手动写入,而且之后读出来的东西也不再是原始的类型,你拿到的是一个 FlatBuffer 的 Table/Struct,加上上面提到的它们不太适合被修改,这个对于有些用例非常的致命。

那么你应该用 FlatBuffers 吗?

如果你看到这里觉得我次奥 FlatBuffers 真是蠢爆了,那其实事实也并不是这样,有些用例下加上一些处理它其实是非常棒的一个技术方案。

  1. 因为 FlatBuffer 反序列化后的东西其实是指向一段数据 buffer 的 Table/Struct,你可以非常容易的重置里面的内容,这样激进的缓存就比较可能,你可以每一个 Table object 都只创建一份,每次都用它来指向不同的 buffer 就可以了。相比起来一个 POCO 的内容很难完美重置。
  2. FlatBuffer 生成的文件和运行时库都比较简单,你可以很轻易的做修改,再加上其静态的序列化方法,很适合用代码生成来辅助序列化你逻辑代码里用到的类型。
  3. 其序列化包比较大的问题其实是有现成解决方案的,Cap'n Proto 和 sproto 中都描述了一种 "zero packing" 的方法,可以大幅减少数据中连续的 0 所占的空间,而且效率上问题也不大。
  4. 有些情况下,"Wire Format" 对应的类型并不一定适合在逻辑代码中用,而 FlatBuffers 很好的把这个边界区分开来了 - 因为你序列化的时候连 object 都没得用。
  5. 像上面提到的,如果你的用例里面需要读写的东西特别大,或者说读的时候很多属性不会访问到,那 FlatBuffers 就很棒了,因为它真的快,而且有一个确定的标准,比自己乱写的序列化格式还是要好很多。

所以是否应该用这个难题,最终还是得具体的开发者根据具体情况来进行调研,这个道理应该还是不会变的。只是要小心有意无意的广告,多花时间尝试和比较才是硬道理。

最后

这里额外还有两个没法撑够长篇幅的东西

  1. Dotween,感觉应该是 tween 库里面比较好的选择。
  2. Unity Test Tools,官方的测试工具集,其实 5.3 里面已经集成了 Editor Test Runner。

最后其实如果你插件用的多的话应该就有感觉,其实 Unity 项目里大量引入第三方代码并不一定是个好主意,需要承担风险除了 bug 以外还有其不支持新 Unity 这些等等等等。但说实话现在做个 Unity 游戏什么插件都不用几乎是不可能的,所以花时间评估真的是非常重要。希望这里列出的几个 Assets 和对应的介绍能够帮到你。

To the extent possible under law, the person who associated CC0 with this work has waived all copyright and related or neighboring rights to this work.

home | top