在这里插入图片描述

上一期把同步预览、备份和回滚补上以后,文件写入这条链路基本有了安全边界。

但目录一大,另一个问题马上就暴露出来了:点击对比以后,界面只显示一句“正在对比文件”,进度条一直转,却看不到程序走到哪里,也不知道还要等多久。

如果选错了目录,还不能取消。只能等它把左右目录全部扫完,再把每个文件的 SHA-256 算完。

原来的目录扫描流程很直接:

扫描左侧目录
  └── 发现一个文件,立即计算 SHA-256
扫描右侧目录
  └── 发现一个文件,立即计算 SHA-256
合并相对路径
构建结果树
一次性刷新表格

小目录这样写没有明显问题。文件数量上千、目录嵌套较深,或者里面有几个大文件时,问题就比较集中:

  • 不知道当前是在遍历目录,还是在读取大文件。
  • 不知道已经处理多少文件、多少字节。
  • 选错目录以后不能停。
  • 左右两侧完全串行扫描和 Hash,等待时间偏长。
  • 新扫描一开始就清空旧表格,失败后什么结果都看不到。
  • 后面如果直接加取消,旧任务还可能在后台完成后覆盖新任务。

所以这期进入开发计划里的 P2:把扫描拆成明确阶段,增加实际进度、取消操作和受控并行 Hash。

这次看起来只是多一条进度条和一个取消按钮,真正需要处理的却是后台任务、磁盘 I/O、结果一致性和 Swing 线程安全。

先确定一个原则:进度必须有依据

扫描一开始,程序还不知道目录里有多少文件。

如果这时直接显示 20%50%,这些数字没有计算基础,只能靠估算。目录遍历到一半时,又可能刚好进入一个包含几万个文件的子目录,进度甚至需要倒退。

所以本期把任务拆成四个阶段:

发现文件 → 计算 Hash → 构建结果 → 更新界面

1. 发现文件

目录发现阶段只做三件事:

  • 遍历左右目录。
  • 应用目录名、扩展名和通配符过滤规则。
  • 记录文件路径、相对路径、大小和修改时间。

这个阶段不知道最终总量,因此使用不确定进度条,只显示实时数据:

  • 左侧发现文件数。
  • 右侧发现文件数。
  • 已发现总字节数。
  • 已排除目录和文件数量。
  • 当前访问路径。

2. 计算 Hash

发现结束后,候选文件数和总字节数已经确定,这时再切换为确定进度。

Hash 阶段同时显示两组数字:

已完成文件 / 总文件
已读取字节 / 总字节

主进度按字节计算,而不是只按文件数。

原因很简单:一个 4 GB 文件和一个 2 KB 文件不能各占一份相同进度。按文件数计算时,界面可能很快显示 99%,然后卡在最后一个大文件上很久。

3. 构建结果

Hash 全部完成后,再合并左右相对路径,判断相同、不同、仅左侧和仅右侧,并构建可折叠目录树。

4. 更新界面

结果树准备完成后,Swing 表格不能一次塞入大量行。程序按每批最多 200 行准备新的表格模型,全部完成后再替换当前结果。

这四个阶段确定后,进度条才不只是一个动画,而是能反映实际工作的状态。

UI 先确认,再动扫描代码

这次仍然没有直接修改正式代码,而是先做实施方案和三张效果图。

扫描进度放在主窗口左右路径和结果区之间,不使用阻塞式弹窗。这样有旧结果时,用户仍然可以查看、滚动和展开目录。

在这里插入图片描述

扫描期间旧结果标记为上次结果,同步和双击编辑暂时禁用。只有新任务完整成功后,旧结果才会被替换。

取消后不弹二次确认。目录对比是只读操作,取消不会修改磁盘文件,再加一道确认只会增加操作负担。

在这里插入图片描述

任务真正退出后,状态区保留取消阶段、处理数量和耗时,并提供“重新开始”。左右路径不清空,上一次有效结果也不变化。

正常完成后不自动弹统计窗口,避免每次按 F5 都多关一个窗口。用户可以点击底部详情,或者从工具菜单打开。

