第37课:加一个内置工具
更新: 6/16/2026 字数: 0 字 时长: 0 分钟
为什么这课重要
前面 36 课你学了很多东西:变量、循环、类、接口、XAML、数据绑定、导航。但如果只学不用,那些知识跟你硬盘里吃灰的电子书没什么区别。
这一课的目标很直接——在 TubaTools 里加一个你自己的内置工具。不是"理解原理",是真的写代码、真的跑起来。等你走完这一遍,你对整个 WinUI 3 项目的结构、接口怎么用、注册机制怎么工作,会有一种"啊原来就是这回事"的感觉。
内置工具是什么
TubaTools 里的"内置工具"指的是直接写死在代码里的功能模块。每一个内置工具都是一个类,它实现了 IBuiltinTool 接口,然后把自己注册到 BuiltinToolRegistry 里。注册之后,首页的工具网格里就会出现它,用户点一下就能运行。
跟"外部工具"(通过 JSON 配置文件加载的 .exe)不同,内置工具完全用 C# 和 WinUI 3 API 编写,可以直接创建窗口、弹对话框、调用系统功能,灵活性高得多。
IBuiltinTool 接口
一个内置工具必须实现 IBuiltinTool。这个接口定义在 Services/IBuiltinTool.cs 里,只有 7 个属性和 1 个方法:
public interface IBuiltinTool
{
string Id { get; } // 唯一标识,比如 "cpu-ranking"
string Name { get; } // 显示名称,比如 "CPU 天梯图"
string Description { get; } // 描述文字
string Glyph { get; } // Segoe Fluent Icon 的 Unicode 码点
string Category { get; } // 分类,比如 "硬件信息"、"系统工具"
BuiltinToolKind Kind { get; } // 工具类型
Task ExecuteAsync(BuiltinToolContext context); // 执行入口
}Id —— 全局唯一的名字
Id 是工具的身份证号。TubaTools 用它来从注册表中查找工具、处理搜索高亮、避免重复注册。命名一般用小写加连字符,比如 "port-viewer"、"junk-cleaner"。如果你写 "my-tool" 而另一个人也写了 "my-tool",后注册的那个会直接抛异常——Register 方法里做了重复检查。
Name 和 Description
Name 会显示在工具卡片的大字标题位置。Description 是卡片上的小字说明。保持 Name 简短(一般 4-6 个字),Description 一句话说清楚这工具干什么。
Glyph —— 图标码点
TubaTools 使用 Windows 内置的 Segoe Fluent Icons 字体来渲染工具图标。Glyph 存的是单个字符的 Unicode 码点,格式是 "\uXXXX",比如 "\uE774" 是一个网络端口图标,"\uEEA1" 是一个芯片图标。
怎么知道该用哪个码点?打开 Windows 自带的"字符映射表",字体选 "Segoe Fluent Icons",随便挑一个,复制它的 Unicode 值就行。或者直接参考已有的工具用了什么码点,扒一个差不多的。
Category —— 分类
Category 是一个字符串,用来把工具分组。首页的分类下拉框就是从所有已注册工具的 Category 里自动收集的。如果你写 "网络工具",而项目里已经有其他网络工具,它们会自动归到一起。如果你想新建一个分类,直接写个新的字符串就行,比如 "我的工具",下拉框里会自动出现这个分类。
Kind —— 工具类型
BuiltinToolKind 是一个枚举,有 4 个值:
| 值 | 含义 |
|---|---|
Dialog | 打开一个弹窗(ContentDialog),工具的功能在弹窗里完成 |
BackgroundTask | 在后台执行,不阻塞界面,执行完显示结果 |
ProgressTask | 有进度反馈的长任务,比如 JunkCleaner 的扫描 |
InstantAction | 立刻执行一个动作,不打开复杂 UI,比如 PortViewer 直接弹个窗口 |
Kind 决定了工具卡片上显示的标签("弹窗"、"后台任务"、"进度任务"、"即时操作"),也暗示了 ExecuteAsync 里的大致写法。但实际上它就是个标签——具体怎么执行完全由你的代码决定。JunkCleaner 标记为 ProgressTask,但代码里照样走了弹窗路线。
ExecuteAsync 和 BuiltinToolContext
这是核心。当用户点击工具卡片的"运行"按钮,框架会构建一个 BuiltinToolContext,然后调用 ExecuteAsync。返回值是 Task,所以你可以用 async/await。
BuiltinToolContext 给了你三个东西:
XamlRoot:UI 元素的根,创建弹窗和对话框必须传这个,不然会崩OnProgress:一个回调,传一条消息给主界面的状态栏CancellationToken:取消令牌,用户切到别的工具时会被取消
典型的用法像这样——PortViewerTool 的完整实现:
public sealed class PortViewerTool : IBuiltinTool
{
public string Id => "port-viewer";
public string Name => "端口占用";
public string Description => "查看系统所有 TCP/UDP 端口占用情况,定位占用进程。";
public string Glyph => "\uE774";
public string Category => "网络工具";
public BuiltinToolKind Kind => BuiltinToolKind.InstantAction;
public Task ExecuteAsync(BuiltinToolContext context)
{
context.OnProgress?.Invoke("正在打开端口占用...");
App.MainWindow?.DispatcherQueue.TryEnqueue(() =>
{
var window = new TubaWinUi3.Pages.PortViewerWindow();
window.Activate();
});
return Task.CompletedTask;
}
}注意 PortViewer 用 InstantAction,逻辑非常短:发一条进度消息,然后调度到 UI 线程创建一个新窗口。不涉及 await,所以直接返回 Task.CompletedTask。
再看一个用了 await 的例子——JunkCleanerTool 简化后的核心部分:
public async Task ExecuteAsync(BuiltinToolContext context)
{
var dialog = context.CreateDialog("垃圾清理");
dialog.Content = BuildDialogContent();
await dialog.ShowAsync();
}它用 context.CreateDialog 创建了一个 ContentDialog,这个方法会自动帮你把 XamlRoot 和当前主题设置好,不需要每次手动写。然后 await dialog.ShowAsync() 阻塞直到用户关闭弹窗。
BuiltinToolRegistry —— 注册机制
光写好一个类还不够,必须把它注册到 BuiltinToolRegistry。这个类定义在 Services/BuiltinToolRegistry.cs,是一个静态类:
public static class BuiltinToolRegistry
{
private static readonly List<IBuiltinTool> _tools = [];
public static IReadOnlyList<IBuiltinTool> Tools => _tools;
public static void Register(IBuiltinTool tool)
{
if (_tools.Any(t => t.Id == tool.Id))
throw new InvalidOperationException($"内置工具 '{tool.Id}' 已注册。");
_tools.Add(tool);
}
public static void RegisterDefaults()
{
Register(new CertBlockTool());
Register(new PortViewerTool());
Register(new HostsEditorTool());
Register(new KeyboardTestTool());
Register(new JunkCleanerTool());
// ... 其他工具
}
}RegisterDefaults 在应用启动时被调用一次(在 App.xaml.cs 里),把所有内置工具一次性注册进去。Register 方法会检查 Id 冲突,冲突了就抛异常——这是一个很常见的防御性写法。
如果你要加自己的工具,需要做两件事:
- 在
RegisterDefaults里加一行Register(new MyNewTool()); - 确保
MyNewTool类实现了IBuiltinTool
工具的 UI 是怎么连起来的
注册完工具之后,剩下的都是自动的。BuiltinToolsPage 的 LoadTools 方法从 BuiltinToolRegistry.Tools 读取所有工具,转成 BuiltinToolViewModel,绑定到 GridView。用户点击后,ExecuteToolAsync 构建 BuiltinToolContext 并调用 ExecuteAsync。
下面是这个调用链的 Mermaid 流程图:
sequenceDiagram
participant User as 用户
participant Page as BuiltinToolsPage
participant VM as BuiltinToolViewModel
participant Tool as MyTool (IBuiltinTool)
participant Registry as BuiltinToolRegistry
Registry->>Registry: RegisterDefaults() 在启动时调用
Registry->>Registry: Register(new MyTool())
User->>Page: 打开内置工具页面
Page->>Registry: Tools (读取全部工具列表)
Registry-->>Page: IReadOnlyList<IBuiltinTool>
Page->>VM: new BuiltinToolViewModel(tool)
Page->>Page: 绑定到 GridView
User->>Page: 点击工具卡片
Page->>Page: ExecuteToolAsync(vm)
Page->>Tool: ExecuteAsync(context)
Tool-->>Page: Task
Page->>Page: 显示结果 / 打开窗口一步步实操:加一个"系统时间"工具
下面带你走一遍完整流程。我们加一个最简工具:点击后在弹窗里显示当前系统时间。
第一步:创建类文件
在 Services/BuiltinTools/ 下面新建 ClockTool.cs:
using Microsoft.UI.Xaml.Controls;
namespace TubaWinUi3.Services;
public sealed class ClockTool : IBuiltinTool
{
public string Id => "clock-tool";
public string Name => "系统时间";
public string Description => "显示当前系统日期和时间。";
public string Glyph => "\uE823"; // 时钟图标
public string Category => "系统工具";
public BuiltinToolKind Kind => BuiltinToolKind.Dialog;
public async Task ExecuteAsync(BuiltinToolContext context)
{
var dialog = context.CreateDialog("系统时间", "关闭");
var now = DateTime.Now;
var timeText = $"当前时间:{now:yyyy-MM-dd HH:mm:ss}";
dialog.Content = new TextBlock
{
Text = timeText,
FontSize = 18,
Margin = new Microsoft.UI.Xaml.Thickness(0, 10, 0, 0)
};
await dialog.ShowAsync();
}
}第二步:注册
打开 Services/BuiltinToolRegistry.cs,在 RegisterDefaults 方法里加一行:
Register(new ClockTool());位置无所谓,放在其他 Register 调用的中间或末尾都行。
第三步:编译运行
F5 运行项目,打开"内置工具"页面,翻到"系统工具"分类,你应该能看到一个叫"系统时间"的卡片,图标是时钟。点"运行",弹出一个对话框显示当前日期时间。
就三步。没有配置文件,没有 JSON,没有额外的注册表操作。这就是接口+注册模式的威力——框架只管"有什么工具",不管"工具怎么实现"。你写了一个新类,注册一下,UI 自动识别。
深入一点:不同 Kind 的写法差别
上面 ClockTool 用了 Dialog,最简单的模式。如果你想写其他类型,区别主要在 ExecuteAsync 的写法上。
BackgroundTask 模式
BackgroundTask 适合不需要 UI 交互的后台操作。比如一个"清理临时文件夹"的工具:
public async Task ExecuteAsync(BuiltinToolContext context)
{
context.OnProgress?.Invoke("正在清理临时文件...");
await Task.Run(() =>
{
var tempPath = Path.GetTempPath();
foreach (var file in Directory.GetFiles(tempPath))
{
try { File.Delete(file); }
catch { /* 跳过被占用的文件 */ }
}
}, context.CancellationToken);
context.OnProgress?.Invoke("清理完成");
}这里用到了 context.CancellationToken,如果用户在清理过程中点了别的工具,CancellationToken 会被触发,Task.Run 内部的循环会收到取消信号。
ProgressTask 模式
ProgressTask 和 BackgroundTask 的区别在于前者需要向用户展示进度。JunkCleaner 就是典型的 ProgressTask——它实际上也创建了 UI(一个 ContentDialog),但在卡片上标的是 ProgressTask 而不是 Dialog。这说明 Kind 只是一个语义标签,决定了卡片上显示什么文字,不限制你用什么方式实现。
如果你想做一个有进度条的下载工具,可以这样:
public async Task ExecuteAsync(BuiltinToolContext context)
{
var dialog = context.CreateDialog("下载文件");
var progressBar = new ProgressBar { Minimum = 0, Maximum = 100 };
dialog.Content = progressBar;
dialog.ShowAsync(); // 不 await,让弹窗立即显示
for (int i = 0; i <= 100; i += 10)
{
context.CancellationToken.ThrowIfCancellationRequested();
progressBar.Value = i;
context.OnProgress?.Invoke($"下载中... {i}%");
await Task.Delay(500, context.CancellationToken);
}
dialog.Hide();
}常见坑
1. 忘了传 XamlRoot
如果你手动 new ContentDialog() 而不传 XamlRoot,运行时会抛出 System.ArgumentException。用 context.CreateDialog() 可以避免这个问题——它内部帮你设置好了。
2. UI 线程问题
WinUI 3 的 UI 元素只能在 UI 线程上操作。如果你在 Task.Run 里面尝试修改 TextBlock 的 Text,会抛异常。解决方式是用 DispatcherQueue.TryEnqueue:
App.MainWindow?.DispatcherQueue.TryEnqueue(() =>
{
myTextBlock.Text = "更新后的内容";
});PortViewerTool 的 ExecuteAsync 里就有这个用法——创建窗口必须在 UI 线程上,所以用 TryEnqueue 调度到主线程。
3. CancellationToken 没检查
如果你的工具做耗时操作(扫描文件、网络请求、循环计算),记得时不时检查一下 context.CancellationToken.ThrowIfCancellationRequested()。这不是强制要求,但不检查的话用户切到别的工具后你的代码还在后台跑,浪费资源。
4. Id 冲突
如果你抄了别人的 Id,程序启动时 Register 会直接抛异常,应用闪退。确保 Id 在整个项目里是唯一的。建议加一个自己的前缀,比如 "myapp-clock" 而不是 "clock"。
Mermaid 类图
下面这张图展示了内置工具体系中各部分的静态关系:
classDiagram
class IBuiltinTool {
<<interface>>
+string Id
+string Name
+string Description
+string Glyph
+string Category
+BuiltinToolKind Kind
+ExecuteAsync(BuiltinToolContext) Task
}
class BuiltinToolKind {
<<enum>>
Dialog
BackgroundTask
ProgressTask
InstantAction
}
class BuiltinToolContext {
+XamlRoot XamlRoot
+Action~string~ OnProgress
+CancellationToken CancellationToken
+CreateDialog(string, string) ContentDialog
}
class BuiltinToolRegistry {
<<static>>
-List~IBuiltinTool~ _tools
+Register(IBuiltinTool) void
+RegisterDefaults() void
+GetCategories() IReadOnlyList~string~
+GetByCategory(string) IReadOnlyList~IBuiltinTool~
}
class PortViewerTool {
+Id → "port-viewer"
+Name → "端口占用"
+Kind → InstantAction
+ExecuteAsync(context)
}
class ClockTool {
+Id → "clock-tool"
+Name → "系统时间"
+Kind → Dialog
+ExecuteAsync(context)
}
class CpuRankingTool {
+Id → "cpu-ranking"
+Name → "CPU 天梯图"
+Kind → Dialog
+ExecuteAsync(context)
}
IBuiltinTool <|.. PortViewerTool : implements
IBuiltinTool <|.. ClockTool : implements
IBuiltinTool <|.. CpuRankingTool : implements
BuiltinToolRegistry ..> IBuiltinTool : manages
IBuiltinTool ..> BuiltinToolContext : uses
IBuiltinTool ..> BuiltinToolKind : uses图中的三种工具代表了三种不同的 Kind:PortViewer 是 InstantAction(不弹对话框,直接开新窗口),ClockTool 是我们新写的 Dialog(弹窗交互),CpuRankingTool 也是 Dialog 但创建的是一个完整的 Window 而非 ContentDialog——它开了一个独立的窗口来显示 CPU 排行榜页面。这说明即使是同一种 Kind,实现方式也可以差很多。
小结
给 TubaTools 加一个内置工具这件事,本质上就是:
- 写一个类,实现
IBuiltinTool的 7 个属性 + 1 个方法 - 在
BuiltinToolRegistry.RegisterDefaults里注册它 - 运行,工具自动出现在 UI 里
整个过程不需要懂 XAML,不需要改任何 .xaml 文件,不需要配数据绑定——这些都是框架帮你做好的。你只需要关心你的工具要做什么,然后把它写进 ExecuteAsync。
这其实是一个非常好的设计。它把"工具怎么被发现和展示"(框架的事)和"工具具体干什么"(你的事)分开了。你写完一个工具类,只要实现了正确的接口,框架就能自动把它挂到 UI 上。这种模式在软件工程里叫"插件架构"——TubaTools 的内置工具体系就是它的一个微型实现。
小练习
练习 1:补全代码
下面的工具类缺少了几个属性的实现,请补全:
public sealed class NotePadTool : IBuiltinTool
{
public string Id => "notepad";
public string Name => "快速记事";
// 1. 请补全 Description(一句话描述这个工具)
// 2. 请补全 Glyph(找一个合适的 Segoe Fluent Icon 码点)
// 3. 请补全 Category(你觉得应该归到哪个分类?)
public BuiltinToolKind Kind => BuiltinToolKind.Dialog;
}练习 2:分析问题
有人写了下面这段代码,运行后点击工具,弹窗显示不出来,还报了一个异常。问题出在哪里?
public async Task ExecuteAsync(BuiltinToolContext context)
{
var dialog = new ContentDialog
{
Title = "测试",
Content = new TextBlock { Text = "Hello" },
CloseButtonText = "关闭"
};
await dialog.ShowAsync();
}练习 3:设计题
假设你要给 TubaTools 加一个"文件夹大小统计"工具——用户选择一个文件夹,工具递归计算该文件夹的总大小并显示结果。
- 你会选择哪种
BuiltinToolKind?为什么? ExecuteAsync里大概需要哪几步?- 你觉得这个工具的 Id 叫什么合适?
练习答案(点击展开)
练习 1 参考答案
public sealed class NotePadTool : IBuiltinTool
{
public string Id => "notepad";
public string Name => "快速记事";
public string Description => "打开一个简易文本编辑器,快速记录文字内容。";
public string Glyph => "\uE70F"; // 编辑/记事图标
public string Category => "系统工具"; // 或者自建"办公工具"
public BuiltinToolKind Kind => BuiltinToolKind.Dialog;
}答案不唯一,Glyph 和 Category 都可以根据自己的判断选择。
练习 2 答案
问题是 new ContentDialog() 没有传 XamlRoot。在 WinUI 3 里,ContentDialog 必须知道它属于哪个 XamlRoot 才能正确弹出来。正确写法是使用 context.CreateDialog("测试", "关闭"),它会自动设置 XamlRoot。或者手动 new ContentDialog { XamlRoot = context.XamlRoot, ... }。
练习 3 参考思路
Kind选ProgressTask,因为递归扫描大文件夹可能耗时较长,需要向用户展示进度。ExecuteAsync大概步骤:- 用
FolderPicker让用户选文件夹(需要传XamlRoot或者用context.CreateDialog包一个 UI) - 创建进度 UI(ProgressBar + TextBlock)
- 在
Task.Run里递归遍历文件夹,每隔一定文件数更新进度 - 显示最终结果(总大小、文件数、文件夹数)
- 用
- Id 建议
"folder-size"或"folder-size-scanner",小写加连字符,见名知意。