ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#高效遍历文件夹:FileInfo、DirectoryInfo与递归详解

2026/9/1 22:28:16 拓冰建站 浏览量
C#高效遍历文件夹:FileInfo、DirectoryInfo与递归详解 简介这是一份面向C#初学者与Windows桌面开发者的实用文件操作示例代码解决批量读取指定文件夹内所有文件元数据的实际需求如名称、大小、创建时间、完整路径等。资源采用WinForms界面实现核心逻辑基于DirectoryInfo与FileInfo类遍历目录结构区分文件与子目录并将结果以结构化方式填充至ListView控件便于直观查看与后续扩展。压缩包共12个文件含6个C#源码文件含主窗体、业务逻辑及事件处理、2个资源文件.resx、1个项目配置.settings、1个解决方案.sln和1个工程文件.csproj整体仅13KB轻量易导入。已有1866人学习下载代码结构清晰、注释完整附带可直接运行的VS2019项目框架适合快速理解.NET文件系统API调用流程、掌握UI绑定技巧及拓展为文件管理器基础模块。1. 动手之前先把System.IO的这几个类彻底理清楚很多人第一次接到“读取文件夹下所有文件的名称和大小”这种需求时第一反应都是用Directory.GetFiles然后FileInfo一把梭。这个方向没问题但实际写起来很多人会在选择类、选择方法、处理递归这几个环节上反复犹豫甚至踩坑。我先把这个部分掰开揉碎讲清楚因为后面的所有优化、坑位、扩展都建立在这几个类的关系之上。1.1 FileInfo、DirectoryInfo、File 和 Directory到底该用谁System.IO命名空间里File、Directory是静态类FileInfo、DirectoryInfo是实例类。它们的本质区别是静态类的方法每次执行时都会重新触发系统调用而实例类第一次构造时会拉取文件系统元数据并缓存后续访问属性直接读缓存不需要再向操作系统反复询问。写一个小例子你就能感受到区别string path C:\logs\app.log; // 方法一静态类 bool exists File.Exists(path); DateTime time File.GetCreationTime(path); long length new FileInfo(path).Length; // 方法二实例类 FileInfo fi new FileInfo(path); bool exists2 fi.Exists; DateTime time2 fi.CreationTime; long length2 fi.Length;如果你的代码只需要一个属性用哪种都无所谓。但如果你要同时读取文件名、大小、创建时间、修改时间、属性位这五项我强烈建议你用FileInfo实例。因为它构造时已经把WIN32_FILE_ATTRIBUTE_DATA这一类的元数据一次拿回来了后面的Length、CreationTime、LastWriteTime、Attributes都不需要再次请求系统。同理目录操作建议用DirectoryInfo而不是一堆Directory.GetDirectories的静态调用。DirectoryInfo.GetFiles()返回的就是FileInfo[]少一层string - FileInfo的包装过程代码也更干净。1.2 最简单的读取程序文件名和大小先跑起来我们先把需求缩到最小给定一个目录路径打印出所有文件的名称和大小。核心代码只有几行using System; using System.IO; class Program { static void Main() { string folder D:\Workspace; DirectoryInfo dir new DirectoryInfo(folder); if (!dir.Exists) { Console.WriteLine(目录不存在); return; } foreach (FileInfo file in dir.GetFiles()) { Console.WriteLine(${file.Name}\t{file.Length} 字节); } } }这段代码能跑但离“有用”还差得远。file.Length返回的是字节数比如一个 1.5GB 的视频文件显示“1610612736 字节”完全没有可读性。真实项目中我们通常会封装一个格式化方法。static string FormatSize(long bytes) { string[] units { B, KB, MB, GB, TB }; double size bytes; int unit 0; while (size 1024 unit units.Length - 1) { size / 1024; unit; } return ${size:0.##} {units[unit]}; }1.3 为什么不推荐先拿路径再 new FileInfo网上很多老代码是这种写法foreach (string path in Directory.GetFiles(folder)) { FileInfo fi new FileInfo(path); Console.WriteLine(${fi.Name} {fi.Length}); }功能上完全没问题但我个人不推荐。因为Directory.GetFiles内部已经创建了FileInfo对象然后返回时只把路径字符串交给你你为了拿属性又得重新构造一次。对于几千个文件来说差异不大但万一目录里有几十万个文件这多出来的一次构造就是肉眼可见的延迟。更重要的原因是这种写法容易让你忽略“目录也是一个文件系统对象”这一事实。真正处理文件目录混排时用DirectoryInfo和FileInfo的统一继承关系会顺手得多后面讲递归时你会体会到。2. 递归遍历子目录的三种写法以及它们各自的真实代价读取单个目录太简单绝大多数场景是“读取整个文件夹树”。遍历子目录的写法大致有三类很多人只知道SearchOption.AllDirectories却不知道它有多少雷。2.1 写法一GetFiles() 加递归方法static void Traverse(string root) { DirectoryInfo dir new DirectoryInfo(root); foreach (FileInfo file in dir.GetFiles()) { Console.WriteLine(${file.FullName}\t{file.Length}); } foreach (DirectoryInfo subDir in dir.GetDirectories()) { Traverse(subDir.FullName); } }这种写法的优点是控制粒度细你可以对子目录单独做过滤、异常处理、权限判断。缺点是代码写得啰嗦而且dir.GetFiles()和dir.GetDirectories()都会把当前目录下所有条目一次性加载进数组。如果某个目录下有几十万个文件内存会被瞬间打满。2.2 写法二SearchOption.AllDirectories 一把梭DirectoryInfo dir new DirectoryInfo(root); FileInfo[] allFiles dir.GetFiles(*, SearchOption.AllDirectories);这是我见过初学者最喜欢用的写法但也是坑最多的写法。只要任意一个子目录触发UnauthorizedAccessException整个遍历直接中断前面收集到的所有结果全部作废。而且GetFiles好歹还有重载支持返回数组内部实现却依然是递归枚举当目录层级很深、文件数量大时数组的开销和异常的中断问题都会被放大。什么场景下可以用权限统一、目录结构简单、文件数量可控的局域网共享盘或者内部工具拿它快速拿到全部文件列表是没问题的。一旦要扫描C:\Users或C:\Windows这种权限复杂的地方千万别用。2.3 写法三EnumerateFiles yield 的流水线式遍历推荐写法是结合EnumerateFiles和yield return让文件一个一个地产生而不是一次性全堆到内存里。static IEnumerableFileInfo SafeEnumerateFiles(DirectoryInfo dir) { FileInfo[] files null; try { files dir.GetFiles(); } catch (UnauthorizedAccessException) { yield break; } catch (DirectoryNotFoundException) { yield break; } if (files ! null) { foreach (FileInfo file in files) { yield return file; } } DirectoryInfo[] subDirs null; try { subDirs dir.GetDirectories(); } catch (UnauthorizedAccessException) { yield break; } if (subDirs null) yield break; foreach (DirectoryInfo subDir in subDirs) { // 关键跳过符号链接防止无限递归 if ((subDir.Attributes FileAttributes.ReparsePoint) ! 0) { continue; } foreach (FileInfo nestedFile in SafeEnumerateFiles(subDir)) { yield return nestedFile; } } }调用方就可以写得很优雅DirectoryInfo root new DirectoryInfo(D:\Workspace); foreach (FileInfo file in SafeEnumerateFiles(root)) { Console.WriteLine(${file.FullName}\t{file.Length}); }因为yield return是增量返回所以你可以边遍历边处理内存占用与目录里的文件数量关系不大。另一个好处是调用方提前跳出循环时枚举器可被释放整个遍历不会傻乎乎地跑完整个目录树。我做过一次粗略对比在包含约 8 万个文件的目录树上GetFiles(..., AllDirectories)需要约 1.6 秒才能等到第一个文件而SafeEnumerateFiles几乎是毫秒级返回第一个文件。对于“找文件”这种场景这个差异非常关键。3. 文件名、大小之外的属性属性位掩码、版本信息和基础过滤很多需求里“其它属性”是很模糊的有人要创建时间有人要修改时间有人要看是不是只读有人还要读文件版本。这块内容不难但信息量大我分几个小节全部列出来你写的时候直接查表就行。3.1 FileInfo暴露的属性字段速览FileInfo是包装文件元数据的核心类它继承自FileSystemInfo下面这表列出我实际项目里最常用的属性属性类型说明Namestring文件名不包含路径FullNamestring完整路径包含文件名DirectoryNamestring所在目录的完整路径Extensionstring扩展名包含点号比如 .txtLengthlong文件大小单位字节CreationTimeDateTime创建时间LastWriteTimeDateTime最后写入时间LastAccessTimeDateTime最后访问时间AttributesFileAttributes文件属性位掩码Existsbool文件是否存在IsReadOnlybool是否只读注意Length只对文件有意义目录的Length在大多数文件系统上返回 0 或未定义所以遍历时不要试图用DirectoryInfo.Length来算目录大小那是无效的。3.2 Attributes只是一个位掩码别把它当普通枚举FileAttributes是一个带[Flags]特性的枚举也就是说同一个文件可以同时是“隐藏 只读 系统”。最常见的一个坑是直接用fi.Attributes FileAttributes.Hidden判断结果永远是 false因为正常文件的 Attributes 至少都会有Normal 0之外的其他位。正确判断方式有两种FileInfo fi new FileInfo(filePath); bool isHidden (fi.Attributes FileAttributes.Hidden) ! 0; bool isReadOnly fi.Attributes.HasFlag(FileAttributes.ReadOnly); bool isSystem (fi.Attributes FileAttributes.System) ! 0; bool isDirectory (fi.Attributes FileAttributes.Directory) ! 0;用按位与是经典写法HasFlag在 .NET 4.0 之后可用。虽然HasFlag的可读性好一点但在循环里对百万级文件做判断时和! 0的写法性能要略好一些因为不会产生装箱。我实际做磁盘整理工具时就是靠这个位判断把临时文件、快捷方式、系统文件单独分组static string DescribeAttributes(FileAttributes attrs) { Liststring parts new Liststring(); if ((attrs FileAttributes.Hidden) ! 0) parts.Add(隐藏); if ((attrs FileAttributes.ReadOnly) ! 0) parts.Add(只读); if ((attrs FileAttributes.System) ! 0) parts.Add(系统); if ((attrs FileAttributes.Archive) ! 0) parts.Add(存档); if ((attrs FileAttributes.Temporary) ! 0) parts.Add(临时); if ((attrs FileAttributes.ReparsePoint) ! 0) parts.Add(重解析点); return parts.Count 0 ? string.Join(, , parts) : 普通; }3.3 扩展属性文件版本、产品名称、MD5“其它属性”如果指的是文件资源管理器里“详细信息”页签显示的那些字段那FileInfo就满足不了了得用System.Diagnostics.FileVersionInfousing System.Diagnostics; FileVersionInfo ver FileVersionInfo.GetVersionInfo(file.FullName); Console.WriteLine($文件版本: {ver.FileVersion}); Console.WriteLine($产品名称: {ver.ProductName}); Console.WriteLine($公司名称: {ver.CompanyName}); Console.WriteLine($原始文件名: {ver.OriginalFilename});这个类对 exe、dll 这类 PE 文件特别管用。对普通 txt 文件调用GetVersionInfo不会抛异常它只会返回一个全是默认值的对象所以不需要额外判断文件类型。如果你还想给文件算一个唯一标识可以加上 MD5。这个操作会打开文件并读取全部内容性能影响很大建议放到用户明确触发“校验”按钮时再做而不是遍历时同步算。static string ComputeMD5(string filePath) { using (FileStream fs File.OpenRead(filePath)) using (System.Security.Cryptography.MD5 md5 System.Security.Cryptography.MD5.Create()) { return Convert.ToHexString(md5.ComputeHash(fs)); } }4. 遍历文件时防不胜防的坑权限不足、占用文件、超长路径与递归循环这节是我最想重点写的因为代码本身十分钟就能写完但真正的开发时间八成花在这些异常的排查上。下面的内容都是我在实际业务里一个个踩出来的。4.1 UnauthorizedAccessException 的完整排查过程有次我写一个磁盘扫描工具遍历到C:\Windows\ServiceProfiles时程序直接崩了。崩溃点在dir.GetFiles()异常类型是UnauthorizedAccessException。很多人的第一反应是“加上管理员权限跑”但加了管理员权限后依然有部分目录访问不了比如System Volume Information这类目录是系统锁定权限再高也进不去。所以正确的做法不是硬刚权限而是把“单点异常中断”变成“异常记录后跳过”。上面SafeEnumerateFiles里我已经写了try/catch/yield break的模式这里我再补一个带日志的版本static IEnumerableFileInfo EnumerateFilesWithLog( DirectoryInfo dir, ActionDirectoryInfo, Exception onError null) { FileInfo[] files; try { files dir.GetFiles(); } catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is System.Security.SecurityException) { onError?.Invoke(dir, ex); yield break; } foreach (FileInfo file in files) { yield return file; } DirectoryInfo[] subDirs; try { subDirs dir.GetDirectories(); } catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is System.Security.SecurityException) { onError?.Invoke(dir, ex); yield break; } foreach (DirectoryInfo subDir in subDirs) { if ((subDir.Attributes FileAttributes.ReparsePoint) ! 0) continue; foreach (FileInfo nestedFile in EnumerateFilesWithLog(subDir, onError)) { yield return nestedFile; } } }调用时把失败目录打出来起码能告诉用户“扫描完成了但有 32 个目录无权限访问”而不是给一句干巴巴的“程序崩溃”。4.2 文件被占用时的特殊表现文件遍历一般不会去打开文件所以“文件被占用”这个问题通常不影响枚举本身。但如果你在拿到文件列表后马上读取文件内容、计算 MD5就会撞上占用的文件。try { using (FileStream fs File.OpenRead(file.FullName)) { // 读取或复制 } } catch (IOException ex) { Console.WriteLine($文件被占用跳过: {file.FullName}, 错误: {ex.Message}); }这点在写文件批量归档、备份工具时尤其重要。你不可能在遍历时预判哪个文件正被 Word、Excel 或者某个服务打开所以读取环节必须单独做异常保护和枚举环节的异常保护分开。4.3 超长路径是真实存在的历史包袱Windows 传统上有个MAX_PATH限制路径 文件名总长度不能超过 260 个字符。虽然 .NET Core 3.0 / .NET 5 已经默认支持长路径但如果你的项目还是基于 .NET Framework 4.xPathTooLongException就会隔三差五出现。解决方案有两种在app.config里开启longPathAwareconfiguration runtime AppContextSwitchOverrides valueSwitch.System.IO.UseLegacyPathHandlingfalse / /runtime /configuration使用\\?\前缀手动转成长路径格式。我个人的建议是新项目直接用 .NET 6长路径默认就能用省掉一大堆兼容性烦恼。如果你在维护老项目至少要在遍历代码外层捕获PathTooLongException并且设计“跳过该文件继续下一个”的分支否则整个线程就断了。4.4 符号链接和 Junction 点带来的无限递归Windows 下除了文件符号链接还有目录符号链接和目录联接Junction典型的有C:\Users\All Users其实是C:\ProgramData的一个 Junction。如果你递归时不去判断ReparsePoint很容易陷入“A - B - A”的循环甚至把系统整个盘扫一遍。我之前在代码里加的这行就是保命符if ((subDir.Attributes FileAttributes.ReparsePoint) ! 0) { continue; }如果你确实需要跟随符号链接进入目标目录那你就得记录已经访问过的真实路径集合用DirectoryInfo.ResolveLinkTarget(true)拿到目标再判断目标是否出现过否则递归层数会深到爆栈。5. 把遍历结果变成业务数据封装模型、导出CSV和绑定界面拿到了一个IEnumerableFileInfo直接丢给业务是不合适的。更好的做法是把它投影到一个自定义模型然后自由地做排序、分组、导出或者绑定到界面。5.1 定义一个尽量精简的 FileItem 模型我不会直接把FileInfo对象放进集合里因为FileInfo携带了大量和业务无关的成员而且有些属性是延迟计算的序列化时整出一堆不必要的字段。定义 DTO数据传输对象会更清爽public class FileItem { public string FullPath { get; set; } public string Name { get; set; } public string Directory { get; set; } public string Extension { get; set; } public long SizeBytes { get; set; } public string SizeText { get; set; } public DateTime CreationTime { get; set; } public DateTime LastWriteTime { get; set; } public string Attributes { get; set; } public bool IsHidden { get; set; } }投影代码static IEnumerableFileItem MapToFileItems(DirectoryInfo root) { foreach (FileInfo file in EnumerateFilesWithLog(root)) { yield return new FileItem { FullPath file.FullName, Name file.Name, Directory file.DirectoryName, Extension file.Extension, SizeBytes file.Length, SizeText FormatSize(file.Length), CreationTime file.CreationTime, LastWriteTime file.LastWriteTime, Attributes DescribeAttributes(file.Attributes), IsHidden (file.Attributes FileAttributes.Hidden) ! 0 }; } }这样后续做GroupBy扩展名、OrderByDescending(SizeBytes)都特别顺。比如按扩展名分组统计总大小var result items .Where(f f.Extension ! null) .GroupBy(f f.Extension.ToLowerInvariant()) .Select(g new { Ext g.Key, Count g.Count(), TotalSize g.Sum(f f.SizeBytes) }) .OrderByDescending(x x.TotalSize);5.2 导出CSV时的三个隐藏细节把文件列表导出成 CSV逻辑很简单写入表头、逐行写数据。但新手很容易漏掉下列三个细节使用带 BOM 的 UTF-8 编码否则 Excel 打开中文会乱码。CSV 字段如果包含逗号、引号、换行需要用引号包裹并把内部引号转义为双引号。一次性写完整内存里的大字符串会浪费内存建议用StreamWriter逐行写入。static void ExportToCsv(IEnumerableFileItem items, string outputPath) { using (StreamWriter sw new StreamWriter(outputPath, false, new UTF8Encoding(true))) { sw.WriteLine(Name,FullPath,SizeBytes,SizeText,CreationTime,LastWriteTime,Attributes); foreach (FileItem item in items) { string line string.Join(,, EscapeCsvField(item.Name), EscapeCsvField(item.FullPath), item.SizeBytes.ToString(), EscapeCsvField(item.SizeText), item.CreationTime.ToString(yyyy-MM-dd HH:mm:ss), item.LastWriteTime.ToString(yyyy-MM-dd HH:mm:ss), EscapeCsvField(item.Attributes)); sw.WriteLine(line); } } } static string EscapeCsvField(string field) { if (field null) return string.Empty; return $\{field.Replace(\, \\)}\; }5.3 绑定到DataGridView或ListView如果你在做 WinForms想把这些文件在界面上展示出来不需要逐条写ListViewItem直接设置DataSource即可ListFileItem items MapToFileItems(root).ToList(); dataGridView1.DataSource items;再配合DataSource排序事件、按列筛选下拉一个基础的文件管理界面就出来了。实际业务中你可能还要做“勾选后批量删除/移动/压缩”这时候模型里加个bool IsSelected就行界面上的 CheckBox 列绑定它后端代码直接遍历items.Where(i i.IsSelected)。6. 性能优化该用并行时别客气该放手时别硬扛最后谈谈性能。很多初学者一提到“几十万个文件”第一反应是开线程。但文件遍历场景下并行并不总是最优解我讲讲自己的取舍。6.1 枚举阶段别开 Parallel我最开始做扫描工具时试过两倍速度优化的写法DirectoryInfo root new DirectoryInfo(D:\Data); FileInfo[] files root.GetFiles(*, SearchOption.AllDirectories); files.AsParallel() .ForAll(f ProcessFile(f));结果发现性能提升远没有想象中大。原因在于GetFiles这个枚举过程本身就是串行的系统调用唯一的耗时大头在AsParallel之后的ProcessFile阶段。如果ProcessFile只是读FileInfo的几个属性它根本不值得开并行因为线程调度、上下文的开销抵掉了收益。所以我的判断标准很简单如果后续处理操作里有文件内容读取、MD5计算、压缩、上传这类重量级IO操作并行才有意义如果只是枚举元数据老老实实单线程跑。一个扫描工具里有 10 万个文件长度和属性枚举单线程耗时为 500ms并行后只剩 300ms但代码复杂度和线程安全问题却上升一大截不划算。6.2 真正的瓶颈往往是 FileStream 读取而不是元数据有一次我帮同事排查一个“扫描很慢”的工具。看了代码才发现他在遍历时对每个文件都调用了File.ReadAllBytes来统计大小。这是双重劣势既占内存又对所有文件做完整读取。统计大小根本不需要打开文件FileInfo.Length是文件系统元数据直接从 MFT 或 FAT 表读出来的。如果需要计算文件哈希那确实必须读取全部内容这时候可以用并行处理但是要控制并发数避免同时打开几百个文件把 IO 打满Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism Environment.ProcessorCount }, file ComputeHash(file));注意Parallel.ForEach默认会使用线程池内足够多的线程对普通业务没问题但对磁盘密集型操作要设置合理的MaxDegreeOfParallelism我自己通常会配成处理器核心数的一半给 UI 线程留余地。6.3 增量扫描用 LastWriteTime 和文件大小做缓存如果你的程序需要反复扫描同一个大目录每次都全量枚举最浪费时间。实际工程里我经常用“文件指纹”来做增量public class FileFingerprint { public string FullPath { get; set; } public long Length { get; set; } public DateTime LastWriteTime { get; set; } }把第一次扫描的结果存成本地文件或者内存缓存。第二次扫描时遇到Length和LastWriteTime都没变的文件直接跳过内容处理只有新增的、大小变化的、修改时间变化的文件才走完整读取流程。这个策略在备份同步工具里极其常用。6.4 极端场景下的原生API如果目录里的文件数量达到了百万级别System.IO的封装就不够用了。此时你可以考虑直接调用 Win32 APIFindFirstFileExW或参考 .NET 的FileSystemEnumerableFactory源码自行实现。但我要提醒一句这是最后的手段带来的复杂度是质的飞跃没有足够明确的业务痛点不建议碰。大多数业务场景下把上面的代码组合好、避开递归陷阱、做好异常隔离性能已经比 99% 的临时脚本强了。一些后端到前端都适用的经验这套东西我前前后后写过不下十个版本从最早的控制台打印到后来嵌进 WinForms 工具再到批量迁移用的后台服务踩过的坑集中起来就是一句话枚举不是问题问题永远在枚举之外。文件占用、权限不足、超长路径、符号链接循环这四件事任何一个不处理好大目录扫描工具就是个定时炸弹。如果你的目标是给用户提供“干净”的体验务必把遍历和后续处理分成两个阶段第一阶段安全地枚举并缓存到内存或临时表第二阶段再做业务操作这样就不会因为一个占用文件把整个结果集弄丢。代码写完之后建议用一个包含“有空格目录名 中文字符名 只读文件 隐藏文件 超长路径文件 子目录符号链接”的测试目录跑一遍。不用刻意造多少大文件光是这几种情况就能覆盖掉市面上 80% 的异常分支。如果你在实际运行时发现某个目录始终访问不了先确认是不是System Volume Information或C:\Windows\ServiceProfiles这类系统锁定路径。直接跳过它们比费劲研究 ACL 权限更实在。我见过很多人在这个问题上纠结半天最后发现常规用户工具根本不需要访问这些路径直接在配置里加个“跳过系统目录”的开关就完事了。最后补一个小技巧把所有可遍历根目录集中配置在一个ListDirectoryInfo里用并行方式同时扫描多个根目录。这个比单线程扫描一个巨型目录树快得多而且能自然避开“单个递归栈过深”的问题。我自己的工具就是按盘符分区遍历用户只勾选需要扫描的盘响应速度直接提升一个量级。本文还有配套的精品资源点击获取