前言
这篇博客,我打算结合我最近正在开发的 C盘管理工具 内部的功能实现,给各位读者一些在实现方式上的参考,文章较长,但会很详细,请做好心理准备。 ------ 这篇文章是 文件清理模块中 目录树解析的实现方案参考。
问题引入
C盘管理工具,无非就是两部分,一块是监控,一块是清理,做这个应用的初衷就是奔着清理去的。所以清理非常重要 。 而清理,需要提前确定 可能包含清理项的目录列表 和 清理方式 。 所以,会分两篇讲,这一篇是讲 目录列表的解析以及搭建。
前置知识
TreeView + TreeNode
TreeView 是Winform 中的 树状容器,支持节点之间以树的形式建立关系并进行展示。Explorer左边的树状目录视图 就是一个TreeView :
核心属性以及这篇文章中可能使用到的方法有:
cleanTreeView.Nodes 【属性】 节点集合,内部都是TreeNode
cleanTreeView.StateImageList 【属性】状态图片集合 <-> 用来做复选框cleanTreeView.BeginUpdate() 【方法】开始内部调整,会拦截外部的一切修改。
cleanTreeView.EndUpdate() 【方法】结束内部调整,开始接收外部的变化。
cleanTreeView.BeforeCheck 【事件】节点被选择之前触发 <-> 禁止顶层目录被选择
cleanTreeView.AfterCheck 【事件】节点被选择之后触发 <-> 调整关联目录的选中状态
TreeNode 是 TreeView Nodes 属性中的个体,内部有 txt , TreeNode[] children 属性,是一个可以拥有多个子节点的 节点。
FileInfo,Path,Directory... 文件操作相关
这些是 构建目录树的基础,Path 主要用来获取路径,Directory 用来判断并区分文件类型,FileInfo 用来记录文件大小以及路径等信息。
Path 使用到的 方法有:
Path.getFullPath(string) 获取全量路径(绝对路径)
Path.getPathRoot(string) 获取对应文件的最顶层磁盘目录 <-> 区分是否是同盘区Path.combine(string,string) 对路径进行合并
CancellationTokenSource
这是一个 .Net 中可以在 任务执行途中进行主动取消 的类。通常被用来强行中断某个任务。
常规情况下,由于任务之间的关联性不强 ,可以挨个创建token 然后取消。
在这次的内容中,由于一个任务会触发多个异步任务,这些任务具备自己的 token,和其他任务的关联性 强。
所以常规的对每一个任务使用
new CancellationTokenSource()然后挨个去取消是非常不被推荐的一种行为.
如果需要 联动性的取消操作,就需要使用
CancellationTokenSource.CreateLinkedTokenSource(externalToken)
创建一个 token 与 externalToken 进行链接 ,使得 externalToken 的取消事件触发之后,可以同时关联到token, 然后 一起结束 。
思路整理:
创建目录树,需要基于一个基础目录,随后逐个查找并绑定后续的文件或者目录节点,并逐层统计大小信息然后填入目标目录。
-
基础目录不能被删除。
-
节点数过多,耗时就会很长,可能发生无响应的情况,导致程序卡死。
-
对一个目录的选择 需要同时选择它的所有子文件,同理取消的时候也需要改变状态。
解决方案有:
1.1 监听事件,在选择完成之前 根据 Tag 值来取消选中。
2.1 在执行操作前先统计文件数量,如果数量超出上限5w ,就仅显示目录。优先在后台处理好全部的绑定关系 ,完成之后再将顶层目录 与 TreeView 进行绑定,避免了 中途绑定时 TreeViw 的自我调整过程,
3.1 监听事件,在选择完成之后 同步子节点以及父节点的状态。
具体实现
首先是用户选择目录的存在性判断以及文件数量的统计,然后是 构建文件之间的关系,将信息装入 TreeNode,并构建TreeNode 之间的关系,最后是 绑定到 TreeView.
构建文件集合
如果需要统计文件数量以及文件目录,大小等信息,那还不如在一次递归内就全部做完 ,考虑到可能会很大,直接用主线程可能出现无响应的问题,所以需要异步执行。并将这些信息统一到一个新的类中:
CleanupFileEntry 可清理文件实体
CSharp
/// <summary>
/// 扫描得到的可清理文件/目录条目
/// </summary>
public class CleanupFileEntry
{
public string FullPath { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
// 文件大小;目录为其下所有文件大小总和
public long SizeBytes { get; set; }
public DateTime? LastWriteTime { get; set; }
public bool IsDirectory { get; set; }
}
ScanDirectoryAsync 进行递归前的数据准备,之后开启递归(异步)
Csharp
/// <summary>
/// 递归扫描目录,返回全部文件与目录条目(目录条目在文件之前,父目录先于子目录)。
/// 跳过重解析点(符号链接/挂载点),无权限目录静默跳过。
/// </summary>
public async Task<List<CleanupFileEntry>> ScanDirectoryAsync(
string rootPath,
IProgress<int>? progress = null,
CancellationToken cancellationToken = default)
{
var root = new DirectoryInfo(rootPath);
if (!root.Exists)
throw new DirectoryNotFoundException($"目录不存在: {rootPath}");
var entries = new List<CleanupFileEntry>();
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
await Task.Run(() => ScanRecursive(root, entries, progress, cts.Token), cts.Token);
return entries;
}
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
这里使用到了 token 之间的链接,使得 cancellationToken 取消时,可以让 cts 也取消
ScanRecursive 递归,按顺序将文件存入集合,返回顶层目录
Csharp
/// <summary>
/// 递归扫描目录,返回全部文件与目录条目(目录条目在文件之前,父目录先于子目录)。
/// </summary>
private static CleanupFileEntry ScanRecursive(
DirectoryInfo dir,
List<CleanupFileEntry> entries,
IProgress<int>? progress,
CancellationToken ct)
{
if (ct.IsCancellationRequested)
return new CleanupFileEntry { FullPath = dir.FullName, Name = dir.Name, IsDirectory = true };
var dirEntry = new CleanupFileEntry
{
FullPath = dir.FullName,
Name = dir.Name,
IsDirectory = true
};
entries.Add(dirEntry);
// 扫描文件
try
{
foreach (var file in dir.EnumerateFiles())
{
if (ct.IsCancellationRequested) return dirEntry;
try
{
var fileEntry = new CleanupFileEntry
{
FullPath = file.FullName,
Name = file.Name,
SizeBytes = file.Length,
LastWriteTime = file.LastWriteTime
};
entries.Add(fileEntry);
dirEntry.SizeBytes += fileEntry.SizeBytes;
}
catch
{
// 单个文件读取失败(权限/占用)跳过
}
}
}
catch (UnauthorizedAccessException) { }
catch (DirectoryNotFoundException) { }
// 扫描子文件夹
try
{
foreach (var subDir in dir.EnumerateDirectories())
{
if (ct.IsCancellationRequested) return dirEntry;
// 跳过重解析点(符号链接/挂载点),避免循环
try
{
if (subDir.Attributes.HasFlag(FileAttributes.ReparsePoint))
continue;
}
catch { continue; }
try
{
dirEntry.SizeBytes += ScanRecursive(subDir, entries, progress, ct).SizeBytes;
}
catch { continue; }
}
}
catch (UnauthorizedAccessException) { }
catch (DirectoryNotFoundException) { }
progress?.Report(0);
return dirEntry;
}
同步到 TreeNode
文件列表记录完成之后 就需要根据实际情况去构建对应的 TreeNode 了。
需要注意的是,我的案例中,在之前的递归中 就已经处理好了目录和文件之间的相对顺序,所以读者去实现的时候 需要根据自己的情况 妥善解决。
BuildCleanTreeAsync 引入构建方法,做绑定
CSHARP
/// <summary>
/// 后台构建节点树后一次性挂载,避免大量节点在 UI 线程创建导致卡死
/// </summary>
private async Task BuildCleanTreeAsync(
List<CleanupFileEntry> entries,
string rootPath,
long totalSize,
bool dirOnly,
CancellationToken ct)
{
var rootFull = rootPath.TrimEnd('\');
var rootNode = await Task.Run(() =>
BuildCleanTreeNodes(entries, rootFull, totalSize, dirOnly), ct);
cleanTreeView.BeginUpdate();
cleanTreeView.Nodes.Clear();
cleanTreeView.Nodes.Add(rootNode);
rootNode.Expand();
cleanTreeView.EndUpdate();
}
rootNode.Expand() 是把它直接关联 的子节点都展示出来(自己展开)
BuildCleanTreeNodes
CSHARP
/// <summary>
/// 在后台线程构建节点树(TreeNode 无句柄,可跨线程构建父子关系,挂载后再由 UI 线程渲染)。
/// 跳过扫描根目录自身(entries 首项即根),避免树中出现重复根节点。
/// </summary>
private static TreeNode BuildCleanTreeNodes(
List<CleanupFileEntry> entries,
string rootFull,
long totalSize,
bool dirOnly)
{
var rootName = Path.GetFileName(rootFull);
var rootNode = new TreeNode($"{rootName} [{FormatBytes(totalSize)}]")
{
Tag = null,
StateImageIndex = -1
};
// 目录节点(扫描顺序保证父目录先于子目录)
var dirNodes = new Dictionary<string, TreeNode>(StringComparer.OrdinalIgnoreCase)
{
[rootFull] = rootNode
};
foreach (var entry in entries.Where(e => e.IsDirectory))
{
var entryFull = entry.FullPath.TrimEnd('\');
if (string.Equals(entryFull, rootFull, StringComparison.OrdinalIgnoreCase))
continue;
var node = new TreeNode($"{entry.Name} [{FormatBytes(entry.SizeBytes)}]")
{
Tag = entry,
StateImageIndex = 0
};
dirNodes[entryFull] = node;
var parentDir = Path.GetDirectoryName(entryFull) ?? "";
if (dirNodes.TryGetValue(parentDir.TrimEnd('\'), out var parent))
parent.Nodes.Add(node);
else
rootNode.Nodes.Add(node);
}
if (!dirOnly)
{
// 文件节点
foreach (var entry in entries.Where(e => !e.IsDirectory))
{
var node = new TreeNode($"{entry.Name} [{FormatBytes(entry.SizeBytes)}, {entry.LastWriteTime:yyyy-MM-dd HH:mm}]")
{
Tag = entry,
StateImageIndex = 0
};
var parentDir = Path.GetDirectoryName(entry.FullPath) ?? "";
if (dirNodes.TryGetValue(parentDir.TrimEnd('\'), out var parent))
parent.Nodes.Add(node);
else
rootNode.Nodes.Add(node);
}
}
SortCleanTreeNodes(rootNode.Nodes);
return rootNode;
}
SortCleanTreeNodes 对节点整体进行一次排序(每次递归都会调用)
CSHARP
/// <summary>
/// 目录在前,其余按大小降序排列
/// </summary>
private static void SortCleanTreeNodes(TreeNodeCollection nodes)
{
foreach (TreeNode node in nodes)
SortCleanTreeNodes(node.Nodes);
if (nodes.Count < 2) return;
var list = nodes.Cast<TreeNode>().ToList();
// 目录在前,其余按大小降序排列
list.Sort((a, b) =>
{
// 目录在前
bool aDir = a.Tag is CleanupFileEntry ae && ae.IsDirectory;
bool bDir = b.Tag is CleanupFileEntry be && be.IsDirectory;
if (aDir != bDir) return aDir ? -1 : 1;
// 其余按大小降序排列
long aSize = a.Tag is CleanupFileEntry ae2 ? ae2.SizeBytes : 0;
long bSize = b.Tag is CleanupFileEntry be2 ? be2.SizeBytes : 0;
return bSize.CompareTo(aSize);
});
for (int i = 0; i < list.Count; i++)
{
if (nodes[i] == list[i]) continue;
nodes.Remove(list[i]);
nodes.Insert(i, list[i]);
}
}
这个排序方法是对树节点的展示效果进行最终的决定性排序。
总结 ------ From DeepSeek
本文以 C盘管理工具的文件清理模块为背景,提供了一套高效、可取消、防卡顿的目录树解析方案。核心思路是:
- 异步递归扫描 ,一次性收集目录、文件及大小信息,并利用
CancellationTokenSource实现联动取消; - 后台构建 TreeNode,避免 UI 线程阻塞,完成后一次性挂载到 TreeView;
- 事件联动(BeforeCheck/AfterCheck)控制父子节点选中状态,并限制顶层目录不可删除;
- 对节点进行目录优先、大小降序排序,提升浏览体验。
该方案兼顾性能与用户体验,适合处理大规模文件系统扫描场景,可作为同类工具的参考实现。