给游戏引擎开发者的 64 个建议(1):客户端

在过去的十年间,越来越多的游戏改版为网络游戏,这是个强烈的趋势。当为一款游戏添加网络支持面临着全世界的挑战时,我最近的经验(同时作为一名玩家和咨询者)表明,太多游戏开发者违反了做一款足够好的网络应用程序的基本准则。我们经常看到用户界面“一动不动”、网络中断未被激活(同时Internet的其余部分是可访问的)、游戏随机崩溃,还有高峰期的服务器超载。坏消息是,这些问题直接影响到游戏玩家的满意度(比开发者通常以为的管理和图像因素来得直接得多)。

好消息是,处理这些问题并非是一门高精尖科技,只要网络引擎开发者确实知道他们在做什么——以及他们确实读了这篇文章。

在这篇文章里,我们主要聚焦于网络开发的某些方面,这对很多游戏引擎开发者而言并不那么明显。如果我写的一些东西对你来说显而易见,那么很抱歉,但请别因为我写了而太厉害地抨击我。我向你保证,有大量拥有百万级玩家的流行游戏都违反了这份清单上的其中一项(可能有一两项除外)。因此,我写这篇文章是为了给出一份建议清单,它会帮你避免开发者在为有大量交互的应用程序,如游戏或股票交易程序,实现网络层时会犯的最烦人同时也是最容易犯的错误。

在这篇文章的第(1)部分,我们将抛开网络协议,讨论客户端网络开发的常见事项。接下来的章节有:

  • (2a). 协议和API(上)
  • (2b). 协议和API(下)
  • (3a). 服务器端(存储处理和发展的体系)
  • (3b). 服务器端(配置、优化和测试)
  • (4). TCP-vs-UDP的大论战
  • (5). UDP
  • (6). TCP
  • (7). 安全

0. 范围

整体来看,游戏引擎获得了大量的网络支持。这就是我们为什么限制了我们给出忠告的范围。更具体地说:

  • 我们将专注于有客户端的游戏app,不考虑浏览器端或基于AJAX的游戏。而App游戏和基于浏览器的游戏有很多类似之处,但它们的确各自也有相当不少的差异之处。

不过,这篇文章试图要覆盖大部分和游戏的网络层开发相关的其他方面:

  • 我们不会将自己限制在一个指定的游戏类型(比如MMORPG的模拟世界类)。当MMORPG在这篇文章的一定范围内,社交游戏、多人策略游戏(既是实时游戏又是基于回合制的那种游戏)、赌场游戏以及股票交易游戏也是如此。令人惊奇地是,大多数的风格型游戏需要的网络支持相当类似(尽管很多时候依赖于时间因素,正如我们将在(4). TCP vs UDP的大论战里讨论的那样)。
  • 我们也不必将自己局限于一个平台:实际上,我们强烈支持开发跨平台的引擎,包括网络引擎。作为练习,我自己开发了一套网络引擎,它能在5个以上不同的平台上运行(这些平台的清单在第6条建议下列出)。
  • 当这篇文章是出自一名游戏引擎开发者的观点时,我们应当注意到相当多的时候开发者需要为他们自己开发游戏引擎。在这些情况下,这篇文章的大部分忠告仍然适用。
  • 当“哪个现有的引擎或者网络引擎是最好的?”这样的问题不在这篇文章的探讨范围内时,我们仍然期望本文在回答这个问题时能派得上用场。然而,这个问题的答案取决于你的游戏细节,因此你需要读读这篇文章,决定哪些忠告适用于你的游戏,哪些不适用。换句话说:如果你的游戏引擎或框架提供了网络访问功能——你可以用这篇文章作为允许一窥游戏框架的工具,再看看如果他们的网络实现是否适合你的游戏。

现在,抛开前提,让我们开始吧:

1. 一定要在客户端使用事件驱动编程

大多数客户端的框架都有个所谓的“主线程”(或者是能隐含在这个“主线程”里运行的“主循环”),而这个“主线程”基本上只访问特定的事件(起初是UI事件)。这个模式适用于整个客户端框架的领域,从Windows GUI、Direct X和Cocoa,到Unity 3D、Android和iOS。这个现象也有个能解释它的原因:因为不这样做的话,开发就会成为一场噩梦。事实上,据我所知只有一个没有这样运作的框架:它是Java开发的原始AWT框架,而在AWT框架里开发一个app是一件公认相当痛苦的事情(中肯地说,AWT框架从来就没流行过;Google特别需要为Android开发一整套全新的GUI框架)。

