审查一轮 Agent PR 批量修复:27 个拉取请求、四类 bug,以及一次合并丢掉的那一行
2025 年 11 月 18 日,26 个拉取请求在四分十九秒内针对 CandyRush 开出
(#117 至 #142,UTC 08:23:42 至 08:28:01),第 27 个(#143)在 11 月 20 日跟进。
每一个都来自一个 AI agent,各自在独立的 claude/* 分支上,并且每个只做一件事:
修一个对象池 bug、替换一次查找、取消订阅事件处理器、收紧一个 DI 注册、修拼写、
补测试。
这轮批量修复由我指挥,落地什么由我把关。结果如下,全部来自 GitHub API 和
git log:
| 数量 | |
|---|---|
| 开出的拉取请求 | 27 |
| 按原样合并 | 19(11 月 20 日 3 个,11 月 21 日两分钟内 16 个) |
| 因合并冲突关闭,再重新组合进 #144 | 8 |
| 11 月 22 日为让合并后的整体能编译、能运行而做的后续提交 | 21 |
| 合并后发现的运行时回归 | 1(敌人波次不再生成) |
这轮修复解决了四类真实的 bug。但更有用的教训在于闸门本身:每个 diff 单独看都 说得通,问题出在组合上。
四类 bug
多建了一个对象的对象池(#126)
对象池的构造函数在 i 小于或等于 startingSize 时继续循环,所以配置为 50 的池
会建出 51 个。修复是两处各改一个字符:
- for (int i = 0; i <= startingSize; i++)
+ for (int i = 0; i < startingSize; i++)
同一个 PR 还按池对象缓存了 GetComponent 的结果,并新增了 293 行 EditMode 测试,
断言精确的实体数量。PR 描述声称缓存后组件查找"快 50-100 倍"。没有人测量过;这是
agent 的估计。修复和缓存都还留在对象池里,而对象池现在位于我共享的 UTools 包中。
被标成 O(n³) 的查找(#131)
HasEvolution 为升级卡片回答"这个能力能否进化,它和谁配对?"。旧版本遍历每个
能力,再遍历每个进化的需求,再把需求遍历一遍。这个 PR 把它换成了初始化时构建一次
的字典。
标题写着从 O(n³) 到 O(1)。重新读一遍,这说法夸大了:每个进化都只有两个需求,所以 内层循环是常数级的,旧代码对能力数量是线性的,而且有提前返回。字典仍然是更好的 结构。PR 正文里"预期的帧时间改善"从未被测量。
这个 diff 还悄悄改变了一个行为。旧循环返回第一个匹配;字典保留的是最后一次写入。 如果某个能力同时是两个进化的需求,答案就会变。这个 PR 对这两种情况都没有加测试。
比窗口活得更久的事件处理器(#123)
有几个 UI 窗口订阅了事件却从不取消订阅,所以已销毁的窗口仍能被发布者引用,并可能
在销毁后收到回调。修复给七个 UI 行为(GameScreen、Chest、Lobby、Settings、
Upgrades、Characters,以及 Boss 血条)加上了 OnDestroy 清理,另外加了一个编辑器
工具,扫描脚本中有 += 却没有对应 -= 的地方。
PR 正文说"10 个关卡后有 1000+ 个悬空引用,内存持续增长直到崩溃"。那是描述里的 推算,不是测量,我不会把它当事实重复。清理本身无论如何都是对的。那个扫描工具在 2026 年 9 月的一次工具精简中被删除(#949)。
返回 null 的 DI 工厂(#137)
组合根用一个工厂注册 IUpgradesManager,当配置资源缺失时,这个工厂记录一条警告
然后返回 null。之后每个使用方都会在远离原因的地方抛出
NullReferenceException。修复把判断反过来,在启动时直接抛异常:
if (upgradesConfig?.upgradesDatabase == null)
{
throw new System.InvalidOperationException(
"[RootLifetimeScope] UpgradesConfiguration or database not found ...");
}
return new UpgradesService(saveManager, upgradesConfig.upgradesDatabase);
新增六行,删除五行。DI 容器永远不应该为已注册的服务交出 null;在组合阶段失败,
才能指出真正的问题。
这轮修复的其余部分是代码卫生:用层常量代替魔法数字(#117)、平台 Strategy 模式
(#118)、编辑器脚本保护(#127)、接口覆盖测试(#129,336 行)、PlayMode DI
集成测试(#134,851 行)、空值运算符现代化(#133)、类名拼写修正(#138)、
43 个 ScriptableObject 文件上的校验特性(#141)、XML 文档(#142),以及一个
.editorconfig(#143)。
闸门拦住了什么
CI 分不出好坏
在这一批里,我检查过的每个拉取请求上 Unity 测试 job 都失败了,包括 #137 那个只有 六行的修复。对所有东西都是红的检查,什么也拦不住。真正的闸门是编辑器:合并、 编译、试玩。
没能挺过碰撞的八个 PR
前 16 个合并之后,剩下的 PR 中有 8 个已经无法干净应用。每一个都在关闭时留言说明
工作已被记录、留待以后。当天下午,它们被 cherry-pick 进一个合并用的 PR,#144
(+1,280/-948,76 个文件),冲突手工解决。它的描述列出了所携带的破坏性变更:一次
公共 API 重命名(Aquired 改为 Acquired,103 处),以及 lifetime scope 现在需要
在 Inspector 中指定资源,而不是用 Resources.Load。
部分都说得通,整体却编译不过
11 月 22 日,一个 agent 会话逐一处理了组合带来的破坏。这些提交读起来像一份目录, 列出了任何单个 PR 都无法暴露的相互作用:
#126给对象池加了using System;,使Object.Instantiate和Object.Destroy在System.Object与UnityEngine.Object之间产生歧义。#121引入了一个AnimationTriggers常量类,它在宝箱窗口中与UnityEngine.UI.AnimationTriggers冲突。#120把WaveData的字段改成了属性;生成器和两个测试文件仍在使用字段。- 一个 Boss 行为最终出现了重复的字段和重复的
Construct方法。
总共二十一个提交,其中包括删除 #132 标记过的 [Obsolete] 代码、完成从
Resources.Load 的迁移,以及清除所有编译警告。
被一次合并丢掉的那一行
影响最大的那个问题,编译完全通过。
#122 把 Timeline playable 行为里的服务定位器换成了 VContainer 注入,并在
GameLifetimeScope 中注册了新的 ITimelineBehaviorInjector。在手工解决 #144 的
冲突时,这唯一的一行注册丢了。我沿着历史追踪了它:
2273228d3 #122 merged GameLifetimeScope registers ITimelineBehaviorInjector: yes
bb3128679 #144 merged registration: no
c142d6d7e Resources migration registration: no
707c08c18 fix commit (Nov 22) registration: yes
编译时什么都没失败。运行时,波次行为拿不到依赖。恢复这一行的提交记录了症状: Timeline 行为注入失败,没有敌人生成。编译器看不到缺失的注册;运行游戏才能看到。 修复就是把那一行加回去。
我的收获
单一职责的小 PR 容易审查,却难以组合。 这些 PR 每个一分钟就能读完。成本转移到 了合并环节,而在那里没有人会以同样的认真去读 diff。手工解决冲突的合并本身就是一次 新的变更,应当得到与它所替代的 PR 同样的审查。
PR 正文里的说法是假设。 "50-100 倍"、"O(n³)"、"1000+ 个悬空引用":每一个都 出自变更的作者之手,没有一个经过测量。现在我把描述里未经测量的数字当作要么验证、 要么删除的东西。
红色的闸门不是闸门。 每个 PR 上的 CI 都失败时,唯一的信号就是编译和试玩。让 测试套件变绿、变快,后来成了一个单独的项目;见 流水线重构。
组合根需要运行时检查。 缺失的注册照样能编译。在这轮修复里,这个缺口表现为 "没有敌人",而这类失败本应由容器校验测试或冒烟测试来负责,而不是靠人。
在根部尽早失败。 #137 是值得保留的模式:一个在启动时抛出异常并给出缺失资源路径
的工厂,比一个记录日志后返回 null 的工厂更有价值。