首页 / 横向专题 / 面试问答库

面试问答库

全部答案来自这套工程的真实实现,不是背的八股。 每题标了难度:L1 讲清楚是什么、 L2 讲清楚为什么这么设计、 L3 讲清楚踩过什么坑怎么修的。

怎么用这一页 不要按顺序背。 面试官问的是「做过什么」, 要能从任意一个模块切进去,讲成「背景 → 怎么做的 → 遇到什么问题 → 怎么定位 → 最后怎么解」。
第 9 章准备了 5 个可以完整讲 5 分钟的故事,那才是真正要练的。

1. 项目概述

简单介绍一下这套客户端的模块划分L1
一个 UE5 + puerts 的多人游戏项目,本站覆盖八个功能方向,可以分成三类:

① 位置与场景切换:传送、传送点、坠落重生、边界检查重生。 这四个的共同点是都在动玩家位置,都要处理「服务器权威位置」和「客户端表现」的同步, 也都会撞上 UE5 世界分区(WP)的流送时序。

② 关卡玩法:关卡子弹、计时器玩法。 这两个是给策划用的工具——策划在宇宙编辑器里配,运行时按配置执行。

③ 副本流程:副本结算流程、爬塔。 结算是三阶段镜头 + MVP 特写 + 全家福合影; 爬塔是星级条件系统,局内实时 HUD + 局外存档展示。

最有代表性的是传送,它是全项目最长的一条链路, 经历过一次协议流程重构,累计修过至少 8 个线上问题。
这些模块里技术含量最高的是哪个L2
传送。因为它是唯一一个要同时协调四方的:
服务器(位置权威、状态机)、UE 的 WP 流送(地形什么时候可用)、 表现层(黑屏、相机、动画)、输入层(什么时候解锁移动)。

任意两方的时序错一点,症状就完全不同:
· 解锁早于地形就绪 → 穿地下坠
· 黑屏解得早于相机碰撞恢复 → 穿模裁剪
· LoadDone 漏发 → 服务器永远认为这个玩家不可主控
· 流送判定卡死 → 一分钟后地形卸载,人掉下去

这些问题共同点是「都不报错」,只能靠把多条时间线的日志摆在一起看。

2. 引擎与语言层

puerts 是什么?TS 写游戏逻辑有什么坑L1
puerts 是 UE 和 JS/TS 的双向绑定层,import * as UE from 'ue' 就能直接调 C++ 类型。

四个必须知道的坑:
① 出参要用 $ref——C++ 的 Out 参数在 TS 里不能直接传变量, 要 const box = $ref(new UE.FVector()); Foo(box); $unref(box)。
② int64 不能直接当 number 用——协议里的时间戳、大 ID 是 Long 对象, 必须 MathUtils.LongToNumber 转,直接拿去算会得到 NaN 或对象拼接。
③ UObject 可能已经被 GC 了——TS 侧持有的引用不阻止 UE 销毁对象, 用之前要 UE.KismetSystemLibrary.IsValid()。「上一帧还好好的」在这里不成立。
④ 跨语言调用有开销——Tick 里每帧调几十次 UE 接口会明显掉帧, 能缓存的结果要缓存。
为什么项目里禁止用 number | undefinedL2
这是项目自定义的 eslint 规则 no-number-or-undefined,报错文案就写着 「会有隐藏类型切换,导致性能问题」。

本质是 V8 的隐藏类(hidden class): 一个字段一会儿是 number 一会儿是 undefined,V8 会认为对象形状变了, 之前编译好的内联缓存失效,反优化回解释执行。 在每帧跑的逻辑里这个代价很实在。

同一组规则里还有 no-uninitialized-vars(成员变量必须显式初始化)—— 同一个原因,未初始化字段第一次赋值时会改变对象形状。

实践上的替代:用哨兵值(-1、0), 或者用一个独立的 boolean 标记「有没有值」。
六个 Tick 阶段有什么区别,怎么选L3
ControllerBase 暴露六个 Tick,对应 UE 的 TickGroup: PreWorldActor / PrePhysics / DuringPhysics / EndPhysics / PostPhysics / PostUpdateWork。

选择原则一句话:写位置用 PrePhysics,读物理结果用 PostPhysics。