在这里插入图片描述

详情里显示文件数、总字节、Hash 并发数、发现耗时、Hash 耗时、构建耗时、界面更新时间和平均吞吐量。

方案确认后,才开始修改正式扫描代码。

第一步不是开线程,而是拆掉旧扫描流程

旧实现把目录遍历、过滤、文件读取和 SHA-256 全部写在主窗口里。

核心结构类似这样:

Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
            throws IOException {
        snapshot.files.put(relativePath,
                new FileInfo(file, relativePath, attrs.size(),
                        attrs.lastModifiedTime().toMillis(), hash(file)));
        return FileVisitResult.CONTINUE;
    }
});

每发现一个文件就立刻执行 hash(file)。这种写法拿不到最终文件数,也不能单独控制发现和 Hash 的并发。

本期新增了独立的 CompareScanService,主窗口不再负责读取文件正文。

新的数据流是:

ScanRequest
  ↓
Directory Discovery
  ↓
List<FileCandidate>
  ↓
Parallel Hash
  ↓
ScanResult
  ↓
CompareResult + CompareNode
  ↓
Swing Table Model

FileCandidate 在发现阶段只保存:

side
absolutePath
relativePath
size
modifiedTime

进入 Hash 前,候选列表冻结。这样可以准确计算总文件数和总字节数,也能根据任务规模决定并发数。

扫描核心不依赖 Swing。临时目录测试可以直接调用服务,验证 Hash、取消和异常,不需要先打开主窗口再模拟按钮。

左右目录可以并行发现,但 Hash 不能随便开线程

左右目录发现最多使用两个任务,每侧一个。发现过程主要读取目录项和文件属性,不读取完整文件内容。

Hash 阶段没有简单按 CPU 核数开线程。

文件 Hash 同时受磁盘读取和 CPU 影响。线程越多,不代表越快:

  • 同一块机械硬盘上并发读多个文件,磁头来回寻道可能更慢。
  • 网络共享高并发会放大延迟和服务端压力。
  • 大量小文件的瓶颈可能是目录和文件打开次数。
  • 少量文件本身没有必要创建多线程调度。

Java 8 标准 API 也不能可靠识别本地磁盘到底是 SSD 还是机械硬盘。因此程序没有显示一个看起来很专业、实际上不可靠的“磁盘类型识别”。

当前采用保守策略:

条件Hash 线程数
文件对比1
候选文件少于 64 个1
总字节少于 64 MB1
UNC、SMB、CIFS、NFS 等网络路径1
左右目录位于同一个本地 FileStore最多 2
左右目录位于不同本地 FileStore最多 4
CPU 不超过 2 核1

全局硬上限是 4。

每个线程使用 1 MB 读取缓冲区,Hash 额外缓冲内存最多约 4 MB。后续如果真实项目数据证明同盘两个线程仍然会让某些机械盘退化,再增加可持久化的性能设置,不在这一期先放一个复杂线程数输入框。

文件多时,不能给每个文件创建一个 Future

如果目录里有几十万个文件,为每个文件调用一次 submit(),会同时创建大量任务对象,并把它们放进线程池队列。

本期使用固定工作线程和一个原子索引:

worker 1 ─┐
worker 2 ─┼─> AtomicInteger nextIndex ─> candidates
worker N ─┘

每个线程完成当前文件后,通过 nextIndex.getAndIncrement() 领取下一项。线程总数固定,候选文件再多也不会生成同等数量的 Future

左右候选按顺序交错放入列表,避免先把左侧全部算完,再开始右侧。

取消不是调用 Thread.stop

扫描任务可以停,但不能用强制终止线程的方式处理。

本期使用共享 CancellationToken,内部是 AtomicBoolean。以下位置都会检查:

  • 进入目录前。
  • 访问文件时。
  • Hash 线程领取下一项前。
  • 每读取 1 MB 内容后。
  • Hash 完成准备提交结果前。
  • 构建结果循环中。
  • 每批 Swing 表格发布前。

