CDiskClean 开发历程 —— 文件清理_目录树解析实现方案参考

前言

这篇博客,我打算结合我最近正在开发的 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盘管理工具的文件清理模块为背景,提供了一套高效、可取消、防卡顿的目录树解析方案。核心思路是:

  1. 异步递归扫描 ,一次性收集目录、文件及大小信息,并利用 CancellationTokenSource 实现联动取消;
  2. 后台构建 TreeNode,避免 UI 线程阻塞,完成后一次性挂载到 TreeView;
  3. 事件联动(BeforeCheck/AfterCheck)控制父子节点选中状态,并限制顶层目录不可删除;
  4. 对节点进行目录优先、大小降序排序,提升浏览体验。

该方案兼顾性能与用户体验,适合处理大规模文件系统扫描场景,可作为同类工具的参考实现。

相关推荐
离陌在学C#1 小时前
C# 泛型:从基础到高级应用
开发语言·c#
Vertira2 小时前
C# 与 OpencvSharp 编程[基础目录]
c#
格林威4 小时前
多相机并行采图最佳实践:Task.WhenAll + 异常处理 + 资源释放
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·机器视觉
yuhulkjv33512 小时前
Claude表格复制到word不再崩溃,AI导出鸭批量导出+格式无损一键搞定
人工智能·ai·c#·word·ai导出鸭
arbboter17 小时前
【网络工具】NetProxy网络代理用户手册
开发语言·网络·c#·网络代理·网络异常·代理工具
微小冷17 小时前
海康威视相机C#二次开发环境配置
数码相机·c#·.net·海康威视·相机开发
吴可可1231 天前
C#重写机制与CAD图元开发要点
c#
A_nanda1 天前
c#WPF开发常见问题
开发语言·c#·wpf
唐青枫1 天前
别只会解析参数:C#.NET Spectre.Console.Cli 命令行工具实战详解
c#·.net