首页
/ PowerToys Settings 设置项搜索引擎的设计与实现:从 XAML 结构索引到 FuzzMatch 模糊匹配

PowerToys Settings 设置项搜索引擎的设计与实现:从 XAML 结构索引到 FuzzMatch 模糊匹配

2026-09-06 18:21:03作者:郁楠烈Hubert

PowerToys 的设置界面承载着数十个模块、上百个可配置设置项。当用户只记得某个功能的名称却不清楚它位于哪个模块页面时,一个能跨页面全文检索的设置搜索能力就至关重要。本文以仓库内的设计规格文档 doc/specs/settings-search.md(标题为 "PowerToys Settings – Search Index (Hard-sealed)",即"已冻结/密封"的架构约定)为主体,结合 src/settings-ui 下的真实源码,完整讲解其"构建期 XAML 静态索引 + 运行期模糊匹配"的落地方案。读完本文,你将掌握 PowerToys 设置项元数据模型、构建期索引管线(XamlIndexBuilder)与运行期搜索服务(SearchIndexService)的设计细节与实现路径。

PowerToys 设置搜索结果页界面(doc/specs/search-result.png) 搜索关键词后,"Search results" 页把命中的模块与设置项分组展示,用户点击结果即可跳转到对应设置位置。


1. 索引什么:设置页面的控件层级与最小索引单元

1.1 页面的逻辑结构

规格文档首先明确了 PowerToys 设置页的控件组织方式:所有面向用户的设置都包含在 <controls:SettingsPageControl>,其逻辑与视觉结构是嵌套的:

SettingsPageControl
 └─ SettingsGroup
     └─ [SettingsExpander]
         └─ SettingsCard

各层级的职责:

  • SettingsGroup:在设置页内定义一个功能分区(section)。
  • SettingsExpander(可选):在分组内部继续对相关设置做折叠式归类。
  • SettingsCard:真正承载一个可调控控件(或一组紧密相关的控件)的设置单元。

注意:并非所有 SettingsCard 都被包裹在 SettingsExpander 中,它们可以直接位于 SettingsGroup 之下。

对索引而言,规格明确了两点约束:

  1. 最小可索引单元是 SettingsCard——它是用户交互的最小单元,对应一个个可配置设置项;
  2. 由于存在"设置项藏在 Expander 里"的情况,SettingsExpander 里的条目也必须一并索引

这一点在真实页面中随处可见。以 src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml 为例:页面根元素是带 x:Uid="Awake"SettingsPageControl,内部由 SettingsGroupx:Uid="Awake_BehaviorSettingsGroup")划分分区,直接挂载 SettingsCard,并通过 SettingsExpander.Items 把"日期/时间"两个子设置卡(Awake_ExpirationSettingsExpanderDateAwake_ExpirationSettingsExpanderTime)收纳进 AwakeExpirationSettingsExpander

1.2 模块(Module)层级的索引信息

模块本身是另一类需要被索引的主体。对模块,索引的是其 ModuleTitle(模块标题)ModuleDescription(模块描述),这两个字符串通过 x:Uid 绑定到本地化资源文件(.resw)中的键传入。页面级示例可见 src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml,根控件写作 x:Uid="AdvancedPaste" ModuleImageSource="ms-appx:///Assets/Settings/Modules/AdvancedPaste.png"

1.3 SettingsCard / SettingsExpander 的显示字符串约定

为了让索引可本地化,每个 SettingsCard / SettingsExpander 都应带有 x:Uid,对应的显示字符串定义在 .resw 文件中,遵循以下键名约定:

  • {x:Uid}.Header:该设置的可见标题/标签;
  • {x:Uid}.Description:(可选)提示或说明文字。

索引即围绕这些 SettingsCard 元素及其 x:Uid 关联的资源构建——它们正是用户将来要搜索的对象。真实 XAML 的写法与规格完全一致,例如 Awake 页的"启用"开关卡同时给出了 Name="AwakeEnableSettingsCard"x:Uid="Awake_EnableSettingsCard"


