为什么我不用单例

突然发现,其实自己倒是很少写技术相关的内容,仔细想想,还是有些内容是希望聊聊,记录下来的。这是一些经过长期思考、验证,至今依然觉得有其道理的内容。

今天先从一个简单的问题着手吧。

编程学到一定程度,从大学开始接触的话大概在大二大三的样子,基本都会接触一个新鲜事物——设计模式。回想一下我看的第一本设计模式相关的书好像是《设计模式之禅》。设计模式给我的最大震撼是终于理解了之前看到的很多写法到底为什么这么写,这也是自己从把代码作为一个固定模式使用,转变为开始自行思考写法。换言之,从“编写”代码,到“设计”代码的一个转折吧。

初学设计模式,我也和很多人一样,印象最深的,就是“单例模式”了。对于自己已经熟知的写法进行组合,竟然可以产生这样的效用。而真正写好、写正确一个单例,又没这么简单。我至今都很痴迷这种简单与复杂的交织。

大学后来写Web写App,单例倒是确实没怎么用到过,所以渐渐也没那么在意了,再到后来,幻刃成立之后,开始写游戏项目,这个问题很显然地就出现了。

Unity 是脚本驱动的,每个 GameObject 上都可以挂载不同的脚本,每个脚本负责自己的数据和逻辑,这个思路是很清晰的。那么我们来设想一下,一个初次开发游戏的开发者,会怎样组织代码呢?

一开始,肯定是每个脚本自己管自己的事情,需要相互引用就“拖一下”,逻辑简单的时候这样是不会出现什么大问题的。

然后呢,他就会发现,自己有挺多地方有着差不多的代码,这时候呢,挺有可能他也听说了一个新鲜词,叫做“DRY原则”,自己也深以为然,于是开始思考怎么解决这个问题。

首先,可能最容易想到的代码复用方式就是“工具类”。这个平时调用过一些库的话,很容易就能看到并理解这种方式。为什么不是类继承呢?因为这在设计上实在要求太高了,大概在这个阶段我自己对于继承实在还停留在 Rectangle 可以继承 Shape,甚至刚刚搞明白如果我再想要一个 Square 类那该是怎样的继承关系(这个问题确实还挺有意思)。什么接口呀,抽象类呀,还都没在工程上用过呢。后面我也会具体谈到继承的方式在工程上面使用的一些具体情况。

说回工具类,工具类嘛,在面向对象语言中的实现基本就是一个名称以Tools或者Utils什么的结尾,只包含静态方法的一个特殊类,这样,需要复用的方法,就可以复制粘贴过去,然后在任何位置直接调用了。

这种方式好用吗?太好用了,于是你渐渐的就会有自己的MathTools、LogTools、NetTools、FileTools等等以及更多业务相关的类。

再然后,你会发现这种方式好像可以解决一个新遇到的问题,那就是怎样进行脚本间通讯的问题。

比方说吧,大部分游戏,肯定有个概念是主角嘛,Unity中的话,这就是一个GameObject 了。主角对象上可能还挂了一个脚本,负责一些基本逻辑,比如叫做Player。那么,在其他脚本中,如何获取这个脚本呢?

一个比较Unity的做法是直接 GameObject.FindWithTag(“Player”) 拿到GameObject,然后 GetComponent<Player>(),或者是 Object.FindObjectOfType()。这类方法,即使是初学者也会很快明白是不对的。

  1. 需要遍历查找,性能非常差;
  2. 通过 Tag 的形式依赖字符串,所有需要手动保证不输入错误的地方,我觉得都是有问题的;
  3. 如果有大量目标需要查找时,非常难以区分与管理。

那么,很自然的,解决方法就有了,写一个叫做 PlayerManager 的静态类,声明一个公开的 Player 字段,在 Player 初始化的时候,把自己赋值上去就好了。虽然这种初始化时把自己的引用传递出去的方式感觉稍微有些违和,但是至少问题是解决了的。

这个问题本质上其实是任何时候,获取一个特定的引用,在面向对象语言中,基本需要依靠“静态”这个概念,这点是跑不掉的,那么除了直接使用静态字段,还有没有别的方法呢?

很自然,单例天然的解决了这个问题。更因为C#的CLR级别泛型支持,有一个特别方便的单例写法。(本篇不讨论单例正确写法问题,我们先用最朴素的单例实现方法,不考虑懒加载与线程安全问题)