选错的症状很典型:在 PostPhysics 里改角色位置, 这一帧的物理已经算完了,改动要等下一帧才被消费—— 表现出来就是抖动或者慢半拍,而且很难联想到是 Tick 阶段的问题。

项目里贴地检测用的是 PostPhysics—— 需要拿到物理算完之后的真实落地状态; 而传送解锁后的位置修正放 PrePhysics。

3. 框架与架构

项目的分层是怎样的L1
业务层是 MVC + 三个静态单例注册表:
ConfigManager(47 个 Config,配置表加工与索引)→ ModelManager(136 个 Model,服务器数据缓存)→ ControllerHolder(192 个 Controller,逻辑与协议收发)。

实体层是 ECS:@RegisterComponent(EComponent.Xxx) 注册组件, 六阶段生命周期(OnCreate / OnInit / OnStart / OnActivate / OnDeactivate / OnEnd)。

上面还有宇宙编辑器(Src/UniverseEditor/), 策划用它配关卡玩法。它是独立的一套代码,用 gulp 构建,不走 npm compile。
为什么 Model 声明成可空、Controller 声明成非空L2
public static TowerClimbModel: TowerClimbModel | undefined = undefined;
public static TowerClimbController: TowerClimbController = undefined!;
因为生命周期不一样:Controller 从进游戏活到退出,中间不会被销毁, 所以直接断言非空,业务代码不用到处判空;
Model 会在切场景、换模式时被销毁重建, 所以必须可空,用的时候 ModelManager.XxxModel?.。

这个约定的价值在于类型系统直接告诉调用方「这东西可能没有」, 不用去翻文档。反过来说,如果哪天有人把 Model 改成非空, 就会在切场景的间隙出现难查的空指针。
Model 的 OnLeaveLevel 和 OnClear 分别清什么L3
OnLeaveLevel 清关卡级数据(这一局的怪、这一局的星级进度、定时器), OnClear 清账号级(登出时才清)。

分不清的后果是真实发生过的:爬塔星级数据只在 OnClear 里清, 结果换一个副本进去,上一局的星级还亮着—— 因为换关卡不触发 OnClear。

判断方法:问一句「不退出游戏,重新进一次这个玩法,这个数据应该是新的还是旧的」。 应该是新的就放 OnLeaveLevel。

定时器两个都要清——残留定时器会在下一局突然触发,症状极难定位。
事件系统为什么用弱引用?会带来什么问题L3
用弱引用是为了避免忘记 Remove 导致对象永远不被回收—— UI 面板关了但事件还挂着,面板对象就泄漏了。

代价是「事件处理方法已被回收」这个报错:
// ❌ 匿名箭头函数没人持有,下一次 GC 就没了
EventSystem.Add(EEventName.X, () => { this.Refresh(); });

// ❌ bind 每次返回新对象,Remove 时传的是另一个,摘不掉
EventSystem.Add(EEventName.X, this.OnX.bind(this));

// ✅ 箭头函数属性,被 this 持有,且 Add/Remove 传的是同一个对象
private readonly OnX = (): void => { this.Refresh(); };
所以项目里回调一律写成 private readonly 箭头函数属性, 这不是风格问题,是正确性问题。
怎么防止事件循环派发L3
框架用 EmittingMask 位掩码:派发某事件时先置位, 如果在处理过程中又派发同一个事件,检测到位已置就打 Critical 日志并拦截。

业务侧的解法是「脏标记 + 延迟一帧」: 收到变化时只置标记不立刻刷新,起一个 TimerSystem.Next 在下一帧统一刷。 这样同一帧内的 N 次触发合并成 1 次,顺便还解决了性能问题。

MainUiTaskPanel 就是这么写的, 用位掩码记「哪些部分脏了」,下一帧只刷脏的那部分。

4. 网络与协议

协议怎么分类,客户端怎么用L1
三类:
SCNotify(服务器单向推,284 条)→ Net.Register / Net.UnRegister
CSReq + SCRsp(请求应答,252 条)→ Net.Call / Net.CallAsync
CSPush(客户端单向推,1 条)→ Net.Push

