架构要点
我觉得架构的核心既不在于应用DRY(Don’t Repeat Yourself)来精简代码,也不在于套用设计模式的23板斧来解耦合。当然这些很重要,也很有意义,但这些都是通往最终目标的手段或者有用的副产品。架构终极目的在于:以最低的成本对抗风险。
由于成本的限制,没有这样一发银弹,来满足关于架构的所有需求。在没有限制的情况下,如果能够获得无限的成本,那就可以完全对抗风险,成本包括金钱、人力等等。显然无论从资本的层面,还是实际项目的实行,这种奢望都不切实际。因此我们应将重心调整为:对已知和未知风险的预防,而不是期望拥有更多的成本。
由于架构与项目密切相关,项目的复杂度直接决定了架构的复杂度。要制作一个文字对话游戏,例如《生命线》,架构可以只考虑进度控制与文本信息的读取;如果要做成回合制的游戏,例如三消或养成类,则还需考虑丰富界面的表现效果以及某些特殊逻辑下的玩法;如果做成半回合制半实时的游戏,例如卡牌类游戏,则需要考虑对用户操作状态的阶段性控制;如果要做成实时类型的游戏,例如MOBA,则需要考虑多客户端的同步机制;如果要做成泛实时类型的游戏,例如MMORPG,则还需要考虑到半实时的网络同步,并处理各种由于大量数据引发的性能瓶颈。
架构更像是一门平衡的艺术。好架构不仅要能够应对已知风险,还要处理未知的挑战。最常见的就是,某些游戏有了新颖的玩法,需要引入到现有项目中。为了接入某些渠道,或者不同国家的版本,有时也需要做些定制化的改动,如人事变动当然也会引起项目的风险。未知的风险总会出现,而好的架构应能在多数情况下,给出一个可控的增额成本。
在项目的不同阶段,架构的使命也在发生改变。创建项目初期,开发效率被放在首位。凭借快速迭代的开发方式,可做出许多Demo,展现给不同的人并听取反馈意见,从而改进。随着项目的进行,功能和模块需要量产时,易扩展和易维护逐渐变得重要起来。一方面,系统型的功能需要快速产出,例如商城、排行榜、聊天、好友等。另一方面,美术资源也需要有完善的更新机制,简单的UI改版,要能够实时预览。进入到项目的中后期,游戏运行效率低、某些模块有Bug等问题会逐渐得到重视,这时的架构要保证版本稳定。在这个阶段,需要花费大量时间对性能瓶颈进行优化,并规整各模块代码,以减少Bug。项目上线后,推出周期性活动和玩家数据反馈又成为架构新的关注点。因此,架构的演变还是要审时度势,有点“道可道,非常道”的感觉。
优化技巧
众多的概念仍需应用到实践中。每个人的偏好不同,技术倾向也有差别,但不同类型的项目,优化架构还是有不少共通方法。最常见的要数使用MVC架构,分离界面和数据,如图1所示iOS的MVC架构。
图1 iOS的MVC架构
常见的方法还包括:通过设计模式解耦代码模块。从浅显易懂的《大话设计模式》到精深的《GoF》,这些好书都值得细细研读。虽然设计模式的修炼不能在一朝一夕达成,但多多修习绝对大有裨益。
具体到项目上,代码架构要适合团队。从宏观上看,每个团队都是独一无二的组合。如果在那个时间点,团队成员不曾相遇,不曾并肩而战,就不会出现那惊世骇俗的作品。从微观上看,每个团队却又有着相似的苦与乐,既有项目起航的干劲满满,又有产品创意对市场需求不得以的妥协。通常来说,团队里会有“孙悟空”,也会有“猪八戒”,每一份子可能会用不同的方式“斩妖除魔”。能力不同,责任不同,每个人推动项目运作的手法也有差异。总的说来,架构的规划要基于项目的周期和成本,并与对应人员能力相匹配。要避免搞没人会用的设计模式,或者让能力有欠缺的人把项目逻辑弄成一团浆糊。
尽早制定代码规范也很重要。可以让每个人的代码变得“可以理解”,最理想的状况是,整体代码似乎出自一人之手。对于经常发生人事变动的IT行业,这种规范的效用非常巨大。
如果有可能,将暴露的出的变量减到最少。这里的变量指得是公有变量、静态变量、接口访问到的变量等等。暴露的变量越多,意味着与其他模块耦合越多。另外还有个不起眼的黑洞,就是单例模式。建议尽量不要使用单例模式,它会使得代码无法维护。例如:
public class A {
public static A Instance {
get {
//get func
}
}
public int Value{get;set;}
}
public class B {
private void func1() {
int value = A.Instance.
getValue();
}
private void func2(int value) {
A.Instance.Value = value;
}
}
public class C {
private void func1() {
int value = A.Instance.Value;
}
}
这种情况下,A模块就与B和C模块耦合到一起,如果代码中有很多这样的调用,整个系统中的模块几乎无法分离。当调用类B中的func2时,整个逻辑变得更为复杂。因为全局变量被多处更改。当有多线程加入时,绝对是个灾难。
如果项目刚刚开始,建议创建自己的基类。项目中的基类尽量不要使用系统默认的类,例如MonoBehivor,而使用这样的方式:
public class ActorBase: MonoBehivor {
//some data or func
}
public Hero: ActorBase {
}
这种基类最大的优点是扩展时便于更改。如果直接使用耦合在引擎的系统,很容易破坏引擎本身的封装。若不扩展基类,某些功能的实现会较为麻烦。同时也要防止基类太大,如虚幻中的Actor,也会有些麻烦。
实时交互强的项目,要尽早重写游戏循环。Unity3D采用了一种双时间线循环机制,可应付大多数交互情况。但项目中涉及状态同步,或要求高精度还原现场,(常见类型有MOBA、MMO,战斗回放的SLG等),这类游戏用Unity3D自带的时间循环体系就显得吃力。这种时间循环有基于帧和基于时间的两个update,它们相互独立。渲染在基于帧的时间之后,可以保证渲染不会在双帧之间。而物理与AI系统,放置到定时更新的FixedUpdate中,以保证更新的稳定。但由于没有追赶机制,其无法处理峰值卡顿后的数据同步问题。在高精度的要求下,使用Time.deltaTime做计算也会有很严重的问题。一方面,在限帧的情况下,因为其中包含了限帧的等待时间,无法确定逻辑真正的执行时间,除非这个计算是基于限帧之后的。如果是这样,一旦目标机器掉帧,结果值将出现波动。另一方面,即使不限帧,这各种值也无法使用。因为帧执行时间不稳定,在确定次数的情况下,结果一定不同,确定时间下,运行次数又有差异。因此不能用该值做物理和AI计算。若使用该值计算位移,在确定的时间点,部分机器可能会抽掉最后一次位移计算,而这必然会影响结果。很难想象如果到项目的中后期,再对游戏循环进行改动,会耗费多少时间。
除却上面这些,还有不少影响架构的技术点。常见的有:资源管理,对象池管理,内存管理,编写、规划Shader,渲染优化……它们就像夏夜天幕上的点点星辰很多很杂,虽然只有微弱的光,却架起了整个寰宇。
流水线
架构不是项目的全部。为了整体项目能够正常运转,多角色的合作流程必须完备,传统制造业将它称之为:流水线。它被定义为一种工业上的生产方式,指每个生产单位只专注处理某个片段工作,以提高工作效率及产量。从分工上看,策划产出配表及策划案,美术产出资源,程序产出功能实现,运营产出数据反馈分析意见等等。如何将这些元素更融洽地搭配起来,并能快速推动进度,就是流水线要解决的问题。
流水线包含但不仅限于:美术资源的导出导入预览工具,策划配置游戏元素的工具,多渠道打包工具,资源的管理和上传工具等。这些支撑流水线正常运转的工具,总称为配套工具链。因此,程序的工作除了游戏本身,还包括能够提供出一套配套工具链。通常在没有工具链的情况下,架构是完成的,但不完整。完成意味着能正常运行,但如果想量产,则需要完整。工具链需要能与架构契合,当架构趋于稳定时再着手制作工具链,是比较明智的做法。
打造工具链时,要兼顾自己的项目和团队。因为没有什么比开发了一个工具却没人会用更打击人。更多时候,随手的代码,随意放置的按钮,对特定人员的帮助也许超乎开发者的想像。简单的工具不一定没用,强大的工具未必所有人都熟知。
要强调的是,设计工具是为了解决特定问题,而不是为了完成以工具为中心的项目。工具链的开发很容易陷入对极致的追求中。不断的优化工具是个无底洞。头脑清醒的管理者要控制好开发的程度,不要陷入盲目的工具链开发当中。
配套工具链
目前我所供职的公司主要负责一款MMOARPG游戏,在开发过程中,我们制作和使用了以下配套工具链。
图2 角色编辑工具
图2所示是我们开发的一个角色编辑工具,主要用于配置不同角色触发事件的时间点。为了调整出更加细致的动作表现效果,不同的事件点可能会有细微差别,这些都需要慢慢优化调整。因此便设计了这样的预览与编辑工具,方便相关人员调整配置。
整个工具被放置在一个名为AnimConfigWindow的窗口中,选中角色身上的对应子节点可开启工具。它可以预览角色动作,并编辑对应动作事件的触发点。点击左上角的按钮可以切换动作,选好动作后,再点击三角形的播放按钮,可以按标准速度播放动作。在右上角可以更改播放速度,或设置循环方式。在播放条中拖动鼠标,可以更改红色的时间线,模型会依照时间线位置实时更新动作,操作方式与Unity3D自带的动画预览很相似。点击左侧的事件类型区域按钮,可以切换当前类型,例如编辑攻击产生的时间点,或特效产生的时间点。切换类型后,白色轴线会移动到对应行的位置。选好事件类型和触发时间点后,点击Inspector中的按钮可以插入和删除事件。与事件相关变量也可以在Inspector中编辑,其中不同类型事件的可编辑信息是不一样的。插入的事件会在事件帧区域用绿色的方块标志。在右侧区域列出了当前类型事件帧的所有的触发时间点,点击它可以控制时间轴直接切换到对应位置,同时模型也切换到事件触发的动作的状态。最终这些动作相关的信息会保存到预设上,供运行时触发。
图3 关卡配置工具
如图3所示是关卡配置工具。游戏中包含了副本的玩法,策划需要一个工具来配置不同的关卡。怪物分组会直接影响其出现逻辑,而这些怪物又形象一样,很难分清每个怪物的分组,因此可以将每组怪物,用不同颜色的标签标注。分组的配置错误一目了然。工具中还可以查看的警戒范围,配置怪物巡逻路线、角色触发剧情、陷阱、机关等功能,是整个流水线中的重要一环。核心代码如下:
public enum LabelIcon {
Gray = 0,
Blue,
Teal,
Green,
Yellow,
Orange,
Red,
Purple
}
public static void SetIcon( GameObject
gObj, LabelIcon icon ) {
if ( labelIcons == null ) {
labelIcons = GetTextures( "sv_
label_", string.Empty, 0, 8 );
}
SetIcon( gObj, labelIcons[(int)
icon].image as Texture2D );
}
private static void SetIcon( GameObject
gObj, Texture2D texture ) {
var hack = typeof( EditorGUIUtility
);
var f = hack.GetMethod(
"SetIconForObject", BindingFlags.
NonPublic |BindingFlags.
Static );
f.Invoke( null, new object[] { gObj,
texture } );
}
图4 UTomate插件
上面说了两个自研工具,接下来说大家可下载到的插件。如图4所示是一个流程化执行任务的工具,名叫UTomate,可在AssetStore中找到。它能够很好地完成打包,批量渲染场景,移动上传文件之类的繁琐操作。在项目中,我们使用它制作了自动打包流程,大致如下:
- 编译AssetBundle;
- 将AssetBundle拷贝到StreamingAsset目录下;
- 生成各平台的安装包;
- 打开到生成安装包的目录。
这一系列流程如果用代码硬写出来估计需要耗费大量时间,而且万一流程有变动,更改起来需专人维护。有了这个神器,只需要重写几个关键的执行流程,然后再拖拽就可以了,如图5所示。
图5 打包流程图
这样是不是很方便呢?如果后期有分渠道SDK的打包需求,只需要在该基本的打包流程上做少量更改,增加一些文件的复制删除,更改一些编译宏,即可制作不同渠道的版本出包流程。
管理
最后浅谈项目管理。策划、运营提出改进需求,美术实现效果,程序实现功能,测试保证质量。这样一个看似简单的迭代,通常不会一帆风顺。如何调配这些步骤,成为项目管理的难点。我供职过上市公司,自己也创过业,经历过几种不同的组织管理结构,但在管理方面上,我依然没能总结出真知灼见,唯一的体会是,管理上的问题比技术更棘手。特意提到管理的原因在于:有些技术经理认为,自己只负责技术,而管理层面应该完全推掉。这种错误的行为,很容易导致团队涣散。建议技术经理或主程多花些时间在代码审校、进度监管、需求审核,以及与团队成员沟通,这些管理相关的事务上。