2. 导航模型:从命中条目到目标控件的三段式路由

2.1 可搜索元素的元数据

规格给出了一条搜索命中记录应有的元数据形状。核心是一个入口类型枚举与一个元数据类:

enum EntryType
{
    SettingsPage,
    SettingsCard,
    SettingsExpander,
}

public class SearchableElementMetadata
{
    public string PageName { get; set; }    // Used to navigate to a specific page
    public EntryType Type { get; set; }     // Used to know how should we navigate(As a page, a settingscard or an expander?)
    public string ParentElementName { get; set; }
    public string ElementName { get; set; }
    public string ElementUid { get; set; }
    public string Icon { get; set; }
}

这套模型在仓库中已落地为共享的 SettingEntry 结构体,见 src/settings-ui/Settings.UI.Library/SettingEntry.cs(字段为 Type / Header / PageTypeName / ElementName / ElementUid / ParentElementName / Description / Icon)。可以推断,为了在结果页按模块分组展示,运行时模型在元数据之上又补充了 Header(本地化标题)与 Description(本地化描述)两个字段,而构建期 JSON 中它们为空、由运行期回填。

2.2 三段式导航步骤

根据条目命中结果导航到目标设置的步骤是:

  1. 页面间导航——先切到所属模块页;
  2. (可选)展开 Expander——如果设置项藏在折叠器内部,先展开它;
  3. (可选)页内导航——定位并滚动到具体控件。

页面导航通过页面名解析出页面 Type 后交给 NavigationService

Type GetPageTypeFromPageName(string PageName)
{
    var assembly = typeof(GeneralPage).Assembly;
    return assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{PageName}");
}

NavigationService.Navigate(PageType, ElementName,ParentElementName);

这套"按类型名反射找页面"的机制在运行期服务中同样存在:src/settings-ui/Settings.UI/Services/SearchIndexService.csGetPageTypeFromName 使用 typeof(GeneralPage).Assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"),并带有进程内类型缓存 _pageTypeCache

页内导航依赖 x:Name 注册表进行元素查找:在目标页的 OnNavigateTo 中先 FindName 找到父元素(Expander)并调用 Expand(),再对目标元素调用 StartBringIntoView() 让系统将其滚动到可视区域:

Page.OnNavigateTo(ElementName, ParentElementName){
    var element = this.FindName(name) as FrameworkElement;
    var parentElement = this.FindName(ParentElementName) as FrameworkElement;

    if(parentElement) {
        expander = (Expander)parentElement;
        if(expander){
            expander.Expand();
        }

        // https://learn.microsoft.com/en-us/uwp/api/windows.ui.xaml.uielement.startbringintoview?view=winrt-26100
        element.StartBringIntoView();
    }
}

这也解释了为何索引必须同时记录 ElementName(对应 XAML 的 x:Name)与 ParentElementName(所在 Expander 的名字)——它们是页内定位的两把"钥匙"。


3. 运行期搜索:线性扫描 + PowerToys FuzzMatch

当用户开始输入(例如 shortcut,或中文"快捷键")时,需要遍历全部条目判断是否匹配。规格明确评估过算法复杂度:由于总条目量在千级以内(规格原文为 "Total entry is within thousand",精确数字待填),朴素的"逐条本地化文本比对"在当前规模下性能可接受,因此不引入复杂的匹配算法或倒排索引:

// Match
query = UserInput();
matched = {};

indexes = BuildIndex();

foreach(var entry in indexes) {
    if(entry.Match(query)) {
        matched.Add(entry);
    }
}

匹配判定直接复用 PowerToys 自带的模糊匹配实现("let's use powertoys FuzzMatch impl for now"):

MatchResult Match(this Entry entry, string query) {
    return FuzzMatch(entry.DisplayedText, query);
}

struct MatchResult{
    int Score;
    bool Result;
}

3.1 运行期服务的真实实现

