那次优化开始得很像一次普通的代码整理:一组视觉 smoke 脚本里有许多看起来一模一样的辅助函数,文件越多,重复越明显。有人很容易在这里形成一个直觉——既然函数体相同,就抽到公共库;既然行数少了,重构就算成功。
但真正动手以后,我发现这不是一道“怎样把重复代码删掉”的题,而是一道“哪些重复值得共享”的题。过程中同时暴露出 2 个真 bug:一个数据包偏移错误让版本解析失效,另一个扫描器在本来只读的检查里意外写出了字节码缓存。如果一开始只盯着重复函数,反而会错过更应该先修的根因。
代码去重的目标不是让重复函数归零,而是让共享语义集中,同时让业务差异继续可见。
先修会改变判断的根因
我当时先读规范和代码结构,再看这批脚本到底在做什么。很快发现,视觉脚本的数量不少,既有通用的文本提取、坐标计算、截图和外部命令调用,也有针对不同页面的点击、捕获、退出和粘贴操作。它们都长得像“自动化动作”,但调用的模块、等待时间和返回结果并不完全一样。
更麻烦的是,基础扫描本身还有问题。数据区偏移没有跟着新版本调整,导致版本解析恢复不了;扫描器虽然名义上是 no-write,却仍然可能生成 __pycache__。如果这 2 个问题不处理,后面的重复统计就不可靠:你无法确定扫描结果代表真实代码,还是代表一个已经被错误缓存或错误解析污染的工作区。
所以我选择先修根因,再建立基线,是因为重构需要一个可信的比较起点。修正偏移后,版本解析恢复正常;给扫描器加入禁写字节码设置后,无写扫描不再产生缓存。随后记录原有行为:完整测试是 61 个通过、5 个跳过,帮助命令和模块导入也都作为后续检查入口保留下来。
这一步看起来没有减少任何重复行,却决定了后面每一个“变少”是不是有意义。如果不先把环境稳定下来,最终可能得到一份更短的代码,却不知道是重构成功,还是检查工具失真。
先量化逐字重复,再谈共享
建立基线后,我没有凭肉眼从 19 个脚本里挑函数,而是用 AST 识别函数边界,再用哈希比较函数体。这个组合解决了 2 个常见误判:只搜文本会把注释和格式差异当成代码差异;只看函数名又会把同名但不同实现的函数混在一起。
量化的结果也改变了我对任务的理解。第一轮确实有一批辅助函数是逐字重复的,例如执行外部命令、收集视觉快照、加载识别结果、提取条目文本和计算中心点。这些函数拥有稳定的输入输出,也不应该知道某一个页面的业务流程,放进共享库后,调用方只需要保留导入和使用。
但统计同时显示,capture、click、escape、paste_at 也存在重复对。它们的名字和部分代码很像,甚至哈希可以找出相同片段,可它们并不都属于同一种共享语义。有的 capture 依赖当前页面自己的状态分类器;有的 click 依赖各自加载的鼠标模块,等待参数和返回值也不同;escape 的延时不一样;paste_at 还有额外的激活和点击步骤。
哈希只能证明“现在看起来相同”,不能证明“放到一起以后仍然等价”。
因此量化工具的作用是缩小判断范围,不是替人做合并决定。它告诉我哪里值得打开、哪里需要解释、哪些重复是稳定的公共能力,最后的边界仍然要回到调用关系和行为契约。
第一轮只抽真正无业务的辅助函数
第一轮我把共享范围限定在 9 个辅助函数。它们处理的是执行、截图、识别结果和文本取值,本身不关心当前打开的是哪个页面,也不决定下一步点击哪里。把它们移到平铺的共享库后,11 个脚本改成导入同一份实现,净减少 276 行。
这里最重要的不是数字,而是抽取标准。一个函数如果只负责把输入转换成稳定结果,并且调用方不需要偷偷依赖某个页面模块,它才有资格进入公共层。相反,只要函数里出现页面状态分类、特定模块对象或流程专用的事件记录,就先留在原处。
抽取以后,我没有只看差异统计,而是逐项跑了帮助命令、模块导入和完整测试。结果仍然是 61 个测试通过、5 个跳过,说明第一轮没有改变基线行为。与此同时,剩下的重复组被重新量化,文本提取和中心点计算中还能看到可以合并的多数派实现。
第二轮继续按同一原则推进。文本提取的 11 份逐字副本收敛成共享实现,中心点计算也抽走多数派版本;有 1 个版本因为参数和过滤条件不同,继续留在原脚本。这个“多数派进入共享、变体留原处”的处理,比强行设计一个万能参数更简单,也更容易证明行为没有被改写。
加载器可以共享,加载的业务不能乱搬
第三轮面对的是 17 份加载器。它们使用不同路径加载页面脚本,但加载动作本身结构一致,调用方又需要保留原来的模块名和系统模块行为。于是我把加载动作收敛成一个共享入口,继续让每个脚本传入自己的路径和名称。
这和把业务函数搬走是两件事。加载器只是通用机制,加载出来的对象仍然各自拥有页面特有的 mouse、菜单、状态分类和操作函数。共享的是加载过程,不是加载结果里的业务含义。 如果为了继续减少行数,把这些业务函数也一并塞进公共库,表面上会更整齐,实际上会让调用链变长,隐藏每个页面的差异。
这也是我没有合并 capture、click、escape 和 paste_at 的原因。它们虽然有重复,但延时、返回类型、额外步骤以及依赖的加载模块不同。继续搬动需要先设计新的参数协议,再证明每个调用方的行为等价;这已经不是简单去重,而是在重构业务接口。收益只是再少几行,风险却会扩散到所有视觉流程。
如果不守住这个边界,最容易出现的不是立刻报错,而是 smoke 脚本“还能跑”,却在等待过短、返回形状变化或页面状态判断错误时变得不稳定。这样的回归很难从行数统计里看出来,只有真实操作或完整验收时才会暴露。
用行为不变结束,而不是用数字结束
3 轮完成后,累计净减少 459 行。这个数字足够说明共享库确实收到了重复能力,但它不是继续重构的理由。每一轮都跑帮助命令、模块导入和完整测试,并额外执行不写入缓存的清点;测试始终保持 61 个通过、5 个跳过,脚本清点也与基线一致。
我把验收分成 2 层。第一层看结构:目标函数是否还存在逐字副本,加载器是否已经集中,公共库是否被正确导入。第二层看行为:命令能否启动,模块能否导入,测试结果是否一致,扫描是否仍然只读。只有 2 层都通过,行数减少才算有效。
这里最容易误判的是把“扫描器报告没有重复”当成“代码已经更好”。扫描器只能覆盖预先定义的函数和哈希范围,不能理解业务差异;同样,测试通过也不自动证明所有未覆盖的 UI 细节都等价。所以我还要保留每个变体的理由:超时不同、空字符串过滤不同、键顺序影响输出,或者调用了特定模块。理由被写下来,下一次维护者才不会把它们当成遗漏继续合并。
这件事让我重新意识到,好的重构不是把代码变成最少的形状,而是把稳定的共同点和不稳定的差异分别放在合适的位置。真正可靠的去重,会让公共部分更集中,也让不能共享的部分更容易被看见。
下次我会怎么做
下次遇到类似任务,我会按 4 步走。先修会污染判断的基础问题,确认扫描和版本解析可信;再建立行为基线,把测试、导入、帮助命令和只读检查的结果记下来;然后用 AST 加哈希找逐字重复,按共享语义分轮抽取;最后每轮都做结构与行为双重验收。
我会把“能不能合并”改成 3 个更具体的问题:它的输入输出是否稳定?它是否依赖页面或业务状态?它的等待、返回值和模块耦合是否与其他版本完全一致?前 2 个答案不清楚时不抽,最后一个存在差异时就保留原处。
最容易走的错误捷径,是把代码行数下降当成质量提升。以后我宁愿留下 2 份确实不同的函数,也不为了让重复统计归零而引入一个充满开关的万能函数。去重的停止点,不是数字变成 0,而是继续合并已经需要改变行为或接口。
一个流程成熟的标志,不是它能把所有相似代码搬进同一个文件,而是下一次面对相似代码时,能够快速说明哪些共享、哪些保留,以及凭什么这样判断。这样留下来的不只是更短的实现,还有一条别人可以复用的决策链。