Skip to content

Latest commit

 

History

History
398 lines (245 loc) · 11.3 KB

File metadata and controls

398 lines (245 loc) · 11.3 KB

拍卖刷新复盘与工程教训

更新时间:2026-03-26

为什么有这份文档:这次拍卖刷新不是“多调一个方法”就结束的小修小补,而是一段完整的逆向案例。
我们先后踩到了错误容器、错误关闭路径、错误时序、错误成功判定等一整串坑。把这段过程写下来,是为了让后续做黑市、锻造、原生通知、更多 QoL 功能时,不再靠记忆重复试错。

补充说明:如果你想看这条链路在 v1.8.15 阶段的原始断点检查清单、探针设计和“尚未收口前”的判断标准,可以继续看:拍卖刷新断点研究

[01] 这次问题到底难在哪

表面现象其实很简单:

  • Alt+R 有蜂鸣,说明热键触发了
  • 拍卖“查看展品”有时不刷新
  • 有时看上去刷新了,但其实只是复制 / 累计旧展品
  • 有时又会变成“按一下关、按一下开”的开关行为

真正难的地方在于,这个问题同时跨了四层:

  1. 输入层:热键是不是进来了
  2. 业务层:随机拍品到底在哪一步生成
  3. 显示层:玩家眼前看到的展品 UI 到底挂在哪个容器
  4. 时序层:生成、清理、关闭、重开谁先谁后才不会串状态

如果只盯着其中一层,很容易得到“看起来合理、实际完全错位”的实现。

[02] 这次逆向里最关键的几次误判

1. 误把 AuctionController / PlotController 当成展品 UI 真容器

一开始最自然的想法是:

  • 拍卖相关,应该就在 AuctionController
  • 展品相关,应该和 PlotController.plotItemGrid 有关

这条判断只对了一半:

  • 拍卖随机数据的业务链,确实和 PlotController.ShowAuctionItem() / GenerateAuctionItem() 有关
  • 但玩家眼前真正显示出来的那一层展品 UI,并不在 auctionPanel,也不在 plotItemGrid

这就是后面所有“清理了但没用”“调用返回了但没关对”的根源。

2. 误把“关闭拍卖事件背景”当成“关闭展品面板”

我们一度尝试过走:

  • PlotBackgroundClicked()
  • plotItemGrid
  • plotInteractItem

这些动作都不是完全没效果,但都没有命中“当前这批展品图标实际挂在哪”。

结果就是:

  • 数据层可能已经变了
  • 旧的显示容器却没真正关掉
  • 再开一次时,新旧内容叠在一起,看起来就像“复制”或“累计”

3. 误把“自动化多做一步”当成“更稳”

拍卖刷新最危险的一点在于:

  • 多做一次 show
  • 多做一次 close
  • 多补一次兜底 reopen

都可能把状态从“差一点正确”直接推回“明显错误”。

这次我们后面反复验证出的结论很明确:

  • 在这种 UI / 业务双层错位的系统里,步骤不是越多越稳
  • 额外动作只会放大错误时序

[03] 真正把问题拆开的证据链

[04] 时间线:我们是怎么从“猜”走到“证据”的

1. 先确认热键链路没问题

最早的日志已经证明:

  • Alt+R 能进 PollInput
  • 蜂鸣能响
  • 刷新分支确实被执行

所以这不是输入路径问题,也不是按键冲突导致完全没触发。

2. 再确认真正的随机入口

通过对日志里这几类信息的对照:

  • ShowAuctionItem pre/post
  • GenerateAuctionItem pre/post
  • 展品前后内容摘要

可以确认一个关键事实:

  • PlotController.ShowAuctionItem() 会走到 GenerateAuctionItem()
  • 真正的拍品 reroll 业务入口就在这条链上

这一步非常重要,因为它把“是不是根本没重掷”这个疑问先排除了。

3. 用运行时快照证明“显示层不在原先猜的容器里”