点击取消后:

  1. 按钮变成“正在取消”。
  2. 设置取消令牌。
  3. 目录遍历停止继续访问。
  4. Hash 线程不再领取新文件。
  5. 正在读取的线程在下一个 1 MB 检查点关闭输入流。
  6. 后台任务完全退出后,界面显示“重新开始”。

没有在用户点击按钮的瞬间立即允许第二个扫描任务启动。否则旧任务还在读磁盘,新任务又建立一组线程,两批 I/O 会同时争抢资源。

下面是 960×620 最小窗口下的实际扫描界面:

在这里插入图片描述

界面显示当前阶段、文件数、字节数、Hash 并发数、耗时和当前文件。长路径会在界面中间省略,完整路径保留在悬浮提示里。

取消后为什么还要保留旧结果

旧版开始新扫描时会先执行:

leftTableModel.setRowCount(0);
rightTableModel.setRowCount(0);

如果扫描失败,原来还能参考的结果也被清空了。

现在把 lastResult 当作已提交结果处理:

  • 新任务开始:旧结果继续显示,只读。
  • 新任务取消:旧结果不变。
  • 新任务失败:旧结果不变。
  • 新任务完整成功:提交新结果。

这和同步事务不是一回事,但思路相似:未完成的新结果不能替换已经可用的旧结果。

下面是实际取消后的状态:

在这里插入图片描述

取消后路径仍然保留,状态区显示停止阶段、已处理文件和字节数,右侧可以直接重新开始。

旧任务不能覆盖新任务

有取消和重新开始以后,会出现一个并发边界:旧任务可能因为底层文件读取延迟,晚于新任务返回。

如果只在 SwingWorker.done() 里直接更新界面,就可能发生:

任务 1 开始
任务 1 取消
任务 2 开始
任务 2 完成并显示新结果
任务 1 延迟返回,又把旧结果盖回界面

所以每次扫描都有单调递增的 taskId。所有进度、完成结果和界面发布批次都携带任务编号。

EDT 更新前必须满足:

event.taskId == activeCompareTaskId

不匹配的事件直接丢弃。

窗口关闭时还会取消令牌并让当前任务 ID 失效,避免后台任务继续尝试更新已经销毁的 Swing 组件。

Hash 时文件变化,不能接受混合内容

扫描项目目录时,构建工具、日志程序或编辑器可能正在修改文件。

如果文件在 Hash 过程中发生变化,读取到的可能是前半段旧内容和后半段新内容。这个 Hash 既不对应修改前文件,也不对应修改后文件。

发现阶段已经记录文件大小和修改时间。Hash 完成后,程序重新读取属性:

  • 大小和修改时间未变化:接受 Hash。
  • 发生变化:回退第一次读取的进度字节,更新总字节,再 Hash 一次。
  • 第二次仍变化:本次扫描失败。

这里没有无限重试。一个持续变化的日志文件如果一直重算,任务永远无法结束。

不可读文件也不会被静默跳过。

跳过以后生成的结果看起来完整,实际上少了一个未知文件,后续同步可能误判。因此本期采用严格语义:不可读文件让新任务失败,同时保留上一次有效结果,并显示失败路径。

进度事件也不能发得太勤

Hash 线程每读取 1 MB 都会更新原子字节计数,但不会每次都调用 Swing。

进度快照最多每 100 ms 发布一次。

如果每读取一个缓冲区就向 EDT publish(),高速磁盘和多个线程会产生大量界面事件。最终用户看到的不是更平滑的进度,而是事件队列拥堵、表格滚动卡顿。

后台线程只更新计数或生成不可变进度快照,Swing 组件只在 EDT 中读取最后一份快照。

结果分批准备,但旧表格不能提前清空

这一部分在实际检查时又改了一次。

最初虽然计划每批发布 200 行,但实现时如果先把当前表格清空,再逐批加入新行,用户仍然会看到旧结果突然消失。取消发生在发布阶段时,还需要复杂地把旧表格重新构建回来。