public class Singleton<T> where T : class, new()
{
    private static readonly T _instance = new T();

    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    {
    }

    public static T Instance
    {
        get { return _instance; }
    }
}

public class SingletonExample: Singleton<SingletonExample>
{
    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    {
    }
}

直接一个继承就搞定了。

然后你就可以在任何地方,使用 SingletonExample.Instance 访问这个对象。

咋一看,这和使用静态类没什么区别啊,只是静态类的字段需要多个static,单例访问的时候需要多写个Instance。

再深入想一层呢?有一个很大的区别就是在单例是可以懒加载的,因为其实本质是是个对象,在需要的时候,还可以解除引用进行GC;还要考虑到序列化方面的问题;再有,比如你写了一个FileSystem的静态类,然后发现其实你需要对不同平台编写不同的实现,使用单例的话只需要写多个子类继承FileSystem然后分别实现,最后使用编译选项控制FileSystem的单例实例化的具体对象就可以了,这些情况静态类就都别想了。

这么说来岂不是使用单例还是挺好的,除了每次需要多写个Instance比较繁琐,但是也是可以依靠IDE代码补全,或者干脆命名为I来省事。

我们回过头来想想,单例本质上是解决什么问题的呢?对了,是解决在同一Runtime下确保只存在一份该类的实例这一问题。只有一份实例之后,可以全局便捷访问到,只是 一个附带的作用而已。所以实际上来说,我们是利用了一个副作用,而忽略了本来使用单例的目的(其实我们确实不管理是不是唯一,甚至在Unity中我们也并不能保证,一个继承了Monobehaviour的单例,因为并不是也不能通过new的方式实例化,所以生成了多个是很容易也常见的错误)。实际上,如果想解决“只有一个实例”和“在全局便捷的访问”其实分别会有更加合适的方法。

再说到全局访问,一个人写小规模代码的时候这是个很方便的做法,但是项目一旦开始扩大,人多了以后,你会发现这几个问题。

  1. 滥用,你可能花了很大心思保证两个系统的解耦合,但是因为他们都可以很便捷的被访问到,那么接下去的事情就不是你能控制的了。此外,如果单例成为了给实例提供方便的访问方法的项目标准做法,那么势必会出现大量单例,虽然很明显,不是每个单例的创建者都希望别人在任何位置使用它,但是你却不能用除了约定以外的方式来确保这一点。
  2. 你无法控制启动顺序,单例的懒加载作为一大优势,同时也可能成为一个难以解决的问题。懒加载单例的加载顺序,是由调用方决定的,大多数情况下,单例们都可以自动根据依赖关系顺利启动,但是这种自动,很多情况下其实都不是你希望的顺序。甚至,很容易造成循环依赖
  3. 别人很难通过代码本身了解到框架的设计者提供了那些功能,文档的编写和维护对于小团队来说成本是挺高的,大家协作开发时,也不可能每写一个功能前都问问大家,是否曾经在框架中已经实现了。我在早起开发中,就经常遇到代码库中,同样功能的函数被不同人实现多次的情况,甚至有时候自己都不记得了,又再写了一遍。

说到这里,我们会发现,对于一个游戏运行时脚本系统来说,单例完全就是个不需要的设计,我们想解决问题,其实很好解决。

所有这些需要便捷访问的模块,我一般称作“服务”,他们全部作为普通的类来实现就可以了,然后写一个静态类,把他们都放进去。服务也可以有子服务,子服务的生成销毁与访问都交给父服务即可,逐级推到最上层,会有一个主服务,负责按顺序实例化所有服务,并注入到这个静态类中。这个静态类,一般被称为“服务定位器”

因为有了“服务”这个概念,就隐含了服务定位器中的对象,一定就是希望可以全局便捷访问的,而服务与服务之间,则是尽量解耦合。当你遇到需要和脚本外部交互的情况,第一反应就应该是敲一个L.(我喜欢把服务定位器类命名为L,表示ServiceLocator),IDE的自动补全即会自动提示我可以使用的服务。即使没有IDE,打开看一眼L类,有哪些功能也都清清楚楚。服务相关的脚本也都可以放在一起。静态工具类也都可以放在一起,并且明确只会包含状态无关的纯函数,比如拓展数学库等。按照这样的方式,单例就可以明确得禁止在项目中使用了,即使可能有人把不应该的模块当做了服务,也非常容易被发现并得到修正。

