这两个月,我的主要工作是跟进公司内一个 MMORPG 项目,做一些代码审查提出改进意见的工作。
在数月前,项目经理反应程序不太稳定,经常出一些错误,虽然马上就可以改好,但是随着开发工作推进,不断有新的 bug 产生。我在浏览了客户端的代码后,希望修改一下客户端的 UI 框架以及消息分发机制等,期望可以减少以后的 bug 出生概率。由于开发工作不可能停下来重构,所以这相当于给飞行中的飞机换引擎,做起来需要非常小心,逐步迭代。
工作做了不少,其中一个小东西我觉得值得拿出来写写。
我希望 UI 部分可以严格遵守 MVC 模式来实现。其实道理都明白,但实际操作的时候,大部分人又会把这块东西实现得不伦不类。撇开各种条条框框,纸上谈兵的各种模式,例如 MVC MVP MVVM 这些玩意,我认为核心问题不在于 M 和 V 大家分不清楚,而是 M 和 V 产生联系的时候,到底应该怎么办。联系它们的是 C 还是 P 或是 VM 都只为解决一个问题:把 M 和 V 解耦。
我们的底层使用的是 Unity3D 及它的 UGUI ,UGUI 提供了 UI 需要的显示控件对象,我们开发的业务逻辑则是围绕这些对象开发的。
由于在移动设备上内存有限,项目又做了一系列的对象管理工作,UI 控件并不一定常驻内存,会根据需要加载或删除。又由于这是一个网络游戏,UI 操作和反馈经常需要和服务器打交道,又许多异步操作。故而有一段时间频发的 bug 来自于异步操作访问了被删除的控件对象。
我认为其根本原因在于 M 和 V 没有很好的解耦。
对于 UGUI 引擎提供的控件,应该完全封装在 View 中;而业务数据则应该全部放在和这些控件无关的数据结构即 Model 中。而 Model 改变引起的 View 更新逻辑,如果即不存在于 Model 的方法中,也独立于 View 之外的话,那么这类 Bug 应当是不会产生的。
在迭代的代码中,我们要求 Model 必须是一个纯粹的数据结构,也不一定和显示(View)结构一一对应。比如玩家的 HP 在 Model 中只是一个字段,但可能反应在 View 里的多处地方;而 View 里某个呈现的状态也可以是 Model 中多个字段的复合结果。
业务代码应该可以任意修改 Model ,不必关心 View 是否有效,理论上及时界面控件全部不存在,甚至是一个文本界面,代码也应该可以正常工作。修改 Model 的行为不需要立刻去更新 View ,即修改 M 和更新 V 不需要也不应该是一个同步行为。它们在框架中是两个明确独立的执行阶段,执行流程会清晰的多。
这就类似网页前端,你的业务部分可以提交完整的网页 DOM ,然后浏览器更新 DOM 呈现成图像。当然,如果 UI 结构很复杂的话,每帧次都提交全新的完整 DOM 效率很低,如果可以筛选出每次的差异,根据差异来更新控件,效率就高的多了。
去年底,我写了这么一个 lua 模块: https://github.com/cloudwu/tracedoc
它可以构造一张 lua 表对象,并跟踪对这张表的所有修改。调用 commit 方法可以比较和上次 commit 的版本间的差异,并生成差异集。
这个差异是指的最终叶节点的值差异,比如如果原来的表里有一个字段是 a.x = { 1, 2} ;如果你重新写 a.x = {1, 2} 对 a.x 重新赋值,因为新的值还是 { 1, 2} ,模块会认为没有变化。
在表里只可以存放 lua 原生数据类型:数字、字符串、布尔量。表内可以用 table 创建子结构,但子表的 key 只能是数字或字符串。表里也可以存放对象的引用,但必须用 table + metatable 的形式。所有带 metatable 的 table 都被识别为对象,不会递归比较内部细节。
为了方便 UI 框架的使用,还允许使用者定义一组映射函数,当变更集变更的时候,调用变更数据节点在表内的路径串对应的预定义函数。
这样,方便使用者实现对应的数据绑定特性。
除了在 UI 框架中的应用,这个模块还可以用于 skynet 服务器服务间的数据同步。
比如,我们可以为每个玩家创建一个 agent 服务管理玩家的数据,但多个玩家在一个场景中战斗时,场景服务也需要读写每个玩家的部分数据。
即使对数据做精心的划分:分为玩家私有数据(例如背包)和玩家交互数据(例如玩家的战斗 buf ,属性等),把它们分开存放在 agent 和场景服务中。依然也有一定的需求两类数据间发生交互。
如果我们在设计上能保证一组数据是存在一个写入者,其它服务都是读取者,我们可以把数据放在拥有写入权的地方,而其它地方都是这份数据的副本。
数据体和数据副本间的同步就可以利用上面这个模块。
在分布式系统中,不存在完全的状态同步,但共享状态需要保证版本的原子性,也就是说如果你的一次数据修改若涉及多个字段,那么需要保证读取方每次都读到这组数据不同字段的同一个版本,而不能是 a 是上个版本, b 是下个版本。
如果采用这个模块,我们可以定期对数据表做 commit ,生成差异集发送到副本所在的服务,让它每次都原子性的 patch 差异集。这样持有副本方就一定能保证读到的是一个完整的版本了。至于版本落后数据源一小段时间,则是在设计范畴内的了。