命名规律:World + 模块 + 动作 + 后缀,Req 和 Rsp 连号(14716/14717)。
Protocol.d.ts 是 proto 自动生成的,手改会被下次生成覆盖。
收到一个协议包,客户端要做哪些防御L2
四条,每条都有过因为漏掉而出的线上 bug:
① Model 可能是空的——切场景间隙收到包,ModelManager.XxxModel 是 undefined。
② 字段可能不存在——proto3 的 optional 字段没赋值时不下发, notify.starList 可能是 undefined 而不是空数组。
③ 增量要按主键「有则更新、无则插入」,不能假设顺序—— findIndex 找到就替换,找不到才 push。
④ 写完 Model 必须 Emit 事件—— 最容易漏的是提前 return 的分支: 「数据没变化,直接 return」,结果 UI 就永远不刷了。
同场景传送和跨场景传送的 LoadDone 有什么区别L3
是两个不同的协议,用错会让服务器状态机对不上:
· WorldClientSameSceneLoadDoneCSReq = 14716(回包 14717)—— 同一个场景内的瞬移
· WorldClientLoadDoneCSReq = 14525 —— 跨场景加载完成

服务器靠这个包决定「这个玩家能不能被设为主控」。 漏发的后果是玩家复活后永远操控不了角色—— 真实案例:REVIVE 分支里有个提前 return, 把后面的 LoadDone 发送跳过了。

客户端侧的可靠性设计:退避重试 5s/5s/10s/20s/40s,共 5 次窗口 80 秒, 靠 serverMoveVersion 做幂等去重, 任一取消条件(收到回包、版本过期、离开场景)命中就停。
要删掉一个废弃协议,怎么做才干净L2
以 PreServerMove 的三条协议(15504/15505/15506)为例。

客户端侧要动三处:NetDefine.ts 的枚举、 所有 Net.Register / Net.UnRegister 调用点、 相关的 Model 字段和处理函数。

关键的坑:Protocol.d.ts 是 proto 生成的, 客户端单方面删掉,下次跑生成脚本会原样写回来。 所以必须同步协调服务器删 proto 定义, 在删除的地方留一句注释说明这件事,否则下一个人会以为是误删又加回来。

5. UE5 世界分区(WP)相关

WP 流送和传送有什么关系L2
大世界地形不是一次性全加载的,是按 Cell 网格动态流送。 传送到一个远处的点,那里的地形可能还没加载—— 这时候如果解锁了玩家移动,人就直接穿地掉下去了。

所以传送流程里必须等流送完成。问题是「完成」的判定不可靠, 这是下面两个大坑的共同来源。
讲讲 IsStreamingCompleted 假阳性那个 bugL3
现象:无缝传送解锁后,玩家偶发穿地下坠。
定位:日志显示流送判定已经返回 completed 了,位置也对,就是踩空。
根因:UE 的 IsStreamingCompleted 判的是 Cell 状态 = Activated, 但 Activated 只代表「Actor 已经 spawn 出来了」, 不代表碰撞体(collision)已经构建完成。中间有个窗口期, 射线能穿过去。

修法:不信引擎的判定,自己在落点周围打 3×3 射线做贴地预检, 真的打到地面才解锁。这个方案的好处是它验证的正是真正关心的东西—— 不是「引擎认为加载好了」,而是「这里真的踩得住」。

方法论:当引擎 API 的语义和实际需求不完全一致时, 直接验证真正关心的那个条件。
「传送后一分钟地形消失」是怎么回事L3
这是全站根因链最长的一个。

现象:同图传送后一切正常,大约一分钟后脚下地形突然卸载,人掉下去。
「固定时长」是最大的线索——固定时长基本等于「某个超时或某个定时清理」。

根因链:
1. WP 流送的 IsStreamingCompleted 永久返回 false(这次是真卡住不是假阳性)
2. SceneWaitWpStreamingCmd 是个没有超时的等待命令,永远不完成
3. 它卡在命令队列头部,后面所有 Cmd 被饿死
4. 其中包括 MoveCmd——它负责「玩家灯」的交接
5. 玩家灯没交接到新位置,落点周围的 cell 没有源持有
6. 到了卸载周期,这些 cell 被回收 → 地形消失 → 坠落

修法(治本 + 双保险):
· 给 SceneWaitWpStreamingCmd 加 10 秒超时放行—— 单点卡死不能饿死整个队列
· 加 3×3 贴地检测作为独立的第二道保险

方法论:任何「等待」都必须有超时。 队列式架构里,一个无超时的等待就是一颗雷。