最后改成离屏准备:

  1. 后台构建完整目录树。
  2. EDT 每批最多 200 行写入新的左右表格模型。
  3. 旧表格模型继续显示。
  4. 新模型全部完成后,一次调用 setModel() 切换。
  5. 最后才更新 lastResult 并恢复同步和双击编辑。

因此“成功后才替换旧结果”不仅覆盖扫描和 Hash,也覆盖最后的界面发布阶段。

实施过程中修正的几个问题

这期不是代码一次写完就结束。测试和真实窗口检查中又发现了几处问题。

1. 读取失败被误判成取消

某个 Hash 工作线程读取失败后,需要通知其他线程停止。如果只共用一个取消标记,主窗口可能看到标记已设置,就把真实 I/O 异常显示成“用户取消”。

最终单独保留第一个 IOException。工作线程停止后,服务优先抛出真实失败;只有没有业务异常且用户主动设置令牌时,才进入取消状态。

2. 文件变大后进度可能超过 100%

第一次 Hash 期间文件变大,重试时如果只回退已读取字节,却不修正总字节,最终已处理字节可能超过发现阶段记录的总量。

现在重试时同时调整总字节,并在进度快照层再次限制已完成值不能超过总量。测试增加了明确断言。

3. 最小窗口按钮文字被截断

第一次真实截图中,取消后的“重新开始”按钮在 960px 宽度下显示成了省略号。

最后固定了任务操作按钮的最小宽度,再重新运行视觉测试。UI 效果图只能说明方向,真实 Swing 窗口仍然必须跑起来检查字体和布局。

4. 批量发布不能逐行触发表格事件

即使每批处理 200 行,如果内部仍循环调用 addRow(),就会产生 200 次表格事件。

最终每批直接向模型数据增加行,然后每侧只触发一次 fireTableRowsInserted()

这次测试了哪些内容

本期新增 CompareScanServiceTest,真实创建左右目录和文件,覆盖:

  • 左右目录发现。
  • 目录过滤跳过整个子树。
  • 相同文件 Hash 一致。
  • 不同文件 Hash 不同。
  • 文件对比固定单线程。
  • 小任务固定单线程。
  • 同一 FileStore 并发不超过 2。
  • 全局工作线程不超过 4。
  • Hash 中途取消。
  • 文件在 Hash 期间变化后重试一次。
  • 重试后总字节正确调整。
  • 文件被删除或无法读取时归类为失败,不误报取消。
  • 已处理文件数和字节数不超过总量。

另外增加 CompareProgressVisualSmokeTest,在项目目录下生成左右各 80 个、每个 2 MB 的测试文件,完整执行:

启动主窗口
  ↓
自动选择左右目录
  ↓
进入 Hash 阶段
  ↓
截取扫描中界面
  ↓
点击取消
  ↓
检查重新开始按钮和保留状态
  ↓
重新扫描
  ↓
检查 80 行结果完整显示

窗口固定为 960×620,用来检查最小尺寸下的长路径、进度条、状态文字和按钮。

最后继续运行原有非视觉回归测试,包括:

  • 过滤规则。
  • 编码检测和保存。
  • 换行保真。
  • 文本差异算法。
  • 差异行对齐。
  • 差异块应用。
  • 同步计划。
  • 同步、备份和回滚集成测试。

Java 8 全源码和测试源码编译通过,现有测试全部通过。

这次实际修改了哪些内容

1. 扫描服务

  • 新增独立 CompareScanService
  • 目录发现与 Hash 完全拆开。
  • 左右目录最多两个发现任务。
  • 生成不可变候选列表和准确总量。
  • 文件变化自动重试一次。
  • 不可读文件保持严格失败语义。

2. 并行 Hash

  • 自动选择 1 到 4 个工作线程。
  • 网络路径、小任务和文件模式保持串行。
  • 同一个 FileStore 最多两个线程。
  • 固定线程通过原子索引领取文件。
  • 每个线程使用 1 MB 缓冲区。

3. 任务与取消

  • 增加取消令牌。
  • 发现、Hash、构建和界面发布都检查取消。
  • 后台任务完全结束后才允许重新开始。
  • 任务 ID 过滤旧进度和旧结果。
  • 关闭窗口时取消并解绑活动任务。