当我们把可执行程序或者app联网时,它的事件驱动模式应该怎样变化呢?答案是,“不应该变化”。从逻辑上而言,所有的游戏网络通信包括发送和接收的信息,每个接收的网络信息应当被看作是游戏事件驱动逻辑的又一个触发事件(伴随着传统UI事件的触发,比如点击鼠标或按下键盘等)。通过把消息注入到主线程的“消息队列”中(例如,在Win32环境下通过PostMessage()或者PostThreadMessage()来注入)可以相当轻易地实现事件触发。假如你使用的图形框架(比如Unity3D)不支持这个概念,你可能需要创建队列并使用它来模拟该框架(瞧,例如,[Unity3D2012])。和在一个单线程里强制访问所有事件(包括UI事件和网络消息)比起来,是否将事件作为信息传递(像在Win32下),抑或将事件作为回调(像在[Unity3D2012]里)都不那么重要了。

注意:如果用的是Unity,这个小技巧几乎用不上,因为Unity的内建网络机制(该机制已使用Unity的事件访问线程)对“实时世界模拟器”而言足够好用。然而,使用Unity的网络即意味着用UDP方式传播信息,这种方式,如同我们将在(4)里看到的一样,可能是也可能不是最好的方式,这取决于游戏本身——特别是如果游戏偏离了“实时世界模拟器”的话。

在某些情况下,事件处理线程可能和框架的“主线程”不一样,但重要的是,要把所有至少有点相关的事件都放在一个单独线程中处理。然而,仅仅是通信相关的事(那些和游戏逻辑一点关系都没有的事),比如信息集结、加/解密和(压缩)解压缩,可能(以及如果可能的话,应该)在“主线程”外处理。我们将在接下来的第3条建议里讨论一些线程分离的细节

2.一定不要在事件处理线程外调用应用程序回调

当我年轻并且是个相对经验不足的开发新手时,我为一个股票交易开发了一个网络框架(不要问一个缺乏经验的开发者是怎么拿到这个照理说来这样大的一个任务的——我自己也不知道为什么)。我不得不承认,尽管第一次尝试开发网络库,我已经做得相当好了,但是我还是犯了一个重大错误。我创建了一个属于网络框架的线程,用它来调用应用层的一个回调(如果我没弄错的话,它是一个回应属于我的sendMessageOverTheNetworkAndCallbackOnReply()类型函数的回调)。这个微不足道的回调给后来几任使用该框架的开发者带来了相当大的不便。首先,对后来的开发者而言,游戏中的交互(和潜在的资源竞争(!))变得相当难理解(对我而言一切显而易见,但它还是我的问题,还有一个原因是:这是个可以避免的问题,而我害得他们不得不处理它)。其次,它引起了好几个很难追踪的缺陷和资源争用。最后,这个框架不是太糟糕,整体而言程序运行得相当不错,但是如果没有做这个单次回调的话,在该框架上的开发原本可以比现在更平滑。

有好几年我曾被分配任务,为一款相当大的多玩家游戏开发一个网络框架(我乐意炫耀的是这款游戏同时在线用户50万,每天收发5亿个网络数据信息)。这次我学了乖,避免了这种线程回调。整个框架运行得非常流畅(同时也更容易移植到多平台上)。

底线:如果你需要从网络层向应用层回调,首先把事件传送给事件处理线程(通常是‘主’线程),然后在事件处理线程生成的网络层库的调用里处理事件,必要时调用应用级回调。

换句话说,下面这种方式不错:

network thread –> inter-thread-communication –>event-processing thread –> network-library-call –>application-callback –> no-thread-sync-needed

还有如下的访问方法是可工作的,但是长远来说对其他开发者而言没有那么好:

network thread –> network-library-call –>application-callback –> thread-sync-required

对以上描述的“好的”方法来说,事件处理线程总是需要调用回调函数,这样能大大简化程序的开发。所有应用级的处理都成为了严格意义上确定性的处理(这些转化成了“更少机会产生资源争用”),同时应用层没有任何必要的线程同步。以上的措词是公认的松散,而方法可能听上去很复杂,但是它将省掉开发者开发过程中很多的麻烦。

3. 一定不要从事件处理线程里调用潜在的锁定网络函数