6. 模块深度题

传送

「黑屏残留 13 秒」是怎么定位的L3
现象:换落点后黑屏一直不消失,大概十几秒后自己好了。

第一步是量化:不要接受「十几秒」这种描述, 从日志里量出来是 13 秒——然后去找代码里有没有一个 20 秒的超时 (13 秒是从某个中间点开始算的)。找到了:黑屏有个兜底超时。
这说明黑屏不是「正常解除」的,是被超时兜底强行解的, 也就是解除回调根本没被调用。

根因:SetResultCallback 是覆盖语义不是追加语义。 Helper 里设了一个解黑屏的回调,SceneLoadingController 后面又调了一次 SetResultCallback,把前面那个直接冲掉了。

方法论:看到 SetXxxCallback 先确认它是覆盖还是追加。 黑屏/遮罩残留类问题,第一顺位查「解除回调有没有被覆盖」, 不要一上来就扎进渲染帧。
为什么会「卡在 Seq2 循环播放」L3
现象:map 主动传送时卡在过场动画,循环播。
根因:代码用了 Once 监听 EndClientLoading 事件, 但传送流程里会先加载一个过场中转关卡, 这个加载自己也会派发一次 EndClientLoading—— 把 Once 提前消费掉了。真正的目标关卡加载完时,已经没人在听了。

修法:把 Once 的注册时机挪到 LoadTransitionLevelAsync 之后, 避开中转关卡的那一次派发。

方法论:Once 监听的风险在于「以为的第一次」不是「真正的第一次」。 用 Once 之前先想清楚:从注册到目标事件之间,还有谁会派发同一个事件。
reason 有那么多种,怎么管理分支L2
传送有 9 种 reason(map 主动 / 复活 / GM / 集合 / 交互 / ACTION / DROP_RESET / 被动 BLACK / ROLLBACK), 每种的表现来源不一样。

有效的做法是先做一张 reason → 走哪条路 → 表现来源的对照表, 写进文档。因为这类分支代码读起来是「一堆 if 嵌套」, 但真正要回答的问题永远是「这个 reason 应该长什么样」。

关键设计:不是所有 reason 都由传送模块接管表现。 比如 REVIVE,传送模块识别到之后主动不接管,交回复活链路—— 否则两条链路都在改相机和黑屏,互相打架。

副本结算

结算的全家福镜头,相机距离怎么算L3
先用 GetActorArrayBounds 取全队 ProxyActor 的整体包围盒, 再根据相机 FOV 反算「要装下这个盒子,相机得退多远」。

坑在于必须算两次:
· 用水平 FOV 算装下宽度需要的距离
· 用垂直 FOV 算装下高度需要的距离
· 取两者较大值

只算一个轴的话,另一个轴会出框—— 四人队伍时宽度是瓶颈,两人队伍时高度可能是瓶颈, 所以只用一个轴在某些队伍配置下必然穿帮。 再加一个留白系数防止贴边。
「名字和模型对不上」是什么问题L3
经典的两个数组共用一个下标。

场景里的 ProxyActor 是按站位序排的(体型半径累加,从中间往两边铺), UI 上的名字是按队伍成员序排的。 代码里两个数组都用 i 遍历,只要两个顺序不一致就错位。

修法:不用下标对齐,用 uid 作为主键对齐。 把 GetSettlementPositionOrder 改成返回成员序索引, 建一个 ProxyActorByUid 的映射。

方法论:两个数组要对应, 永远用业务主键(uid / id)关联,不要用下标—— 下标相等是一个「碰巧成立的假设」,任何一边改了排序规则就崩。
「设置了可见但角色不显示」L3
QA 报的是「SetActorVisible=true 但角色不显示」, 听起来像可见性 bug,实际是相机根本没切过去。

某个副本缺 Seq_DungeonSettlement_20100201_3 这个资产, 切 Camera3 的代码加载失败但没报错,静默返回了—— 相机还停在上一个位置,角色确实显示了,只是不在画面里。

两个教训:
① 「不显示」类问题第一句话要问「相机视角对不对」, 能一句话排除一半可能性。
② 资源加载失败必须打 Error 日志。 静默失败会把「资产问题」伪装成「逻辑 bug」, 让人花几倍时间在代码里找不存在的问题。

爬塔