终究我们会发现,我们大多数使用单例解决的,都不是单例应该解决的问题,反而会带来大量的副作用,所以,用正确的方法解决问题,而不是解决问题。


对于有一些经验的游戏开发初入门者,我一直很推荐三本书:《游戏引擎架构》、《网络游戏核心技术与实战》以及游戏编程模式。最后一本篇幅不长,但是就游戏中使用的设计模式问题进行了大量的有针对性的讨论,关于单例与服务定位器的部分,都有专门的章节描述,比我写的不知道高到哪里去了。

每次看到都想吐槽《网络游戏核心技术与实战(オンラインゲームを支える技術)》这本书为什么翻译了个这么《21天学通C++》的名字,导致我一直以为是本垃圾书没有看……

再次断更了很久

再次断更了很久啊,回想一下,中间发生的事情太多以至于也不知道从何说起了。

先说项目吧,第二个里程碑结束后,第三个里程碑遇到了巨大的需求变更,因为受到了Slay the spire的启发,最终的需求文案中将游戏流程改为了类似的外围Roguelike。在我的感觉有点Project Love的即视感。当然这个倒不是关键,但是类似的遗物系统的加入,着实让我受到了不小的打击。

原本的数值框架,是根据当前有的需求,在考虑了怎样方便的在表格中配置后,设计出来的。主要思想是把所有可能变化的数据,设计了一个最小单位,称作辅助配件,·这样,所有的数值变化都可以认为是N个辅助配件的叠加。在所有的数值表上,任何变化(包括初始化),只需要在对应的字段填值即可。当然,因为分为叠加和相乘,所以每个变量都会有两个字段。而根据最新的需求,因为遗物的加入,则需要允许几乎所有的数值都可以变化,导致之前的设计就完全不可能使用了。

当然这只是个表面问题,单纯问题的解决倒不是最主要的打击点。主要是我开始发现,在数值架构这个问题上,单纯的由程序根据策划当前版本需求设计数据结构是不可能的。首先,策划会要求使用Excel,这个要求实际上是需要把所有结构化数据全部放入一个二维结构里。在策划对于自己的体系没有一个清晰的了解,我这边也没有相关经验的情况下,如果这次也是按照之前的方法,就很有可能出现后面再加入新需求,还得改数值架构的情况。

于是,我就开始考虑其他人是怎么做的。

这个需求对于国内MMO手游来说肯定是已经解决了的,所以问题就是他们是怎么解决的。

当然……答案其实我也差不多能猜到……所以还是想看看有没有更好的方案。

最佳方向其实是看看Slay the spire……所以……就看了……

打开目录,发现大部分的容量都被一个.jar包占据了……右击打开压缩文件,从库文件看是用的一个叫做LibGDX的框架。Google之,原来是个Java写的框架……那……原谅我怀着罪恶的心态学习一下了。

先看了看LibGDX,下下来构建了一个项目,把解压出的资源放入对应路径,代码用jd看了看,再修复了各种奇葩的问题,尝试运行……好吧,竟然跑起来了。

突然很想帮他们移植一个手机版本……

从架构上来说,首先,他们的数值是全部写死在代码里的,一个遗物一个类。遗物的抽象类中有很多回调函数,可以允许你在切面上进行操作。比如一个遗物是下一张牌提升伤害,他们的写法就是在打出牌的回调里,把这张牌的伤害硬加上去了……而整个项目的几乎所有位置上,都充斥着类似

if(有某个遗物){
    做某事
}

这样的代码。

回调的方法本身没什么问题,虽然我更希望直接注册全局事件,这样耦合更低。但是这种使得几乎所有模块都和各种具体的遗物强耦合的写法实在是令我无法接受。同样,写死所有参数的方式,对于后面程序与策划的解耦合,也是一场巨大的灾难。

这个问题,不是个纯技术问题,换句话说,不可能由我一个人,或者任何一个程序员一个人来解决,这种无力感在年前的很长一段时间都沉重地压在我身上。

在1月底的时候,我突然接到运营那边的消息,说ICEY的所有版本需要紧急修改,一个是需要加入公示信息(写明版号文网文什么的,国产游戏打开都有),还有个是需要根据送审版本,把犹大的名字改掉,时间呢,是2月之前。

