« 一个简单的 lua 对象回收再利用方案 | 返回首页 | 为什么 Windows 的文件系统会有盘符,使用反斜杠分割路径 »

跟踪数据结构的变更

这两个月,我的主要工作是跟进公司内一个 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 差异集。这样持有副本方就一定能保证读到的是一个完整的版本了。至于版本落后数据源一小段时间,则是在设计范畴内的了。

Comments

如果用在数据同步上,这个结构取值做到了,但赋值要怎么做?比如数据源又新增的元素:t.a = {1,2,3}, 副本怎么通过change表同步更新(要先创建a这个table)?

local lv = doc._lastversion[k]
-- print(lv,k,v)
if getmetatable(lv) ~= tracedoc_type then
-- gen a new table
v = tracedoc.new(v)
v._parent = doc
else
print("=========asdfas1111df==============",lv)
local keys = {}
for k in pairs(lv) do
keys[k] = true
end
-- deepcopy v
for k,a in pairs(v) do
lv[k] = a
keys[k] = nil
end
-- clear keys not exist in v
for k in pairs(keys) do
print("**********************",k)
lv[k] = nil
end
return
end
lv中遍历出来的key分别是_changes
_lastversion
_parent
_dirty
在最后一个for循环遍历时,岂不是把表给清空了哇,因为传入的参数是{1,3}

思路很清晰

@MMM

然后你就得到了lua复刻版的React+Redux

很不错的想法。有一个问题请教,如何保证发过去的patch是顺序被应用的,会不会出现后发过去的patch先被应用的情况?

@gh

skynet 中每个服务内都没有并发,所以同步副本的写入并不需要锁。准确说,skynet 框架下很少考虑锁的问题。

所谓无锁有锁是很低层面的问题,这里并不做讨论。把锁暴露到业务层去考虑多半是设计有问题了。

UI就是用来表现数据的。逻辑用来改变数据。你可以搞个配置把数据和控件联系起来。有个专门的逻辑根据这个配置来根据数据的变化来更新相应控件的数据。然后再有个CSS之类的配置。可以根据相同的数据给出不同的表现。这些事做了。基本上界面逻辑工作就很少了

你把数据体同步到数据副本的时候不还是要副本进行写入操作?你不加锁不会导制逻辑问题?如果加锁就没必要用你这个设计了,你这个设计就是为了无锁异步对吧,代价是多费内存

Post a comment

非这个主题相关的留言请到:留言本