这是网络开发者所能犯的最讨厌的错误之一。正如之前所说,你应该在单个线程内处理事件。然而,在事件句柄内(这些句柄通常在事件处理线程内被隐性地调用)调用一个看上去很无辜的gethostbyname(),通常在你的办公环境下运行没有明显问题,这事儿既行得通又方便。但是在实时环境里的有些情况下,它会阻塞好几分钟(!)。如果你从GUI线程调用了这样一个函数,这通常意味着,对用户来说,当函数被阻塞时,GUI看上去一直是“一动不动的”或者“中止的”。从用户体验的观点来看,这是个大大的不。

和GUI进行网络交互的适当方式,是对所有网络函数的调用,不管是非阻塞式的,还是在单独的线程内。在这种情境下,你需要把事件状态机设置得更复杂(你将得到有效的状态通知譬如“等待DNS解析”),但是同时它将允许避免“一动不动的”GUI(这本身是一件好事),也将额外允许你处理网络延迟,包括:

  • 适当的时候通知用户。比如,在程序僵死一秒或五秒时,你知道有问题发生了,用户一般也知道有问题发生了,所以让她知道你已经注意到了这个问题并且正着手解决它,这样比较好。
  • 必要时退出操作并重试(比如,它和在(6)里简短讨论到的保持应用层活跃相关联)
  • 允许用户体面地终止请求/游戏(而不是强制她求助于使用任务管理器)

应该注意到,这一项看上去和上面的第1条建议和第2条建议相矛盾,但它其实没有。对问题:“嘿,所以我应该单线程开发还是多线程开发?”的答案是:“系统级网络调用应该要么是非阻塞性的,要么由非事件处理线程调用;同时,所有的事件应该在事件处理线程中处理”。这表明,如果使用线程,你应该在非事件处理的网络处理线程中调用一些像阻塞recv()的函数,把这个调用的结果转化成一个事件,并且把这个事件通过某种队列传送到事件处理线程(详情参照上面的第1条建议)。严格说来,诸如像解密/解压一类的事情可能在这两种线程里的任意一个中被处理,尽管要避免事件处理线程成为性能瓶颈,一般来说把加密/压缩工作留给网络处理线程要好些。

调用网络线程的另一种方法是非阻塞型的IO。这儿有好些警告(包括gethostbyname()和getaddrinfo()没有在至少一个主要的平台上有非阻塞型的对应函数),总的说来,我不敢肯定访问非阻塞型的IO值得它给客户端带来的麻烦(服务器端又是另一回事,它将在文章的Part III里被描述)。

4.一定不要把用户当成免费错误处理器

有一些开发者用一种非常简单的(而我要说,可悲的)方法来处理网络错误。就是说,他们只是把错误摆在用户的面前并且说一些像“服务器有一个小问题。请重试”的话。这种方法特别惹人讨厌,而且毫无帮助(除了能使得开发者的生活稍微容易一点,以牺牲用户的生活为代价)。绝对没有理由不在内部处理网络错误(除了开发者耍懒),非自动化地重试。我们应该发生某种通知给用户,告诉他们有问题发生了,但这种通知不应该需要任何用户输入。要完成这样一个通知,你可以把它作为一条信息显示在屏幕的某些主要区域,或者做一个对话框(没有‘ok’键,只有一个‘cancel’键(!))。当你处理通知提示的问题时,通知会自动消失(是的,如果当你搞定问题时,用户东张西望,而同时你能处理这些问题,没有理由用你的问题去烦用户)。

给那些辩解说依赖用户会减少网络拥堵的人提个醒:互联网应当服务于用户需求,而不是别的事,作为该观点的强烈拥护者,我敢肯定我们作为开发者的责任是让用户的生活更方便。即便我同意控制网络拥堵很重要,终端用户的需求仍应排第一位。另一方面,当用户不是真的需要网络访问时,减少网络请求,像一个合理的阻止重试的超时(像是好几分钟,这通常足够让用户感到沮丧,从电脑前走开)还有显示“抱歉,我们努力试过了但现在真的无能为力”将是一件好事。

噢,还有为了遵守之前的第1-3条建议,你通常应该在网络处理线程里检测一个网络问题,把它转化为一个事件,并在事件处理线程里处理该事件(比如,弹出一个对话框)。

5. 一定要给用户提供有意义的错误信息提示