星级用位掩码存有什么好处和风险L2
starMask 第 i 位为 1 表示第 i 档达成。好处是一个 int 表达任意组合, 而且档位之间不要求递进(可以只达成第 3 档不达成第 1 档)。

风险:脏数据会让统计出的星数超过配置档位数。 所以 CountStarsFromMask 里做了封顶—— 按 tower_max_stars 截断,宁可少显示也不显示不可能的值。
增量协议不刷聚合字段怎么办L3
服务器的设计是:20532 下发全量星级信息(含整体 mask), 20533 下发单档更新——但 20533 不重新下发整体 mask。

结果就是达成了一档,UI 上的整体星数不变。

解法(双源 OR 反算):客户端不直接用服务器的 mask, 而是 GetCurrentTowerStarMask() = 全量基线 mask OR 所有收到的增量位。 这样不管服务器刷不刷,客户端算出来的都是对的。

方法论:和服务器的字段契约有缺口时, 优先在客户端做「能自洽的反算」,而不是等服务器改—— 改协议要双边排期,反算当天就能上,而且反算逻辑本身也是一层校验。
「星级档位显示翻倍」L3
HUD 的档位 item 是运行时按数量 CopyItem 复制出来的, CopyItem 是 async。

在 await 期间又收到一次数据变化,创建函数被重入, 第二次进来时 ItemList.length 还是旧的,于是又创建了一遍。

修法三件套:
IsCreatingStarItems 锁防重入 + NeedRefreshAfterCreate 脏标记记住「创建完还要再刷一次」+ finally 里解锁(异常也不会永久卡死)。

方法论:任何带 await 的函数都要假设它会被重入。 只加锁不加补刷会漏掉最后一次更新,只加补刷不加锁会翻倍, 不用 finally 解锁会在异常后永久失效——三样缺一不可。

关卡子弹

15 位 TypeId 是怎么设计的L2
定长分段拼装:6 位 + 5 位 + 3 位 + 1 位, 每段有独立含义,拼起来就是唯一的子弹类型 id。

好处是可以从 id 反查出各段配置,不需要额外的映射表; 排查时看到一个 id 就能拆出它是哪套配置。

代价是定长约束很硬——某一段超出位数会静默污染相邻段。 所以拼装函数里要做范围校验。
为什么 grep「Bullet」找不到子弹协议L3
因为关卡子弹没有专属协议,它借用环境怪(EnvMonster)系统做中转—— 子弹在服务器侧就是一种特殊的环境怪。

这个设计的合理性:复用了已有的实体同步、生命周期管理、 权威判定,不用为子弹再造一套网络层。

但对排查很不友好:按功能名 grep 找不到东西。 所以文档里专门标了这一条—— 找不到协议时,先怀疑「这个功能是不是复用了别的系统」, 而不是怀疑自己 grep 错了。

计时器与重生

「没勾自动开始却在计时」L3
BombTimerModel.GetDisplayTime 完全无视 status, 一律按墙钟(当前服务器时间 - 起始时间戳)推进; GetDisplayTimerName 也只过滤了 Stopped,没过滤 Paused。

修法:改成白名单——只有 status === Running 才加 timeDiff。

方法论:状态过滤永远用白名单(=== Running) 不用黑名单(!== Stopped)。 黑名单在加新状态时会静默漏掉,白名单会在加新状态时强制显式处理。

另外这个 case 还有一半在服务器:如果服务器下发的 status 本身就是 Running, 那是服务器忽略了 AutoStart 配置。客户端修完要同步确认服务器侧, 否则只是把症状盖住了。
边界重生那个 bug 为什么说「一个现象三个 bug」L3
现象是「边界重生的提示文本错误 / 重复」,查下来是三个独立问题叠在一起:

① 脏数据:测试地图导出的文本带 test/ 前缀
② 死代码:代码里写了「EdTid 找不到就回退到 Tid」, 但 EdTid 运行时根本不导出,这个回退分支永远走不到, 也就是说兜底逻辑看起来有实际没有
③ 时序:DoSimpleRespawn 中间会插一段黑屏, 把本来一次显示的文本劈成了两段,看起来像重复

方法论:修完一个原因先别急着收, 问一句「这能解释全部现象吗」。 如果只能解释一部分,说明还有别的 bug 藏在同一个症状下面。