这次最值钱的探针不是单纯多打几条日志,而是做了运行时快照:

  • 记录 AuctionController 当前状态
  • 记录 PlotController 当前状态
  • 枚举当前激活的 ItemIconController
  • 把它们的层级路径打印出来

最后看到的关键证据是:

  • auctionPanel active = false
  • plotItemGrid children = 0
  • 但真实展品图标还活着,而且路径在:
    • Canvas/ChoosePanel/ChoosePanelRoot/ChooseItemList/Viewport/Content/...

这一步直接推翻了前面的错误假设。

真正的结论是:

  • 拍卖随机链在 PlotController
  • 展品显示链在 ChooseController / ChoosePanel

业务层和显示层根本不是同一个控制器。

4. 用 interop 元数据把猜测变成可调用签名

确认到 ChoosePanel 之后,下一步不是继续盲调,而是去看 interop 元数据。

这次真正有价值的结论包括:

  • ChooseController.Instance
  • choosePanel
  • chooseRoot
  • itemList
  • HideChoosePanel()
  • UnshowChoosePanel()

这一步带来的收益是:

  • 我们终于能调用“真正的关闭入口”
  • 不再需要拿 PlotController 假装做 close

5. 再用存档 / 读档实验确认随机时机

还有一个很关键的排除过程:

  • 触发拍卖事件后先存档
  • 读档后第一次查看展品与第二次查看展品结果不同

这说明:

  • 随机结果不是在“事件触发那一刻”就固定好的
  • 真正的掷骰子时机在“触发事件”和“查看展品”之间更靠后的位置

这也是为什么后面我们必须盯住 ShowAuctionItem -> GenerateAuctionItem 这条链,而不是去碰更早的事件入口。

[05] 最终稳定方案为什么能成立

[06] 当前稳定方案

截至 2026-03-26,拍卖刷新最终稳定下来的核心思路不是“模拟玩家乱点一串原生按钮”,而是做一个很小的目标态状态机:

目标态

  • 不管当前“查看展品”是开着还是关着
  • Alt+R 的最终结果都应该是:展品处于打开状态

关键原则

  1. pending reroll 先挂起
  2. 真正的成功判定,不看蜂鸣、不看 UI 闪动,只看 GenerateAuctionItem 是否成功跑到
  3. 只有 GenerateAuctionItem 成功后,才清掉挂起状态
  4. 显示层操作只针对真实 ChoosePanel
  5. 所有时序都尽量压缩成最少步骤

稳定时序

从打开态进入时:

  1. ResetPlotItemDisplay(...)
  2. 延迟几帧
  3. HideChoosePanel()
  4. 再延迟几帧
  5. EnsureOpen

从关闭态进入时:

  1. 直接进入 EnsureOpen

为什么这套比“简单开关式脚本”更稳

因为它描述的是“目标态”,不是“操作录制”:

  • 不是单纯切一下开关
  • 不是假设当前一定处于某个固定状态
  • 而是每一步都先判断当前状态,再决定该做什么

这对拍卖这种“显示层和业务层分离”的系统尤其重要。

[07] 这次真正该记住的工程教训

[08] 教训清单

1. 先证明“玩家眼前看到的 UI”挂在哪里,再谈刷新

这次最根本的一条教训就是:

  • 业务入口和显示容器可以完全不在同一个控制器上

如果这一点没先证明,后面所有“清空列表”“关面板”“重开界面”都可能打在假目标上。

对未来功能的直接启发是:

  • 黑市
  • 锻造
  • 原生通知
  • 商店 / 选择类界面

都要优先回答这个问题:

“真正显示给玩家看的对象,究竟挂在哪个控制器 / 哪个容器下?”

2. 成功判定必须绑定真实业务成功点

这次如果只是看:

  • 按键响了
  • UI 关了
  • 面板开了

都会被误导。

真正可靠的成功点只有一个:

  • GenerateAuctionItem 成功执行

