1. 引言
先回顾一个使用 Files.walk() 的遍历示例:
Path start = Paths.get("my-folder");
try (Stream<Path> stream = Files.walk(start)) {
stream.forEach(System.out::println);
}
如果只是遍历文件和文件夹并对它们做点事情,这样用非常好。但如果需求更复杂呢?
- 需要递归删除一个文件夹及其所有文件和子目录(而不是仅在其为空时才能删除)。
- 需要递归复制或移动目录。
- 需要收集统计信息(例如计算所有文件的总大小,按扩展名对文件分组)。
- 需要处理错误(例如某些文件夹无访问权限时,不希望整个程序崩溃)。
在这些场景下 Stream API 就不那么方便了:不得不在 lambda 中到处写 try-catch,需要控制遍历顺序(例如先删除文件再删除文件夹),代码会变得难以阅读。
为了解决这些问题,Java 提供了基于 FileVisitor 的文件系统树遍历机制。
2. FileVisitor 接口:它的工作方式
接口 FileVisitor<T> 是一种“事件处理器”,在遍历文件系统时会在访问到每个文件和文件夹时收到通知。
当你调用 Files.walkFileTree(start, visitor) 时,Java 会从给定路径开始遍历树,并在每个阶段调用你 FileVisitor 中的相应方法。
FileVisitor 接口的主要方法
接口大致如下:
public interface FileVisitor<T> {
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;
FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;
FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;
FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}
- preVisitDirectory — 在进入目录之前调用。
- visitFile — 针对每个文件调用。
- visitFileFailed — 当无法访问文件时调用。
- postVisitDirectory — 在离开目录后调用(即处理完该目录的所有文件和子目录之后)。
以上每个方法都返回 FileVisitResult,用来决定遍历如何继续:
- FileVisitResult.CONTINUE — 继续遍历。
- FileVisitResult.SKIP_SUBTREE — 跳过当前目录及其所有内容。
- FileVisitResult.SKIP_SIBLINGS — 跳过当前节点的“同级”项(同一层级的文件和文件夹)。
- FileVisitResult.TERMINATE — 立即终止遍历。
SimpleFileVisitor 类
每次都去实现四个方法会很麻烦,尤其是你只需要其中一两个的时候。因此 Java 提供了一个方便的类适配器 SimpleFileVisitor:它已经为所有方法提供了“默认”实现(直接 CONTINUE),你只需重写需要的那些方法。
示例:
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class MyVisitor extends SimpleFileVisitor<Path> {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println("文件: " + file);
return FileVisitResult.CONTINUE;
}
}
3. 使用 Files.walkFileTree:基础示例
示例 1:打印所有文件和文件夹
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class TreePrinter {
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
System.out.println("文件夹: " + dir);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println(" 文件: " + file);
return FileVisitResult.CONTINUE;
}
});
}
}
输出:
文件夹: my-folder
文件: my-folder/file1.txt
文件: my-folder/file2.txt
文件夹: my-folder/subdir
文件: my-folder/subdir/nested.txt
可以看到,遍历是“深度优先”的:先进入目录,然后处理其文件,再转入其子目录。
4. 示例:递归删除目录
一个最常见的任务:删除一个文件夹及其全部内容。若直接使用 Files.delete(path) 删除,而该文件夹不为空——就会抛出异常。必须先删除所有文件和子文件夹,再删除文件夹本身。
使用 FileVisitor 可以这样做:
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class RecursiveDelete {
public static void main(String[] args) throws IOException {
Path dirToDelete = Paths.get("test-folder");
Files.walkFileTree(dirToDelete, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file); // 删除文件
System.out.println("已删除文件: " + file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir); // 在删除完内部内容后再删除文件夹
System.out.println("已删除文件夹: " + dir);
return FileVisitResult.CONTINUE;
}
});
}
}
重要说明:
文件夹的删除发生在 postVisitDirectory,也就是删除完其内容之后。若在删除内部文件之前尝试删除文件夹——会报错。
5. 示例:统计目录中所有文件的总大小
我们来编写一个 FileVisitor,统计某个目录及其子目录中所有文件的总大小。
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class DirectorySizeCalculator {
private static long totalSize = 0;
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
totalSize += attrs.size();
return FileVisitResult.CONTINUE;
}
});
System.out.println("总大小: " + totalSize + " 字节");
}
}
注意:
我们使用字段 totalSize 来累加大小。在实际应用中,最好避免使用静态字段,通过对象传递变量更合适,但为简化示例——这里这样处理。
6. 示例:使用 FileVisitor 按模式(扩展名)查找文件
假设需要查找目录及其子目录中的所有 .txt 文件,并将其打印出来。
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class TxtFileFinder {
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".txt")) {
System.out.println("找到 .txt 文件: " + file);
}
return FileVisitResult.CONTINUE;
}
});
}
}
如果想收集找到的文件列表,可以准备一个集合:
import java.util.ArrayList;
import java.util.List;
// 在 main 内部:
List<Path> txtFiles = new ArrayList<>();
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".txt")) {
txtFiles.add(file);
}
return FileVisitResult.CONTINUE;
}
});
System.out.println("共找到: " + txtFiles.size());
7. 错误处理与遍历要点
如果无法访问某个文件或文件夹怎么办?
遍历时有时会遇到无权限的文件或文件夹(例如没有权限,或文件被其他进程占用)。这种情况下会调用方法 visitFileFailed。
示例:
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.err.println("文件访问错误: " + file + " (" + exc + ")");
return FileVisitResult.CONTINUE; // 即使出错也继续遍历
}
如果希望在出错时中止遍历,返回 TERMINATE 即可。
如何跳过整个文件夹?
如果不想进入某个特定文件夹(例如 .git 或 node_modules),可以在方法 preVisitDirectory 中返回 SKIP_SUBTREE:
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (dir.getFileName().toString().equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE; // 不要进入该文件夹及其子目录
}
return FileVisitResult.CONTINUE;
}
8. 实战:实现自己的 FileVisitor
我们来实现一个 FileVisitor,它:
- 查找所有扩展名为 .java 的文件,
- 统计其数量,
- 统计这些文件的总大小。
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class JavaFilesStats {
public static void main(String[] args) throws IOException {
Path start = Paths.get("src"); // 例如,项目源代码
class JavaFileVisitor extends SimpleFileVisitor<Path> {
int count = 0;
long totalSize = 0;
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".java")) {
count++;
totalSize += attrs.size();
System.out.println("找到 .java 文件: " + file);
}
return FileVisitResult.CONTINUE;
}
}
JavaFileVisitor visitor = new JavaFileVisitor();
Files.walkFileTree(start, visitor);
System.out.println("总计 .java 文件: " + visitor.count);
System.out.println("总大小: " + visitor.totalSize + " 字节");
}
}
9. 使用 FileVisitor 的常见错误
错误 1:在删除内部文件之前就尝试删除目录。 如果你在 preVisitDirectory 中调用 Files.delete(dir),会抛出异常——必须先删除所有文件和子目录,然后再删除目录本身(在 postVisitDirectory 中执行)。
错误 2:忽略访问错误的处理. 如果不重写 visitFileFailed,程序可能在遇到受保护的文件时意外结束。最好显式输出错误并继续遍历。
错误 3:认为“隐藏文件”的行为在所有操作系统上都相同。 在 Linux 和 macOS 中,以点开头的文件(.gitignore)被视为隐藏;在 Windows 中则需要设置特定属性。不要混淆这两种机制。
错误 4:在多线程应用中用静态字段累加结果。 如果并行启动多个遍历,静态字段会导致混乱。应使用实例字段(或上面的局部类那样)。
错误 5:在 FileVisitor 内部使用流时忘记关闭资源。 如果你的 FileVisitor 读取/写入文件,请使用 try-with-resources 以避免资源泄漏。
GO TO FULL VERSION