7. 宇宙编辑器

Scheme 是什么L1
Scheme 是「一个配置项在编辑器里长什么样」的元数据描述: 字段类型、中文名、提示文案、默认值、下拉选项、校验规则、渲染方式。

策划在宇宙编辑器里看到的每一个输入框,背后都有一个 Scheme。 写 Scheme 本质上是在写策划的说明书—— CnName 和 Tip 写清楚,能省掉大量沟通。
给已有节点加一个可选参数,要注意什么L3
真实案例:「退出副本 / 传送副本 / 传送关卡」三个节点加过传送表现参数 (进入/离开分别选飞天遁地或淡入淡出)。

四个关键点:
① 必须 Optional: true—— 否则全项目已有的配置全部变成「缺字段」报错。
② Scheme 抽出来共享——三个节点用同一个 teleportShowScheme, 以后加第三种表现只改一处。
③ CreateDefault 返回默认值——不配等同于旧行为,向后兼容。
④ 最重要:executor 侧要真的把参数透传下去。

第 ④ 点当时就漏了:同一个 action 注册了 player 版和 entity 版两套 executor, 只在 player 版里透传了 ShowInfo, 结果通过交互触发的走 entity 版,配了没生效。
加参数之后一定要 grep 这个 action 的所有注册点。
「非法字段」报错怎么查L3
第一顺位怀疑编辑用的 Scheme 和校验用的 Scheme 不是同一个, 而不是怀疑数据脏。

真实实例:TowerStarConfig 在 levelPlayScheme(校验侧)里是 IgnoreAllFields 的空壳, 在 levelPlayDungeonScheme(编辑侧)里被覆盖成真实 scheme。 两边不一致时,校验侧的 OnBaseFix 会走到这段:
if (!fields[key] && !key.startsWith('_') && !scheme.IgnoreAllFields) {
    keysToRemove.push(key);   // 然后 delete,只打个 warn
}
它不只是报错,是会真的把字段删掉——策划配的东西静默消失。