哦,2月之前啊,我看看屏幕左下角。

2018-01-31。

好的,我加油。

另一边,其实策划同学也已经感觉按照现在的构想,数值这边确实是很有压力的。于是也一直在积极招人。很快,我就看到了一份非常令人激动的简历。所以,确实大部分时候,一个人是否合适,光看简历已经能基本确定了,有点玄学。于是年前一周,晓浩入职。

因为制作人年会的关系,我也算是蹭了两次心动年会了。我们之前年前基本上就是大家一起吃一顿,还从来没搞过正经的年会。今年,我觉得还是很有必要可以开始准备了,只要开始了,肯定会越来越好的嘛。

唱歌跳舞就算了,想想就觉得如果准备节目那绝对是大家与我和争争两边的双重地狱。除了节目,年会还能干吗呢?嗯……抽奖……

花了一下午写了个抽奖游戏,拜托冬哥给大家都画了一幅大头照,自己玩玩感觉效果不错。

结果……效果真的不错……谁不想要哪个奖品,就一定会抽到他,哈哈,声控抽奖,节目效果满分。

年会上也宣布了今年的年终奖,大家一起过个愉快的新年吧。

年后回来。

晓浩在几次主动找我的聊天中,其经验与能力很快展现了出来。很快,我就看到了全新设计的数值框架。问题解决。根据他自己的意愿与实际能力,肖哥和我一致希望他直接担任产品经理和项目经理,他本身也很有动力。于是,我们开始具体执行了。

结果,遇到了之前从未考虑过的新问题。

因为之前的经历和经验,我虽然依然在寻找主程,但是已经不作为确定性的条件了,基本是抱着至少这个项目,大概率自己来带的觉悟。

结果,第一次跟晓浩沟通具体需求实现问题上,就发生了问题。

当时我只拿到了一份数值属性说明,晓浩来找我确认这样出是否可以。在我看来,我在完全没有拿到其他文档,甚至连是否有其他文档都不清楚的情况下,单凭这一点信息,是无法确认的。虽然单纯的把这个数据结构实现出来是完全没有问题,但是仅仅是这样做完全没有意义,我理解的确认,是需要确认这部分实现,在整个框架下是否可行,以及是否有充足的可扩展性。不然的话,我永远会担心这块。

就这个问题,以及产生问题的原因,我和晓浩进行了大量的交流,甚至一度状态有些紧张。第二天,我开始渐渐发现问题的根源:理论上,因为晓浩是产品经理和项目经理,所以产品的需求实现与项目的进度保证都应该是他的责任,但是因为我具体的负责了项目,那么,我是一定会自己去保证的,从这个角度来看,我和晓浩的合作是一定会产生冲突的;而交流的问题,因为客观上我和晓浩的实际职位与项目组内职位发生了错位,面对一个同时是自己上级和下级的人,即使晓浩已经理解这个问题,还是不免产生大量的客观冲突。

这个问题想要解决,不外乎两种方法:硬怼和换人(当然换的人是我XD)。

硬怼可以解决问题,但是不解决根本问题,客观上来说,只要我负责项目具体内容,那么我不自己来保证项目是不太可能的,更加严重的,这样做明显会严重的影响晓浩的心情与能力发挥。而换人,当然问题就是怎样找到合适的人了。

因为年后回来以后就和争争商量了重点要放在招人方面,也开始有一些简历收到。

最终,还是依靠晓浩推荐了他原来和合作伙伴,徐哥过来。他们之前已经有五六年的合作经验,也有了不少已经上线的项目,从理论上说,确实是解决当前问题的最好方案了。

因为很早以前晓浩就对服务器这边提出了一些担忧,正好这个阶段我也没法在客户端这边进行继续的工作,于是开始跟诸谦一起推进服务器这边。目前已经把大部分的流程都走通了,就等实际需求过来,设计好协议就可以用了。

目前的计划来看,我后续主要是作为PMO负责人对项目进度进行监督,服务器在没有明确负责人的情况下,需要负责推进以及对各部门进行技术支持——使用各种技术解决大家的问题以及提升效率。

从下个月开始,项目就需要开始进入快速推进了,以完成六月底的CJ版本计划。

结果只说了项目……