目录大小浏览器
本文介绍如何利用多线程构建一个响应式的目录大小浏览器应用程序。源代码包含C#和VB.Net版本。
引言
本文已经多次提及一个问题,即如何收集磁盘驱动器中文件和目录的磁盘空间使用情况信息。然而,我并未找到一个易于实现的合适解决方案,因此我决定为此任务制作一个小程序。
对于这个程序,我有一些要求,它需要满足:
- 直观显示累积目录空间使用情况
- 方便查找大文件
- 程序拥有提升的权限,以便访问所有文件夹
- 在浏览目录时,用户界面响应迅速且信息丰富
下面是一些程序运行结果的截图。接下来,我们将详细介绍程序是如何实际构建的。
提升的权限
选项 1: Manifest
由于我想能够搜索所有目录,所以第一步是设置项目,使其使用管理员权限。替代方案 1 是确保授予管理员权限,否则应用程序将无法运行。这可以通过向项目中添加一个清单文件来完成。只需为项目选择“添加新项...”并添加一个“应用程序清单文件”。
下一步是添加正确的信任信息要求。新创建的清单文件包含注释中的典型配置选项,所以只需选择 `requireAdministrator` 级别,如下所示:
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
完成此操作后,当您启动程序时,Visual Studio 会请求管理员权限。运行编译后的可执行文件时也会发生同样的情况。
这种方法的缺点是权限要求已硬编码到应用程序中。应用程序本身可以在没有管理员权限的情况下运行,只是无法访问所有文件夹。
选项 2: 在应用程序启动时提升权限
如果您不想使用硬编码的提升权限,可以从项目中删除清单文件。但是,如果应用程序能够控制是否提升权限,仍然会很方便。
进程的权限在进程启动后无法更改,但我们可以随时以更高的权限启动新进程。基于这个想法,启动方法会检查进程是否拥有管理员权限,如果没有,则会询问是否可以提升权限。代码如下:
[System.STAThread()]
public static void Main() {
DirectorySizes.Starter application;
DirectorySizes.DirectoryBrowser browserWindow;
System.Security.Principal.WindowsIdentity identity;
System.Security.Principal.WindowsPrincipal principal;
System.Windows.MessageBoxResult result;
System.Diagnostics.ProcessStartInfo adminProcess;
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.AppStarting;
{ // --- Alternative for using manifest for elevated privileges
// Check if admin rights are in place
identity = System.Security.Principal.WindowsIdentity.GetCurrent();
principal = new System.Security.Principal.WindowsPrincipal(identity);
// Ask for permission if not an admin
if (!principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) {
result = System.Windows.MessageBox.Show(
"Can the application run in elevated mode in order to access all files?",
"Directory size browser",
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Question);
if (result == System.Windows.MessageBoxResult.Yes) {
// Re-run the application with administrator privileges
adminProcess = new System.Diagnostics.ProcessStartInfo();
adminProcess.UseShellExecute = true;
adminProcess.WorkingDirectory = System.Environment.CurrentDirectory;
adminProcess.FileName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
adminProcess.Verb = "runas";
try {
System.Diagnostics.Process.Start(adminProcess);
// quit after starting the new process
return;
} catch (System.Exception exception) {
System.Windows.MessageBox.Show(exception.Message, "Directory size browser",
System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Exclamation);
return;
}
}
}
} // --- Alternative for using manifest for elevated privileges
application = new DirectorySizes.Starter();
browserWindow = new DirectorySizes.DirectoryBrowser();
application.Run(browserWindow);
}
<System.STAThread>
Public Shared Sub Main()
Dim application As DirectorySizes.Starter
Dim browserWindow As DirectorySizes.DirectoryBrowser
Dim identity As System.Security.Principal.WindowsIdentity
Dim principal As System.Security.Principal.WindowsPrincipal
Dim result As System.Windows.MessageBoxResult
Dim adminProcess As System.Diagnostics.ProcessStartInfo
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.AppStarting
' --- Alternative for using manifest for elevated privileges
' Check if admin rights are in place
identity = System.Security.Principal.WindowsIdentity.GetCurrent()
principal = New System.Security.Principal.WindowsPrincipal(identity)
' Ask for permission if not an admin
If (Not principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) Then
result = System.Windows.MessageBox.Show(
"Can the application run in elevated mode in order to access all files?",
"Directory size browser",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Question)
If (result = System.Windows.MessageBoxResult.Yes) Then
adminProcess = New System.Diagnostics.ProcessStartInfo()
adminProcess.UseShellExecute = True
adminProcess.WorkingDirectory = System.Environment.CurrentDirectory
adminProcess.FileName = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName
adminProcess.Verb = "runas"
Try
System.Diagnostics.Process.Start(adminProcess)
Return
Catch exception As System.Exception
System.Windows.MessageBox.Show(exception.Message, "Directory size browser",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Exclamation)
Return
End Try
End If
End If
' // --- Alternative for using manifest for elevated privileges
application = New DirectorySizes.Starter()
browserWindow = New DirectorySizes.DirectoryBrowser()
application.Run(browserWindow)
End Sub
如果用户接受提升,则会使用 `runas` 动词启动一个新进程。然后,该进程会结束,以便用户界面在新创建的进程中启动。
这种方法的缺点是进程会发生变化。如果应用程序只是简单运行,这没关系,但如果您正在调试应用程序,那么原始进程将结束,Visual Studio 调试器也将关闭。因此,为了调试具有提升权限的应用程序,您需要将调试器附加到新进程。
正因为如此,我在下载文件中保留了清单。所以,如果您想尝试这种方法,请注释掉清单文件。
程序本身
主要逻辑
该应用程序包含几个主要类。它们是:
- `DirectoryBrowser` 窗口,即用户界面。
- `DirectoryHelper`,一个包含所有信息收集逻辑的静态类。
- `DirectoryDetail` 和 `FileDetail`,这些类用于保存数据。
数据收集在一个名为 `ListFiles` 的递归方法中完成。让我们先整体看一下,然后分部分仔细研究。所以,该方法如下所示:
/// <summary>
/// Adds recursively files and directories to hashsets
/// </summary>
/// <param name="directory">Directory to gather data from</param>
/// <returns>Directory details</returns>
private static DirectoryDetail ListFiles(DirectoryDetail thisDirectoryDetail) {
DirectoryDetail subDirectoryDetail;
System.IO.FileInfo fileInfo;
// Exit if stop is requested
lock (DirectoryHelper._lockObject) {
if (DirectoryHelper._stopRequested) {
return thisDirectoryDetail;
}
}
RaiseStatusUpdate(string.Format("Analyzing {0}", DirectoryHelper.ShortenPath(thisDirectoryDetail.Path)));
//List files in this directory
try {
// Loop through child directories
foreach (string subDirectory
in System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(x => x)) {
subDirectoryDetail = ListFiles(new DirectoryDetail(subDirectory,
thisDirectoryDetail.Depth + 1,
thisDirectoryDetail));
thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize;
thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles;
thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail);
// Break if stop is requested
lock (DirectoryHelper._lockObject) {
if (DirectoryHelper._stopRequested) {
break;
}
}
}
if (!DirectoryHelper._stopRequested) {
// List files in this directory
foreach (string file
in System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*",
System.IO.SearchOption.TopDirectoryOnly)) {
fileInfo = new System.IO.FileInfo(file);
lock (DirectoryHelper._lockObject) {
FileDetails.Add(new FileDetail() {
Name = fileInfo.Name,
Path = fileInfo.DirectoryName,
Size = fileInfo.Length,
LastAccessed = fileInfo.LastAccessTime,
Extension = fileInfo.Extension,
DirectoryDetail = thisDirectoryDetail
});
}
thisDirectoryDetail.CumulativeSize += fileInfo.Length;
thisDirectoryDetail.Size += fileInfo.Length;
thisDirectoryDetail.NumberOfFiles++;
thisDirectoryDetail.CumulativeNumberOfFiles++;
DirectoryHelper.OverallFileCount++;
}
}
// add this directory to the collection
lock (DirectoryHelper._lockObject) {
DirectoryDetails.Add(thisDirectoryDetail);
}
DirectoryHelper.OverallDirectoryCount++;
DirectoryHelper.RaiseCountersChanged();
} catch (System.UnauthorizedAccessException exception) {
// Listing files in the directory not allowed so ignore this directory
lock (DirectoryHelper._lockObject) {
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
}
DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message));
} catch (System.IO.PathTooLongException exception) {
// Path is too long
lock (DirectoryHelper._lockObject) {
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
}
DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message));
}
if (thisDirectoryDetail.Depth == 1) {
if (DirectoryComplete != null) {
DirectoryComplete(null, thisDirectoryDetail);
}
}
return thisDirectoryDetail;
}
' Adds recursively files and directories to hashsets
Private Function ListFiles(thisDirectoryDetail As DirectoryDetail) As DirectoryDetail
Dim subDirectoryDetail As DirectoryDetail
Dim fileInfo As System.IO.FileInfo
' Exit if stop is requested
SyncLock (DirectoryHelper._lockObject)
If (DirectoryHelper._stopRequested) Then
Return thisDirectoryDetail
End If
End SyncLock
RaiseStatusUpdate(String.Format("Analyzing {0}", DirectoryHelper.ShortenPath(thisDirectoryDetail.Path)))
' List files in this directory
Try
' Loop through child directories
For Each subDirectory As String
In System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(Function(x) x)
subDirectoryDetail = ListFiles(New DirectoryDetail(subDirectory,
thisDirectoryDetail.Depth + 1,
thisDirectoryDetail))
thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize
thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles
thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail)
' Break if stop is requested
SyncLock (DirectoryHelper._lockObject)
If (DirectoryHelper._stopRequested) Then
Exit For
End If
End SyncLock
Next subDirectory
If (Not DirectoryHelper._stopRequested) Then
' List files in this directory
For Each file As String
In System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*",
System.IO.SearchOption.TopDirectoryOnly)
fileInfo = New System.IO.FileInfo(file)
SyncLock (DirectoryHelper._lockObject)
FileDetails.Add(New FileDetail() With {
.Name = fileInfo.Name,
.Path = fileInfo.DirectoryName,
.Size = fileInfo.Length,
.LastAccessed = fileInfo.LastAccessTime,
.Extension = fileInfo.Extension,
.DirectoryDetail = thisDirectoryDetail
})
End SyncLock
thisDirectoryDetail.CumulativeSize += fileInfo.Length
thisDirectoryDetail.Size += fileInfo.Length
thisDirectoryDetail.NumberOfFiles += 1
thisDirectoryDetail.CumulativeNumberOfFiles += 1
DirectoryHelper.OverallFileCount += 1
DirectoryHelper.RaiseCountersChanged()
Next file
End If
' add this directory to the collection
SyncLock (DirectoryHelper._lockObject)
DirectoryDetails.Add(thisDirectoryDetail)
End SyncLock
DirectoryHelper.OverallDirectoryCount += 1
DirectoryHelper.RaiseCountersChanged()
Catch exception As System.UnauthorizedAccessException
' Listing files in the directory not allowed so ignore this directory
SyncLock (DirectoryHelper._lockObject)
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
End SyncLock
DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message))
Catch exception As System.IO.PathTooLongException
' Path is too long
SyncLock (DirectoryHelper._lockObject)
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
End SyncLock
DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message))
End Try
If (thisDirectoryDetail.Depth = 1) Then
RaiseEvent DirectoryComplete(Nothing, thisDirectoryDetail)
End If
Return thisDirectoryDetail
End Function
此时,我们先跳过 `Raise...` 方法和锁定。我们稍后会回到它们。该方法的作用是接收一个目录作为参数,遍历其子目录,并为每个子目录再次调用 `ListFiles` 方法以实现递归。
// Loop through child directories
foreach (string subDirectory
in System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(x => x)) {
subDirectoryDetail = ListFiles(new DirectoryDetail(subDirectory,
thisDirectoryDetail.Depth + 1,
thisDirectoryDetail));
thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize;
thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles;
thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail);
// Break if stop is requested
lock (DirectoryHelper._lockObject) {
if (DirectoryHelper._stopRequested) {
break;
}
}
}
' Loop through child directories
For Each subDirectory As String In System.IO.Directory.EnumerateDirectories(thisDirectoryDetail.Path).OrderBy(Function(x) x)
subDirectoryDetail = ListFiles(New DirectoryDetail(subDirectory,
thisDirectoryDetail.Depth + 1,
thisDirectoryDetail))
thisDirectoryDetail.CumulativeSize += subDirectoryDetail.CumulativeSize
thisDirectoryDetail.CumulativeNumberOfFiles += subDirectoryDetail.CumulativeNumberOfFiles
thisDirectoryDetail.SubDirectoryDetails.Add(subDirectoryDetail)
' Break if stop is requested
SyncLock (DirectoryHelper._lockObject)
If (DirectoryHelper._stopRequested) Then
Exit For
End If
End SyncLock
Next subDirectory
当递归结束时,`ListFiles` 方法会返回一个 `DirectoryDetail` 对象,其中包含该目录及其子目录收集到的数据。当执行从递归返回时,当前目录的 `CumulativeSize` 和 `CumulativeNumberOfFiles` 会根据从递归返回的值进行递增。这有助于后续按大小查看目录。下一步是收集当前目录中文件的信息。
// List files in this directory
foreach (string file in System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*",
System.IO.SearchOption.TopDirectoryOnly)) {
fileInfo = new System.IO.FileInfo(file);
lock (DirectoryHelper._lockObject) {
FileDetails.Add(new FileDetail() {
Name = fileInfo.Name,
Path = fileInfo.DirectoryName,
Size = fileInfo.Length,
LastAccessed = fileInfo.LastAccessTime,
Extension = fileInfo.Extension,
DirectoryDetail = thisDirectoryDetail
});
}
thisDirectoryDetail.CumulativeSize += fileInfo.Length;
thisDirectoryDetail.Size += fileInfo.Length;
thisDirectoryDetail.NumberOfFiles++;
thisDirectoryDetail.CumulativeNumberOfFiles++;
DirectoryHelper.OverallFileCount++;
}
lock (DirectoryHelper._lockObject) {
DirectoryDetails.Add(thisDirectoryDetail);
}
DirectoryHelper.OverallDirectoryCount++;
DirectoryHelper.RaiseCountersChanged();
' List files in this directory
For Each file As String In System.IO.Directory.EnumerateFiles(thisDirectoryDetail.Path, "*.*",
System.IO.SearchOption.TopDirectoryOnly)
fileInfo = New System.IO.FileInfo(file)
SyncLock (DirectoryHelper._lockObject)
FileDetails.Add(New FileDetail() With {
.Name = fileInfo.Name,
.Path = fileInfo.DirectoryName,
.Size = fileInfo.Length,
.LastAccessed = fileInfo.LastAccessTime,
.Extension = fileInfo.Extension,
.DirectoryDetail = thisDirectoryDetail
})
End SyncLock
thisDirectoryDetail.CumulativeSize += fileInfo.Length
thisDirectoryDetail.Size += fileInfo.Length
thisDirectoryDetail.NumberOfFiles += 1
thisDirectoryDetail.CumulativeNumberOfFiles += 1
DirectoryHelper.OverallFileCount += 1
DirectoryHelper.RaiseCountersChanged()
Next file
这是一个简单的循环,用于遍历目录中的所有文件,并为找到的每个文件创建一个新的 `FileDetail` 对象。同时,目录的累积计数器也会递增。
正如您所见,在整个过程中,`DirectoryDetail` 和 `FileDetail` 对象都会被添加到单独的集合(`FileDetails` 和 `DirectoryDetails`)中。即使只需要 `DirectoryDetails` 集合(如果每个目录都包含 `FileDetails`),但当我们希望按目录列出和排序文件时,拥有一个包含所有文件的单个集合会更好。
当然,事情可能会出错,而且确实会出错。因此,这里有一个 `UnauthorizedAccessException` 的捕获块。尽管我们使用了管理员权限,但某些目录仍会导致此异常。主要是 NTFS 中的“目录连接点”。有关更多信息,请访问 NTFS 重解析点。当遇到此类目录时,它会被列入 `SkippedDirectories` 集合,然后通过事件再次显示给用户。如果引发 `PathTooLongException`,则会应用相同的处理方式。
} catch (System.UnauthorizedAccessException exception) {
// Listing files in the directory not allowed so ignore this directory
lock (DirectoryHelper._lockObject) {
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
}
DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message));
} catch (System.IO.PathTooLongException exception) {
// Path is too long
lock (DirectoryHelper._lockObject) {
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path);
}
DirectoryHelper.RaiseDirectorySkipped(string.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message));
}
Catch exception As System.UnauthorizedAccessException
' Listing files in the directory not allowed so ignore this directory
SyncLock (DirectoryHelper._lockObject)
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
End SyncLock
DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message))
Catch exception As System.IO.PathTooLongException
' Path is too long
SyncLock (DirectoryHelper._lockObject)
DirectoryHelper.SkippedDirectories.Add(thisDirectoryDetail.Path)
End SyncLock
DirectoryHelper.RaiseDirectorySkipped(String.Format("Skipped {0}, reason: {1}",
thisDirectoryDetail.Path, exception.Message))
End Try
在所有数据收集完成后,方法返回。
if (thisDirectoryDetail.Depth == 1) {
if (DirectoryComplete != null) {
DirectoryComplete(null, thisDirectoryDetail);
}
}
return thisDirectoryDetail;
If (thisDirectoryDetail.Depth = 1) Then
RaiseEvent DirectoryComplete(Nothing, thisDirectoryDetail)
End If
Return thisDirectoryDetail
根据递归的深度,填充的 `DirectoryDetail` 对象将被返回到上一级递归或原始调用者。
异步收集数据
正如开头所述,我希望即使在数据收集过程中,用户界面也能保持响应。事实上,我希望在收集完某个目录及其子目录的信息后,就能立即对其进行浏览。这意味着需要异步进行数据收集。由于这是一个 WPF 应用程序(框架 4 或以上),将使用 `Task` 对象。
`GatherData` 方法用于启动递归,它看起来像这样:
/// <summary>
/// Collects the data for a drive
/// </summary>
/// <param name="drive">Drive to investigate</param>
/// <returns>True if succesful</returns>
private static bool GatherData(System.IO.DriveInfo driveInfo) {
DirectoryHelper.RaiseGatherInProgressChanges(true);
DirectoryHelper.ListFiles(new DirectoryDetail(driveInfo.Name, 0,
driveInfo.TotalSize - driveInfo.AvailableFreeSpace));
DirectoryHelper.RaiseStatusUpdate("Calculating statistics...");
DirectoryHelper.CalculateStatistics();
DirectoryHelper.RaiseStatusUpdate("Idle");
DirectoryHelper.RaiseGatherInProgressChanges(false);
return true;
}
' Collects the data for a drive
Private Function GatherData(driveInfo As System.IO.DriveInfo) As Boolean
DirectoryHelper.RaiseGatherInProgressChanges(True)
DirectoryHelper.ListFiles(New DirectoryDetail(driveInfo.Name, 0,
driveInfo.TotalSize - driveInfo.AvailableFreeSpace))
DirectoryHelper.RaiseStatusUpdate("Calculating statistics...")
DirectoryHelper.CalculateStatistics()
DirectoryHelper.RaiseStatusUpdate("Idle")
DirectoryHelper.RaiseGatherInProgressChanges(False)
Return True
End Function
它会引发一个事件来通知搜索正在进行,然后开始递归。但有趣的部分是这个方法是如何被调用的。
/// <summary>
/// Starts the data gathering process
/// </summary>
/// <param name="drive"></param>
/// <returns></returns>
internal static bool StartDataGathering(System.IO.DriveInfo driveInfo) {
DirectoryHelper.FileDetails.Clear();
DirectoryHelper.DirectoryDetails.Clear();
DirectoryHelper.ExtensionDetails.Clear();
DirectoryHelper.SkippedDirectories.Clear();
DirectoryHelper.OverallDirectoryCount = 0;
DirectoryHelper.OverallFileCount = 0;
DirectoryHelper._stopRequested = false;
DirectoryHelper._gatherTask = new System.Threading.Tasks.Task(
() => { GatherData(driveInfo); },
System.Threading.Tasks.TaskCreationOptions.LongRunning);
DirectoryHelper._gatherTask.Start();
return true;
}
' Starts the data gathering process
Friend Function StartDataGathering(driveInfo As System.IO.DriveInfo) As Boolean
DirectoryHelper.FileDetails.Clear()
DirectoryHelper.DirectoryDetails.Clear()
DirectoryHelper.ExtensionDetails.Clear()
DirectoryHelper.SkippedDirectories.Clear()
DirectoryHelper.OverallDirectoryCount = 0
DirectoryHelper.OverallFileCount = 0
DirectoryHelper._stopRequested = False
DirectoryHelper._gatherTask = New System.Threading.Tasks.Task(
Function() GatherData(driveInfo),
System.Threading.Tasks.TaskCreationOptions.LongRunning)
DirectoryHelper._gatherTask.Start()
Return True
End Function
方法的开头只是为了清除变量,以防这不是第一次运行。`Task` 构造函数用于指定要作为此任务执行的代码。在本例中,调用了 `GatherData` 方法。同时,任务被告知代码将是长时间运行的,因此没有必要进行细粒度的调度。
当调用 `Start` 方法时,`DataGather` 方法将在其自己的独立线程中开始执行,而此方法将继续在 UI 线程中运行。现在我们有两个不同的线程在工作,一个运行 UI,另一个收集目录数据。
从另一个线程获取信息
现在,当另一个线程在工作并取得进展时,我们当然希望知道事情确实在发生。我决定通过告知用户当前正在调查的目录以及到目前为止已找到的文件或文件夹数量来通知用户。此信息通过事件发送到用户界面,所以这里没有什么特别之处。该类包含一些静态事件,例如:
/// <summary>
/// Event used to send information about the gather process
/// </summary>
internal static event System.EventHandler<string> StatusUpdate;
/// <summary>
/// Event used to inform that the overall statistics have been changed
/// </summary>
internal static event System.EventHandler CountersChanged;
' Event used to send information about the gather process
Friend Event StatusUpdate As System.EventHandler(Of String)
' Event used to inform that the overall counters have been changed
Friend Event CountersChanged As System.EventHandler
当创建窗口时,它会以正常方式连接这些事件。
// Wire the events
DirectoryHelper.StatusUpdate += DirectoryHelper_StatusUpdate;
DirectoryHelper.CountersChanged += DirectoryHelper_CountersChanged;
' Wire the events
AddHandler DirectoryHelper.StatusUpdate, AddressOf DirectoryHelper_StatusUpdate
AddHandler DirectoryHelper.CountersChanged, AddressOf DirectoryHelper_CountersChanged
然而,如果这些事件试图直接更新状态项以包含作为参数传递的目录名称,或修改任何其他用户界面对象,则会引发 `InvalidOperationException`。
An exception of type 'System.InvalidOperationException' occurred in WindowsBase.dll but was not handled in user code
Additional information: The calling thread cannot access this object because a different thread owns it.
请记住,UI 运行的线程与发送事件的数据收集器是不同的。为了更新窗口,我们需要将上下文切换回 UI 线程。这可以通过 UI 线程创建的调度器,并调用 `BeginInvoke` 方法来执行一段执行 UI 操作的代码来实现。
因此,状态更新非常简单,如下所示:
/// <summary>
/// Updates the status bar
/// </summary>
/// <param name="state"></param>
private void UpdateStatus(string state) {
this.Status.Content = state;
}
' Updates the status bar
Private Sub UpdateStatus(state As String)
Me.Status.Content = state
End Sub
而事件处理程序中执行 `UpdateStatus` 方法的调用如下所示:
private void DirectoryHelper_StatusUpdate(object sender, string e) {
this.Status.Dispatcher.BeginInvoke((System.Action)(() => { UpdateStatus(e); }));
}
Private Sub DirectoryHelper_StatusUpdate(sender As Object, e As String)
Me.Status.Dispatcher.BeginInvoke(Sub() UpdateStatus(e))
End Sub
这需要一些解释。`BeginInvoke` 的此重载是通过 `Action` 委托或 VB 中的 Sub 来调用的。这个匿名委托会执行 `UpdateStatus` 方法。所以上面的代码中没有预定义的委托。另一种选择是定义一个委托,例如:
private delegate void UpdateGatherCountersDelegate(bool forceUpdate);
Private Delegate Sub UpdateGatherCountersDelegate(forceUpdate As Boolean)
然后定义具有与委托定义相同方法签名的实际方法:
/// <summary>
/// Updates the counters
/// </summary>
void UpdateGatherCounters(bool forceUpdate) {
if (forceUpdate || System.DateTime.Now.Subtract(this._lastCountersUpdate).TotalMilliseconds > 500) {
this.CountInfo.Content = string.Format("{0} directories, {1} files",
DirectoryHelper.OverallDirectoryCount.ToString("N0"),
DirectoryHelper.OverallFileCount.ToString("N0"));
this._lastCountersUpdate = System.DateTime.Now;
}
}
' Updates the counters
Sub UpdateGatherCounters(forceUpdate As Boolean)
If (forceUpdate Or System.DateTime.Now.Subtract(Me._lastCountersUpdate).TotalMilliseconds > 500) Then
Me.CountInfo.Content = String.Format("{0} directories, {1} files",
DirectoryHelper.OverallDirectoryCount.ToString("N0"),
DirectoryHelper.OverallFileCount.ToString("N0"))
Me._lastCountersUpdate = System.DateTime.Now
End If
End Sub
然后使用预定义的委托调用 `BeginInvoke`,如下所示:
void DirectoryHelper_CountersChanged(object sender, System.EventArgs e) {
this.CountInfo.Dispatcher.BeginInvoke(new UpdateGatherCountersDelegate(UpdateGatherCounters), false);
}
Private Sub DirectoryHelper_StatusUpdate(sender As Object, e As String)
Me.Status.Dispatcher.BeginInvoke(Sub() UpdateStatus(e))
End Sub
在数据收集过程中访问集合
好的,现在我们可以在数据收集过程中更新 UI。其中一个事件 `DirectoryComplete` 在根文件夹的数据收集完成后触发。在此事件中,该文件夹被添加到目录列表中,用户可以开始浏览它。用户可以展开目录并查看子目录。用户还可以选择目录并查看该特定文件夹中的文件列表以及该路径下 100 个最大的文件。
目录使用 `AddRootNode` 方法添加:
/// <summary>
/// Used to add root folders
/// </summary>
/// <param name="directoryDetail">Directory to add</param>
private void AddRootNode(DirectoryDetail directoryDetail) {
AddDirectoryNode(this.DirectoryTree.Items, directoryDetail);
}
' Used to add root folders
Private Sub AddRootNode(directoryDetail As DirectoryDetail)
AddDirectoryNode(Me.DirectoryTree.Items, directoryDetail)
End Sub
此方法简单地调用一个通用方法来添加目录节点,因为当目录节点展开时也会使用相同的方法。所以,添加一个节点看起来像这样:
/// <summary>
/// Adds a directory node to the specified items collection
/// </summary>
/// <param name="parentItemCollection">Items collection of the parent directory</param>
/// <param name="directoryDetail">Directory to add</param>
/// <returns>True if succesful</returns>
private bool AddDirectoryNode(System.Windows.Controls.ItemCollection parentItemCollection,
DirectoryDetail directoryDetail) {
System.Windows.Controls.TreeViewItem treeViewItem;
System.Windows.Controls.StackPanel stackPanel;
// Create the stackpanel and it's content
stackPanel = new System.Windows.Controls.StackPanel();
stackPanel.Orientation = System.Windows.Controls.Orientation.Horizontal;
// Content
stackPanel.Children.Add(
this.CreateProgressBar("Cumulative percentage from total used space {0}% ({1}))",
directoryDetail.CumulativeSizePercentage,
directoryDetail.FormattedCumulativeBytes));
stackPanel.Children.Add(new System.Windows.Controls.TextBlock() {
Text = directoryDetail.DirectoryName });
// Create the treeview item
treeViewItem = new System.Windows.Controls.TreeViewItem();
treeViewItem.Tag = directoryDetail;
treeViewItem.Header = stackPanel;
treeViewItem.Expanded += tvi_Expanded;
// If this directory contains subdirectories, add a placeholder
if (directoryDetail.SubDirectoryDetails.Count() > 0) {
treeViewItem.Items.Add(new System.Windows.Controls.TreeViewItem() { Name = "placeholder" });
}
// Add the treeview item into the items collection
parentItemCollection.Add(treeViewItem);
return true;
}
' Adds a directory node to the specified items collection
Private Function AddDirectoryNode(parentItemCollection As System.Windows.Controls.ItemCollection, directoryDetail As DirectoryDetail) As Boolean
Dim treeViewItem As System.Windows.Controls.TreeViewItem
Dim stackPanel As System.Windows.Controls.StackPanel
' Create the stackpanel and it's content
stackPanel = New System.Windows.Controls.StackPanel()
stackPanel.Orientation = System.Windows.Controls.Orientation.Horizontal
' Content
stackPanel.Children.Add(Me.CreateProgressBar("Cumulative percentage from total used space {0}% ({1}))",
directoryDetail.CumulativeSizePercentage,
directoryDetail.FormattedCumulativeBytes))
stackPanel.Children.Add(New System.Windows.Controls.TextBlock() With {
.Text = directoryDetail.DirectoryName})
' Create the treeview item
treeViewItem = New System.Windows.Controls.TreeViewItem()
treeViewItem.Tag = directoryDetail
treeViewItem.Header = stackPanel
AddHandler treeViewItem.Expanded, AddressOf tvi_Expanded
' If this directory contains subdirectories, add a placeholder
If (directoryDetail.SubDirectoryDetails.Count() > 0) Then
treeViewItem.Items.Add(New System.Windows.Controls.TreeViewItem() With {.Name = "placeholder"})
End If
' Add the treeview item into the items collection
parentItemCollection.Add(treeViewItem)
Return True
End Function
其思想是,每个目录节点都会在 `ProgressBar` 中显示累积的磁盘空间使用百分比以及目录名称。这些控件放置在 `StackPanel` 中,然后该 `StackPanel` 又被设置为 `TreeViewItem` 的标题。
到目前为止一切顺利,但如果选择了 `TreeViewItem`,程序会像这样列出目录的内容:
/// <summary>
/// Populates the file list for a directory in descending order based on the file sizes
/// </summary>
/// <param name="tvi">Directory to populate files for</param>
private void ListDirectoryFiles(System.Windows.Controls.TreeViewItem tvi) {
DirectoryDetail directoryDetail;
this.FileList.ItemsSource = null;
this.Top100FileList.ItemsSource = null;
if (tvi != null) {
directoryDetail = (DirectoryDetail)tvi.Tag;
this.FileList.ItemsSource = DirectoryHelper.FilesInDirectory(directoryDetail);
this.Top100FileList.ItemsSource = DirectoryHelper.BiggestFilesInPath(directoryDetail, 100);
}
}
' Populates the file list for a directory in descending order based on the file sizes
Private Sub ListDirectoryFiles(tvi As System.Windows.Controls.TreeViewItem)
Dim directoryDetail As DirectoryDetail
Me.FileList.ItemsSource = Nothing
Me.Top100FileList.ItemsSource = Nothing
If (Not tvi Is Nothing) Then
directoryDetail = CType(tvi.Tag, DirectoryDetail)
Me.FileList.ItemsSource = DirectoryHelper.FilesInDirectory(directoryDetail)
Me.Top100FileList.ItemsSource = DirectoryHelper.BiggestFilesInPath(directoryDetail, 100)
End If
End Sub
文件列表在以下代码中获取:
/// <summary>
/// Lists all the files in a directory sorted by size in descending order
/// </summary>
/// <param name="directoryDetail"></param>
/// <returns></returns>
internal static System.Collections.Generic.List<FileDetail> FilesInDirectory(
DirectoryDetail directoryDetail) {
System.Collections.Generic.List<FileDetail> fileList;
lock (DirectoryHelper._lockObject) {
fileList = DirectoryHelper.FileDetails
.Where(x => x.DirectoryDetail == directoryDetail)
.OrderByDescending(x => x.Size).ToList();
}
return fileList;
}
' Lists all the files in a directory sorted by size in descending order
Friend Function FilesInDirectory(directoryDetail As DirectoryDetail) As System.Collections.Generic.List(Of FileDetail)
Dim fileList As System.Collections.Generic.List(Of FileDetail)
SyncLock (DirectoryHelper._lockObject)
fileList = DirectoryHelper.FileDetails.Where(Function(x) x.DirectoryDetail Is directoryDetail)
.OrderByDescending(Function(x) x.Size).ToList()
End SyncLock
Return fileList
End Function
让我们仔细看看。如果注释掉 `lock` 语句,并且在数据收集过程中同时填充列表,您很有可能遇到以下错误:
An unhandled exception of type 'System.InvalidOperationException' occurred in System.Core.dll
Additional information: Collection was modified; enumeration operation may not execute.
此时发生的情况是,当我们尝试填充文件列表时,另一个线程同时将数据添加到同一个集合中。因此,由于集合的内容已更改,枚举失败。
为了防止这种情况发生,我们需要一种机制,一次只允许一个线程枚举或修改集合。这意味着需要锁定。
`lock` 语句确保一次只有一个线程可以进入代码的临界区。如果另一个线程试图锁定同一个对象,它将必须等待直到锁被释放。在文章开头,我们看到了一个新 `DirectoryDetail` 如何被添加到集合中,如下所示:
lock (DirectoryHelper._lockObject) {
DirectoryDetails.Add(thisDirectoryDetail);
}
SyncLock (DirectoryHelper._lockObject)
DirectoryDetails.Add(thisDirectoryDetail)
End SyncLock
现在,当我们读取集合时,我们尝试锁定同一个 `_lockObject`。这确保了枚举和修改不会同时发生。
然而,这并非全部。您可能想知道为什么枚举代码的末尾有一个 `ToList()` 方法调用。如果代码只为调用者返回一个 `IOrderedEnumerable
换句话说,当这个集合返回到 WPF 控件时,它会尝试枚举集合,并且会遇到完全相同的问题:“集合已修改;枚举操作可能无法执行”。因此,代码会在锁的范围内创建一个副本(列表),之后就可以安全地返回列表并让 WPF 控件循环遍历集合。
停止执行
最后一个细节是如何在所有目录都检查完毕之前停止数据收集过程。这同样涉及锁定。有几个受控的位置可以在需要时停止执行。例如,在进行目录检查之前或在子目录处理之后。
代码通过一个静态变量来检查是否请求了停止,如下所示:
// Break if stop is requested
lock (DirectoryHelper._lockObject) {
if (DirectoryHelper._stopRequested) {
break;
}
}
' Break if stop is requested
SyncLock (DirectoryHelper._lockObject)
If (DirectoryHelper._stopRequested) Then
Exit For
End If
End SyncLock
如果在数据收集期间按下了停止按钮,`_stopRequest` 会在 `StopDataGathering` 方法中被更改:
/// <summary>
/// Stops the data gathering process
/// </summary>
/// <param name="drive"></param>
/// <returns></returns>
internal static bool StopDataGathering() {
if (DirectoryHelper._gatherTask.Status != System.Threading.Tasks.TaskStatus.Running) {
return true;
}
lock (DirectoryHelper._lockObject) {
DirectoryHelper._stopRequested = true;
}
DirectoryHelper._gatherTask.Wait();
return true;
}
' Stops the data gathering process
Friend Function StopDataGathering() As Boolean
If (DirectoryHelper._gatherTask.Status <> System.Threading.Tasks.TaskStatus.Running) Then
Return True
End If
SyncLock (DirectoryHelper._lockObject)
DirectoryHelper._stopRequested = True
End SyncLock
DirectoryHelper._gatherTask.Wait()
Return True
End Function
在更改请求状态后,UI 线程会调用该任务的 `Wait` 方法,以便 UI 线程停止,直到数据收集线程完成。
以上涵盖了程序的主要部分。如果您想深入研究,项目中还有很多细节,但这应该能让您对应用程序的构建有一个大概的了解。
哦,对了,如果您在任何文件列表中双击一个文件,它应该会为您打开包含该文件的文件夹。
希望您觉得这很有用,当然,非常欢迎所有评论和建议。
参考文献
以下是一些在阅读代码时可能非常有用的参考资料:
历史
- 2015年2月10日:创建文章。
- 2015年2月13日
- 添加了对 PathTooLongException 的捕获,并重新格式化了跳过的目录信息。
- 增加了浏览映射网络驱动器的能力。
- 在大型目录的情况下,增加了更频繁的计数器更改。
- 2015年2月16日:添加了提升权限的替代选项。
- 2015年2月17日:添加了 VB 版本。