4. 结果保护

  • 新扫描开始时不再清空旧结果。
  • 取消和失败保留上一份有效结果。
  • 新表格按每批 200 行离屏准备。
  • 完整成功后一次切换左右模型。

5. Swing 界面

  • 增加全宽扫描状态区。
  • 显示阶段、文件数、字节数、排除数、并发数和当前路径。
  • 增加取消、正在取消和重新开始状态。
  • 增加本次扫描详情入口。
  • 扫描期间旧结果可浏览,但禁止同步和双击编辑。

6. 测试与文档

  • 增加扫描服务测试。
  • 增加取消与重新开始视觉烟测。
  • 生成扫描中和取消后真实截图。
  • 更新 README、开发计划和 P2 实施方案。
  • 开发计划中的扫描性能状态改为已完成。

最终版提示词

如果需要给一个现有 Java 8 Swing 文件工具增加同类扫描能力,可以直接使用下面这份提示词:

请继续开发一个现有的 Java 8 Swing 文件对比工具。工具已经支持文件和目录
SHA-256 对比、可折叠目录树、过滤规则、F5 刷新、联动滚动、差异块编辑、
编码与换行保真,以及同步预览、备份和失败回滚。

本次实现 P2“扫描进度、取消和受控并行 Hash”。当前实现使用一个
SwingWorker,先扫描并 Hash 左侧目录,再扫描并 Hash 右侧目录;开始任务时
会清空旧结果,界面只有不确定进度,不能取消。

功能和技术要求:

1. 将单次对比拆成发现文件、计算 Hash、构建结果和更新界面四个阶段。
2. 目录发现阶段只遍历目录、应用过滤规则并读取必要属性,不计算 Hash。
3. 发现阶段不知道最终总量,使用不确定进度条,实时显示左右文件数、
   已发现字节数、排除目录数、排除文件数和当前访问路径,不显示虚假百分比。
4. 发现完成后冻结文件候选列表,再计算准确总文件数和总字节数。
5. Hash 阶段以已读取字节/总字节为主进度,同时显示已完成文件/总文件。
6. 空文件虽然不增加读取字节,但完成后必须增加文件计数。
7. Hash 使用 1 MB 缓冲区,每读取一个缓冲区检查取消。
8. 后台计数可以高频更新,但 Swing 进度事件最多每 100 ms 发布一次,
   避免阻塞 EDT。
9. 增加受控并行策略,工作线程全局硬上限为 4。
10. 文件模式、候选少于 64 个、总字节少于 64 MB、CPU 不超过 2 核、
    UNC/SMB/CIFS/NFS 网络路径使用 1 个 Hash 线程。
11. 左右目录位于同一个本地 FileStore 时最多 2 个线程;位于不同本地
    FileStore 时最多 4 个线程。
12. Java 8 无法可靠识别 SSD/HDD,不得在 UI 中显示伪磁盘类型或基于
    不可靠结果承诺性能。
13. 不为每个文件创建 Future。固定工作线程通过 AtomicInteger 原子索引
    从不可变候选列表领取下一项,避免大目录产生海量任务对象。
14. 左右候选交错排列,并发上限是全局上限,不是左右各创建一组线程。
15. 增加 CancellationToken,在进入目录、访问文件、领取 Hash 项、每个
    1 MB 读取块、结果构建循环和每批 EDT 发布前检查。
16. 点击取消不二次确认。按钮立即显示正在取消,后台线程完全退出后再显示
    重新开始,不允许新旧两个 I/O 任务同时运行。
17. 每次任务分配单调递增 taskId。所有进度、完成结果和 EDT 发布批次必须
    校验 taskId,旧任务不能覆盖当前进度或结果。
18. 新任务开始时保留上一份有效结果。扫描期间允许滚动和展开旧结果,
    但禁用同步和双击编辑。
