在我们一头扎入模式之前,我想先讲一些对软件架构和其在游戏上的应用的理解, 也许能帮你更好的
理解这本书的其余部分。 至少,在你被卷入一场关于软件架构有多么糟糕(或多么优秀)
的辩论时, 这可以给你一些武器支援。
软件架构是什么?
如果你把本书从头到尾读一遍, 你不会找到在3D图形背后的线性代数或者游戏物理背后的微积分。 这书不会告诉你如何用α-β修剪你的AI树,也不会告诉你如何在音频中模拟房间中的混响。
相反,这本书告诉你在程序之间的事情。 与其说这本书是关于如何写代码。不如说是关于如何组织代码的。 每个程序都有一定组织, 哪怕是“把所有的代码都塞到main()函数中”, 所以我认为讲讲什么造成了好的组织是很有意思的。我们如何区分好架构和坏架构呢?
我思考这个问题五年了。当然,像你一样,我有关于好设计的直觉。我们因为糟糕的代码质量遭受了太多苦恼,现在我最大的心愿就是把这些好的代码设计范式分享出来,减轻工程师的痛苦。
承认这一点,我们中大多数都该对它们中的一些负责。
只有很少的幸运者有相反的经历, 有机会在好好设计的代码库上工作。 那种代码库看上去是间豪华酒店,里面的门房随时准备满足你心血来潮的需求。 这两者之间的区别是什么呢?
什么是好的软件架构?
对我来说,好的设计意味着,当需求发生改变,就好像整个程序是在为改变打造而成的。我只需几个选择函数调用可以完美的解决的任务,同时丝毫不改变代码平静表面下的逻辑
这听起来很漂亮,但它不是完全可操作的。“只要写你的代码,这样的变化不会影响它的平静的表面。”没错。
让我打破了一些。第一个关键部分是,架构和变革关联在一起的。总有人已修改代码库。如果没有人触及代码—-无论是因为代码写的太完美还是因为他太糟糕,没有人会玷污自己的文本编辑器。那么它的设计是无关紧要的。评价架构设计就是评价它如何应对变化的。 如果没有变化,它就是一个永远不会离开起跑线的运动员。
你如何应对变化?
在你改变代码去添加新特性,去修复漏洞,或者随便什么需要你使用编辑器的时候, 你需要理解现在的代码在做些什么。当然,你不需要理解整个程序, 但你需要将所有相关的东西装进你的大脑。
有点诡异,这字面上是一个OCR过程。
我们倾向于这一步忽略,但通常编程中最耗时的部分。如果你觉得从磁盘分页加载一些数据到RAM慢,想想把这些数据通过神经加载到你的大脑呢?
一旦你把所有正确的上下文都记到了大脑里, 你思考了一会,然后找到了解决方案。 可以有很多来回这里,但通常比较简单。 一旦你理解了问题和需要改动的代码,实际的编码工作有时候是微不足道的。
你用肥手指在键盘上敲打一阵子,直到屏幕上显示着正确颜色的光芒, 然后就算搞定了,对吧?还没呢! 在你为之写测试并发送其到代码评审之前,你经常有一些清理工作要做。
我是不是说了“测试”?噢,是的,我说了。为有些游戏代码写单元测试很难,但代码库的一
大部分是完全可以测试的。我不会在这里发表演说,但是我建议你,如果还没有做自动测试,
请考虑一下。 除了手动验证以外你就没别的事要做了吗?
你将一些代码加入了你的游戏,但你不想下一个人调到你留下来的坑。 除非改动很小,否则就还需要一些重组做才能让你的新代码无缝集成。如果你做对了,那么下一个见到代码的人甚至无法说出哪些代码是新加入的。
总之,编程流程是这样的:
解耦怎么帮了忙?
虽然并不明显,但我认为很多软件架构都是关于学习阶段。 将代码载入到神经元太过缓慢,找些策略减少载入的总量是很值得做的事。 这本书有整整一个章节是关于解耦模式, 还有很多设计模式是关于同样的主题。
你可以用多种方式定义“解耦”,但我认为如果有两块代码是耦合的, 那就意味着你无法只理解其中的一个。 如果你解耦了他俩,你就可以独自的理解某一块。 这当然很好,因为只有一块与你的问题相关, 你只需要将这一块加载到你的猴脑中而不需要加载另外一块。
对于我来说,下面是软件架构的关键目标: 最小化你在处理前需要进入大脑的知识。
当然,也可以从后期阶段来看。 另一种解耦的定义是当一块代码有变化时,没必要修改另外的代码。 我们肯定需要修改一些东西,但耦合程度越小,变化会波及的范围就越小。
代价是什么?
听起来很棒,对吧?解耦任何东西,然后你能像风一样编码。 对每个变化都只修改一两个特定的方法,就像你在代码库的水面上跳舞,只留下倒影。
这就是人们对抽象,模块化,设计模式和软件架构兴奋的原因。 在有好架构的程序上工作是很好的体验,每个人都希望能更有效率的工作。 好架构能造成生产力上巨大的不同。很难再夸大它那强力的影响。
但是,就像生活中的任何事物一样,没有免费的午餐。好的设计同样需要汗水和规则。 每一次做出改动或是实现特性,你都需要将它优雅的集成到程序的其他部分。 你需要花费大量的努力去管理代码, 在开发过程中面对数千次变化仍然保持它的管理结构。
你需要去考虑代码的那一部分需要去解耦和抽象同样你也需要考虑那一部分需要考虑未来的变化而设计的具有扩展性。
通常人们热衷于这点,他们假想以后开发者只需要引入他的代码库就可以。因为这个代码库已经变得功能强大,扩展性很强。
首先最棘手的部分, 每当你添加了一层抽象或者支持扩展的部分,你是在假设你以后这儿需要灵活性。 但是添加代码和复杂性到游戏中,这都需要时间来开发,调试和维护。
如果你猜对了,后来接触了代码,那么功夫不负有心人。 但预测未来很难,如果模块化最终没有帮助,那它就有伤害。 毕竟,你得处理更多的代码。
当人们过分关注这点时,会是你的代码失控。因为 接口和抽象无处不在。插件系统,抽象基类,虚方法,还有各种各样的扩展方法。
回溯所有的脚手架去找真正做事的代码就要消耗无尽的时间。 当你需要做出改变,当然,有可能某个接口能帮上忙,但能不能找到就只能祝你好运了。 理论上,解耦意味着在扩展代码之前需要了解的代码更少, 但抽象层本身就会填满你的心灵暂存磁盘。
像这样的代码库让人反对软件架构,特别是设计模式。 人们很容易沉浸在代码中而忽略你要发布游戏的这点。 无数的开发者听着加强可扩展性的警报,花费多年时间制作“引擎”, 却没有搞清楚做引擎是为了什么。
性能和速度
软件架构和抽象有时会被批评,尤其是在游戏开发中: 它伤害了游戏的性能。 许多让代码更灵活的模式依靠虚拟调度、 接口、 指针、 消息,和其他机制, 他们都会消耗运行时成本。
一个有趣的反面例子是在C++中的模板。模板编程有时可以给你抽象
接口而无需运行时开销。
这是灵活性的两极。但你写代码调用类中的具体方法时,你在写作时修改类——你硬编码了
调用的是哪个类。但通过虚方法或接口时,直到运行时你才知道调用的类。这更加灵活
但增加了运行时开销。
模板编程是在两者这件。你在编译时初始化模板,决定调用哪些类。
还有一个原因。很多软件架构的目的是使程序更加灵活。 这让改变它需要较少的努力。编码时对程序有更少的假设。 您可以使用接口,让您的代码可与任何实现它的类交互,而不仅仅是现在写的类。 今天。您可以使用观察者和消息让游戏的两部分交流, 而以后它可以很容易地扩展为三个或四个部分相互交流。
但性能与假设相关。实践优化基于确定的限制。 永远不会超过256种敌人吗?好,我们就可以将ID编码为为一个字节。 在一个具体类型中只调用一个方法吗?好,我们可以做静态调度或内联。 所有的实体都是同一类?太好了,我们可以做一个 连续数组存储他们。
但这并不意味着灵活性是坏的!它可以让我们快速改进我们的游戏, 开发速度是获取有趣开发经验的绝对重要因素。 没有人,哪怕是Will Wright,能在纸面上构建一个平衡的游戏。这需要迭代和实验。
越快尝试想法看看效果如何,你就越能尝试更多的东西,就越有可能找到有价值的东西。 就算找到正确的机制之后,你也需要足够的时间调整。 一个微小的不平衡可能破坏整个游戏的乐趣。
这里没有简单方案。 让你的程序更加灵活,你能在损失一点点性能的前提下更快的做出原型。 同样的,优化代码会让它不那么灵活。
就我个人经验而言,让有趣的游戏变快比让快速的游戏变有趣简单得多。 一种折中的办法是保持代码灵活直到设计定下来,再抽出抽象层来提高性能。
糟糕代码的优势
这就来到了下一观点:不同的代码风格各有千秋。 这本书的大部分是关于保持干净可控的代码,所以我坚持应该用正确方式写代码,但糟糕的代码也有一定的优势。
编写良好架构的代码需要仔细的思考,这会转为时间上的代价。 在项目的整个周期中保持良好的架构需要花费大量的努力。 你需要像露营者处理营地一样小心处理代码库:总是保持其优于你刚开始的时候。
当你要在一个项目上花费很久时间的话,这很好。 但,就像我早先提到的,游戏设计需要很多实验和探索。 特别是在早期,写一些你知道要扔掉的代码是很普遍的事情。
如果你只想试试游戏的某些主意是不是正确的, 良好的设计意味着你在屏幕上看到和获取反馈之前要消耗很长时间。 如果最后证明这个点子不对,那么当你删除代码的时候,那些花在让代码更优雅的时间就完全浪费了。
原型——一坨勉强拼凑在一起,只能回答设计问题的简单代码——是一个完全合理的编程习惯。 虽然当你写一次性代码的时候,必须保证你可以扔掉它。 我见过很多次糟糕的经理人在玩这种把戏:
老板:“嗨,我们有些想试试的点子。只要原型,不需要做的很好。你能多快搞定?”
那么几天后我能给你一个临时的代码文件。”
老板:“太好了。”
几天后
老板:“嘿,原型很棒,你能花上几个小时清理一下然后变为成品吗?”
你得让人们清楚,可抛弃的代码即使看上去能工作,也不能被维护,必须重写。 如果有可能要维护这段代码,你就得防御性好好编写它。
一个保证你的原型代码不会变成真正用的代码的技巧是使用一种和游戏不同的语言。
这样,你在实际应用于游戏中之前必须重写。
保持平衡
有些因素在相互制约:
- 为了在项目的整个生命周期保持其可读性,我们需要好架构。
- 我们需要更好的运行时性能。
- 我们需要让现在的特性更快的实现。
有趣的是,这些都是速度:我们长期开发的速度,游戏运行的速度,和我们短期开发的速度。
这些目标至少是部分对立的。 好的架构长期来看提高了生产力, 也意味着维护每个变化都需要更多努力让代码保持整洁。
草就的代码很少是运行时最快的。 相反,提升性能需要很多的编程时间。 一旦完成,它会污染代码库:高度优化的代码不灵活,很难改动。
总有今日事今日毕的压力。但是如果尽可能快地实现特性, 代码库就会充满黑魔法,漏洞和混乱,阻碍未来的产出。
没有简单的解决方案,只有权衡。 从我收到的邮件看,这伤了很多人的心,特别是那些只是想做个游戏的人。 它似乎是在恐吓,“没有正确的答案,只有不同的口味的错误。”
但,对我来说,这让人兴奋!看看任何人们从事的领域, 你总能发现某些相互抵触的限制。无论如何,如果有简单的答案,每个人都会那么做。 一周就能掌握的领域是很无聊的。你从来没有听说过有人讨论挖坑事业。
游戏你会;我没有深究这个类比。 我知道的是,可能有挖坑热爱着,挖坑规范,以及
一整套亚文化。 我算什么人,能在此大放厥词?
简单
最近,我感觉如果有什么能简化这些限制,那就是简单。 在我现在的代码中,我努力去写最简单,最直接的解决方案。 你读过这种代码后,完全理解了它在做什么,想不出其他完成的方法。
我的目的是正确获得数据结构和算法(大致是这样的先后),然后在从那里开始。 我发现如果能让事物变得简单,就有更少的代码, 就意味着改动时有更少的代码载入脑海。
它通常跑的很快,因为没什么开销,也没什么代码执行。 (虽然大部分时候事实并非如此。你可以在一小段代码里加入大量的循环和递归。)
但是,注意我并没有说简单的代码需要更少的时间编写。 你会这么觉得是因为最终获得了更少的代码,但是好的解决方案不是往代码中注水,而是蒸干代码。
Blaise Pascal有句著名的信件结尾,“我没时间写的更短。”
另一句名言来自Antoine de Saint-Exupery:“完美是可达到的,不是没有东西可
以添加的时候,而是没有东西可以删除的时候。”
言归正传,我发现每次我重写本章,它就更短。有些章节比他们刚完成时短了20%。
我们很少遇到优雅表达的问题。取而代之的是各站各样的情况。 你想要X在Z情况下做Y,在A情况下做W,诸如此类。
最不消耗心血的解决方法就是为每段用况编写一段代码。 如果你看看新手程序员,他们经常这么干: 他们为每个手头的问题编写逻辑循环。
但这一点也不优雅,那种风格的代码遇到一点点没想到的输入就会崩溃。 当我们想象优雅的代码时,我们想的是通用的那一个: 只需要很少的逻辑就可以覆盖各种各样的情况。
找到这样的方法有点像模式识别或者解决谜题。 需要努力去识别散乱的用例下隐藏的规律。 完成的时候你会感觉好得不能再好。
就快完了
几乎每个人都会跳过介绍章节,所以祝贺你看到这里。 我没有太多东西回报你的耐心,但我还有一些建议给你,希望对你有用:
- 抽象和解耦让扩展代码更快更容易,但除非确信需要灵活性,否则不要做。
- 在你的整个开发周期中考虑并为性能设计,但是尽可能推迟那些底层的,基本事实的优化,那会锁死你的代码。
相信我,在发布前两个月不是你开始思考“游戏运行只有1FPS”的繁琐问题的时候。
快速的探索你游戏的设计空间,但不要跑的太快,在身后留下一团乱麻。毕竟,你总得回来处理他们。
如果你打算抛弃这段代码,就不要尝试将其写完美。摇滚明星将旅店房间弄得一团糟,因为他们知道明天会有人来打扫干净。
但最重要的是,如果你想要做出让人享受的东西,那就享受做它的过程。