我最近业余时间写了一个Windows小工具,叫colibri。名字取自蜂鸟,意思是它要像蜂鸟一样,体型小、反应快、动作灵巧。这个工具本质上是一个快捷启动器:按下全局热键,弹出一个输入框,输入几个字母,回车就打开对应的应用、文件或网址。整个操作过程用不了1秒钟,而它常驻后台的内存占用不到30MB。
开发这类工具的初衷其实很朴素。我平时电脑上装的应用越来越多,桌面图标堆了好几屏,开始菜单的磁贴区域也翻不到底。用Windows自带的搜索吧,偶尔要等索引,结果还经常混进Web内容;用第三方启动器吧,功能确实强大,但常驻内存动不动一两百MB,还要登录账号、频繁更新。我想要的其实很简单:一个输入框、一份本地索引、一次回车执行,仅此而已。
这个项目的受众或者说适合参考的人群,主要是在Windows平台做桌面端开发的朋友,尤其是对程序启动速度、内存占用这些指标比较敏感的人。文章里我会把colibri的整个实现思路、核心代码片段、遇到的坑以及性能优化过程都交代清楚,偏向实操,能直接对照着改。
1. colibri的项目定位:为什么要做一个轻量启动器
1.1 从需求到产品:启动器到底在解决什么
启动器解决的不是“打开软件”这个动作本身,而是降低“找到软件再打开”的认知成本。大多数人每天高频使用的应用也就十几个,但系统里安装了上百个,桌面和开始菜单被大量低频应用占据。每找一个软件,视线要在屏幕上扫一圈,点开目录、翻页,这个动作看起来只要几秒,累积下来是非常可观的损耗。
colibri的做法是把“找”这件事彻底去掉。所有应用在首次启动时被扫描进本地索引,之后只需输入应用名称的拼音首字母或英文关键词,程序通过三重排序算法把最可能的结果排到第一位,回车即可启动。这套交互逻辑类似浏览器的地址栏,输入即所得,几乎不需要学习成本。
选择做本地索引而不是实时搜索,是性能和准确率的权衡。实时搜索每个应用目录下的EXE文件虽然也能做,但结果会包含大量的DLL、卸载程序和资源文件,噪声很大。本地索引则可以在扫描阶段就过滤掉无效项,并在软件卸载时自动移除对应条目。
1.2 技术方案选型:为什么最终选了C#和WPF
项目立项时我列了几个候选方案:Electron、Python打包、C++原生、C# WPF。先说结论,我选了C# .NET 6 + WPF,并且用NativeAOT发布成了单文件。
Electron的优势是前端技术栈,生态丰富,UI能做得很花哨。但它最大的问题在于启动路径太长:Chromium内核的加载本身就有一个明显延迟,而且要常驻一个渲染进程。我测试过,一个空的Electron应用,空闲内存占用在150MB起步,这在“轻量启动器”这个定位上是不可接受的。
Python打包方案的问题类似,虽然开发效率高,但打包出来的体积大、启动速度受解释器影响,而且分发时需要处理各种DLL依赖。C++原生性能最好,但开发效率太低,尤其是WPF的声明式UI和XAML绑定这套东西,在C++里实现要费很多功夫。
C# WPF的平衡点非常好。XAML写界面很高效,数据绑定和命令模式让前后端逻辑清晰,.NET 6的发布功能把自包含单文件做得干净利落,再加上NativeAOT之后连CLR依赖都能剪掉大部分,体积和启动速度都接近原生程序。最终发布出来的colibri单文件体积在14MB左右,冷启动在120ms以内。
1.3 功能边界:一个启动器该有哪些能力和不该有哪些负担
功能边界是这类工具最需要克制的地方。colibri的核心能力就三项:应用/文件/网址启动、模糊匹配与使用频率排序、全局热键无感呼出。除此之外哪些都不做。
没有设置面板,配置通过一个轻量的JSON文件管理,支持手动编辑。没有插件系统,想想还是太复杂,以后真有需求再加。没有后台服务,程序就是一个普通用户态进程,随系统启动,常驻系统托盘,需要时呼出。
这个克制的设计有一个直接好处:代码量少,出bug的概率低。整个项目到现在大概4500行C#代码,我一个人维护起来没什么压力。做个不恰当的类比,Windows里很多东西做得“重”,是因为它们要满足全行业的场景,而你自己的工具只要满足你自己的场景,天然就能做得更轻。
2. 核心细节拆解:快、准、静三者如何兼得
2.1 快:全局热键和窗口显示的秘密
启动器的“快”体现在两个层面:呼出速度快和响应速度快。呼出速度的关键是窗口不能被销毁重建,而是常驻内存,只是隐藏和显示之间切换。
传统做法的坑是这样的:很多人用Timer轮询检测热键,或者把窗口做成每次按键时动态创建。前者浪费CPU,后者在窗口初始化、XAML加载、数据绑定时会产生明显延迟。colibri的做法是在程序启动时就创建主窗口,然后通过Win32的RegisterHotKey注册一个全局热键,系统在热键触发时向窗口消息队列投递WM_HOTKEY消息。
这里有一个容易被忽略的细节:WPF窗口默认的消息泵和Win32窗口不完全一样,直接接收WM_HOTKEY需要重写Window的SourceInitialized事件,拿到句柄后调用HwndSource.AddHook来拦截消息。具体来说是这样的:
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID) { ToggleWindow(); handled = true; } return IntPtr.Zero; }ToggleWindow的逻辑判断当前窗口是否可见:如果可见就隐藏,如果不可见就弹出并聚焦。关键点在于,无论显示还是隐藏,都不要走ShowDialog这类阻塞调用。窗口的Visibility切换配合SetForegroundWindow,能确保输入框立刻获得焦点,用户不需要再点一下鼠标。
经常做桌面工具的朋友可能会遇到一个经典问题:用RegisterHotKey注册的快捷键,在运行某些全屏游戏或管理员权限程序时可能失效。这是因为普通用户态进程的窗口消息被高权限窗口“盖住”了,系统按自带的UIPI机制拦截了消息。解决办法是让colibri以管理员权限运行,但这会导致每次UAC弹窗,反而影响体验。我的取舍是默认用户态运行,遇到冲突时提示用户手动修改热键。
2.2 准:模糊匹配算法如何做到“猜你喜欢”
模糊匹配是启动器的灵魂。输入“not”应该匹配Notepad,也匹配“NoteMarks”,但顺序上Notepad排在前面。输入“wj”应该能匹配“文件管理器”,因为这是中文拼音首字母。要做到这一步,单纯用字符串包含是远远不够的。
colibri的匹配层分三步走。第一步是索引数据预处理:每个可启动项有一个名称、一个搜索关键字、一个拼音首字母串。搜索引擎关键字在扫描时生成,比如“Visual Studio Code”的搜索关键字是“vsc”,“代码”也作为一个附加别名。
第二步是匹配打分。输入关键词后,对每个索引项计算一个得分,得分由三部分组成:连续性得分、首字母得分、位置得分。连续性得分看输入字符在原名称中是否连续出现,连续得3分,间隔一个字符得2分,间隔更多则1分;首字母得分看输入词的每个字符是否匹配项名称的每个词的首字母,命中一个加2分;位置得分看首次匹配位置,越靠前加分越多。
第三步是使用频率加权。每个启动项记录最近30天的启动次数,并按天衰减。一个应用你最近天天用,即使它的名称匹配度略低于另一个几乎没打开过的应用,排序时它也会靠前。这个逻辑非常实用,我实际用下来,命中率在90%以上,唯一排错的情况是两个同名的不同应用,毕竟程序没法理解语义。
核心匹配函数的简化代码如下:
internal static int ScoreMatch(string target, string query) { var score = 0; var matched = -1; for (var i = 0; i < query.Length; i++) { var nextMatched = target.IndexOf(query[i], matched + 1, StringComparison.OrdinalIgnoreCase); if (nextMatched < 0) return 0; score += (nextMatched == matched + 1) ? 3 : (nextMatched == 0 ? 3 : 1); score += (i == 0) ? 1 : 0; matched = nextMatched; } return score; }这里没有用正则,直接走字符遍历,一是为了性能,二是为了代码足够直观。数据量在几千条时,每次输入重算全量匹配的耗时在1ms以内,完全没有必要上复杂的数据结构。
2.3 静:低内存占用是怎么做到的
WPF的内存占用是可以被调教得很小的。colibri常驻内存实测稳定在27MB左右,这比Electron低了将近一个数量级。为了这个数字,我在代码里做了几件事。
第一,避免全局静态集合装满后不释放。以前写过GC.KeepAlive滥用、事件处理器不注销的代码,导致内存只涨不跌。这次所有索引集合统一走一个IndexService,内容变化时用不可变集合替换引用,避免ConcurrentDictionary的底层数组碎片化。
第二,禁用不必要的高频刷新。WPF的Render线程虽然没有跑动画的时候几乎不耗CPU,但其内部有与显示器的垂直同步,我们常驻之后即使看不见窗口,渲染线程也确实在转。我把窗口隐藏时的RenderMode设为SoftwareOnly,并在隐藏时调用GC.Collect(2, GCCollectionMode.Optimized, false)做一次轻度回收,实测内存能再压低4-5MB。
第三,小心使用DispatcherTimer。常见错误是在后台线程里需要更新UI时,用DispatcherTimer做轮询。colibri中所有的命令执行、索引刷新都用async/await配合Dispatcher.InvokeAsync,而不是定期轮询。只有当界面真的需要变化时才通知UI线程,常驻时的CPU占用几乎为0,一台8核CPU上跑起来完全无声无息。
注意:很多“轻量”工具其实在后台偷偷做了很多全盘扫描和遥测上报,这种行为极其消耗资源。colibri的索引只在启动时做一次增量扫描,后续全靠文件系统的FileSystemWatcher监听变化,不会到处乱翻磁盘。
3. 实操实现:从零搭建colibri的完整过程
3.1 环境准备和项目骨架搭建
先交代一下开发环境,Windows 11,Visual Studio 2022,.NET 6 SDK(实际上.NET 8也行,但当时用的是6,项目迁移成本很低)。
新建项目时选择WPF Application,目标框架选.NET 6 (Windows),不要选.NET Framework 4.8,因为自包含发布和NativeAOT都依赖新SDK。项目名字就叫Colibri,解决方案结构如下:
- Colibri.App:WPF主程序,负责UI、消息循环、窗口生命周期
- Colibri.Core:类库,负责索引、匹配、进程启动、配置管理
- Colibri.Tests:单元测试项目,重点测匹配算法和配置序列化
把UI和逻辑分开是我在这次项目里坚持下来的原则。WPF的代码后置文件里应该只放UI事件和窗口行为,所有业务逻辑都在Core里,这样单元测试能直接跑通核心代码,无需启动界面。
3.2 解析快捷方式和建立应用索引
应用索引的数据来源有三个:开始菜单的Programs目录(覆盖所有用户,路径是CommonStartMenu和StartMenu),桌面的快捷方式,以及注册表的App Paths键值对。每个来源都是一个IEnumerable 的提供者,统一汇入IndexBuilder。
解析.lnk文件是这里的重要细节。.lnk不是纯文本,直接用File.ReadAllText读出来是一堆乱码。正确做法是调用Windows Shell COM接口IShellLink来解析:
public static string ResolveLnkTarget(string lnkPath) { var shell = new Shell32.Shell(); var folder = shell.NameSpace(Path.GetDirectoryName(lnkPath)); var link = folder.ParseName(Path.GetFileName(lnkPath)); if (link == null) return string.Empty; return ((Shell32.ShellLinkObject)link.GetLink).Path; }这段代码需要引用Windows API Code Pack或直接写COM互操作,稍微有些繁琐,但这是解析快捷方式唯一可靠的办法。直接读文件尾部找TargetPath字符串也行,但遇到Unicode和系统变量展开时极其容易出错,不建议在生产环境用。
注册表App Paths的方式也值得提。很多命令行工具和绿色软件,比如Beyond Compare、git-bash,不会在开始菜单创建快捷方式,但会写App Paths注册表项。读取的路径是:HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths,以及HKEY_CURRENT_USER下的同名路径。
解析完成后,每个LaunchableItem里保存以下字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| Id | 唯一标识 | 001 |
| Name | 显示名称 | Visual Studio Code |
| Path | 程序路径 | C:\Users\me\AppData\Local\Programs\VSCode\Code.exe |
| Arguments | 启动参数 | 可为空 |
| Pinyin | 拼音首字母串 | vs_code |
| SearchKeys | 附加别名 | vsc, 编辑器 |
| IconPath | 图标路径 | 同Path |
| LaunchCount | 30天启动次数 | 12 |
索引数据持久化到本地一个JSON文件,路径在%APPDATA%\Colibri\index.json。下次启动时先加载JSON,如果文件存在且文件的快照时间在24小时以内,就跳过全盘扫描,只做一次增量检查。这样冷启动时间控制得很低,用户体感就是“秒出结果”。
3.3 主界面布局和交互闭环
主窗口设计遵循极简原则:一个顶部圆角输入框,下面一个结果列表。窗口宽640,高度按结果数量动态调整,最多显示8行,超出后虚拟化滚动。
XAML的结构大致是这样:
<Window x:Class="Colibri.App.MainWindow" Width="640" Height="480" WindowStyle="None" AllowsTransparency="True" Background="Transparent" Topmost="True" ResizeMode="NoResize"> <Border Margin="20" CornerRadius="10" Background="#F5F5F5"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBox x:Name="SearchBox" Grid.Row="0" Height="44" BorderThickness="0" FontSize="18" Padding="12,8" TextChanged="OnSearchTextChanged"/> <ListView x:Name="ResultList" Grid.Row="1" SelectionChanged="OnSelectionChanged"/> </Grid> </Border> </Window>这里有一个视觉细节:窗口背景设为Transparent,内部容器做了圆角,这样的毛玻璃视觉效果在Windows 11上很协调。但Transparent背景的窗口有一个坑,就是阴影无法自动渲染,需要额外使用DropShadowEffect,或者干脆不追求阴影,用一个简单的5像素Margin留白。
文本框的TextChanged事件触发后,调用IndexService.Search,结果绑定到ListView的ItemsSource。每次输入变化时都在后台线程执行搜索,完成后通过Dispatcher回到UI线程更新列表。这种同步在小数据量下几乎无感知,但设计上就必须是异步的,避免未来数据量变大后卡输入。
回车执行的逻辑:当结果列表选中第一项时,enter键直接启动。启动是通过Process.Start,如果LaunchableItem里的Arguments不为空,就拼成完整的ArgumentList。批量启动的模式也支持,按住Ctrl多选后回车即可。
3.4 编译发布:单文件与体积优化
发布是这类工具体验的重要一环。用户不想下载一个安装包,再经过一堆下一步按钮。我用dotnet publish打成了单文件,配置是这样:
dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ -p:EnableCompressionInSingleFile=true \ -p:PublishTrimmed=true \ -p:TrimMode=CopyUsedPublishSingleFile把所有托管DLL打进一个EXE,IncludeNativeLibrariesForSelfExtract把原生库也包含进去,EnableCompressionInSingleFile会启用压缩,体积能再降30%左右。TrimMode=CopyUsed表示裁剪未使用的程序集引用,这个模式比较保守,适合WPF项目,比link模式的破坏性小得多。
采用NativeAOT发布则更加激进,能生成完全原生的二进制,连CoreCLR都不需要了。但NativeAOT目前对WPF支持不够完善,需要等.NET 8的完善版本。当时colibri用的是兼容方案,先PublishTrimmed + Compression。
发布后我实测了一下数据:文件体积14.3MB,冷启动(双击EXE到窗口出现)约85ms,热启动(托盘图标呼出)约35ms。作为对比,原版Windows Terminal的单文件发布版大约是40MB。这个数字对于一个小工具来说完全够用。
注意:PublishTrimmed有时会误判反射所需的类型。colibri里用到了JSON序列化的反射调用,我特意在裁剪配置里保留了System.Text.Json的特定重载,否则打包后跑起来会出现神秘的RuntimeException。
4. 踩坑实录与问题排查清单
4.1 全局快捷键被别的程序抢占了怎么办
RegisterHotKey有一个限制:同一个系统里,同一个热键组合只能被一个进程注册。当用户先打开了另一个也注册了Ctrl+Alt+Space的软件时,colibri再调用RegisterHotKey会返回false。
我处理了两层策略。第一层,启动时检测注册结果,如果注册失败,自动退化为Ctrl+Alt+Shift+Space,并把新的热键写入配置提示用户。第二层,提供一个“测试快捷键”按钮在帮助菜单里,用户可以随时手动重新注册。
更深层的经验是,不要抢用户已经习惯的组合键。比如Win+Space已经是操作系统的输入法切换,如果colibri默认用它,肯定天天被顶掉。我最后默认用了Alt+Space的两键组合,虽然也和一些软件冲突,但冲突的频次相对低。
4.2 窗口焦点丢失导致启动器“卡死”的问题
这是一个WPF窗口的老大难问题。当colibri窗口弹出后,如果用户点击了屏幕上的其他区域,窗口应该自动隐藏。但直接监听Deactivated事件在Windows 10 1809以后有变化,有时点击桌面也会触发Deactivated,导致窗口自己消失,这显然不对。
排查了很久,发现根因是Taskbar的窗口切换机制和普通窗口不同。正确做法是同时监听Deactivated和系统工作区状态变化。当窗口失去焦点时,先判断鼠标位置是否在窗口矩形内,如果不在,才隐藏。再加一层防线:当用户按了Esc键,无条件隐藏。这两个条件组合后,误隐藏和卡死的情况就很少了。
相关代码实现:
protected override void OnDeactivated(EventArgs e) { base.OnDeactivated(e); if (!IsMouseOverIncludingTransparent()) { HideWindow(); } }4.3 某些应用解析不出路径:UWP应用的特殊处理
传统快捷方式几乎都能通过IShellLink解析出EXE路径,但UWP应用(比如Windows自带的计算器、设置、商店)的快捷方式是AppRefs文件,没有一个真实的目标EXE。
处理方案是解析到shell:AppsFolder<PackageFamilyName>这个特殊URL,启动时用explorer.exe来执行。换句话说,对于UWP应用,LaunchableItem里的Path是“explorer.exe”,Arguments是“shell:AppsFolder\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App”。这样Process.Start就能正常启动它,并且能识别成系统应用、配备应用图标。
刚开始我没有做这个解析,结果UWP应用全部消失在索引里。一旦加上AppRefs的处理逻辑后,整个开始菜单的覆盖率从93%提升到99%,剩下的1%是失效的快捷方式和残留在桌面上的僵尸项。
4.4 匹配性能在几千条数据后竟然会卡顿
项目早期我用的是最朴素的遍历,每次输入都重建一个List 然后全量排序。3000条以内是没问题的,但后来我把“文件”也纳入搜索范围,索引膨胀到8000条后,每次按键后重算的时间涨到了60-80ms,已经能感觉到输入不跟手了。
优化方式是引入“预过滤+精排”的两阶段策略。第一阶段用Triple-Bit Map,把搜索关键词的长度限制在4个字符以内——实际使用中,用户很少输入超过4个字来搜索应用,4个字基本能区分所有项目。第二阶段在全量集合上做并行LINQ(AsParallel().Where()),再对满足条件的少数结果跑完整打分。
这次优化之后,8000条数据的匹配耗时回落到4ms以内。有一个补充优化也值得一提:把索引项的搜索关键字转换成大写,匹配时也转大写,用大写对比区避免每轮都做ToLower,因为这个分配也有成本。
4.5 配置文件的损坏与自动恢复机制
配置JSON如果因为异常退出而写坏,下次启动就会直接崩溃。我在加载配置文件时加了容错逻辑:先反序列化,如果抛异常,就备份坏文件为config.bad.json,然后用默认配置重新生成。任何配置字段读取都走GetValueOrDefault,避免因为配置项缺失导致空引用。
这种容错机制在产品稳定下来后几乎不会触发,但在开发期帮了大忙。我自己因为调试杀死进程,至少写坏了3次配置。加上这层保护后,用户也少了一个“为什么启动器打不开”的理由。
4.6 常见问题速查表
这里整理一份我在colibri开发和试用过程中遇到的高频问题,方便遇到同样情况的朋友对照排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 热键无法呼出 | 热键被占用,或进程无权限 | 检查注册结果,更换热键;以管理员方式运行 |
| 部分UWP应用搜不到 | 未解析AppRefs文件 | 增加AppRefs解析逻辑,转explorer启动 |
| 输入后列表刷新慢 | 索引量太大,每次全量匹配 | 做两阶段匹配:预过滤+精排 |
| 程序常驻内存超过60MB | 存在多层事件订阅未释放 | 检查事件句柄,改用弱事件模式 |
| 杀毒软件误报 | 单文件自解压行为敏感 | 走代码签名,或核对白名单 |
| 配置被改坏后无法启动 | 缺少容错恢复 | 加载失败时备份并重置配置 |
5. 性能实测与后续可扩展方向
5.1 实测数据:一台老笔记本上的表现
为了看看colibri在低配电脑上的表现,我找了一台2016年的老笔记本,i5-6200U、8GB内存、机械硬盘,在上面跑了全套测试。结果最能说明问题:冷启动打开colibri到输入可用的延迟是150ms左右,热键呼出在40ms左右,同时拖拽文件、窗口动画时没有明显的掉帧感,常驻内存稳定在34MB(和上文提到的新机器差了一点,因为系统本身加载了大量字体)。
机械硬盘上有一个特殊问题:首次索引扫描大约需要3.8秒,如果放在固态硬盘上只需要1.1秒。我测试后还是决定把扫描放到后台线程,界面先弹出来,结果一边扫一边填充。用户打开colibri后其实不用等索引完成,直接打字就行,因为高频的应用往往最先被扫描出来。
5.2 复用colibri思路到其他工具上
colibri的架构不只用于启动器。把“索引+模糊匹配+高频排序”这套组合提取出来,可以做很多事情:代码片段管理器(用快捷键呼出,输入片段名回车复制到剪贴板)、网页收藏夹快速导航、企业内部的部门联系人搜索,甚至可以做成一款离线密码管理器(当然密码部分要做加密,复杂度会更高)。
我自己的下一步计划是给colibri加一个简单插件接口,让用户能注册一个自定义命令,比如输入“ts [msg]”就把内容拼成一条任务发送到本地的任务队列。这个功能目前还在设计中,核心思路是让插件跑在独立进程里,主程序通过标准输入输出和它通信,这样插件崩溃不会影响colibri本身。
5.3 代码签名的必要性
最后提醒一句,发布到网上的Windows工具最好做一下代码签名。没有签名的exe在SmartScreen里会显示蓝色“更多信息”按钮,用户点开以后还要再点一次“仍要运行”。这对一些技术背景较弱的用户来说是个门槛。签名证书有许多形式,自签名只能骗过本机,公开分发需要买商业证书,一年几百块钱,如果工具能帮别人提升生产力,这笔投入值得。
colibri发布早期没有签名,有几次被Win32/Agent型恶意软件的特征库误杀,提交申诉后解决了,但体验已经受损。之后我加了证书,再被误报的几率就非常低了。
6. 一些实际使用后的体会
colibri从动笔写第一行代码到基本可用,大概用了两个周末,之后花了将近一个月打磨细节。中间最有价值的一段经历是做性能剖析,Windows自带的PerfView可以精确看到每个方法的CPU耗时和内存分配量,根据Profile结果改了搜索算法的两处热点,整体输入流畅度从一个档次提升到另一个档次。
这些小工具最有趣的地方在于,它不需要讨好所有人,只要每天为您省下几分钟,就很值得。我现在的日常是:热键呼出、敲两个字母、回车,文件管理器、终端、编辑器嗖的一下就位。几个月适应下来,再去用没装colibri的机器,会明显觉得少了个什么东西。
如果你也想动手做类似的东西,建议从小处着手,不要一上来就规划插件系统、主题系统、云同步。做一个能用的最小版本,然后按自己的痛点一一迭代。工具之所以像工具,是因为它贴合主人的手。colibri就是为我的手“雕刻”出来的,希望这篇分享对大家有点启发。