19. 取消或失败不得清空、替换上一份有效结果;首次扫描取消时保留已选路径。
20. 完整成功前不得更新 lastResult。后台先构建目录树,再按每批最多 200 行
    在 EDT 中准备新的左右表格模型,全部完成后一次替换旧模型。
21. 每批表格更新每侧只触发一次 fireTableRowsInserted,不能逐行产生事件。
22. 发现阶段记录文件大小和修改时间。Hash 完成后重新读取属性,属性变化时
    回退第一次读取进度并重试一次;第二次仍变化则让本次任务失败。
23. 文件重试后必须修正总字节,进度中的已完成文件和字节永远不能超过总量。
24. 不可读、被删除或持续变化文件让本次任务失败并显示阶段、侧别和路径,
    不能静默跳过,也不能误报成用户取消。
25. 关闭窗口、返回首页或切换模式时取消并解绑活动任务,后台事件不能访问
    已销毁 Swing 组件。
26. 主界面增加全宽扫描状态区,显示阶段、进度、统计、当前路径和取消按钮。
    长路径中间省略,并通过 tooltip 显示完整路径。
27. 取消后状态区常驻,显示停止阶段、处理量、耗时、结果是否保留和重新开始。
28. 正常完成后不自动弹窗。通过底部状态或工具菜单打开本次扫描详情,显示
    左右文件数、总字节、排除数、实际并发数、Hash 重试数、各阶段耗时、
    总耗时和平均吞吐量。
29. 将扫描核心拆成不依赖 Swing 的服务,便于临时目录测试。
30. 增加测试,至少覆盖空目录、中文和深层路径、过滤子树、串行与并行 Hash
    一致性、并发上限、发现取消、Hash 取消、文件变化重试、持续变化、读取失败、
    进度边界、旧任务隔离、取消保留旧结果和分批发布。
31. 完成后使用 Java 8 编译全部源码和测试,运行现有编码、换行、差异块、
    同步和回滚回归测试。
32. 启动真实 Swing 窗口,在 960×620 和 1280×760 下使用大量测试文件检查
    扫描中、取消、重新开始、完成详情、长路径和按钮文字,并生成最终截图。

修改前先阅读现有扫描实现和开发计划,给出实施计划、进度口径、取消语义、
并发策略、错误处理和 UI 效果图。我确认后再修改正式代码。实现完成后更新
README、开发计划、P2 实施方案,并记录实际测试结果。

下一步

按照开发计划,扫描性能完成后,下一项准备做 P2“对比历史缓存”。

目前每次启动程序都要重新选择左右路径。实际工作里经常反复检查同一组项目目录,例如开发目录和发布目录、配置模板和环境配置、两个版本的部署包。

下一步计划保存最近使用过的文件或目录对比任务,包括:

  • 对比模式。
  • 左右路径。
  • 当时使用的过滤条件或预设。
  • 最近打开时间。

历史记录只负责恢复任务,不直接缓存旧结果。点击历史记录后仍然要检查路径并重新扫描,避免磁盘内容已经变化,界面却展示过期状态。

后面还有过滤规则持久化与预设、窗口和偏好设置、发布打包。顺序继续按开发计划推进。

最后

这期最直观的变化是一条进度条和一个取消按钮,但真正完成的是扫描任务的边界。

以前点击对比以后,程序只有“开始”和“结束”两个状态。现在它知道自己在发现文件、读取 Hash、构建结果还是更新界面,也知道什么时候可以安全停止、什么时候可以提交新结果。

并行 Hash 也没有追求线程越多越好。磁盘 I/O 和 CPU 计算不是同一种负载,同盘、跨盘、网络路径和小任务需要不同策略。先用 1 到 4 个线程做保守控制,比把处理器核心数全部开满更适合一个桌面文件工具。

当前使用体验仍然不算完整。程序还不能从最近对比中一键恢复任务,过滤规则关闭后不会保留,也没有提供用户可调的性能策略;扫描完成详情目前只保存本次数据,没有长期性能记录。

后面继续按开发计划优化。下一期先处理对比历史缓存,让经常使用的左右目录不用每次重新选择。

更多推荐