因此后续同类功能都应该遵守:

  • pending 状态只在真实业务成功点清理
  • 不要在“看起来差不多成功了”的中间态提前收尾

3. 探针要回答问题,不是越多越好

这次有用的探针只有两类:

  1. 能回答“真实 UI 在哪里”的快照
  2. 能回答“业务成功没成功”的链路日志

没用的探针特征则很明显:

  • 高频刷屏
  • 不能改变判断
  • 只是让日志更长

所以项目里的经验可以沉淀成一句话:

  • 诊断日志必须“问题驱动、按需开启、验证后收敛”

4. 状态机比录宏式脚本更适合 IL2CPP UI 问题

这次所有不稳定的版本,本质都像在做“操作录制”:

  • 先点这个
  • 再点那个
  • 再补一次

但真正稳定下来的版本更像一个最小状态机:

  • 当前是否打开
  • 是否需要 reset
  • 是否需要 close
  • 最终是否已经回到打开态

这是一条非常可复用的工程经验。

5. 先把“能稳定工作”做出来,再优化手感

我们最后稳定下来不是因为一上来就追求“一键丝滑无动画”,而是先接受:

  • 先让它不复制
  • 先让它不累计
  • 先让它真实重掷

稳定之后,才继续收“从开态 / 关态进入都最终打开”的手感问题。

这条顺序非常重要:

  • 先正确
  • 再稳定
  • 最后再优化体感

如果顺序反了,往往会回到“更丝滑,但更错”。

[09] 可以复用到后续功能的研究流程

[10] 可复用流程

以后再做复杂 QoL 功能,推荐统一走这套顺序:

第一步:把现象描述成“输入、业务、显示、时序”四层问题

不要直接问“哪个方法有 bug”,先问:

  • 输入进来了吗
  • 真正的业务成功点是什么
  • 玩家眼前看到的显示层是谁
  • 哪一步时序会导致旧状态残留

第二步:做最小化运行时快照

只抓最有判别力的对象:

  • 当前控制器单例
  • 当前激活容器
  • 当前关键图标 / 列表路径

这一步的目标不是“全扫场景”,而是尽快证伪错误假设。

第三步:用 interop 元数据把猜测改成签名

一旦锁定到目标控制器,就去确认:

  • 属性名
  • 方法名
  • 参数签名

不要继续凭印象猜字段。

第四步:先做最小状态机,不做大而全工具层

优先回答:

  • 需要哪些最少状态
  • 成功点是什么
  • 失败时怎么取消挂起

而不是一开始就抽象成大框架。

第五步:稳定后再收敛日志和复杂度

验证完成以后:

  • 把高频日志收回
  • 保留必要的摘要日志
  • 不把临时探针和研究脚本混进正式功能路径

[11] 对后续开发的直接启发

这次拍卖刷新复盘,对后面几个方向都有直接帮助:

1. 黑市 / 锻造 / 三选一刷新

优先检查:

  • 随机结果在哪里生成
  • 结果展示挂在哪个容器
  • 原生关闭入口是不是和业务入口分离

2. 原生 UI 通知

这次已经证明一个原则:

  • 不要一上来就自己注入 UI
  • 应优先找原生提示链、原生显示控制器、原生 prefab

3. 后续 QoL 设计

这次最终胜出的不是“大而全的一键脚本”,而是:

  • 足够小
  • 足够稳
  • 每一步都有真实证据支撑

这也和当前项目的总原则完全一致:

  • 稳定优先
  • 低运行负担
  • 尽量复用原生控制器

[12] 结论

这次拍卖刷新最值得沉淀的,不是某一行代码,而是一套可复用的方法:

  1. 先证明真实显示层
  2. 再锁定真实业务成功点
  3. 用最小状态机描述目标态
  4. 挂起状态只在真实成功后清理
  5. 稳定后再优化体验,不要反过来

如果后面继续做黑市、锻造、原生通知、更多上下文刷新,这份复盘应该作为第一参考,而不是只把它当成“拍卖专属问题”的历史记录。