首页 / 横向专题 / Bug 案例大全
Bug 案例大全
每条都是这套工程里真实发生过并已定位修复的 bug。统一格式:
现象 → 根因 → 修法 → 可迁移的教训。
最后一栏最值钱——同类问题下次直接按教训去查,不用重走一遍排查。
传送 9
结算 5
爬塔 3
实体/表现 5
其它 5
0. 速查索引
| 症状关键词 | 直达 |
|---|---|
| 传送后卡住不动 / 一直加载 | #1 #2 #5 |
| 穿地 / 下坠 / 掉出世界 | #3 #5 #19 |
| 黑屏不消失 / 黑屏残留 | #4 #8 |
| 角色不可见 / 半透明 / 材质异常 | #14 #15 #12 |
| 名字与模型对不上 | #10 |
| UI 数量翻倍 / 重复元素 | #16 |
| 配了没生效 / 报「非法字段」 | #17 #18 |
| 数据换关卡后残留 | #13 #20 |
| 换装 / 头发错误 | #21 |
| 实体莫名消失 | #22 #23 |
1. 传送模块
#1 跨地图传送必卡在 OnEndClientLoading 状态标志未复位
现象
只要先做过一次同步瞬移,之后再做跨地图传送就必卡,
加载界面停在那里不走,没有任何报错。冷启动后第一次跨图传送正常。
排查路径
「必现 + 有前置条件」是典型的状态残留特征。
搜传送流程里的所有布尔成员,逐个看有没有「置 true 但某条路径不置回 false」。
根因
IsStreamingLevel 在同步瞬移开始时置 true,
但同步瞬移的完成回调里漏了复位。
残留的 true 让后续跨图传送的 OnEndClientLoading 分支判断走错,永远等不到结束。
修法
在 executor 的完成回调里补
this.IsStreamingLevel = false;
教训
凡是「进入某状态置 true」的标志,必须穷举所有退出路径。
成功路径容易记得,失败路径、超时路径、被打断路径最容易漏。
更稳的写法是把置位/复位收进一对方法(
更稳的写法是把置位/复位收进一对方法(
BeginStreaming() / EndStreaming()),
让复位点唯一,而不是散落各处赋值。
#2 map 主动传送卡在 Seq2 循环 Once 监听被提前消费
现象
从地图界面点传送,过场动画第二段一直循环播放,永远进不去。
根因
代码先注册了
但过场关卡的加载本身也会发一次
EventSystem.Once(EndClientLoading, ...),
再去 LoadTransitionLevelAsync 加载过场关卡。但过场关卡的加载本身也会发一次
EndClientLoading,
这个「早到的」事件把 Once 监听消费掉了。
真正的目标关卡加载完时,已经没有监听者了。
修法
把
Once 注册挪到 LoadTransitionLevelAsync 之后,
错开过场关卡自身产生的那次事件。
教训
Once 是「下一次」不是「我想要的那一次」。
注册 Once 之前先问:从现在到目标时刻之间,这个事件还会不会被别的原因触发?框架层面 Once 是在调用回调前就摘除的,所以一旦被提前消费就彻底没了,不会补发。
更健壮的写法:用带标识的事件参数,在回调里判断「是不是我等的那一次」,不是就重新注册。
#3 无缝传送解锁后玩家穿地下坠 流送完成判定假阳性
现象
无缝传送(不黑屏)到达落点后,角色直接穿过地面往下掉,几秒后被坠落重生拉回。概率性,网络/机器越慢越容易复现。
根因
代码用 WP 的
但这个 API 返回 true 只代表 Cell 处于 Activated 状态, 不代表碰撞体已经构建完成。中间有几帧到十几帧的窗口, 角色在这个窗口内被解锁,脚下没有碰撞体,直接掉下去。
「Activated 了为什么碰撞体还没好」—— 三个原因叠在一起: 状态断言本身不承诺物理就绪、查询孔径有盲区、HLOD 让画面看着有地。
IsStreamingCompleted 判断「地面准备好了,可以解锁移动」。但这个 API 返回 true 只代表 Cell 处于 Activated 状态, 不代表碰撞体已经构建完成。中间有几帧到十几帧的窗口, 角色在这个窗口内被解锁,脚下没有碰撞体,直接掉下去。
「Activated 了为什么碰撞体还没好」—— 三个原因叠在一起: 状态断言本身不承诺物理就绪、查询孔径有盲区、HLOD 让画面看着有地。
修法
解锁前追加一道物理层面的实证检查:
在落点周围打 3×3 共 9 条向下射线,
全部命中(或达到阈值)才认为地面真的就绪,否则下一帧重试。
教训
引擎的「完成」标志描述的是它自己的内部状态,不一定是你要的那个语义。
「Cell 激活完成」≠「碰撞可用」,「资源加载完成」≠「渲染就绪」,「动画播完」≠「骨骼已更新」。
判定的黄金标准是直接验证你真正依赖的那个能力—— 要碰撞就打射线,要渲染就查可见性,别信中间层的布尔值。
判定的黄金标准是直接验证你真正依赖的那个能力—— 要碰撞就打射线,要渲染就查可见性,别信中间层的布尔值。
#4 换落点后遮丑黑屏残留 13 秒 回调被覆盖
现象
传送中途换落点,到达后黑屏迟迟不消失,整整 13 秒才亮。
最初以为是「差一帧没刷新」,往渲染方向查了很久。
排查路径
关键转折点是量化时长:不是「感觉有点慢」,是精确 13 秒。
一个固定的、和帧率无关的时长,指向超时兜底而不是渲染。
一查超时配置正好 20s(减去前置耗时约 13s),根因方向立刻锁定。
根因
解除黑屏靠
SetResultCallback 注册的回调。
但 SceneLoadingController 在后续流程里又调了一次 SetResultCallback——
这个 API 是覆盖语义不是追加语义,把解黑屏的回调直接冲掉了。
黑屏只能等 20s 超时才被兜底关掉。
修法
避免覆盖:改为在已有回调基础上链式追加,或把解黑屏移到不会被覆盖的时机。
教训
① 看到「单数回调」型 API(
② 先量化再猜根因。「大概有点慢」查不出东西, 「稳定 13 秒」直接指向定时器/超时配置。固定时长 = 有人在计时。
③ 黑屏/遮罩残留类问题,第一顺位查「解除回调有没有被覆盖或吞掉」, 不要一上来扎进渲染帧。
SetXxxCallback)先假定它是覆盖语义,
全局搜一下还有谁在调。名字里是 Set 不是 Add 就是强信号。② 先量化再猜根因。「大概有点慢」查不出东西, 「稳定 13 秒」直接指向定时器/超时配置。固定时长 = 有人在计时。
③ 黑屏/遮罩残留类问题,第一顺位查「解除回调有没有被覆盖或吞掉」, 不要一上来扎进渲染帧。
#5 同图传送约 1 分钟后地形卸载坠落 流送等待永久挂起
现象
同图传送后玩起来一切正常,大约 1 分钟后脚下地形突然消失,角色坠落。
根因链
四环连锁:
① WP 的
② 导致
③ 后续的
④ 落点的临时灯到期关闭后,没有任何来源持有那些 Cell → WP 把地形卸载 → 坠落。
「1 分钟」正是临时灯的存活时长。
① WP 的
IsStreamingCompleted 在某些情况下永久返回 false;② 导致
SceneWaitWpStreamingCmd 这条命令永远等不到,卡在队列里;③ 后续的
MoveCmd(玩家「灯」的交接)从此再没执行过;④ 落点的临时灯到期关闭后,没有任何来源持有那些 Cell → WP 把地形卸载 → 坠落。
「1 分钟」正是临时灯的存活时长。
修法
教训
① 「延迟固定时长后出问题」= 找那个时长对应的定时器。
1 分钟、13 秒、20 秒这种整数时长,一定有配置项在背后。
② 命令队列里的任何等待都必须有超时。 一个 Cmd 卡住 = 它后面所有 Cmd 全部饿死, 而症状往往出现在后面某个不相干的 Cmd 上,离根因很远。
③ 排查「A 之后 B 没执行」时,先确认 B 到底有没有被调用(打日志), 再查 B 内部逻辑。这里
② 命令队列里的任何等待都必须有超时。 一个 Cmd 卡住 = 它后面所有 Cmd 全部饿死, 而症状往往出现在后面某个不相干的 Cmd 上,离根因很远。
③ 排查「A 之后 B 没执行」时,先确认 B 到底有没有被调用(打日志), 再查 B 内部逻辑。这里
MoveCmd 根本没跑,查它的代码是白费功夫。
#6 复活后无法被选为主控 提前 return 漏发协议
现象
玩家复活后,服务器一直认为该角色「不可主控」,操作没响应。
根因
传送流程按
服务器收不到 LoadDone,「不可主控」标记永不重置。
reason 分支。REVIVE 分支为了跳过一些不需要的表现,
提前 return 了,
但 LoadDone(ClientSameSceneLoadDoneCSReq 14716)的发送写在函数末尾的公共段里——被跳过了。服务器收不到 LoadDone,「不可主控」标记永不重置。
修法
REVIVE 分支 return 之前补发 LoadDone。
教训
「提前 return 优化」是漏做收尾动作的头号原因。
一个函数里如果末尾有必须执行的协议发送 / 状态复位 / 资源释放,
中途加 return 分支就是在埋雷。
防御写法:把必做的收尾抽成独立方法, 让每条 return 路径显式调用一次——宁可写三遍,也别指望公共段兜底。 review 时的检查点:数一数函数里有几个
防御写法:把必做的收尾抽成独立方法, 让每条 return 路径显式调用一次——宁可写三遍,也别指望公共段兜底。 review 时的检查点:数一数函数里有几个
return,
再数一数收尾动作出现了几次,对不上就有问题。
#7 退出副本仍是飞天遁地 双实现只改了一份
现象
给「退出副本」节点新加了传送表现参数(淡入淡出),
从菜单退出时生效,从场景交互物退出时还是老的飞天遁地。
根因
同一个 action 注册了 player 版和 entity 版两套 executor,
菜单走 player 版、交互物走 entity 版。
加参数时只在 player 版里透传了
ShowInfo,entity 版原样没动。
修法
entity 版同步透传 ShowInfo。
教训
「一个功能在 A 场景生效、B 场景不生效」= 十有八九有两份实现。
改任何 executor / handler 之前,先用 action 名或事件名全局搜一遍注册点, 数清楚有几处。这个项目里 player / entity 双注册是常见模式, 改一份忘一份是高频事故。
搜索关键词用注册用的枚举值而不是函数名——函数名会不同,枚举值一定相同。
改任何 executor / handler 之前,先用 action 名或事件名全局搜一遍注册点, 数清楚有几处。这个项目里 player / entity 双注册是常见模式, 改一份忘一份是高频事故。
搜索关键词用注册用的枚举值而不是函数名——函数名会不同,枚举值一定相同。
#8 loading 期间后处理异常 loadscene 关卡里的 Actor 在生效
现象
传送 loading 画面期间,全屏色调/雾效明显不对,进入目标场景后恢复正常。
根因
loading 期间加载的是
loadscene 这个中转关卡,
里面挂着一个开启状态的后处理 Actor,
它的设置覆盖了全屏,把目标场景的后处理盖掉了。纯资产配置问题,不是代码 bug。
教训
loading/过场期间的画面异常,先查中转关卡里的 Actor,别扎进渲染代码。
后处理体积、雾、方向光这些全局作用的 Actor,
只要所在关卡被加载就会生效,不管它是不是「主场景」。
同理适用于:过场关卡的音效、Level 里遗留的调试用 Actor。
同理适用于:过场关卡的音效、Level 里遗留的调试用 Actor。
#9 传送落地瞬间穿模裁剪 遮罩关得早,相机碰撞恢复得晚
现象
传送落地时能瞥见角色被裁掉一半 / 看到墙内部,持续一两秒。
根因
时间窗口错配:
而相机碰撞检测是关闭状态的,要等到
这中间约 2 秒的裸露窗口里,相机 blend 轨迹穿墙,画面就穿帮了。
OnStartDown 强行把进度条冲到 100,导致 loading 遮罩 1 帧就消失;而相机碰撞检测是关闭状态的,要等到
Seq3 OnFinished 才恢复,长达 2 秒。这中间约 2 秒的裸露窗口里,相机 blend 轨迹穿墙,画面就穿帮了。
教训
遮罩类问题要同时看「什么时候盖上」和「被遮的东西什么时候真恢复」。
这类 bug 的本质是两个独立时间线的错配,
单看任何一条都觉得没问题——遮罩关得没错,相机恢复得也没错,
错的是它们之间的相对顺序没人保证。
排查方法:把两条时间线的关键节点都打上带时间戳的日志,摆在一起看重叠区。
排查方法:把两条时间线的关键节点都打上带时间戳的日志,摆在一起看重叠区。
2. 副本结算
#10 结算阶段 3 名字与模型错位 两个序共用一个下标
现象
全家福镜头里,角色模型和头顶名字对不上,A 的名字挂在 B 的模型上。
根因
ProxyActors 数组是按站位序排的(按体型半径从中间往两边铺),
UI 成员列表是按队伍成员序排的。代码用同一个循环变量
i 同时索引两个数组——
只要体型不同导致站位重排,就错位。
修法
改为按玩家 uid 对齐:建
ProxyActorByUid 映射,
GetSettlementPositionOrder 改为返回成员序索引,两边都用 uid 找对方。
教训
两个数组用同一个下标,前提是它们「同源同序」——而这个前提几乎从不成立。
只要两个列表的排序规则不是同一段代码算出来的,就必须用业务主键关联(uid / id), 不能用下标。
代码嗅觉:看到
只要两个列表的排序规则不是同一段代码算出来的,就必须用业务主键关联(uid / id), 不能用下标。
代码嗅觉:看到
listA[i] 和 listB[i] 出现在同一行,停下来问
「这两个列表凭什么保证同序?」——答不上来就是 bug。
#11 全家福黑边 / 人物出框 只按单轴 FOV 算距离
现象
不同队伍人数、不同体型下,全家福镜头有时留白过多有时角色被切掉。
根因
相机距离只按水平 FOV 算,宽屏下横向刚好,
但纵向(高个角色、召唤物)会超框。
修法
GetActorArrayBounds 取全员包围盒,
水平和垂直 FOV 各算一个所需距离,取较大者,再加留白系数。
教训
「相机把东西装进画面」是二维约束,必须两个轴都满足。
取
同类问题:UI 自适应、缩略图裁剪、小地图范围——凡是「装下」都要双轴校验。
max(distH, distV) 是标准解,只算一轴必然在另一轴翻车。同类问题:UI 自适应、缩略图裁剪、小地图范围——凡是「装下」都要双轴校验。
#12 报「SetActorVisible=true 但角色不显示」 其实是相机没切
现象
QA 报某副本结算阶段 3 角色不显示。
查代码
SetActorVisible(true) 明明调了,日志也打了。
根因
该副本的
Seq_DungeonSettlement_20100201_3 资产根本不存在。
加载失败是 静默失败(silent-fail:不报错、返回值也正常,功能却没生效),
结果相机压根没切到全家福机位——角色是可见的,只是不在画面里。
教训
① 「不显示」有两种完全不同的根因:东西真隐藏了,或者相机没看它。
拿到这类报告,第一个问题永远是「相机视角对不对」,
能一句话砍掉一半排查面。
② 资源加载 API 大多是静默失败。 按 ID 拼路径加载资产的地方,加载结果一定要判空 + 打 Error 日志, 否则策划/美术漏配一个资产,表现层就静默失效, 排查时会误以为是逻辑 bug 而去翻代码。
② 资源加载 API 大多是静默失败。 按 ID 拼路径加载资产的地方,加载结果一定要判空 + 打 Error 日志, 否则策划/美术漏配一个资产,表现层就静默失效, 排查时会误以为是逻辑 bug 而去翻代码。
#14 结算后角色不可见 引用计数没配平
现象
结算流程走完,主角还是隐身的。
根因
显隐用 VisibilityLock 引用计数管理,
上游某条路径
push 了锁但没有对应的 pop,
计数永远 > 0,ShowActor 进来直接短路返回。
修法
结算
OnEnd 加 ForceShowActor 兜底(无视计数强制显示)。
教训
引用计数型 API 的通病:某一方漏 pop,全系统受害且无法自愈。
而且受害者和肇事者往往不是同一个模块——
结算并没做错什么,是上游漏了 pop。
对策两条:
① 计数型 API 一定要配一个
② push/pop 时带上
对策两条:
① 计数型 API 一定要配一个
ForceXxx 逃生口,
让流程终点能强制归位;② push/pop 时带上
reason 字符串,
计数异常时能打出「当前还有谁持有锁」——否则根本查不动。
#15 复活后本地玩家半透明 死亡溶解材质未清
现象
复活后本地角色 Mesh 保持死亡溶解状态,半透明/发光残留。只有本地玩家有,其他玩家正常。
根因
HandleRevertFire(复活回滚)里漏调 StopDeathDissolve。
其他玩家走的是实体重建路径,材质自然是新的,所以看不出问题。
教训
「只有本地/只有他人有问题」是极强的定位线索——
说明两者走了不同的代码路径,直接去找分叉点。
本地玩家常见的特殊性:实体不重建(复用)、有额外的输入/相机绑定、 优先执行本地预测。凡是「重建能自愈」的状态,在复用路径上都得手动清。
本地玩家常见的特殊性:实体不重建(复用)、有额外的输入/相机绑定、 优先执行本地预测。凡是「重建能自愈」的状态,在复用路径上都得手动清。
3. 爬塔
#16 星级档位 UI 翻倍 异步创建被重入
现象
HUD 上星级条目出现两倍甚至三倍数量的重复项。
根因
创建档位 UI 的函数里有
await(加载 prefab)。
在 await 挂起期间,又一个刷新事件进来,第二次调用同一个函数——
此时第一次的条目还没建完,重复检查判不出来,于是建了两套。
修法
加布尔锁
IsCreatingStarItems:进入置 true、结束置 false,
期间的调用不丢弃而是记标记,结束后补刷一次。
教训
任何带
标准模式:
另外
await 的函数都要假设它会被重入。
JS 单线程不代表没有并发——await 就是让出点。标准模式:
if (this.IsCreating) { this.NeedRefresh = true; return; }
this.IsCreating = true;
try { await ...; } finally { this.IsCreating = false; }
if (this.NeedRefresh) { this.NeedRefresh = false; await this.Refresh(); }
关键是「记标记后补刷」而不是「直接丢弃」——
直接 return 会丢掉最新数据,UI 停在旧状态。另外
finally 里解锁是必须的,否则一次异常就永久锁死。
#17 编辑器报「非法字段」但字段明明有 编辑与校验用了不同 scheme
现象
策划在编辑器里配好
TowerStarConfig,保存时报「非法字段」。
但对着 scheme 定义一个个核对,字段全都在。一度当成脏数据处理。
根因
同一份数据挂了两个 scheme:
编辑用的是
校验走的是
空壳本意是「不校验内部」,但实际行为变成了「所有字段都不认识」。
编辑用的是
levelPlayDungeonScheme(定义完整、字段齐全);校验走的是
levelPlayScheme,那里对应位置是一个 IgnoreAllFields 空壳。空壳本意是「不校验内部」,但实际行为变成了「所有字段都不认识」。
教训
同类报错的第一顺位检查项:编辑用的 scheme 和校验用的 scheme 是不是同一个。
不要一上来就怀疑数据脏。
更一般的规律:「写入端和校验端用了两套规则定义」是配置类系统的经典事故。 无论是 scheme、JSON Schema 还是 proto, 只要两端各持一份,迟早不同步。
排查手法:拿报错的字段名全局搜,看它在几个 scheme 文件里出现过。
更一般的规律:「写入端和校验端用了两套规则定义」是配置类系统的经典事故。 无论是 scheme、JSON Schema 还是 proto, 只要两端各持一份,迟早不同步。
排查手法:拿报错的字段名全局搜,看它在几个 scheme 文件里出现过。
#18 新副本星级 HUD 全空 主控树漏放监听节点
现象
策划给新副本配好了星级条件,进副本后 HUD 一条都不显示。
根因
星级链路要求主控树上放一个「开启星级监听」节点(触发
StartTowerStarListen),
服务器才会下发 20532/20533。配了条件但没放节点 = 服务器压根不发包。
教训
「配置生效」往往需要多处协同,只配数据不够。
这类功能应该在文档里明确写出完整的 N 步清单,
并且在客户端加一条「配了条件但没收到开启包」的 Warn 日志——
让系统自己告诉策划漏了哪步,比让他们来问快得多。
更好的做法是在编辑器校验阶段就报错: 配了
更好的做法是在编辑器校验阶段就报错: 配了
TowerStarConfig 却没有对应节点,保存时直接拦下。
4. 实体与表现
#19 飞船平台跳跃丢惯性 + 掉出碰撞体 同帧 Spawn 时序
现象
在移动的落兵船(ConfigId 70215)上跳跃,角色不跟随船的惯性,甚至掉出碰撞体。
排查弯路
最初怀疑
GetActorBounds 的参数用错(bOnlyColliding),
改了之后没效果——这个方向被证伪。
根因
真正原因是同帧 Spawn 时序:
船刚 Spawn 出来的那一帧,碰撞组件还没注册完,
GetActorBounds 返回全零包围盒,
基于它算出来的平台参照系自然是错的。
修法
改为逐帧重试,直到拿到非零包围盒才继续初始化。
教训
① 「刚 Spawn 出来就查询」几乎必然拿到半成品。
UE 的 Actor 构造 → 组件注册 → 碰撞注册 → BeginPlay 是跨多帧的,
同帧查询包围盒、碰撞、物理状态都不可靠。
② 零值/空值要当作「还没准备好」而不是「就是零」—— 包围盒全零、位置全零、数组长度零,这些都值得重试而不是直接用。
③ 排查记录里保留被证伪的方向很重要, 避免下次同样的问题又去改一遍
② 零值/空值要当作「还没准备好」而不是「就是零」—— 包围盒全零、位置全零、数组长度零,这些都值得重试而不是直接用。
③ 排查记录里保留被证伪的方向很重要, 避免下次同样的问题又去改一遍
GetActorBounds 参数。
#20 计时器没勾 AutoStart 却在计时 显示层无视状态
现象
策划没勾 AutoStart,进场后计时器 UI 照样在跑。
根因
两处:
①
②
①
GetDisplayTime 完全无视 status,
一律按墙钟 timeDiff 推进;②
GetDisplayTimerName 只过滤了 Stopped,没过滤 Paused。
修法
改成只有
Running 才叠加 timeDiff,其余状态返回静止值。
教训
「过滤掉坏状态」的写法很脆弱,应该改成「只放行好状态」。
这是白名单 vs 黑名单的老道理,在状态机代码里尤其值钱。
补充:如果服务器下发的
if (s !== Stopped) 在新增 Paused 状态时就漏了;
if (s === Running) 则天然安全——新增任何状态都默认不放行。这是白名单 vs 黑名单的老道理,在状态机代码里尤其值钱。
补充:如果服务器下发的
status 本身就是 Running,
那是服务器忽略了 AutoStart 配置,属于另一个 bug,别在客户端硬修。
#21 试用角色传送后头发错误 句柄复用不更新 id
现象
试用角色传送后,头发/外观变成了之前那个角色的。
根因
AvatarModel.RegisterAvatar 在复用已有句柄时,
忽略了传进来的新 characterId。
句柄的 CharacterId 被永久锁死在首次注册的值上,
后续所有换装查询都按旧 id 走。
教训
「复用缓存对象」的分支里,必须逐字段确认新参数有没有被应用。
典型写法陷阱:
这类 bug 的特征是「第一次对、后面错」, 而且往往在「切换/更换」类操作后暴露。
let h = this.Map.get(key);
if (!h) { h = new Handle(characterId); this.Map.set(key, h); }
// ← 复用分支什么都没做,characterId 被静默丢弃
return h;
新建分支写了的每一个赋值,复用分支都要问一遍「要不要更新」。这类 bug 的特征是「第一次对、后面错」, 而且往往在「切换/更换」类操作后暴露。
#22 跨图传送后新角色实体被清理连坐 清理时机早于创建
现象
从 30100112 传到 80070002,卡在保底流程。
表面看是「换装超时」,改换装逻辑无效。
根因
新角色的 entity 先于 EnterScene 下发,
而
换装超时只是伴随现象——实体都没了当然换不上装。
RemoveRoleEntities(清理上一张图残留)在 EnterScene 时机执行,
把刚到的新 entity 一起清掉了。换装超时只是伴随现象——实体都没了当然换不上装。
教训
「清理旧的」和「接收新的」之间存在竞态时,清理必须带范围限定。
不能无脑
排查心法:最先报出来的错未必是根因。 「换装超时」是症状,往上游追问一句「换装的对象还在吗」就到根因了。 遇到超时类报错,先确认操作对象是否存在,再查操作本身。
RemoveAll,要按场景 id / 批次号只清属于旧场景的那批。排查心法:最先报出来的错未必是根因。 「换装超时」是症状,往上游追问一句「换装的对象还在吗」就到根因了。 遇到超时类报错,先确认操作对象是否存在,再查操作本身。
#23 跨关卡复制实体报「丢失 Json」 Actor 留在源 ULevel
现象
从关卡 A 复制实体到关卡 B,编辑器报「丢失 Json」。
根因
复制逻辑只调了
SetFolderPath(改的是编辑器里的显示目录),
没有真正把 Actor 迁移到目标 ULevel。
Actor 物理上还属于 A 关卡,读 B 关卡的数据自然找不到。
教训
UE 里「文件夹路径」和「所属 Level」是两个独立概念。
一般规律:「改了显示但没改归属」是编辑器工具类 bug 的高频模式—— 凡是看起来生效了但数据层不认的,都往这个方向查。
SetFolderPath 只影响世界大纲里的视觉分组,
不改变 Actor 的归属关卡。跨关卡操作必须显式做 Level 迁移。一般规律:「改了显示但没改归属」是编辑器工具类 bug 的高频模式—— 凡是看起来生效了但数据层不认的,都往这个方向查。
#24 边界重生组件文本错误且重复 三个独立问题叠加
现象
边界重生提示文本显示成
test/xxx,而且同一句话分两段弹了两次。
根因
三个问题叠在一起:
① 测试地图导出时带了
②
③
① 测试地图导出时带了
test/ 前缀的脏文本;②
EdTid 运行时不导出,所以那条兜底分支其实是死代码;③
DoSimpleRespawn 中间的黑屏把一次提示劈成了两段,看起来像弹了两次。
教训
一个现象里可能藏着多个独立 bug,修完一个别急着收工。
「文本错」和「弹两次」看起来是一件事,实际是两条完全不同的链路。
另外:兜底分支要验证它真的能被走到。
另外:兜底分支要验证它真的能被走到。
EdTid 运行时压根没有值,写再多回退逻辑也是死代码——
写兜底之前先确认兜底依赖的数据在目标环境里存在。
5. 跨案例总结:十二条可迁移规律
② 必现 + 有前置条件 = 状态残留
「做过 X 之后 Y 必挂」就去找 X 置了什么标志没复位。
#1
③ A 行 B 不行 = 有两份实现
先全局搜注册点数清有几处,别急着改代码。
#7
④ 只有本地/只有他人 = 路径分叉
直接找分叉点。本地常见特殊性:实体复用不重建。
#15
⑤ 第一次对后面错 = 复用分支漏更新
缓存命中的分支里,新参数是不是被静默丢弃了。
#21
⑦ 批量改状态必须记录改过谁
两次全量扫描之间集合会变,一定漏。
#13
⑧ 两个数组同下标 = 高危
问「凭什么保证同序」,答不上来就换业务主键。
#10
⑨ 提前 return 会跳过收尾
数 return 的个数 vs 收尾动作的个数。
#6
⑩ await 的函数一定会被重入
锁 + 补刷,
finally 里解锁。
#16⑪ 白名单优于黑名单
写给未来的自己:排查的通用开局
拿到一个 bug,按顺序问这五个问题,多数情况下三步内能锁定方向:
① 必现还是偶现?(必现→逻辑/状态;偶现→时序/竞态)
② 有没有前置条件?(有→状态残留)
③ 所有场景都有还是只有某些场景?(部分→多份实现或配置差异)
④ 有没有固定的时间特征?(有→定时器/超时)
⑤ 报出来的这个错,是不是症状而不是根因?(往上游追一层)
① 必现还是偶现?(必现→逻辑/状态;偶现→时序/竞态)
② 有没有前置条件?(有→状态残留)
③ 所有场景都有还是只有某些场景?(部分→多份实现或配置差异)
④ 有没有固定的时间特征?(有→定时器/超时)
⑤ 报出来的这个错,是不是症状而不是根因?(往上游追一层)