所以看到「非法字段」,先确认这两个 scheme 是不是同一个对象。
编辑器代码怎么编译L2
Src/UniverseEditor/** 走 gulp,不走 npm run compile, 输出到 JavaScript_Raw。

两条流水线的产物目录不同,所以只跑运行时那条时控制台一切正常、编辑器里却完全没变化—— JavaScript_Raw 根本没更新。

日常开发用 gulp watch,另有 check_circular 做循环依赖检查。
Game 层能不能 import 编辑器代码L2
只能 import UniverseEditor/Interface(纯类型与枚举), 其它子目录一律禁止。

原因很实际:EditorCommon 里有编辑器专用的依赖 (react-umg、immer、表解析器), Game 层引进来会把编辑器代码打进运行时包, 而且这些代码在游戏运行环境里可能根本跑不起来。

需要编辑器侧的数据怎么办?让业务方查表拿到值,然后作为参数传进来, 不要让底层反向去查编辑器。

8. 工程与流程

npm run compile 显示 0 错误,为什么改动没生效L3
因为诊断阶段是全项目的,而且失败就直接退出,根本不 emit:
const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length > 0) {
    console.error('诊断错误', ...);
    process.exit(EExitCode.DiagnosticError);   // ← 到此为止,不进 emit
}
所以别人提交的一个错误,会导致自己的改动压根没写到磁盘, 游戏里测的还是旧代码。

应急手段:npm run compile-skip-diagnostic 强制 emit, 然后直接 grep 产物 .js 确认改动进去了——不要信控制台输出。

「编译通过」和「产物更新了」是两件事。
跑 tsc 过了算不算改完L1
不算。 自定义编译器除了 tsc 类型检查,还会跑:
· lint-serious-combat(战斗目录严格 lint)
· lint-top-decls(顶层声明白名单检查——只允许 class/interface/type/enum/import/export, 不允许顶层的赋值和函数调用)
· 一堆 AST 插件变换(LogPlugin、BpConstructorPlugin 等)

tsc 过了 compile 挂掉是常态。只有 npm run compile 真的 EXIT=0 才算改完。
怎么保证改动不影响别人L2
① 开工前列 in-scope / out-of-scope 路径,每次改动对一遍。 最容易犯的错是「看到相似的入口顺手也改了」—— 另一个入口可能有别的团队在维护,或者有意保持旧行为。
② 发现范围外的问题就报告,不动手。
③ 保持 CRLF——整文件重写时把行尾变成 LF, diff 会显示整个文件都变了,review 完全没法看。
④ P4 不加锁——p4 edit 就够了, 顺手 p4 lock 会挡住同事编辑。发现已锁立即 unlock。
⑤ 删/改公共逻辑前 grep 全部调用点, 包括动态字符串拼出来的那种。

9. 五个可以完整讲的故事

讲故事的模板 背景(30 秒)→ 现象(30 秒)→ 定位过程(2 分钟,重点)→ 根因(30 秒)→ 解法与权衡(1 分钟)→ 沉淀(30 秒)
面试官真正在听的是「定位过程」—— 从一个模糊的现象怎么一步步收敛到根因。结论谁都会说,过程骗不了人。

故事 1 · 一分钟后地形消失

亮点「固定时长」这个线索的用法;队列架构的单点卡死;治本 + 双保险的取舍
讲什么 从「固定一分钟」判断是超时或定时清理 → 顺着 WP 流送查到 IsStreamingCompleted 永久 false → 发现等待命令没有超时,卡住整个 Cmd 队列 → 队列后面的 MoveCmd 负责玩家灯交接,被饿死 → 落点 cell 没有源持有,到周期被卸载
解法:给等待命令加 10 秒超时放行(治本,让单点故障不扩散) + 3×3 贴地检测(独立第二道保险)
沉淀任何「等待」都要有超时;队列式架构里无超时的等待是雷

故事 2 · 黑屏残留 13 秒

亮点量化模糊描述;从「13 秒」倒推到「是被超时兜底解的」;覆盖 vs 追加语义
讲什么 用户说「十几秒」,先从日志量出准确的 13 秒 → 找到代码里的 20 秒兜底超时,13 秒是从中间某点算起 → 推断:黑屏不是正常解除的,是被兜底强解的,说明解除回调没被调用 → 查到 SetResultCallback 是覆盖语义, SceneLoadingController 又调了一次把 Helper 的回调冲掉了
沉淀先量化再推理;SetXxxCallback 先确认覆盖还是追加

故事 3 · 传送同步流程重构

亮点这是主动改进不是修 bug;删掉硬等待的勇气;重试机制的完整设计
讲什么 旧流程有一个 8 秒硬等待——不管实际加载多快都要等满 → 接入服务器新给的 15512 / 14716 / 14717 三条协议, 改成事件驱动 → 为了可靠性设计了退避重试 5s/5s/10s/20s/40s, 靠 serverMoveVersion 幂等,三个取消条件 → 新增 TeleportEntityBufferController 缓存乱序到达的协议 → 本地和非本地玩家分别做防下坠
范围控制:只改同关卡传送,跨关卡路径一行不动
沉淀用事件驱动替代硬等待;重试必须配幂等和取消条件;重构要划清边界

故事 4 · 「角色不显示」其实是相机没切

亮点不被报障描述带跑;静默失败的危害;问对第一个问题能省几倍时间
讲什么 QA 报「SetActorVisible=true 但角色不显示」, 描述里已经给了「结论」——听起来就是可见性 bug → 先验证角色确实是可见的(打日志确认状态)→ 那问题就不在可见性,而在「可见的东西在不在画面里」 → 查到 Camera3 切换失败:缺资产,加载 API 静默失败, 相机停在上一个位置
沉淀报障里的「结论」要验证不要采信;资源加载失败必须打 Error; 「不显示」先问相机

故事 5 · 编辑器加「传送表现」参数

亮点面向策划的功能设计;向后兼容;双实现漏改的复盘
讲什么 需求:策划希望某些传送不用默认的「飞天遁地」,改成淡入淡出 → 设计成可选参数(不配 = 旧行为), Scheme 抽出来给三个节点共享, 进入/离开分开配 → 上线后发现「通过交互触发的传送配了没生效」 → 查到同一个 action 注册了 player 和 entity 两套 executor, 只透传了 player 版
沉淀加可选参数天然向后兼容;改 action 一定 grep 全部注册点—— 「只有一个实现」是最贵的假设

10. 通用与软技能

拿到一个 bug,第一步做什么L2
问五个问题,一个都不跳:
① 必现还是偶现?——偶现基本等于时序/竞态
② 有没有前置条件?——「做过一次 X 之后才有」直接指向状态没复位
③ 什么场景?——单人/组队、副本/大世界、主控/非主控
④ 有没有固定时长?——固定时长 = 某个超时或定时清理
⑤ 报的是现象还是结论?——「设置了可见但不显示」是结论,要验证

这五问经常能在读代码之前就把范围缩小一个数量级。 直接扎进代码是最慢的路径。
怎么查一个偶现问题L3
① 先补日志,不要先猜。 偶现问题猜的命中率极低,加一轮日志再复现一次, 拿到的信息量远大于盯着代码想半天。
② 日志要打三样:入参、当时的状态标志、走了哪个分支。 缺任何一样都还得再来一轮。
③ 把两条时间线摆在一起 grep—— 时序错配的问题,单看一条时间线是正常的, 两条穿插起来看,错位一眼就出来了。 遮罩早关而相机碰撞晚恢复那个 bug 就是这么看出来的。
④ 回归型问题不要读代码,用二分回退—— 「以前好的现在坏了」,从提交记录二分比读代码快得多。
怎么和服务器/策划/美术对接L2
核心是把「谁的问题」变成可验证的事实,而不是互相猜:

服务器说「我发了」→ 要协议号和字段值, 客户端 grep 对应的 Register 日志, 明确区分是没收到还是收到没处理——两者根因完全不同。
策划说「配了没生效」→ 三件事:表导出了没(ConfigTool.Gui.exe)、 主控树上有没有放对应节点、编辑与校验 scheme 是不是同一个。
美术说「资产给了」→ 确认路径和命名严格匹配代码里的字符串, 资源缺失很多 API 是静默失败。

对上游有依赖时,在客户端加一条 Error 日志把缺失暴露出来, 下次同类问题 5 秒就能定位,不用再走一遍沟通流程。
这套代码里最值得沉淀的能力是什么L2
把踩过的坑变成可复用的规律。

本站的十几条模式就是这么来的: 「任何等待都要有超时」「两个数组对应用主键不用下标」 「状态过滤用白名单不用黑名单」「async 函数都要假设会被重入」 「零值当没准备好而不是就是零」。

这些不是抽象原则,每一条背后都有一个具体的线上问题。 好处是写新代码时会主动避开—— 写到一个 await 就该想「这里会不会被重入」, 写到一个等待就该想「超时在哪」。

另外一点是排查时先量化再推理—— 「十几秒」查不出东西,「13 秒」能直接对上代码里的超时值。
有没有被证伪过的判断L3
两个典型例子:

① 飞船跳跃丢惯性:最初的判断是 GetActorBounds 的参数用错了,改了参数——没用。 后来才发现真根因是同帧 Spawn 的时序: Actor 刚 spawn 出来时包围盒还是全零, 「零」被当成了「就是零」而不是「还没准备好」。 改成逐帧重试直到拿到非零包围盒才解决。
教训:改了没效果要及时承认假设被证伪,回到现象重新推, 而不是在错误的方向上继续加补丁。

② 传送表现只改了一半:加可选参数时 只确认了 player 版 executor 透传, 没注意同一个 action 还注册了 entity 版。 教训:「只有一个实现」是最贵的假设, 改 action 之前先 grep 全部注册点。

11. 关键数字速记

数字会过时 下表以 2026-08-17 核对过的源码为准。行数、条数会随代码变化, 协议号一律以 协议总索引 为单一事实源; 面试前建议按 分级参照表 重新核一遍。
数字含义
284 / 252 / 1SCNotify / CSReq / CSPush 的条数
136 / 192 / 47Model / Controller / Config 的个数
116Src/Game/Module/ 下的业务模块数
15512 / 14716 / 14717同步瞬移下发 / LoadDone 上报 / 回包
14525跨场景 LoadDone(别和 14716 混)
20528副本结算协议
20532 / 20533爬塔星级全量 / 单档增量
5/5/10/20/40LoadDone 重试退避阶梯(秒),共 80 秒窗口
3×3贴地预检的射线网格
10sWP 流送等待的超时放行阈值
[20, 90000]定时器合法间隔(毫秒),越界静默返回 undefined
15 位子弹 TypeId 长度(6+5+3+1)