为什么采用全局管理缓存及状态:麻将客户端状态管理
本文写麻将客户端为什么把游戏数据放在一个全局表里统一管理、缓存为什么集中注册与清理。只写原理,不写实现标识。
一、为什么必须全局管理
麻将客户端面对四个麻烦:服务端多个节点异步下发状态(对局服、匹配服、场景服走不同路径,到达顺序不保证);热更新会重载代码但不能丢对局数据;玩家会掉线重连、最小化恢复、跨局连打;同一状态会被多个事件重复推送(状态同步、房间信息、动作广播、重连、界面显示都可能带同一状态进来)。四个麻烦指向同一个解法:内存里只有一个权威数据源,所有读写走它;所有缓存集中登记,清理时一次清干净。这是本方案的起点,后面几节都是它的展开。为什么是这个解法而不是别的,逐个排除:双数据源(界面一份、逻辑一份)需要持续对账,对账本身就是新缺陷来源,不如不分;每次推送全量重建界面最省事,但玩家选中的牌会被清空,手牌选中反复掉落,体验不可接受;黑名单式清理(默认全清、逐个豁免)漏一个就是一次残留串局,白名单(默认不清、逐个声明)漏一个最多是旧数据多留一会儿,前者致命后者轻微;动画锁强制清零最干脆,但会误释放别的动画的锁,渲染与动画竞争,牌面错乱。只有全局唯一数据源加集中登记清理,同时过掉四个麻烦,所以采用它。采用的不是最聪明的方案,是四个麻烦共同逼出来的唯一解。再往深说一层:麻将的状态是相互依存的,手牌、弃牌、副露、剩余牌数、游戏状态、座位没有一个能独立存在。出牌合法性依赖定缺(缺门不能留),碰杠胡依赖弃牌与手牌,摸牌依赖剩余牌数,终局结算依赖全部累计,箭头指向依赖弃牌堆现状。牵一发而动全身:改一个状态,多个读取方跟着变。
而架构是异步的:多个节点下发状态,到达顺序不保证;事件广播先到后到不确定;定时器回调随时插入。如果采用事件分离(每个事件自带上下文、各模块各自维护),每个处理器收到的都是局部滞后的上下文,还要自己重建与其他状态的依赖关系。同一时刻内存里存在同一状态的多个版本:事件甲里的手牌是旧的,事件乙里的弃牌是新的,合并规则要写在每个处理器里。上下文极其混乱,难以理解:排查一个显示问题,先要确定读的是哪个版本的上下文,再确定合并顺序对不对。
全局状态机把收敛点收到一处:所有写入进同一张表,依赖关系只实现一次(读表即读到全量依赖);异步到达的事件只负责喊变了、触发重读,不携带决策上下文;重复推送由去重整数消化,残留由统一清理兜底。维护复杂度从"事件数乘状态数"降为"状态数":加一个状态,只需管它在表里的读写与清理,不用管它经过哪些事件。收敛复杂度从"每条路径各自收敛"降为"表收敛即全部收敛"。这就是采用全局管理的根本原因:状态互依加异步架构下,分离是发散之源,集中是收敛之器。这可能很反直觉:通常认为解耦和事件分离更先进,全局表像是落后的做法。但事实就是如此——先进与落后不由形式决定,由约束决定。状态互依加异步,就是全局表的主场;换成状态独立加同步调用,事件分离立刻反超。方案没有贵贱,只有约束配不配。对照一下什么时候不该用全局表:纯配置读取(读一次用一次,无状态、无互依、无异步),用全局表就是杀鸡用牛刀,直接读配置即可;单次请求响应(登录、下单,发完等回,无他人并发改同一份数据),用事件传参最干净,不需要权威表。麻将两条全占(互依加异步),所以用全局表。判断方法只有一条:数一数有多少个写者、多少个读者、到达顺序保不保证。单写者单读者保序,不用全局表;多写者多读者乱序,必须全局表。对上号再动手,不要一上来就分层分表。还要说透竞态:分布式异步天然存在竞态,快照与动作谁先到、通知与代答谁先跑、重连补发与本地旧值谁新谁旧,没有一种顺序是保证的。竞态不是异常,是常态;设计时就要按"任何顺序到达结果都一致"来写,而不是先按一种顺序写对、再逐个修其他顺序的 bug。去重整数管重复到达,快照替换管新旧覆盖,收窗清理管残留,每一个机制背后都是一种竞态。把竞态当常态写代码,缺陷少一半;把竞态当异常修代码,修完一个冒出三个。集中登记的好处用气泡定时器举例:四座气泡各有隐藏定时器,分散管理时重建要找四个地方关,漏一个就串局;集中登记后重建只管串行表,一次全关。但前提是登记齐全:漏登记的定时器重建时照样杀不掉,所以创建紧跟登记是必须遵守的约定,漏登记等于没登记。
#mermaid-svg-YEz7ROlUULNzzjfR{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YEz7ROlUULNzzjfR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YEz7ROlUULNzzjfR .error-icon{fill:#552222;}#mermaid-svg-YEz7ROlUULNzzjfR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YEz7ROlUULNzzjfR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .marker.cross{stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YEz7ROlUULNzzjfR p{margin:0;}#mermaid-svg-YEz7ROlUULNzzjfR .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label text{fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label span{color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label span p{background-color:transparent;}#mermaid-svg-YEz7ROlUULNzzjfR .label text,#mermaid-svg-YEz7ROlUULNzzjfR span{fill:#333;color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .node rect,#mermaid-svg-YEz7ROlUULNzzjfR .node circle,#mermaid-svg-YEz7ROlUULNzzjfR .node ellipse,#mermaid-svg-YEz7ROlUULNzzjfR .node polygon,#mermaid-svg-YEz7ROlUULNzzjfR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .rough-node .label text,#mermaid-svg-YEz7ROlUULNzzjfR .node .label text,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label,#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label{text-anchor:middle;}#mermaid-svg-YEz7ROlUULNzzjfR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .rough-node .label,#mermaid-svg-YEz7ROlUULNzzjfR .node .label,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label,#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label{text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .node.clickable{cursor:pointer;}#mermaid-svg-YEz7ROlUULNzzjfR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .arrowheadPath{fill:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YEz7ROlUULNzzjfR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YEz7ROlUULNzzjfR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster text{fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster span{color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YEz7ROlUULNzzjfR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR rect.text{fill:none;stroke-width:0;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape p,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label rect,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YEz7ROlUULNzzjfR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YEz7ROlUULNzzjfR :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
服务端多节点
对局状态快照
匹配通知
重连补发
全局唯一数据源服务端字段+本地字段分离
渲染: 只读数据源
决策: 状态变迁去重
定时器串行化
二、唯一数据源与热更新保留
全局数据表是唯一的游戏数据源:房间、状态、座位、手牌、弃牌、副露、倒计时全部存在里面,界面、推荐、自动托管都从同一个读取入口取数。不存在第二份对局数据,所以不存在两份数据打架。读取入口只返回表引用,不拷贝:调用方读到永远是最新值,但也不能私自改表结构,写操作集中在数据层的几个函数里。
走查例子:假设推荐模块与界面同时读手牌。推荐算出听牌集合,界面画出手牌,两边读的是同一张表,不会出现推荐按新牌算、界面按旧牌画的分裂。若各模块自建缓存,同步时机不同必然分裂,排查时先要确定谁的数据过期,成本极高。统一数据源把这类问题消灭在结构上。
热更新保留旧值:重载时全局表沿用旧表,再与默认模板合并补全新增字段。规则有三条:旧值优先(对局中数据不丢);新增字段由模板补默认值(新版本加字段不崩);协议映射表同样保留(不断连,数字协议与名字映射不断)。走查例子:假设热更新新增连胜字段并新增一条协议。重载后连胜补零,协议映射保留旧二十条并加入新映射,对局中请求不断连。若映射表不保留,重载后所有协议数字对不上名字,请求发错协议,整局协议错乱。本家手牌独立存放:手牌表恒为不含摸牌的张数,摸到的牌独立存一个字段,逐张模拟时先合并再判听。这个约定被验胡、推荐、自动出牌三处共用,改一处即改全部,所以必须写成全局契约而不是各模块自悟。违反契约的代价是注释里写明的坑:逐张模拟打出后若不把摸牌补回,张数检测恒不通过,建议恒空,整个推荐面板空白。契约的价值就在于把这类坑从三处合并为一处:修一次,全好。
走查例子:假设对局中热更新,玩家手牌 13 张加摸牌 1 张。重载后全局表保留,手牌与摸牌都在,新增字段补默认值,倒计时定时器按剩余时间继续跑,玩家无感知。若没有保留机制,重载即回到默认空表,界面清空、倒计时归零、托管状态丢失,对局直接中断。再假设新版本给玩家加了一个新字段(如连胜次数),旧表没有该字段。合并模板后该字段补默认值 0,老数据照用,新功能照跑,不需要写数据迁移脚本。这是模板合并相对整表替换的优点:字段级兼容,不用版本号分支。
三、服务端字段与本地字段分离
快照替换是数据不同步的根源,分离是解法。字段分两类:服务端字段(房间号、游戏状态、庄家座位、当前座位、最后出牌、骰子、剩余牌数、剩余毫秒、回合数、底分、响应座位表)每次随快照全量重建;本地字段(匹配类型、各类等待时长、本家身份、托管状态、待响应表、换牌选择、换入牌、定缺、结算残留、箭头备份、房主标识、分享码)快照替换时原样保留。
快照替换分三步,每步缺一不可:先保存本地字段与摸牌(不存则清零),再清空全部服务端字段(不清则新旧混杂,玩家列表出现重影),最后从快照重建(跳过本地字段名,玩家逐个按白名单过滤)。被踢出的玩家跳过恢复:快照是过去的全量,踢出是现在的决定,用过去的全量覆盖现在的决定就是复活,必须用踢出名单挡一道。显式清理表同理:服务器有意置空的字段,遍历是看不见的(空值不出现),必须走独立通道声明,否则旧值永久残留,表现为改了没生效。重建时跳过本地字段名,玩家逐个按服务端玩家字段白名单过滤重建,被踢出的玩家跳过恢复(快照旧数据不复活已踢出者,否则被踢者换个快照又坐回来)。显式清理表先剥离再处理:空值在遍历中传不透,必须用独立通道声明哪些玩家字段要清零,否则服务器有意置空的字段永远清不掉,旧值残留。增量合并走另一条路:只合新增变化,本地字段跳过不碰,适合高频小更新;全量替换适合重连与状态跳变,两条路按场景选用,不混用。增量合并的座位级合并保留旧玩家已有游戏状态:匹配与开局下发的玩家表仅含基础资料,直接覆盖会清掉对局状态,所以逐座位合并,非服务端字段从旧数据继承,显式声明要清的才清。清玩家指令必须在循环前处理:遍历顺序不定,晚于玩家设置会清掉刚设的数据。私有手牌子字段合并:设置提供的收下,没提供的不动,表深拷贝、值直接赋。流水幽灵项过滤:付款方与收款方为零的项剔除,否则结算面板渲染出付款给空白玩家的行。其他字段直接赋值,表深拷贝隔离引用。合并后逐座位补齐缺失字段,手牌表保底空表。入口先校验:房间号匹配类型状态为数字、座位一到四、玩家为表、编号为数字,不合即拒收并记日志。校验是合并的前门:脏数据进表后排查成本高得多,拒收在前最便宜。首次进房的四个入口(建房、匹配、分享码进房、开局)是例外:此时本地还没有值,服务端下发的本地字段必须完整保存。入口初始化合并只收非空值,空值不覆盖,避免把"没下发"当成"清零"。终局残留三保险中的数据层兜底也在此:新局进入换三张或定缺,待渲染标记无条件清,不管之前是谁置的。
为什么必须分离:换牌选择、定缺、托管状态、箭头备份都是客户端在对局推进中产生的,快照里没有。若不分离,每次快照到达都会把这些清零:换三张选到一半被清空、托管中被快照打回未托管、箭头备份丢失导致终局箭头指错。分离后快照只管服务端权威部分,本地推进不受跨服延迟影响。
走查例子:假设玩家换三张已选 2 张,此时快照到达。替换流程先保存换牌选择,清空服务端字段,重建后再把换牌选择放回,玩家看到的已选牌不动。若没有分离,换牌选择被清空,玩家得重选;若玩家没发现,倒计时结束按空选择提交,换牌失败。再假设玩家托管中快照到达:托管值在本地字段里,快照重建不碰它,托管门禁继续生效;若托管值被清空,玩家一点按钮就发出请求,与服务端托管状态对不上,出现能操作却被服务端拒绝的怪象。第三个例子是箭头备份:碰后箭头回退点存在本地字段,快照到达不清除,终局箭头恢复有据可查;若被清空,终局箭头指向已被碰走的牌。
白名单的另一面是约束:服务端字段新增必须同步进白名单,否则重建时漏掉;本地字段新增必须想清楚生命周期(何时清、谁来清),否则永久残留。每次加字段都回答这两个问题,清单不会腐烂。
快照重建的玩家级细节决定显示对错,走查四个事故:第一,防空表保护。假设对局中快照到达,对手弃牌表为空表(非终局广播可能为空,本家手牌走私有通道不受影响)。若直接覆盖,累积弃牌清零,出牌统计显示对手一张没出过;正确做法是空表不覆盖,已有累积保留。第二,定缺误遮盖。假设上局定缺为万,新局尚未定缺,快照无定缺字段。若从旧数据继承万,新局定缺阶段万花色被遮盖,玩家以为万是缺门,实际还没定;正确做法是快照缺失即置空,绝不继承。第三,旧座位恢复:快照少发座位,从旧数据补回,被踢出者除外,防止快照少发导致玩家消失。第四,私有手牌取服务端值,杠列表与自摸标记跨同步保留(服务端快照不含这两样);摸牌优先从快照取,快照与旧值都没有才兜底——此前无条件用旧值覆盖,把重连首帧服务端下发的摸牌丢弃,自摸按钮消失。第五,待响应状态新局非等待则清空,回合等待列表非终局则清空。第六,世代计数每次替换加一,供调试追踪数据新旧。走查例子:假设玩家报出牌统计不对,查日志世代计数发现快照替换了五次而界面只刷了三次,定位到动画推迟吞了两次渲染;没有世代计数,只能逐帧对日志,排查成本数倍。每一条背后都是一次显示事故,清单即教训。
四、去重整数:同一状态只执行一次
同一状态会被多个事件重复推送(状态同步、房间信息、动作广播、重连、界面显示),阶段变迁函数必须对重复免疫。解法是一个整数:记录上次执行变迁时的状态,本次相同直接返回,不同才执行并更新记录。渲染侧另有一个独立整数,职责相同。
分支规则:出牌与响应阶段同值不重复进出(避免清掉手牌选中);换三张与定缺各走各的进入函数(没进过换三张的重连直接定缺);其他状态只在离开特殊阶段时清理:终局与回合终局清,等待准备加载态不清(无特殊界面可清)。分支用白名单而不用黑名单:新状态加入时默认不清理,必须显式声明才清。出牌与响应阶段同值不重复进出是白名单精神的另一面:已在出牌阶段就别再进一次,重复进入的清理动作会清掉手牌选中。玩家正在选牌,推送一到选中全掉,这种缺陷不崩溃但极伤体验,去重整数专治此类问题。退出房间时两个整数置零,下次进房强制完整重走一遍,不跳过任何分支。
走查例子:假设出牌阶段连续收到三次同一状态推送。第一次执行进入出牌阶段,记录更新;第二三次读到相同记录,直接返回,手牌选中保留。若没有去重整数,每次推送都重进一次出牌阶段,手牌选中被清空三次,玩家选中的牌反复掉落。假设退出房间后重进,记录已在退出时置零,完整重走进入分支,界面重建正确。再假设没进过换三张的重连(如断线时正在定缺):分支直接定缺,不经过换三张进入函数,避免换三张界面弹出来挡住定缺。分支的完备性(每个状态都有明确去向)与去重整数配合,一个管走哪条路,一个管走几次。
五、渲染与决策分离
渲染函数只读数据源画界面,不做状态决策:面板不可见跳过渲染(显示时完整重建);动画进行中推迟渲染(置推迟标志,动画结束补画);房间数据重置先清桌防旧房间残留;终局补偿渲染只对终局相关阶段执行(白名单制,出牌阶段移出白名单,防止旧终局串入新局手牌渲染)。
决策函数只做阶段变迁,不画界面:按状态进入换三张、定缺、出牌分支,终局只清理。渲染先于决策调用:先画出当前状态,再基于当前状态做变迁。若顺序反过来,变迁的清理动作会隐藏刚画好的界面。两者的副作用必须对齐:渲染画什么,决策不能隐藏什么;对不上的改决策,不改渲染。听牌推荐是副作用对齐的实例:此前任何刷新都隐藏推荐面板,玩家点开不足一秒就被下一次刷新杀掉,面板永远显示不出来;现仅在离开出牌与响应阶段时隐藏,手牌变化由动作处理器隐藏,阶段刷新不再误杀。换三张分支渲染完直接返回:牌墙按剩余与骰子算四家张数,玩家信息含空位清理,托管刷新,按钮隐藏,一个分支管一摊事,不与其他阶段共享代码。
换三张进入走动画时间线三分支:时间已过直接进选派;时间未过且动画没跑启动动画链(重连跳过已完成子阶段);动画运行中跳过。三分支覆盖正常、重连、进行中,不多不少。定缺用假阶段锚定消除空窗:状态一到达即置换牌显示标记,闩锁保证换牌切定缺只切换一次,绝不来回;服务端推进到定缺时若换三张动画还在跑立即取消,骰子隐藏;没进过换三张的重建走独立飞入标记,不复用选派标记(复用会走错恢复路径)。每个标记只表达一件事,一个标记两种含义就是一次串局。
动画锁推迟机制:动画进行中到达的渲染请求不丢弃,置推迟标志后返回;动画结束时检查标志补画一次。没有推迟机制的替代方案是动画中直接画,结果是动画与渲染竞争同一批控件,牌面闪烁或位置错乱;另一个替代方案是丢弃请求,结果是动画结束界面停在旧状态,玩家看到过期牌面。推迟是第三条路:不争也不丢,等动画让路。
终局补偿渲染同理:终局到达时若在动画中,先记待渲染标记,任意阶段进来先消费标记再正常渲染;新局开始时丢弃旧终局标记,防止旧终局串入新局。白名单限定补偿只在终局相关阶段执行:出牌阶段移出白名单后,胡牌瞬间先到的终局不再阻塞新局手牌渲染(此前第二局手牌永不渲染)。终局渲染前先缓存当前牌面(副露弃牌手牌与张数),防止清空后服务器数据未到时空渲染,白屏一闪。缓存的是引用快照不是深拷贝:终局瞬间数据不再变化,引用足够;若此时数据还会变,引用缓存会跟着变,就必须深拷贝。用引用还是用拷贝,取决于数据在缓存有效期内变不变,这是缓存设计的第一问。终局渲染后补回缺门标识(渲染清桌逻辑会隐藏它),锚定最后弃牌箭头三选一:终局结果里的权威弃牌优先,其次当前最后出牌,最后是碰前备份。终局补全对齐:操作按钮骰子隐藏、倒计时清理、赢家结果渲染、准备按钮恢复,缺一项终局界面就缺一块;渲染后再强制重锚一次箭头,后续任何界面操作都不得动箭头。已胡玩家徽章与胡牌常驻显示,直到下个回合清桌才重置:重连回来没有动作回放,快照里一旦有赢家就恢复显示,否则已胡玩家到终局才现身。流局效果仅加载态显示,新局清桌后不再重显。
六、定时器串行化与重建
所有可能冲突的异步操作(定时器回调)集中登记在一个串行表里。进入重建临界区时,取消所有已登记的旧定时器,保证上一个任务不干扰下一个。登记动作紧跟创建动作,每个创建都有登记配对,漏登记的定时器重建时杀不掉,会在新局到期触发旧局回调。
被杀定时器的善后分两类:动画锁释放责任单独登记补偿表,被杀时精确执行补偿,保证锁的加解锁严格配对(替代强制清零,强制清零曾误释放其他动画的锁);其余定时器直接取消。最小化恢复传保留参数不杀定时器:杀后重建永远有遗漏(部分重建了、部分没重建),导致倒计时与超时自动决策偶发死掉;保留则剩余时间天然正确(定时器隐藏期间持续运行,归零事件照发),恢复后直接可用,回调带纪元守卫保证隐藏期执行安全。
倒计时时钟单独单杀(不注册串行表):此前只置空引用不关句柄,残留句柄在换局后于新局到期触发旧局回调(自动换牌与自动定缺串局);现先关句柄再置空。开局流程几个计时器变量同样只清引用:因为句柄已由串行表关闭,这里再关一次会重复关闭,清引用防重复,串行表关句柄,两道配合。最小化恢复的回调带纪元守卫与动态查找窗口:隐藏期间回调触发时先对纪元,纪元不对直接返回;找窗口动态找,不缓存旧引用。守卫保证保留下来的定时器在隐藏期执行安全,这是保留模式敢用的前提。走查例子:假设最小化期间换三张倒计时到期,回调触发时界面隐藏。纪元对上,执行自动提交,状态推进;玩家恢复时直接看到定缺界面,倒计时位置显示新阶段倒计时。若无纪元守卫,回调操作隐藏中的旧窗口引用,轻则界面错乱,重则访问销毁对象出错。动态查找保证每次找到的都是当前窗口,不是缓存的上局引用。焦点、气泡、胡牌视图、动画定时器表各自清理,防止重建后操作已销毁的窗口引用或隐藏新创建的气泡。气泡是重灾区:旧气泡隐藏定时器若残留,新局同座位的新气泡一创建就被旧定时器隐藏,玩家看到气泡一闪而过,还以为是显示缺陷,实际是定时器串局。四座气泡列表逐个清空加定时器全关,双保险。胡牌视图两个定时器(隐藏与置顶)同样单杀:重建后操作已销毁的胡牌视图会出错,不止是显示问题。
走查例子:假设换三张倒计时剩 5 秒时玩家最小化。保留模式下定时器继续跑,5 秒后回调触发自动提交,玩家恢复时已进定缺。若采用杀掉模式,恢复时需重建倒计时:重建逻辑必须精确恢复剩余 5 秒,任一分支遗漏则倒计时不工作或自动提交丢失。保留模式把正确性交还给计时器本身,重建逻辑只需管界面。杀后重建遗漏是实测教训:部分重建了、部分没重建,倒计时与超时自动决策偶发死掉,只能等服务器推进后恢复。从那以后最小化恢复一律保留定时器,真重建才杀。
#mermaid-svg-Tzh7oFnnmxnBpkXV{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tzh7oFnnmxnBpkXV .error-icon{fill:#552222;}#mermaid-svg-Tzh7oFnnmxnBpkXV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tzh7oFnnmxnBpkXV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .marker.cross{stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tzh7oFnnmxnBpkXV p{margin:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label text{fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label span{color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label span p{background-color:transparent;}#mermaid-svg-Tzh7oFnnmxnBpkXV .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV span{fill:#333;color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node rect,#mermaid-svg-Tzh7oFnnmxnBpkXV .node circle,#mermaid-svg-Tzh7oFnnmxnBpkXV .node ellipse,#mermaid-svg-Tzh7oFnnmxnBpkXV .node polygon,#mermaid-svg-Tzh7oFnnmxnBpkXV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .rough-node .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .rough-node .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label{text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node.clickable{cursor:pointer;}#mermaid-svg-Tzh7oFnnmxnBpkXV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .arrowheadPath{fill:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster text{fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster span{color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Tzh7oFnnmxnBpkXV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape p,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label rect,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tzh7oFnnmxnBpkXV :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
真重建
有
无
最小化恢复
创建定时器
紧跟登记入串行表
进入重建?
取消全部已登记定时器
有锁释放责任?
精确执行补偿
直接取消
保留定时器剩余时间天然正确
七、分级重置:身份、骨架与全清
重置分三档,按场景选用,不多清不少清:
离开加载态清桌走查:假设上局终局后面板停在加载态显示终局牌面,新局开始状态离开加载态。清理条件成立:清桌、清上局结算(有待渲染标记则保留给渲染消费)、清终局缓存。渲染记录更新为当前状态与回合。若清理条件依赖回合号递增,单局制跨局服务端把回合号重置为零,递增永不成立,清理被跳过,旧终局残留。所以清理条件只看是否离开加载态,不看回合号。掉线重连不经过重置:数据保留,重进恢复依赖服务端补发。走查例子:假设玩家出牌阶段掉线 30 秒,期间错过三次状态推送。重连后服务端补发整包,本地在旧数据上合并,玩家看到牌面从断线前平滑过渡到最新,没有闪白没有重进房间。若重连走重置,本地先清空再等整包,中间有几秒白屏;整包万一延迟,白屏更久。保留的代价是合并逻辑必须处理乱序(补发与本地旧值的先后),这部分由快照替换与增量合并两条路分担。终局残留三保险走查:假设上局终局后面板已弹结算,新局进入换三张,数据层无条件清待渲染标记,界面侧离开加载态再清一次,玩家点退出重进则全清。三道分别堵数据层、界面层、房间层,旧终局三条串局的路全断。
事件发布拷贝隔离:发布时先把回调表抄一份再逐个调用,每个回调独立捕获异常,单个崩溃记日志不影响其他订阅者,无订阅者也记一次。没有隔离的替代方案是一个回调崩溃整条广播链中断,后面订阅者收不到事件,排查时先要确定是谁崩的。请求回调取即清:取回调的同时清除登记,防止重复调用;无回调有错则发错误事件,有回调则错误码与响应一起交付,调用方已自行发布的传参防双发。超时定时器在回调处理时取消,超时与回调谁先到谁赢,赢了的把对方登记清掉,输了的找不到登记直接返回。重置的定时器清理分先后:先关记录在案的全部句柄,再清本地记录,最后清倒计时动画推迟回合换牌请求超时回调与类型索引。顺序不能反:先清记录后关句柄,回调在记录已空时可能误触发。请求回调逐个通知超时错误码,不静默丢弃,等待中的请求方收到明确失败而不是无限挂起。防重入、频控、退出锁、跨局累计(胡牌计数、座位回合信息、踢出名单)同步归零。走查例子:假设玩家退出房间秒进新房,退出锁未释放,新房入口响应到达时发现锁还在,直接跳过面板打开,玩家卡在房间外;频控未重置,进房第一次出牌被判操作过频,直接拒绝;踢出名单未清空,新房同桌玩家被当成踢出者跳过恢复,座位空着。三个锁三种死法,归零一次全消。胡牌计数不清则新局连胜显示串台,座位回合信息不清则新局回合归属错乱。推荐缓存强制清空已在前文,自动动作定时器同样单杀。重置函数是全客户端状态的最大公约数清理点,漏一项就是一类串局 bug。推荐模块缓存在重置时强制清空,防止推荐持有旧手牌:重置不清推荐缓存的后果是新局推荐面板显示上局听牌,玩家照打,句句不沾边。换房跨局清桌时同步重置语音防重标记:否则新局放一遍上局放过的语音,玩家以为出牌了,实际只是旧标记没清。清桌、语音标记、牌桌三者同一次清理,不分开。
走查例子:假设第一局结束同房再来一局。重置保留身份与骨架,清掉手牌副露弃牌与终局残留;新局发牌后快照重建服务端字段,骨架合并为完整玩家,准备按钮可用,房主可踢人:终局、回合终局及加载态恢复准备按钮显示。若骨架带入手牌,第二局换三张阶段对手手牌显示的是上局终局公开牌,且不会被空快照覆盖,玩家看到错牌。自动动作定时器同样单杀:重置不清它,新局到期触发旧局自动动作,牌还没发就自动出牌。若终局残留未清,新局出牌阶段可能弹出上局结算面板。若房主标识未保留,新局房主踢人按钮消失,房间管理瘫痪一半;三个反例对应三类保留字段,缺一不可。
掉线重连走另一条路:不经过重置,数据原样保留,等服务端补发。重置与补发互斥:重置过的等快照重建,没重置的等补发合并。若重连也走重置,本地手牌清空后只能等整包,重连期间界面全空;保留本地数据则重连瞬间仍显示旧牌面,补发到达后无缝更新,体验连续。
#mermaid-svg-CgyTS9XNUqLZw4w1{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CgyTS9XNUqLZw4w1 .error-icon{fill:#552222;}#mermaid-svg-CgyTS9XNUqLZw4w1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CgyTS9XNUqLZw4w1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .marker.cross{stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CgyTS9XNUqLZw4w1 p{margin:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label text{fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label span{color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label span p{background-color:transparent;}#mermaid-svg-CgyTS9XNUqLZw4w1 .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 span{fill:#333;color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node rect,#mermaid-svg-CgyTS9XNUqLZw4w1 .node circle,#mermaid-svg-CgyTS9XNUqLZw4w1 .node ellipse,#mermaid-svg-CgyTS9XNUqLZw4w1 .node polygon,#mermaid-svg-CgyTS9XNUqLZw4w1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .rough-node .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .rough-node .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label{text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node.clickable{cursor:pointer;}#mermaid-svg-CgyTS9XNUqLZw4w1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .arrowheadPath{fill:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster text{fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster span{color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CgyTS9XNUqLZw4w1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape p,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label rect,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CgyTS9XNUqLZw4w1 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
同房再来
退出踢出
掉线重连
对局结束/退出/重连
场景?
保留身份+骨架清手牌副露终局残留
全清房间号匹配类型归零
不重置等服务端补发
新局快照重建
八、为什么不用事件传参而用全局表
一个自然的问题:模块间为什么不直接传参,非要经过全局表?答案是事件是多对多广播,参数是一对一传递。对局状态有多个读取方(界面各控件、推荐、托管、音效、记牌器),若用事件传参,每次状态变更要构造包含全部字段的大包发给所有人,丢一个字段就有一个模块读空;新增读取方要改发送方,发送方与接收方互相耦合。全局表反过来:发送方只管写表,读取方按需自取,新增读取方零改动发送方。代价是写操作必须集中(防止乱写),读到旧值的责任在读取时机(配合去重整数与推迟渲染解决)。事件通知负责喊"变了",全局表负责回答"变成什么样",两者分工。走查例子:假设新增一个记牌器面板,需要弃牌、副露、剩余牌数。用全局表方案,记牌器直接读表,发送方一行不改;用事件传参方案,发送方要在每次变更构造大包并找到记牌器发过去,漏一个字段记牌器少一项,新增发送路径还要处理重连补发。读取方越多,全局表的优势越大;只有一个读取方时,两者差不多。
九、私有手牌契约:13加1
本家手牌表恒为不含摸牌的张数,摸到的牌独立存一个字段,这是全局契约。快照重建时私有手牌取服务端值,杠列表与自摸标记跨同步保留(服务端快照不含这两样,丢了则杠按钮与自摸按钮消失)。摸牌优先从快照取,快照与旧值都没有才用旧值兜底:此前无条件用旧值覆盖,把重连首帧服务端下发的摸牌丢弃。逐张模拟打出后必须把摸牌补回再判听,否则张数检测恒不通过。三个地方(验胡、推荐、自动出牌)共用同一约定,改约定即改三处,所以约定写死不动。走查例子:假设重连首帧服务端下发摸牌为七万,旧值无摸牌。正确流程取快照七万,推荐按十四张算听牌;错误流程用旧值覆盖,快照七万被丢,推荐按十三张算,张数检测不过,建议空白。
十、为什么这套方案成立
六个为什么串起来就是采用理由:为什么唯一数据源——多读取方下事件传参的耦合与漏字段成本高于集中写的约束成本;为什么字段分离——快照不同步是常态,本地推进不能等跨服延迟,分离让两边各走各的时钟;为什么整数去重——重复推送是常态,去重保证同一状态只执行一次,选中不掉;为什么渲染决策分离——渲染管画,决策管变迁,副作用不对齐的锅由决策背,不碰渲染;为什么串行化——异步回调天然并发,集中登记加统一清理是唯一不遗漏的办法,保留模式是杀后重建遗漏教训换来的;为什么分级重置——身份骨架全清三档对应三种场景,多清丢体验(白屏),少清串数据(错牌),分级是两者之间的刻度。每个为什么背后都有一个排除掉的替代方案和一次实测教训,方案是教训的形状。反过来也要说清代价与边界:全局表的代价是写操作必须集中,改表要过评审,随手加字段的时代结束了;读旧值的责任在读取时机,读早了读到旧值不能怪表,要怪自己的调用点没对时机。走查例子:假设某模块在快照到达前读状态做决策,读到的是旧状态,决策错了不能怪表脏,只能怪自己没等快照;正确做法是决策点订阅状态变更事件,快照到达触发重读,或者决策前先读世代计数,世代不对直接返回;表越大合并越贵,所以白名单只放必要字段,流水统计这类只增不改的数据另找地方存,不进全局表。全局管理管的是对局状态,不是所有数据;什么都往全局表塞,表就变成垃圾场,清理时一样遗漏。边界与方案同等重要。
十一、测试建议
以下为建议做法,不是代码事实:改动快照替换后,用换三张选牌中、托管中、箭头备份三个场景验证本地字段不丢,再加被踢玩家不复活一条;改动去重整数后,用同一状态连推三次验证只执行一次,用重连验证完整重走,用没进过换三张的重连验证直定缺分支;改动渲染决策顺序后,验证准备标记不被终局清理链误清,验证动画中渲染推迟后补画;改动定时器后,验证最小化恢复倒计时继续、换局无旧回调串入、动画锁加解锁配对;改动重置后,用同房再来验证房主可踢人、用退出验证面板不重开、用重连验证数据恢复、用终局后换局验证无旧结算弹出;改动私有手牌后,用重连首帧验证摸牌不丢、杠按钮与自摸按钮都在;新增字段后,回答白名单与生命周期两个问题再合入:进服务端表还是本地表,何时清、谁来清,答不上来不准加;合入后补一条走查,证明该字段在快照、重连、换局三条路径下都不丢不串。改动事件发布后,用一个崩溃回调加一个正常回调验证隔离:崩溃的记日志,正常的照常收到。改动请求回调后,验证超时与回调同时到达只处理一次,回调取后登记清空,重复到达直接返回。改动校验后,用座位号五、玩家非表、编号非数字三条脏数据验证拒收;改动推荐缓存后,用重置验证缓存清空,用高频事件验证防抖有效且结果正确。



