WPF 中的文件系统控件(第三版)






4.95/5 (20投票s)
WPF 软件设计的经验教训。
来源 | 二进制文件 | |
---|---|---|
![]() |
目录
引言
本文是之前一篇关于 [1] 开发 WPF 文件系统控件的文章的续篇。自 2014 年以来,很多事情都发生了变化,因此我想更新这个项目,并分享一些非常有趣的经验,内容包括
- WPF 软件设计应用,
- 任务库和进程协调,
- 以及许多其他有趣的亮点。
背景
我的开源编辑器 Edi 的开发仍在进行中,当前重新设计的一部分是进行彻底重构,以确保每个控件都可以独立使用或与其他控件协同使用。此外,生成的控件应该能够使用非特定 WPF 主题库(例如:MLib、MahApps.Metro、MUI)进行主题设置。
概述
本文讨论的项目实现了一组控件,它们可以组合起来实现类似于 Windows Explorer 应用程序的功能。
上述每个控件都在单独的项目中实现,因此可以通过单独的 NuGet 包获取。
- HistoryControlLib
- FolderControlsLib 后端 和 UI
- FilterControlsLib
- FolderBrowser
- FileListView
这组控件真正酷的地方在于它的软件设计。这种设计允许我们在相似但不同的上下文中重用这些组件,而无需更改现有功能。该声明的一个例子是 FolderBrowser 组件及其重用。
上面的对话框重用了前面资源管理器窗口示例中的 FolderBrowser 和 刷新/书签下拉 控件。
FolderBrowser 控件有深色、浅色或通用主题可用。它的设计使其可以与不同的主题库一起使用。但这也可以在不同的使用场景中获得。
(更多详情请参阅链接和附加的演示项目)。
这种灵活的重用之所以能够实现,是因为 FolderBrowserLib 在每个视图项中都进行了视觉解耦。这意味着 FolderBrowserLib 提供了 多个视图 来实现文件夹选择器控件。然后,我们可以发挥我们的想象力,并利用 WPF 的灵活性将这些项放置到我们选择的容器中 :-)
当然,上面展示的控件的软件设计符合 MVVM 规范,这意味着将其集成到其他符合 MVVM 规范的 WPF 项目中是轻而易举的事情。
现在,我们已经了解了这些控件,也知道它们可以灵活使用,是时候揭开帷幕,看看幕后技术上是如何运作的了。下一节将更详细地记录这方面的内容。
技术概述
通用资源管理器
下方(也称为 UML 部署图)通用资源管理器 演示应用程序的依赖关系图提供了一个很好的切入点,可以自上而下地探索解决方案。通用资源管理器演示应用程序显示在左侧,带有标有 Explorer 的符号。该符号显示了应用程序的主要入口点,在我们的案例中,它是同名的可执行 WPF 应用程序项目。
下面的图不包括对项目或组件的引用,例如 log4net、DropDownButtonLib、InplaceEditBoxLib、UserNotification 或 .Net 标准库,例如 mscorlib。不显示这些组件的原因是它只会产生噪音,对本文的目的没有真正的帮助。
在上面的部署图中,我们可以看到 Explorer 可执行文件(除了省略的引用)只有 2 个感兴趣的引用,它是 ExplorerLib dll 项目,它包含对 File System Controls (FSC) 的所有引用,这是本文的主题。
现在,查看 Explorer 项目内部,我们可以看到它只包含 MainWindow
定义,甚至 MainWindow.xaml
也只包含一个 TreeListItemView
用户控件,该控件在 ExplorerLib dll 项目中实现(启动和关闭代码请参见 MainWindow.xaml.cs
)。
ExplorerLib dll 项目包含在 MainWindow.xaml.cs
代码中启动并通过绑定驱动 MainWindow 的 ApplicationViewModel
。主窗口只包含 TreeListItemView
,它绑定到
public ITreeListControllerViewModel FolderTreeView { get; }
ApplicationViewModel
中的属性。ITreeListControllerViewModel
接口在内部类 TreeListControllerViewModel
中实现。所以,实际上是 ApplicationViewModel
中的 TreeListControllerViewModel
驱动着 Explorer 应用程序的 UI。 Explorer 示例应用程序包含大约 6 个控件,它们都绑定到 FolderTreeView
属性的属性。
// Binds to HistoryControl
IBrowseHistory<IPathModel> NaviHistory { get; }
// Binds to FolderBrowser
IBrowserViewModel TreeBrowser { get; }
// Binds to FolderControl
IFolderComboBoxViewModel FolderTextPath { get; }
// Binds to Refresh/Bookmark DropDownButton
IBookmarksViewModel RecentFolders { get; }
// Binds to Filter Control
IFilterComboBoxViewModel Filters { get; }
// Binds to FileListView
IFileListViewModel FolderItemsView { get; }
上面显示的每个属性都追踪到前一个依赖关系图中显示的每个 FSC 项目(FolderBrowser、FileListView 等)。查看这些项目,我们可以看到这些接口后面也存在视图模型,它们控制着每个控件的生命周期。
总而言之,每个 FSC 项目都包含一个视图定义和一个匹配的视图模型,这些都在 MainWindow
和 ApplicationViewModel
代码的子系统中实例化并绑定。
那么,几个值得问的好问题是:
我们将在下一节回答第一个问题,并在下面的后续部分讨论同步问题。
主题资源管理器
本节解释了主题资源管理器中 WPF 主题的工作原理,并将其解决方案与没有引用主题库的通用资源管理器版本进行了对比。我们考虑下方主题资源管理器示例应用程序的依赖关系图:
我们可以看到,Themed Explorer 示例应用程序的软件设计与之前讨论的通用资源管理器非常相似。唯一增加的部分,显示在左上方,是 MLib 项目库、ServiceLocator 以及 Settings 和 SettingsModel 项目。
ServiceLocator 和 Settings 以及 SettingsModel 项目包含我通常用于快速构建可主题化应用程序的标准示例代码。ServiceLocator 来自 Josh Smith 的文章 [3],其主要目的是替换更复杂的容器,如 PRISM、WindsorCastle 或 MVVM Light。Settings 项目包含 应用程序设置,例如,如果尚未定义主题,是默认首选浅色主题还是深色主题。
但本文中更值得关注的是 MLib、MWindowLib 和 MWindowInterfaceLib。这些项目包含了此解决方案中的主要主题定义。MLib 项目包含了 WPF 标准控件(如 ListBox、TreeView 等)的标准主题定义。只需在给定应用程序中加载 MLib/Themes/DarkTheme.xaml
或 MLib/Themes/LightTheme.xaml
文件,即可加载深色或浅色主题。
MLib 主题库不仅支持深色和浅色等主题,还通过使用操作系统中定义的 强调色 来与 Windows 10 指南保持一致。这个强调色在主题资源管理器演示应用程序启动时确定。
namespace Explorer.ViewModels
{
public class ThemeViewModel : Base.ModelBase
{
...
public static Color GetCurrentAccentColor(ISettingsManager settings)
{
Color AccentColor = default(Color);
if (settings.Options.GetOptionValue<bool>("Appearance", "ApplyWindowsDefaultAccent"))
{
try
{
AccentColor = SystemParameters.WindowGlassColor;
}
catch
{
}
// This may be black on Windows 7 and the experience is black & white then :-(
if (AccentColor == default(Color) || AccentColor == Colors.Black || AccentColor.A == 0)
{
// default blue accent color
AccentColor = Color.FromRgb(0x1b, 0xa1, 0xe2);
}
}
else
AccentColor = settings.Options.GetOptionValue<Color>("Appearance", "AccentColor");
return AccentColor;
}
}
}
... 然后将其提供给 MLib 的主题管理器,以便在应用程序启动时正确初始化主题。
namespace Explorer
{
public partial class App : Application
{
...
private void Application_Startup(object sender, StartupEventArgs e)
{
...
var appearance = GetService<IAppearanceManager>();
...
appearance.SetTheme(settings.Themes
, settings.Options.GetOptionValue<string>("Appearance", "ThemeDisplayName")
, ThemeViewModel.GetCurrentAccentColor(settings));
...
}
扩展点
开发控件时面临的一个主要挑战是让它们看起来都一致。如果控件没有引用主题库,这个挑战就更难了。对于所有控件使用一种强调色来说是如此,但也可以适用于其他常见主题项,如字形或自定义颜色等。当然,如果给定控件支持,可以在任何 WPF 应用程序中完全重新模板化给定控件。但是 Themed Explorer 示例应用程序中的 BindToMLib 项目也展示了一种更简单的方法,可以使主题看起来一致。
BindToMLib 项目包含绑定到 MLib 库中键的资源键绑定,并同步目标程序集(例如:HistoryControlLib)中的特定键。
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:options="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
xmlns:reskeys="clr-namespace:WatermarkControlsLib.Themes;assembly=WatermarkControlsLib"
xmlns:MLib_reskeys="clr-namespace:MLib.Themes;assembly=MLib">
...
<SolidColorBrush x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type reskeys:ResourceKeys},
ResourceId=ControlAccentBrushKey}"
Color="{DynamicResource {x:Static MLib_reskeys:ResourceKeys.ControlAccentColorKey}}"
options:Freeze="true" />
...
</ResourceDictionary>
...这就是 WPF 画刷(以及其他可主题化项目)如何在主题库和所有其他组件之间同步的方式。这种 BindToMLib 方法在技术上很酷,因为它不需要我们构建额外的接口(资源键本身就是接口),并且控件不需要特定主题库的显式知识(!)因为 BindToMLib 项目充当了一个扩展点,可以在需要自定义同步项目时随时使用。
MLib 中的 AppearanceManager
也支持 AccentColorChanged
事件,如果需要在后台代码中同步。但我发现我实际上不需要它,而且使用 BindToMLib 似乎更加灵活,因为 Component Resource Keys 的使用会导致(期望的)编译时错误,如果将来任何时候资源键发生变化。
仔细阅读 可主题化资源管理器 依赖图的读者可能会想知道为什么 BindToMLib 引用了 HistoryControlLib,但没有 BindToMLib 引用下面的其他 FSC 控件(?)。当然,这些控件使用了强调色,而且它们也确实同步了,但是如何同步的呢?
解释出奇的简单。MLib 库定义了常见控件(如 TreeView 和 ListView 等)的控件模板和样式。这些定义与当前的强调色(由 Appearance Manager 管理)保持同步,并在全局应用程序级别应用。这意味着,只有当存在具有 MLib 中未包含的标准 WPF 控件主题要求(如历史记录控件)的特殊用途自定义控件时,才需要 BindToMLib 方法。
使用模态对话框为 WPF 设置主题
在主题和架构方面,一个重要点是不仅要考虑传统的后台代码方法(如事件和属性),还要考虑 WPF 所能提供的功能。传统方法也可能适用,正如我们在 ExplorerTest 和 ExplorerTestMLib 项目中的模态浏览器对话框中看到的那样。这两个项目在窗口中间右侧的配置部分都有一个小加号按钮 “+”。这个小加号按钮 “+” 在通用测试应用程序中打开一个通用的模态 FolderBrowser 对话框,并且(你猜对了)在主题演示应用程序中打开一个带有主题的模态 FolderBrowser 对话框。
FolderBrowser 库项目没有引用 MLib。它如何用于打开模态对话框?
解决这个问题的传统解决方案(控制反转)是,FolderBrowser 库被设计为接受一个模态对话框实例(无论是否带主题),以便在运行时构建完整的视图(对话框 + 内容 + 视图模型)。我们可以通过查看 ExplorerTestMLib 项目并审阅 ExplorerTestMLib.Demos.ViewModels.DemoViewModel.cs
文件来验证这一点。
protected override Window CreateFolderBrowserDialog()
{
return new ExplorerTestMLib.Demos.Views.FolderBrowserDialog();
}
这个小方法返回一个模态主题对话框实例。该实例用于 ExplorerTestLib.ViewModels.ApplicationViewModel:
中的基类方法。
private void AddRecentFolder_Executed(object p)
{
string path;
IListControllerViewModel vm;
this.ResolveParameterList(p as List<object>, out path, out vm);
if (vm == null)
return;
var browser = FolderBrowserFactory.CreateBrowserViewModel();
path = (string.IsNullOrEmpty(path) == true ? PathFactory.SysDefault.Path : path);
browser.InitialPath = path;
var dlg = CreateFolderBrowserDialog();
var dlgViewModel = FolderBrowserFactory.CreateDialogViewModel(
browser, vm.RecentFolders.CloneBookmark());
dlg.DataContext = dlgViewModel;
bool? bResult = dlg.ShowDialog();
if (dlgViewModel.DialogCloseResult == true || bResult == true)
{
vm.CloneBookmarks(dlgViewModel.BookmarkedLocations, vm.RecentFolders);
vm.AddRecentFolder(dlgViewModel.TreeBrowser.SelectedFolder, true);
}
}
因此,在通用情况下,这会实例化 ExplorerTestLib.ViewModels.ApplicationViewModel
中定义的通用对话框。而带主题的对话框则通过在继承的 ExplorerTestMLib.Demos.ViewModels.Demos
类中重写来实例化。
本节探讨了开发支持通用应用程序(无特定主题)或非特定主题库的 WPF 控件的一些基本技术。我们已经看到,在不引用特定主题库的情况下开发控件会带来自身的挑战,这些挑战可以通过使用标准技术(如控制反转(参见模态对话框讨论))或资源键绑定来解决。虽然关于 WPF 还有很多可以讨论的,但我们现在将转换思路,以任务库协调 FSC 控件为例进行讨论。
处理浏览器请求以同步视图
前面展示的依赖关系图表明,所有 FSC 控件都可以灵活应用,因为例如 FolderBrowser 控件和 FileListView 控件之间没有依赖关系。但是,没有依赖关系引发了一个问题:这些控件如何同步以显示文件系统的一致信息?
这个问题的答案在于 FileSystemModels.Browse.ICanNavigate
和 FileSystemModels.Browse.INavigateable
接口的实现。ICanNavigate
接口应该由一个能够浏览到文件系统位置的控件实现,并且能够指示它现在已经到达不同的位置(如果用户通过在控件本身中单击请求此更改)。接口定义如下:
public interface ICanNavigate
{
event EventHandler<BrowsingEventArgs> BrowseEvent;
bool IsBrowsing { get; }
}
当控件正在验证某个位置并从文件系统检索数据显示数据时,IsBrowsing
属性设置为 true。此过程可能需要一段时间,因此,如果视图可以绑定到此属性并显示忙碌指示器,则可能很有用。
另一方面,BrowseEvent
不仅模拟当前的浏览状态,还指示状态变化。它告诉监听者:
- 当控件开始查看另一个位置时,
- 该位置是什么,
- 何时完成此过程,
- 以及它是否成功
这些事件通过 BrowsingEventArgs
类和 BrowseResult
枚举表示。
public class BrowsingEventArgs : EventArgs
{
public IPathModel Location { get; private set; }
public bool IsBrowsing { get; private set; }
public BrowseResult Result { get; private set; }
}
public enum BrowseResult
{
Unknown = 0,
Complete = 1,
InComplete = 2
}
如果从控件的角度来看,ICanNavigate
接口是一个输出接口。另一方面,INavigateable
接口是一个输入接口,因为它用于告诉控件:“请在位置 x 显示数据。”。
public interface INavigateable : ICanNavigate
{
FinalBrowseResult NavigateTo(BrowseRequest newPath);
Task<FinalBrowseResult> NavigateToAsync(BrowseRequest newPath);
void SetExternalBrowsingState(bool isBrowsing);
}
我的经验是,可导航的控件也能够(通过用户输入)自行更改其位置,这就是为什么 INavigateable
继承自 ICanNavigate
似乎很合理。上面显示的前两种方法提供了一种请求控件更改其位置的方式。第二种方法可以用于告诉控件另一个控件当前正在更改其位置——因此该控件现在应该忽略用户请求。
HistoryControl 是一个实现 ICanNavigate
但未实现 INavigateable
的控件示例,这就是为什么需要拆分这些接口的原因。
两个接口 ICanNavigate
和 INavigateable
都用于
TreeListControllerViewModel
类和ListControllerViewModel
类
它们在每个演示应用程序中实例化,用于控制 FSC 控件。让我们以 ExplorerTest_FolderBrowserDemo.zip 解决方案中的 ExplorerTestLib 项目为例,理解这些细节。每个提到的控制器类的构造函数都将 Control_BrowseEvent
方法注册到每个控件的 ICanNavigate.BrowseEvent
。因此,每当控件更改位置时,如果该更改请求不是由控制器初始化的,它就会触发一个事件来执行此方法:
void Control_BrowseEvent(
object sender,
FileSystemModels.Browse.BrowsingEventArgs e)
Control_BrowseEvent
方法有两个主要线程,一个用于控件自行更改位置且更改尚未完成的情况。控制器必须告知其他控件正在发生的事情,并等待最终结果事件。
if (TreeBrowser != sender)
TreeBrowser.SetExternalBrowsingState(true);
if (FolderTextPath != sender)
FolderTextPath.SetExternalBrowsingState(true);
if (FolderItemsView != sender)
FolderItemsView.SetExternalBrowsingState(true);
Control_BrowseEvent
方法中的另一个主要线程在控件指示已成功更改到新位置时执行。
var timeout = TimeSpan.FromSeconds(5);
var actualTask = new Task(() =>
{
var request = new BrowseRequest(location, _CancelTokenSourc.Token);
var t = Task.Factory.StartNew(() => NavigateToFolderAsync(request, sender),
request.CancelTok,
TaskCreationOptions.LongRunning,
_OneTaskScheduler);
if (t.Wait(timeout) == true)
return;
_CancelTokenSourc.Cancel(); // Task timed out so lets abort it
return; // Signal timeout here...
});
actualTask.Start();
actualTask.Wait();
当一个控件指示成功更改到新位置时,就可以请求所有其他控件与该位置同步,这在 NavigateToFolderAsync
方法中实现。此方法调用每个需要同步的控件的 NavigateTo
/NavigateToAsync
方法。它也用于在应用程序启动时初始化控制器和所有控件。
上面的代码示例展示了我们如何使用任务以确定性方式等待另一个类的结束。它支持设置为 5 秒的超时/取消选项。有两项安全措施确保没有任务重复运行,甚至可能在此过程中导致磁盘抖动。它们是:
NavigateToFolderAsync
方法中的SemaphoreSlim
和- 每个控制器类中的
OneTaskLimitedScheduler _OneTaskScheduler
。
OneTaskLimitedScheduler _OneTaskScheduler
,基于 Stephen Toub 的博客文章 [5],将所有任务排队并强制按顺序执行,而 SemaphoreSlim
会阻塞一个线程,直到前一个线程退出临界区。这些措施共同确保 UI 显示的一致性,同时保持响应。
下图总结了本节讨论的任务协调。它为我们提供了一个鸟瞰图,展示了左侧的 FolderBrowser 控件如何导航到新位置(例如,用户打开了一个扩展器并选择了其下的一个新项目)。这个新位置会消息给中间的控制器,控制器反过来请求所有其他控件也导航到该新位置。这个主要工作流在 99% 的时间内都会改变显示。有一些次要工作流需要所有控件导航到“新位置”(在应用程序启动或刷新时),但这些工作流使用通过上述相同方法实现的相同机制。
本节讨论了一种相对简单的方法,用于协调一组控件以向用户显示一致的用户界面。撰写本讨论的目的是希望能对那些在开发多个控件(例如:图表控件)时面临挑战,需要应用程序中显示一致数据的人有所帮助。在这个模型中可以考虑更多细节,例如所有控件是否可以导航相同的文件系统路径。但我发现这里概述的模型仍然很有趣,因为它可能适用于许多其他情况。
关于 MLib
大多数主题项目只实现一个程序集,您所需的一切都在该 DLL 中。另一方面,Mlib 的设计有所不同,因为我希望在样式和控件的应用上更加灵活。这就是为什么它至少有两个版本可用。
- MLib, MWindowLib, 和 MWindowInterfaceLib
- MLib, MWindowDialogLib, 和 MWindowInterfaceLib
主题库的两个版本都可在上述 Nuget 链接中找到。第一个版本是本文中讨论的版本,而支持 ContentDialog 的第二个版本已在其他地方记录 [2]。
结论
本文重点介绍了一些重要的概念,这些概念可以在随附代码示例或 GitHub 上的开源控件项目 File System Controls (FSC) 中得到验证。本文对 WPF 控件和任务库使用的描述希望能对其他人有所帮助,因为据我所知,目前还没有包含工作应用程序的完整帖子。尽管 FSC 的用例对我来说非常有趣,但我确信所概述的模式适用于许多其他(不仅仅是 WPF)应用程序。
另一个有趣但尚未完成的对话是设计一个深色和浅色的文件选择器控件。这个可主题化的文件选择器控件可以用于加载或保存文件,并且基于 FSC 实现它并不困难,因为我们只需重用
- FileListView,
- 刷新/书签下拉按钮,以及
- FolderControl
来自之前展示的 Explorer 项目。所以,这里总有可以扩展的地方 :-)
下一节将以更抽象的方式列出前几节中的一些具体学习内容。这些一般性陈述通常难以验证,除非一个人经历过某些问题或技术。如果您需要支持下一节中陈述的实用指南,请参阅前几节和随附的代码示例。
学到的教训
分层视图
WPF 中的一个关键概念是可视树,它允许我们将控件分层堆叠。这种分层应该始终以这样的方式进行组合:控件或其一部分可以灵活地重用而无需更改(参见概述部分中的 FolderBrowser 讨论以及 FolderBrowserLib 中的视图)。
实现(模态)对话框
模态对话框的实现不应导致对特定主题库的依赖。例如,使用控制反转,让应用程序获取引用并将其作为构建实际对话框的运行时参数。
在 WPF 中引用主题库
带有主题的 WPF 控件不应直接引用主题库。
实现此要求使控件比原来更加灵活和通用。试想一下:您必须使用两个控件,每个控件都有自己固定的主题库。这意味着您将不得不在项目中包含多个(不一致的)主题库。相反,我们只使用一个主题库并专注于开发,因为维护应该会容易得多。
当然,在第一次实现时不需要此要求,但当您必须从一个主题库更改为另一个主题库时会很有用。如果您考虑控制反转或扩展点等标准方法,而不是仅仅使用硬引用,那么这将很容易。下面两个最顶部的引用图显示了 2 个有用的场景,其中右上方是首选解决方案,而左上方是通常实现的方式。尽量避免标有红色的场景,因为它一点也不灵活,并且会带来您可能想要避免的后期成本。
引用 IOT 容器
如今,IOT 容器,例如 Caliburn.Micro、PRISM 或 Windsor,经常用于许多 .Net 项目中。我见过一些控件,例如,开发人员在他们的控件中重用了 Caliburn.Micro 中的 OnViewAttached 事件。这样做一开始没问题,但会将您的控件的应用限制在特定的 IOT 上。当您尝试在具有不同 IOT 类型(可能不提供完全相同的接口和/或事件)的不同项目中应用您的控件时,您将体验到这种限制。当然,其他人也会立即体验到这种限制。
我不是说不要使用 IOT,而是想说您不应该在 WPF 控件的开发中直接使用它。例如,上面的 OnViewAttached 事件用于在视图模型附加到控件的视图时初始化视图模型。这个特定的用例也可以使用 OnDataContextChanged 事件并使用几乎相同的程序逻辑来实现。
因此,我的建议是考虑使用标准 .Net 事件和手段作为定制,例如继承和重写,以定制您的 WPF 控件的行为。如果这有助于您减少对某个 WPF 控件的引用,那么这尤其有价值,因为该 WPF 控件的应用现在比以前更不具限制性。
将任务协调与控件实现分离
为控件“做好软件设计”的艺术并非确定性的,并且始终依赖于特定的实现,但通常情况下,少即是多。因此,在尝试将所有内容都塞进一个程序集之前,请考虑不同的方面。这不仅适用于数据建模方面(视图、视图模型、模型层),也应注意交互和任务协调等方面。我真的很喜欢之前展示的视图同步方式,这并不是因为它唯一正确的做法,而是因为它足够灵活,可以更改许多东西而无需更改控件本身。
历史
- 2018-03-31 添加了引用 IOT 容器部分
参考文献
- [1] WPF 文件列表视图和组合框 (第二版)
https://codeproject.org.cn/Articles/760603/A-WPF-File-ListView-and-ComboBox-Version-II
- [2] WPF 桌面应用程序中的 ContentDialog
https://codeproject.org.cn/Articles/1170500/A-ContentDialog-in-a-WPF-Desktop-Application
- [3] 在 MVVM 应用程序中使用服务定位器处理消息框
https://codeproject.org.cn/Articles/70223/Using-a-Service-Locator-to-Work-with-MessageBoxes
- [4] 开源 WPF 主题库
https://github.com/MahApps/MahApps.Metro
https://github.com/firstfloorsoftware/mui/
- [5] 使用 .NET 进行并行编程
https://blogs.msdn.microsoft.com/pfxteam/2010/04/04/a-tour-of-parallelextensionsextras/