实际实现位于 src/settings-ui/Settings.UI/Services/SearchIndexService.csSearch(string query, CancellationToken token),它在规格伪码基础上做了不少工程化增强:

  • 查询归一化NormalizeString 先把文本 ToLowerInvariant() 化,再做 Unicode FormKD 兼容分解并剔除非间距标记(NonSpacingMark),保证中英文、带注音/变音符的查询都能对齐;
  • 并行遍历:用 Parallel.ForEach 对索引条目做多核扫描(MaxDegreeOfParallelism = Environment.ProcessorCount - 1),并支持 CancellationToken 取消(输入变化时会话可随时中止旧查询);
  • 双字段打分:标题(Header)的模糊匹配分数作为主分 captionScoreResult.Score;若描述(Description)也命中,则取其分数乘以 0.8 权重后与主分取较大者,让"描述命中"不至于淹没"标题命中";
  • 过滤与排序:只有 score > 0 且能解析出目标页面 Type 的条目才进入结果集,最终按分数降序排列。

模糊匹配本身来自公共库 src/common/Common.Search/FuzzSearch 中的 StringMatcher.FuzzyMatchusing Common.Search.FuzzSearch;)。这一设计既避免了重复造轮子,也保证了设置搜索与 PowerToys Run 等其他模块拥有一致的"容错"体验(例如拼写近似、子序列匹配)。


4. 搜索结果页的数据模型

命中条目需要被映射成结果页可绑定的集合。规格给出的结果页模型是"模块 + 分组设置"两层结构:模块级结果直接平铺(ModuleResult),设置级结果则按组聚合(GroupedSettingsResults):

ObservableCollection<SettingEntry> ModuleResult;
ObservableCollection<SettingsGroup> GroupedSettingsResults;

public class SettingsGroup : INotifyPropertyChanged
{
    private string _groupName;
    private ObservableCollection<SettingEntry> _settings;
    public string GroupName
    {
        get => _groupName;
        set
        {
            _groupName = value;
            OnPropertyChanged();
        }
    }
    public ObservableCollection<SettingEntry> Settings
    {
        get => _settings;
        set
        {
            _settings = value;
            OnPropertyChanged();
        }
    }
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

这与 doc/specs/search-result.png 所示的界面一致:搜索 "Find" 后,结果页上半部分是命中的模块(如 File Locksmith、Mouse utilities、PowerToys Run),下半部分按设置分组展示具体条目(如 "Mouse utilities" 下的 "Enable Find My Mouse")。对应的页面与 ViewModel 实现在仓库中分别是 src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xamlsrc/settings-ui/Settings.UI/ViewModels/SearchResultsViewModel.cs


5. 索引怎么建:为什么选"构建期索引"而不是"运行期索引"

5.1 两种方案的权衡

规格明确排除了运行期构建索引的方案,理由有二:

  1. 定位困难:设置项大多是静态的,但运行时 SettingsCard 已被编译为原生 WinUI 3 控件(规格作者备注 "I suppose, please correct here if it's wrong",即此处为推断),运行期很难回溯定位所有 SettingsCard;
  2. 性能不佳:若每次都遍历所有页面的元素树来做搜索,性能会很差。

结论是走"构建期索引":借助 XAML 文件解析拿到全部 SettingsCard 条目;同时 XAML 源文件不希望被打进生产发布包,因此做法是用一个独立工程做解析,把生成的索引文件带进生产包

5.2 构建期的 MSBuild 集成

规格给出了在 Settings.UI 工程中挂接一个 BeforeBuild 的 MSBuild Target,用 XamlIndexBuilder 可执行文件扫描 Views 目录并产出 JSON 索引:

  <Target Name="GenerateSearchIndex" BeforeTargets="BeforeBuild">
    <PropertyGroup>
      <BuilderExe>$(MSBuildProjectDirectory)\..\Settings.UI.XamlIndexBuilder\bin\$(Configuration)\net8.0\XamlIndexBuilder.exe</BuilderExe>
      <XamlDir>$(MSBuildProjectDirectory)\Views</XamlDir>
      <GeneratedJson>$(MSBuildProjectDirectory)\Services\searchable_elements.json</GeneratedJson>
    </PropertyGroup>
    <Exec Command="&quot;$(BuilderExe)&quot; &quot;$(XamlDir)&quot; &quot;$(GeneratedJson)&quot;" />
  </Target>

从当前仓库的 src/settings-ui/Settings.UI/PowerToys.Settings.csproj 可以看到该 Target 已演进为直接编译调用 XamlIndexBuilder 工程的形式:Targets="Build",并传入 XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\ViewsGeneratedJsonFile,注释明确说明 "XamlIndexBuilder now outputs directly to Assets\Settings"、"No RID/Platform plumbing needed here"(生成的索引最终以嵌入资源形式随 Settings.UI 程序集发布)。运行期服务正是从嵌入资源 Microsoft.PowerToys.Settings.UI.Assets.search.index.json 反序列化加载的,见 src/settings-ui/Settings.UI/Services/SearchIndexService.cs

5.3 构建期提取逻辑:从伪码到真实实现

规格给出了核心提取思路——用 LINQ to XML 遍历每个 XAML 文件的后代元素,凡元素名是 SettingsCard 就产出 Entry,并回溯父元素判断是否处于 SettingsExpander 内:

for(xamlFile in xamlFiles){
    var doc = Load(xamlFile);
    var elements = doc.Descendants();

    foreach(var element in elements){
        if(element.Name == "SettingsCard") {
            var entry = new Entry{
                ElementName = element.Attribute["Name"],
                PageName = FileName,
                Type = "SettingsCard",
                ElementUid = element.Attribute["Uid"],
                DisplayedText = "",
            }

            var parent = element.GetParent();
            if(parent.Name == "SettingsExpander"){
                entry.ParentElementName = parent.Attribute["Name"];
            }
        }
    }
}

这份逻辑的完整工程化实现位于 src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs(控制台程序,Main(args) 用法为 XamlIndexBuilder <xaml-directory> <output-json-file>)。其 ExtractSearchableElements 逐项落实了规格并做了多处健壮性增强:

  • 三类元素都提取:除 SettingsCard 外,同时处理带 x:UidSettingsPageControl(产出 EntryType.SettingsPage)与带 Name/x:Name/x:UidSettingsExpander,与规格中"Expander 条目也要索引"的要求吻合;
  • Name 与 x:Name 兼容GetElementName 先读普通 Name 属性,再回退读 x:Name(XAML 命名空间 http://schemas.microsoft.com/winfx/2006/xaml);
  • x:Uid 的兜底GetElementUid 若元素自身无 x:Uid,则回退取第一个直接子元素的 x:Uid
  • 父 Expander 回溯GetParentElementName 沿 SettingsExpander.Items(LocalName 为 Items)或直接父节点向上回溯,找到最近的具名 SettingsExpander 作为 ParentElementName
  • 图标提取ExtractIconValue 支持 HeaderIcon 属性与嵌套属性元素两种写法,能从 PathIcon.DataFontIcon.GlyphBitmapIcon.Source 以及 {ui:BitmapIcon Source=...}{ui:FontIcon Glyph=...} 标记扩展中抽取图标;模块页图标则由 src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.csResolveIconFromFirstSettingsCard 从页面内第一张卡的 HeaderIcon 解析;
  • 例外与映射:扫描目录为 ViewsPanels(并兜底扫描根目录),排除 ShellPage.xaml(外壳页不属于任何模块设置);通过 PanelPageMapping 硬编码把 MouseJumpPanel 这类面板归属到宿主页 MouseUtilsPage,并支持 ExplicitExtraXamlFiles 显式补充索引对象;
  • 输出排序与序列化:最终按 PageTypeName 再按 ElementName 排序,以 camelCase JSON(WriteIndented = true)写出。

5.4 运行期索引加载与本地化回填

构建期 JSON 里只有结构性字段(页面、元素名、Uid 等),显示文本在运行期用 ResourceLoader 回填,从而天然支持多语言:

var entries = LoadEntriesFromFile();
foreach(var entry in entries){
    entry.DisplayedText = ResourceLoader.GetString(entry.Uid);
}

真实实现(SearchIndexService.cs)把回填细化为两种键约定,与规格 1.2/1.3 节一一对应:

  • EntryType.SettingsPage:读取 {ElementUid}/ModuleTitle{ElementUid}/ModuleDescription
  • 其余条目:读取 {ElementUid}/Header,找不到再回退 {ElementUid}/Content,并读取 {ElementUid}/Description
  • Header 仍为空的条目被跳过,避免无意义命中;同时把 PageTypeName → 本地化模块名 写入 _pageNameCache,供结果页显示模块标题。

6. 整体工作流程

规格最后用一张流程图收束了整个设计(doc/specs/workflow.png):

PowerToys 设置搜索索引整体工作流程图(doc/specs/workflow.png)

流程可归纳为五个阶段:

  1. Settings initialization & index warm up:应用启动(进入设置)时触发 SearchIndexService.BuildIndex(),从嵌入资源反序列化元数据并用 ResourceLoader 本地化回填,构建不可变索引 _index(并发安全:BuildIndex/Search 均以锁 + 缓存保护,见 SearchIndexService.cs);
  2. 用户在 SearchBox 中输入并回车;
  3. Search within indexes:对索引执行上述"归一化 + FuzzMatch + 并行打分"的检索;
  4. 结果呈现在 SearchResult Page,索引中的 Name / Localized Header / Localized Description / parent name / page / icon 字段在此被消费,按模块与设置组排版(对应第 4 节的 SettingsGroup 模型);
  5. Click a result 后回到第 2 节的三段式导航——进入 SettingsPage,必要时 Expand expander,最终把用户带到目标设置控件。

7. 尚未解决的边界情况(设计遗留清单)

规格诚实记录了当前方案尚未覆盖的边界场景,这些内容对理解方案的适用范围同样关键:

  1. CmdPal(命令面板)页面不在本次范围内:在其中启动与检索设置需要额外的设计与工作量,目前索引不覆盖 CmdPal 相关页面;
  2. "返回"按钮(Go back button):从搜索结果跳转到深层设置后,返回行为尚未设计完善;
  3. 动态构建的设置页面
    • Shortcut guide:其设置项带可见性转换器(visibility converter),存在运行期才动态出现/隐藏的条目;
    • Advanced Paste 的动态配置设置项:条目由配置动态生成,静态 XAML 扫描无法捕获;
    • PowerToys Run 的扩展(extensions):扩展加载后才有完整设置列表。

对于这些场景,静态 XAML 索引天然存在盲区,属于后续设计的已知 TODO,这也解释了为何文档标题强调 "(Hard-sealed)"——即对"可被索引"的页面形态做出硬性约定,超出约定的动态页面暂不在搜索范围内。


8. 结语:一份可对照源码阅读的"密封"设计

doc/specs/settings-search.md 与源码放在一起对照,可以清晰看到一条完整的落地链路:XAML 控件层级约定(SettingsPageControl → SettingsGroup → [SettingsExpander] → SettingsCard)→ 构建期 XamlIndexBuilder 静态解析产出 JSON 索引 → 嵌入生产程序集 → 运行期 SearchIndexService 本地化回填并以 FuzzMatch 并行检索 → 结果分组展示 → 按 PageTypeName + ElementName + ParentElementName 三段导航直达控件。对于任何向 PowerToys 设置体系新增页面的开发者,这份 spec 的实际约束是:让设置卡拥有规范的 x:Uid(对应 resw 中的 {Uid}.Header / {Uid}.Description)、必要时给出 x:Name,并把"折叠态条目"组织在具名 SettingsExpander 之下——遵循这些约定,新设置就会自动进入可搜索索引,无需额外注册。

登录后查看全文
热门项目推荐
相关项目推荐