第 33 课:MainWindow 导航与搜索
更新: 6/16/2026 字数: 0 字 时长: 0 分钟
TubaTools 启动后你看到的第一个东西是什么?一个带有侧边导航栏的窗口。左边是"全部"、"常用"、"硬件信息"这些菜单项,正上方嵌着一个搜索框。点一下菜单,右边内容区域就切换成不同的页面;在搜索框里敲几个字,底下弹出一列匹配结果,选一个就能跳过去。
这些都是 MainWindow 干的活。它不像普通窗口那样只是"显示一块固定内容",而是一个壳——它本身几乎不展示业务数据,它的任务是把侧边栏、搜索框、页面容器拼接在一起,让用户在它们之间自由穿梭。WinUI 3 里管这个叫"导航视图"(NavigationView)架构。
这篇就把 MainWindow 拆开来看:窗口是怎么拼起来的,选菜单后页面怎么跳转,搜索框从打字到打开工具的一整条链路长什么样。
一、MainWindow 的结构:壳的思路
MainWindow 不是什么普通的 Window。它的 XAML 只画了两行:第一行放标题栏(含搜索框),第二行放 NavigationView(含侧边导航和页面容器)。用代码说就是:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="48" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- 第 0 行:标题栏 + 嵌在标题栏里的搜索框 -->
<TitleBar x:Name="AppTitleBar"
Title="图吧工具箱"
BackRequested="TitleBar_BackRequested"
IsBackButtonVisible="{x:Bind NavFrame.CanGoBack, Mode=OneWay}"
IsPaneToggleButtonVisible="True"
PaneToggleRequested="TitleBar_PaneToggleRequested">
<TitleBar.Content>
<AutoSuggestBox x:Name="GlobalSearchBox"
PlaceholderText="搜索工具、设置..."
QueryIcon="Find"
Width="320"
TextChanged="GlobalSearchBox_TextChanged"
SuggestionChosen="GlobalSearchBox_SuggestionChosen"
QuerySubmitted="GlobalSearchBox_QuerySubmitted"
KeyDown="GlobalSearchBox_KeyDown" />
</TitleBar.Content>
</TitleBar>
<!-- 第 1 行:导航视图(侧边栏 + 页面容器) -->
<NavigationView x:Name="NavView" Grid.Row="1"
OpenPaneLength="200"
SelectionChanged="NavView_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem Content="全部" Tag="all" />
<NavigationViewItem Content="常用" Tag="favorites" />
<NavigationViewItem Content="硬件信息" Tag="hardware" />
<NavigationViewItem Content="内置" Tag="builtin" />
<NavigationViewItem Content="硬件监控" Tag="monitor" />
</NavigationView.MenuItems>
<Frame x:Name="NavFrame" CacheSize="10" />
</NavigationView>
</Grid>看到这里的结构图了吗——TitleBar 里套着一个 AutoSuggestBox,NavigationView 里套着一个 Frame。AutoSuggestBox 负责搜索,Frame 负责装载页面,NavigationView 负责在两者之间牵线搭桥。它们各干各的,互不越界。
骨架图:
graph TD
Window["MainWindow : Window"]
Grid["Grid (2 rows)"]
TitleBar["TitleBar (Row 0)"]
SearchBox["AutoSuggestBox (嵌在TitleBar里)"]
NavView["NavigationView (Row 1)"]
MenuItems["MenuItems\n全部/常用/硬件信息/内置/硬件监控\n+ 动态加载的分类项"]
Frame["Frame : NavFrame\nCacheSize=10"]
Pages["HomePage / FavoritesPage\nHardwarePage / BuiltinToolsPage\nSettingsPage / LiteMonitorPage"]
Window --> Grid
Grid --> TitleBar
Grid --> NavView
TitleBar --> SearchBox
NavView --> MenuItems
NavView --> Frame
Frame --> Pages二、导航是怎么工作的
WinUI 3 的 NavigationView 本身不负责"显示什么页面"——它只是提供一个可点击的菜单列表,外加一个 Frame 作为容器。真正决定"点完菜单后右侧显示哪个页面"的逻辑在 C# 里,而且方式是 Frame.Navigate。
Tag 做钥匙
看 XAML 里每个 NavigationViewItem 都带一个 Tag:
<NavigationViewItem Content="全部" Tag="all" />
<NavigationViewItem Content="常用" Tag="favorites" />
<NavigationViewItem Content="硬件信息" Tag="hardware" />这些 Tag 是字符串标记,它们是导航的钥匙。选中某个菜单项时,SelectionChanged 事件触发,C# 代码检查选中项的 Tag,然后决定 Frame 要 Navigate 到哪个页面类型:
private async void NavView_SelectionChanged(NavigationView sender,
NavigationViewSelectionChangedEventArgs args)
{
if (_syncingNavSelection) return;
if (args.IsSettingsSelected)
{
NavFrame.Navigate(typeof(SettingsPage));
}
else if (args.SelectedItem is NavigationViewItem item)
{
switch (item.Tag)
{
case "all":
NavFrame.Navigate(typeof(HomePage), null);
break;
case "favorites":
NavFrame.Navigate(typeof(FavoritesPage));
break;
case "hardware":
NavFrame.Navigate(typeof(HardwarePage));
break;
case "builtin":
NavFrame.Navigate(typeof(BuiltinToolsPage));
break;
case "monitor":
NavFrame.Navigate(typeof(Pages.LiteMonitorPage), false);
break;
case string category:
// 动态加载的分类项 —— Tag 直接是分类名(如"处理器工具")
NavFrame.Navigate(typeof(HomePage), category);
break;
}
}
}Frame.Navigate 的第一个参数是页面类型(typeof(HomePage)),第二个参数是可选的——传给目标页面的数据。这里的 null、"处理器工具"这些字符串就是通过第二个参数塞进页面的。目标页面的 OnNavigatedTo 方法里可以拿到这个参数。
注意那个 case string category: 分支。前面的 "all"、"favorites" 是固定菜单项,但后面从 ToolCatalog 动态加载进来的分类("处理器工具"、"显卡工具"等等)也走同一个 switch,Tag 就是分类名本身,所以用 string category 来兜底匹配。
另外有个 _syncingNavSelection 的布尔变量。它是干什么的?搜索功能有时需要"同步"侧边栏的选中状态——比如搜索到一个内置工具,程序会导航到 BuiltinToolsPage,同时把侧边栏的"内置"菜单项高亮。这时候 SelectionChanged 本来会再次触发并尝试跳转,但程序用一个 _syncingNavSelection 标志位把自己的操作屏蔽掉,避免死循环。
默认页面的选择
MainWindow 启动时不是直接跳到"全部"页——它读一个设置项:
private void NavigateToDefaultPage()
{
var defaultPage = AppSettings.Get("DefaultPage") ?? "all";
NavigationViewItem? targetItem = null;
foreach (var item in NavView.MenuItems)
{
if (item is NavigationViewItem navItem
&& navItem.Tag is string tag
&& tag == defaultPage)
{
targetItem = navItem;
break;
}
}
if (targetItem is not null)
NavView.SelectedItem = targetItem;
else
NavFrame.Navigate(typeof(HomePage), null);
}逻辑很直:从 AppSettings 读出默认页面的 Tag 值,在菜单项里找到匹配的那一个,把它设为选中。找不到就回退到 HomePage。用户如果把默认页面改成了"硬件信息",下次启动就直接跳过去。
三、动态分类:把菜单项从代码里"长"出来
XAML 里只有 5 个固定的 MenuItems。但实际运行时侧边栏远不止 5 项——它还会有"处理器"、"显卡"、"硬盘"、"外设"、"游戏"等等分类,这些分类都是根据系统中可用的工具列表动态生成的。
private void PopulateCategories()
{
// 先清掉动态项:前 5 项是固定的,从第 6 项开始都是动态的
while (NavView.MenuItems.Count > 5)
NavView.MenuItems.RemoveAt(5);
var categories = ToolCatalog.GetCategories();
var otherCategory = categories.FirstOrDefault(c => c.Contains("其他"));
var restCategories = categories.Where(c => !c.Contains("其他"));
foreach (var category in restCategories)
{
NavView.MenuItems.Add(new NavigationViewItem
{
Content = category.Replace("工具", ""),
Tag = category,
Icon = new FontIcon {
Glyph = GetCategoryGlyphStatic(category)
}
});
}
// "其他"分类放到最后
if (otherCategory != null)
{
NavView.MenuItems.Add(new NavigationViewItem
{
Content = otherCategory.Replace("工具", ""),
Tag = otherCategory,
Icon = new FontIcon {
Glyph = GetCategoryGlyphStatic(otherCategory)
}
});
}
}这段做的事情:
- 清掉第 5 项之后的所有菜单项(第 0-4 是固定项)
- 从 ToolCatalog.GetCategories() 获取所有分类("处理器工具"、"显卡工具"……)
- 把"其他"单独拎出来,放到最后
- 每个分类创建一个新的 NavigationViewItem,Content 去掉"工具"两个字,Tag 保留完整分类名
- 根据分类名分配不同的图标 Glyph
GetCategoryGlyphStatic 是一个静态方法,根据分类名返回对应的 Segoe Fluent Icons 字体的 Unicode 码点。比如"处理器"返回 \uEEA1,"显卡"返回 \uF211。每个分类都有自己的图标,一眼能看出来。
这种设计有一个好处:TubaTools 的可扩展性不用改 XAML。以后有人加了新工具、新分类,只要 ToolCatalog 能扫描出来,侧边栏就自动多出一个菜单项。
四、搜索框的完整链路
搜索框是整个 MainWindow 里最复杂的交互组件。它不是简单的"输入-查询-显示"三件套——它涉及异步操作、防抖(debounce)、取消令牌、多种结果类型的分发。拆成四步来看:
第一步:用户打字,触发 TextChanged
private void GlobalSearchBox_TextChanged(
AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput)
return;
var query = sender.Text.Trim();
if (query.Length == 0)
{
PopulateSearchSuggestions();
return;
}
_searchCts?.Cancel();
var cts = new CancellationTokenSource();
_searchCts = cts;
_ = SearchAsync(query, cts.Token);
}首先过滤:只响应用户输入,程序自己改了 Text 不管。然后如果输入为空,恢复默认的快捷提示列表。如果输入不为空,做两件事:取消上一次还没跑完的搜索,发起一次新搜索。
_searchCts?.Cancel() 这里是关键。用户快速打字时,前一个搜索还没返回结果就失去了意义——用户已经改了查询词。Cancel 掉上一个,只保留最新一个。这是一种常见的防抖策略,比单纯加一个 Timer 延迟更精确,因为取消发生在任务层面而不是时间层面。
注意 _ = SearchAsync(query, cts.Token) 前面的弃元符号 _。SearchAsync 返回的是 Task,但在事件处理器里不需要 await 它——事件处理器是 async void 类型,如果 await 会导致搜索变成串行的、阻塞后续按键响应。所以这里把 Task 丢出去,让它自己在后台跑。
第二步:异步搜索
private async Task SearchAsync(string query, CancellationToken ct)
{
try
{
var results = await Task.Run(
() => UnifiedSearchService.Search(query), ct);
ct.ThrowIfCancellationRequested();
DispatcherQueue.TryEnqueue(() =>
{
GlobalSearchBox.ItemsSource = results;
});
}
catch (OperationCanceledException) { }
}搜索实际调用 UnifiedSearchService.Search,包装在 Task.Run 里跑在后台线程上。理由是 Search 方法可能扫描硬盘上的工具目录、读取 JSON 配置、做字符串匹配——这些操作如果在 UI 线程上跑会卡界面。
Cancel 后抛出的 OperationCanceledException 被静默吞掉——这正是想要的行为:用户取消了搜索,不需要提示任何错误。
拿到结果后,用 DispatcherQueue.TryEnqueue 把结果推回 UI 线程。WinUI 3 里 UI 元素只能从 UI 线程操作,后台线程直接设 ItemsSource 会崩。DispatcherQueue 是往返于后台线程和 UI 线程之间的桥梁。
第三步:用户选建议或提交
AutoSuggestBox 有两个与"选中"相关的事件:
- SuggestionChosen:用户用鼠标或键盘在建议列表中高亮了一项(但还没按下回车)
- QuerySubmitted:用户按了回车,或者直接点了某个建议项
TubaTools 的实现里,SuggestionChosen 只是把选中的结果暂存起来:
private void GlobalSearchBox_SuggestionChosen(
AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
{
if (args.SelectedItem is SearchResult result)
_pendingSearchResult = result;
}真正执行跳转的是 QuerySubmitted:
private void GlobalSearchBox_QuerySubmitted(
AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
if (_pendingSearchResult is not null)
{
// 用户明确选了一项
var result = _pendingSearchResult;
_pendingSearchResult = null;
sender.Text = string.Empty;
HandleSearchResult(result);
}
else if (!string.IsNullOrWhiteSpace(args.QueryText))
{
// 用户没选建议,直接回车 —— 取列表第一项
var first = (sender.ItemsSource as System.Collections.IList)?
.Cast<SearchResult>().FirstOrDefault();
if (first is not null)
{
sender.Text = string.Empty;
HandleSearchResult(first);
}
}
}两种情况:如果用了 SuggestionChosen,就用用户选中的结果;如果直接回车没选,就用列表第一项。清空搜索框文本的作用是重置搜索状态,下次打开搜索框时显示默认提示列表。
第四步:结果分发
private void HandleSearchResult(SearchResult result)
{
switch (result.Kind)
{
case SearchItemKind.ExternalTool:
case SearchItemKind.CustomTool:
NavigateToTool(result.MatchKey);
break;
case SearchItemKind.BuiltinTool:
NavFrame.Navigate(typeof(BuiltinToolsPage),
new SearchNavigationTarget {
HighlightBuiltinId = result.MatchKey
});
SyncNavSelection("builtin");
break;
case SearchItemKind.Setting:
NavFrame.Navigate(typeof(SettingsPage),
new SearchNavigationTarget {
HighlightSettingKey = result.MatchKey
});
break;
case SearchItemKind.QuickAction:
HandleQuickAction(result.MatchKey);
break;
}
}搜索结果的 Kind 决定了跳转方式:
- 外部工具 / 自定义工具:查出工具的路径,在 HomePage 里高亮显示这个工具,同时把侧边栏同步到对应分类
- 内置工具:跳到 BuiltinToolsPage,并指定要高亮的内置工具 ID,侧边栏同步到 "builtin"
- 设置项:跳到 SettingsPage,高亮对应设置项
- 快捷操作:当前支持 "navigate:hardware"、"navigate:monitor" 等导航动作
SyncNavSelection 方法把侧边栏的选中状态和实际页面内容保持同步。它的实现很简单——遍历菜单项,找到 Tag 匹配的那一项,设为选中,用 _syncingNavSelection 标志位防止 SelectionChanged 事件再次触发导航。
下面是搜索整条链路的序列图:
sequenceDiagram
participant User
participant SearchBox as AutoSuggestBox
participant Handler as MainWindow (.cs)
participant Search as UnifiedSearchService
participant UI as DispatcherQueue
participant Nav as NavigationView / Frame
User->>SearchBox: 输入 "CPU"
SearchBox->>Handler: TextChanged (reason=UserInput)
Handler->>Handler: Cancel 上次的 _searchCts
Handler->>Handler: 创建新 CancellationTokenSource
Handler->>Search: SearchAsync("CPU", ct)
Note over Search: Task.Run 在后台线程执行搜索
Search-->>Handler: 返回 SearchResult 列表
Handler->>Handler: ct.ThrowIfCancellationRequested()
Handler->>UI: TryEnqueue(设置 ItemsSource)
UI->>SearchBox: ItemsSource = results
SearchBox-->>User: 显示下拉建议列表
User->>SearchBox: 点击某个结果(或回车)
SearchBox->>Handler: SuggestionChosen → 暂存结果
SearchBox->>Handler: QuerySubmitted
Handler->>Handler: HandleSearchResult(result)
Handler->>Nav: Frame.Navigate 到目标页面
Handler->>Nav: SyncNavSelection 同步侧边栏五、其他值得关注的设计细节
窗口最小尺寸限制
MainWindow 没有直接设 MinWidth/MinHeight 属性,而是在 AppWindow.Changed 事件里手动兜底:
private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args)
{
if (!args.DidSizeChange) return;
var size = sender.Size;
var minWidth = 800;
var minHeight = 600;
var needsResize = false;
var newW = size.Width;
var newH = size.Height;
if (size.Width < minWidth) { newW = minWidth; needsResize = true; }
if (size.Height < minHeight) { newH = minHeight; needsResize = true; }
if (needsResize)
sender.Resize(new Windows.Graphics.SizeInt32(newW, newH));
}这样做的原因是 WinUI 3 里窗口本身的 MinWidth/MinHeight 有时不太灵——尤其是跟 AppWindow API 交互的时候。用事件手动强制拉回最小尺寸是一种稳妥的兜底。
搜索框 Escape 键
按 Escape 清空搜索框并关闭建议列表:
private void GlobalSearchBox_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == Windows.System.VirtualKey.Escape)
{
GlobalSearchBox.Text = string.Empty;
GlobalSearchBox.IsSuggestionListOpen = false;
e.Handled = true;
}
}这是桌面应用里搜索框的标配行为——和文件资源管理器搜索框、VS Code 命令面板一致。
Frame.CacheSize = 10
<Frame x:Name="NavFrame" CacheSize="10" />CacheSize 表示 Frame 缓存多少个页面实例。设为 10 意味着最多缓存 10 个访问过的页面,切换到之前访问过的页面时不需要重新创建实例,导航更快。对于 TubaTools 这种工具类应用,用户通常在几个页面之间来回切,缓存效果明显。
六、MainWindow 的启动流程
构造函数里做的事,按顺序列出来:
- InitializeComponent() —— 加载 XAML 定义的控件树
- 标题栏配置 —— 延伸到标题栏区域、设置图标、设置首选高度
- ApplyTitleBarTheme —— 根据系统主题(亮/暗)设置标题栏按钮颜色
- BackdropService.ApplyBackdrop —— 应用窗口背景效果(Mica/亚克力)
- WindowSizeService.ApplySavedWindowSize —— 恢复上次关闭时的窗口大小
- PopulateCategories() —— 从 ToolCatalog 加载动态分类菜单项
- NavigateToDefaultPage() —— 跳到默认页面(或"全部")
- PopulateSearchSuggestions() —— 加载搜索框的默认快捷提示
为什么 PopulateCategories 要在 NavigateToDefaultPage 之前?因为默认页面如果是某个分类页,需要那个分类的菜单项已经存在,否则设置 SelectedItem 会失败。
小结
MainWindow 是整个 TubaTools 应用的中枢——不是因为它承载了最多的业务逻辑,而是因为它定义了用户在应用里"怎么走"。NavigationView + Frame 的模式让页面切换变成一行代码,AutoSuggestBox 让任意页面、任意工具都可以从搜索框直达。动态加载分类的设计让架构在功能增长时不需要改主窗口代码。搜索链路里的 CancellationToken 防抖和 DispatcherQueue 线程切换,是两个在实际开发中最容易出 bug 的地方,处理好了搜索体验就流畅;处理不好要么界面卡顿,要么崩溃。
小练习
第 1 题(选择): NavigationView 在 TubaTools 中有什么作用?
A. 显示应用的主标题和图标 B. 提供侧边导航菜单和页面容器(Frame) C. 管理窗口的大小和位置 D. 处理系统主题切换
第 2 题(填空): 在搜索流程中,_searchCts?.Cancel() 的作用是 ____,防止 ____。
第 3 题(简答): 阅读 HandleQuickAction 方法的源码(MainWindow.xaml.cs 第 420-447 行),如果 action 的值是 "navigate:favorites",程序会依次做哪两件事?
第 4 题(实操): 在 PopulateCategories 方法中,动态分类的菜单项被插入到第 5 项之后。如果要增加一个新的固定菜单项 "日志"(Tag 为 "logs"),让它排在"硬件监控"之后,你应该在 XAML 和 C# 中各加什么代码?
练习答案
第 1 题答案: B。NavigationView 是 WinUI 3 的导航控件,包含侧边栏菜单(MenuItems)和内容区域(Frame)。
第 2 题答案: 取消上一次尚未完成的搜索任务;防止旧的搜索结果覆盖新的搜索结果(或防止无效的异步操作继续执行)。
第 3 题答案:
NavFrame.Navigate(typeof(FavoritesPage))—— 将 Frame 导航到常用页面。SyncNavSelection("favorites")—— 将侧边栏的"常用"菜单项设为选中状态。
第 4 题答案:
- XAML:在
<NavigationView.MenuItems>中"硬件监控"的 NavigationViewItem 后面插入:
<NavigationViewItem Content="日志" Tag="logs">
<NavigationViewItem.Icon>
<FontIcon Glyph="" />
</NavigationViewItem.Icon>
</NavigationViewItem>- C#:在 NavView_SelectionChanged 的 switch 中增加:
case "logs":
NavFrame.Navigate(typeof(LogsPage));
break;- 同时需要把 PopulateCategories 方法里的数字 5 改成 6(因为固定菜单项变成了 6 个)。