从终端用户的观点看来,“网络不可访问”、“拒绝连接”和“连接被中止”这些信息之间根本没什么不同。如果你可以的话,你可能想要告诉用户,他的网线没插好,或者你的服务器宕了,或者是这两者之间的什么原因,然而将他的空间胡乱地用诸如以上这类“对你而言有意义但对他毫无意义”的信息塞满,这可是个坏主意。更糟的是,要把这些技术细节隐藏在试图把网络故障原因“翻译”成像“服务器正有点小毛病”和“你的连接已中断”这样的信息后面,而同一时间有不止一条这样的信息(更糟的是不同的信息有不同外观的UI)。

尽一切办法,一定要把错误信息从你的空间“翻译”到终端用户的空间,不过只要从终端用户的角度看来错误是无法辨别的就行,也就是让错误信息看起来一样(用错误代码的非闯入式的外观,或者‘更多信息’的按钮,让技术支持的生活更简单)。

6. 一定要支持多平台

在现代游戏环境的前提下,单平台的游戏引擎一般不太有吸引力。即使你的引擎是为单个app开发的,你能肯定它永远不会被移植到另一个平台上吗?就算你肯定的话,你也不应该这么做。实际上,使网络代码跨平台比图像开发的问题要少得多(就是说,除非你为某种具体的技术而疯狂到你忽略了其他所有的技术,这样是很糟糕的一件事),因此让你的网络层只适合单平台就不那么理由充分(除非你的整个游戏引擎已经是单平台的了)。

仅供参考——我个人已经看过我自己的网络库在Windows, Linux, Mac OS X, FreeBSD, iOS,甚至是Android上运行,几乎没有什么变化(在Android上从NDK里运行)。因此,它甚至用一种行对行的方式被移植到Java,不过这是另一个故事了。

6a. 一定要在客户端使用伯克利套接口

伯克利套接口……是一个有为网络套接口和Unix域套接口设计的API的计算库,可用于过程间通信(IPC)。——维基百科

如果你用C/C++完成你的网络引擎并且认为你的应用程序是“仅用于Windows”的,使用Windows特定函数(那些有WSA*()前缀的函数)来通信可能是诱人的。别这么做,用伯克利套接口(那些socket()/connect()/send()/recv())函数取而代之吧。至于它们的用法的细节,Google吧。需要更进一步的细节,请参考[Stevens]

对其他编程语言来说,它们提供了它们自己的跨平台API,一般选择一个可移植的网络库,问题就少得多。

7. 一定要考虑给自动更新程序提供一个通道

通常,自动更新不被认为是游戏引擎的一部分。然而,以敝人之见,这里有一个把它包含进网络层的好例子。理由如下:

  • 用户可能想要得到一些可选择的东西(从主题到DLC)
  • 如果能边玩边下载,用户会感激的
  • 用户不会喜欢下载被游戏中断
  • 通过在网络层保持可选式的下载,你在某些情况下可以优先化拥堵网络,最小化游戏下载的影响(我们将在(2b)的第17条建议里讨论某些相关技巧)
  • 当QoS在网络中不起作用时(见(2b)的第17b条建议),两条平行连接更有可能互相干扰
  • 如果你支持可选式下载,它们应该也是自动更新的,这样可选式下载和自动更新的集成就是一件好事
  • 那么,整个自动更新最好作为网络引擎的一部分被实现
  • 作为一项附加福利,你将能在用户玩游戏时下载自动更新,使得他们的游戏时间最大化。

以上这些理由远不是那么绝对,不过,这样一个体系已经被实现了,我看到它工作得极其出色。

一个要注意的小贴士:尽管和网络库作了集成,你也应该通过HTTP(而不是通过你自己的协议)实现自动更新(那些在游戏程序启动之前就启动的自动更新)的初始化。这样不会太增加游戏本身的复杂度,但允许大大地改变网络协议。

自动更新主题的剩余部分相当复杂,所以我很可能会为它单独写一篇文章。

待续……

为了避免太长懒得看的问题,文章被分成了好几部分。请继续阅读(2). 协议和API。

编辑:剩下的部分已经发表了:

(2a). 协议和API(上)
(2b). 协议和API(下)

(3a). 服务器端(存储处理和发展的体系
(3b). 服务器端(配置,优化,和测试)

Part IV. TCP和UDP的大论战

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

1 9 收藏 评论

关于作者:风-晴-雪

1. 在职翻译2. 兴趣点:C/C++,Javascript,PHP,在线教育,游戏,项目管理,职场经验 个人主页 · 我的文章 · 12 ·  

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部