ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

VS 扩展开发实战:VSIX、AsyncPackage 与实验实例调试

VS 扩展开发实战:VSIX、AsyncPackage 与实验实例调试 F5 一按下去实验实例确实起来了菜单里那几个自己加的按钮却一个都找不到再过一会儿VS 还弹出一句“某个扩展导致启动变慢已被禁用”。这两个场景几乎覆盖了 Visual Studio 扩展开发头一周的全部挫败感。Visual Studio 扩展开发和写普通的 .NET 应用完全不是一个套路它跑在 IDE 自己的进程里通过 COM 互操作跟宿主通信靠一份 XML 描述的命令表注册界面靠一张 pkgdef 往私有注册表里写键。任何一环对不上表现都是“什么都没发生”——没有报错没有异常只有安静。这篇文章面向的是准备给 Visual Studio 写第一个插件的人以及写过一两个但总在调试和打包上反复踩坑的人。整篇内容围绕 VSIX 工程、AsyncPackage、.vsct 命令表、工具窗口、线程模型、实验实例调试和发布这几件事展开把每个环节背后的“为什么”拆开讲清楚同时把我自己踩过的坑和排查顺序完整写出来。读到后面你会发现这套东西的门槛不在代码量而在对宿主生命周期和线程规则的理解。1. 先把 VS 插件和 VS Code 插件彻底分开搜索“VS 插件开发”时最容易被带偏的一点是搜出来的结果里一半是 Visual Studio Code 的内容。这两个名字像生态却是两套完全独立的东西混着看只会浪费时间。1.1 两个同名不同物的生态Visual Studio下称 VS的扩展是 .NET 程序通常用 C# 写编译产物是一个.vsix包。安装之后扩展被加载进devenv.exe这个进程内部和 IDE 共享同一个地址空间、同一个 UI 线程。它调用 VS 功能的方式是 COM 互操作——IServiceProvider、SVsUIShell、IVsSolution这一整套接口全是 COM 的皮。Visual Studio Code下称 VS Code的扩展是 TypeScript 或 JavaScript跑在一个独立的 Extension Host 进程里通过 RPC 和主进程通信。它的清单文件是package.json所有能力声明都写在contributes字段里激活时机由activationEvents决定。有意思的是两边打包出来的文件后缀都是.vsix所以经常有人拿着 VS Code 的 vsix 去装到 VS 里然后困惑为什么装不上。这个后缀撞车纯属历史巧合内部结构毫无关系VS 的 vsix 里是一个 vsixmanifest 加若干程序集和 pkgdefVS Code 的 vsix 里是 extension 目录加 package.json。热词里很多词比如“VS Code 中文插件”“VS Code 配 C/C 环境”“某个框架什么时候出 VS Code 扩展”说的全是另一个生态的事。你在找 VS 扩展资料时看到页面里出现package.json、activationEvents、vscode.commands.registerCommand基本可以立刻关掉那不是在讲你这套东西。搜索时给关键词加上vsix、vspackage、AsyncPackage、vsct这类限定词命中率会高出一大截。单纯搜“VS 插件”得到的结果噪音太大。1.2 什么需求值得做成 VS 扩展不是所有“我想在 IDE 里做点什么”的需求都值得写一个 VSPackage。判定标准很简单这个功能是不是必须在 IDE 进程内部运行必须做成进程内扩展的典型场景有这么几类。第一类是编辑器层面的能力比如给某种语言加语法高亮、括号匹配、QuickInfo 悬浮提示、错误波浪线这些都要接入编辑器的投影缓冲区和标签系统没法在外面做。第二类是解决方案和项目系统的集成比如给某个自研的项目文件格式加支持、在解决方案资源管理器里给节点加右键菜单、参与生成流程。第三类是深度 UI 集成比如要一个停靠在 IDE 里的工具窗口和解决方案浏览器联动。反过来如果你的需求是“帮我把一段文本格式化一下再贴回去”“调一个内部接口生成代码骨架”那完全可以做一个命令行工具然后在 VS 里通过“工具 - 外部工具”菜单挂上去或者干脆做成一个独立的桌面小工具。投入产出比差着一个数量级写 VSPackage 你至少要花两天搞清楚线程模型和调试流程写个 exe 可能两小时就完事了。1.3 进程内还是进程外三条路线怎么选即便确定要做 VS 扩展现在也有不止一条路。我把它们的差异整理成一张表这张表是我自己在选型时反复对照过的。维度传统 VSPackageAsyncPackageVisualStudio.Extensibility外部工具 菜单入口运行位置devenv.exe 进程内独立进程通过 RPC 通信完全在 IDE 之外技术栈C# / .NET Framework 4.7.2 起C# / .NET 8任意语言API 风格COM 互操作接口多且旧强类型、异步、面向对象不涉及能力覆盖最全几乎所有 IDE 能力都能碰覆盖常用场景长尾能力还在补取决于外部程序调试体验改代码要重启实验实例进程外重新生成即可重新加载随便调崩溃影响可能直接把 IDE 带崩宿主隔离影响面小无适合谁需要深度集成、老版本也要支持VS 2022 较新版本起步的新项目只需要一个入口我的建议是如果目标用户主要跑在较新的 VS 2022 版本上且需求不涉及非常底层的编辑器内部机制优先试 VisualStudio.Extensibility 那条路写起来舒服太多。如果必须兼容老版本或者需要碰到解决方案事件、自定义项目系统这类深水区那就老老实实用 AsyncPackage。2. 环境准备模板、SDK 与实验实例的完整链条环境这一步看着简单实际上新手卡住的时间往往比写代码还长。核心原因是 VS 扩展开发需要的东西不在默认安装里而且运行方式和你熟悉的“启动调试”完全不同。2.1 安装器里那个必须手动勾的工作负载打开 Visual Studio Installer点“修改”切到“单个组件”或者“工作负载”标签找到Visual Studio 扩展开发Visual Studio extension development。勾上它安装器会带进来 Visual Studio SDK、VSIX 工程模板、VSSDK BuildTools 和调试用的一堆工具。这一步漏掉的典型症状是新建项目对话框里搜不到“VSIX”这个模板。很多人以为是模板缓存问题跑去跑devenv /installvstemplates其实根本原因是工作负载没装。装完之后还需要确认一件事你的 VS 版本和你要引用的 SDK 包版本要对得上。VS 2022 对应的扩展开发包是 17.x 系列工程里通常通过Microsoft.VisualStudio.SDK这个元包引用一堆子包或者分别引用Microsoft.VisualStudio.Shell.15.0、Microsoft.VisualStudio.Shell.Framework、Microsoft.VisualStudio.Shell.Interop.*。元包的好处是省事坏处是把一大堆你可能用不上的程序集都拖进来做出来的 vsix 体积会膨胀加载时也可能变慢。我个人的习惯是先用元包跑通然后看编译输出的依赖列表把确实用不到的换成细粒度引用。2.2 模板生成的那堆文件各自负责什么用“VSIX Project”模板新建一个工程你会看到这么几个文件第一次看确实容易懵source.extension.vsixmanifest部署清单。描述这个扩展是谁写的、叫什么、支持哪些 VS 版本和架构、包含哪些资产。MyPackage.cs包的入口类继承自AsyncPackage负责在合适的时机初始化。MyCommand.cs命令的处理类里面有Execute方法。MyCommandPackage.vsct命令表定义菜单项、按钮、图标、快捷键放在哪里。Resources.resx/VSPackage.resx字符串和图标资源InstalledProductRegistration里的#110就是指向这里的资源 ID。Properties/AssemblyInfo.cs程序集级别的属性。这里最关键的一个认知是.vsct不是运行时读取的它是被编译成.cto再嵌入资源的。你在.vsct里写的所有东西最终会以二进制资源的形式打进程序集VS 在加载扩展时从资源里把命令表读出来。所以改了.vsct之后必须重新生成光改文件不编译是没用的。还有一个容易被忽略的产物是pkgdef。构建过程中会调用CreatePkgDef任务把类上那些特性ProvideAutoLoad、ProvideToolWindow、ProvideOptionPage、ProvideMenuResource等等翻译成注册表键值写进一个.pkgdef文件。VS 启动时会把这些 pkgdef 合并进自己的私有注册表privateregistry.bin。这就是为什么特性写错了会导致功能不生效——pkgdef 里没有对应的键宿主根本不知道你注册过这个东西。2.3 实验实例为什么你调试时打开的是另一个 VS按 F5 的时候VS 不是在你正在用的那个实例里加载扩展而是启动一个叫实验实例Experimental Instance的独立环境。原理是启动devenv.exe时带了/rootsuffix Exp参数这个后缀会改变配置目录让实验实例使用一套完全独立的设置、扩展列表和私有注册表。这个设计非常必要。想象一下如果你正在开发的扩展有 bug 会让 IDE 崩溃直接在主力实例里调试那基本上每次崩完都要重装环境。有了实验实例最坏情况也就是重置一下实验环境主力实例毫发无伤。实验实例的位置和重置方式配置目录%LocalAppData%\Microsoft\VisualStudio\17.0_xxxxxxxxExp具体后缀因实例而异重置方式开始菜单里搜“Reset the Visual Studio 2022 Experimental Instance”或者直接删掉上面那个目录带日志启动在工程属性里把调试命令行参数改成/rootsuffix Exp /log日志会写到%AppData%\Microsoft\VisualStudio\17.0_xxxxxxxxExp\ActivityLog.xml关于重置有一条血泪经验只要扩展的行为和你改的代码对不上第一件事就是重置实验实例。VS 会缓存 MEF 组件、pkgdef 合并结果和程序集缓存过期判断并不总是可靠。特别是你改了.vsct、改了包类上的特性、换了图标资源的时候不重置看到的很可能还是旧行为。如果只是 MEF 相关的改动不生效可以试着删掉%LocalAppData%\Microsoft\VisualStudio\17.0_xxxxxxxxExp\ComponentModelCache再跑一次devenv.exe /updateconfiguration比重置整个实例快得多。2.4 不开 VS 也能构建CI 里的 VSIX 编译本地开发要靠 VS但持续集成环境里往往只装了 Build Tools没有完整 IDE。这种情况需要引入Microsoft.VSSDK.BuildTools这个 NuGet 包它提供了 MSBuild 需要的全部 target 和任务包括CreatePkgDef、CreateVsixContainer、GeneratePkgDefFile这些。在.csproj里通常是这么配的PropertyGroup CreateVsixContainer Condition$(Configuration)Releasetrue/CreateVsixContainer DeployExtension Condition$(Configuration)Debugtrue/DeployExtension GeneratePkgDefFiletrue/GeneratePkgDefFile VSSDKTargetPlatformRegRootSuffixExp/VSSDKTargetPlatformRegRootSuffix /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.VSSDK.BuildTools Version17.* PrivateAssetsall / /ItemGroupDeployExtension控制构建完是否自动把扩展部署到实验实例CreateVsixContainer控制是否产出 vsix 包。这两个要分开调试时你想自动部署但不需要每次都打包发布时反之。混在一起会导致流水线莫名其妙地慢。3. 命令链路从按钮点击到 Execute 的完整路径菜单里点一下按钮代码里的Execute被调用——这个过程中间隔了四五个环节。不理解这条链路遇到“按钮点不动”“按钮灰着”“按钮根本不出现”就只能瞎猜。3.1 .vsct、.cto、pkgdef、vsixmanifest 的分工先把这四个东西的职责理清楚文件编译期角色运行期角色.vsct源文件XML 格式无.cto由 vsct 编译生成嵌入程序集资源VS 从资源读取命令表结构.pkgdef由类上的特性生成合并进私有注册表告诉 VS 有哪些包和注册项.vsixmanifest描述部署信息安装器据此判断能否安装、装到哪关键点在于命令表结构和命令的实现类是分开注册的。.vsct里通过guid和id声明一个按钮包类通过ProvideMenuResource声明“我的命令表在这个资源里”命令处理类通过OleMenuCommandService.AddCommand把某个CommandID和具体方法绑上。三者靠 ID 对齐任何一处对不上按钮要么不出现要么出现但点了没反应。3.2 一个按钮的完整生命周期拿模板生成的“工具”菜单下的按钮举例。.vsct里的声明大致是这样Commands packageguidMyPackage Buttons Button guidguidMyPackageCmdSet idMyCommandId priority0x0100 typeButton Parent guidguidSHLMainMenu idIDM_VS_MENU_TOOLS / Icon guidguidImages idbmpPic1 / Strings ButtonTextRun My Tool/ButtonText /Strings /Button /Buttons /Commands GuidSymbol nameguidMyPackageCmdSet value{a1b2c3d4-1111-2222-3333-444455556666} IDSymbol nameMyCommandId value0x0100 / /GuidSymbol注意这里有两层 guid外层guidMyPackage是包本身的 GUID必须和包类上[Guid(...)]里的值完全一致内层guidMyPackageCmdSet是命令集的 GUID用来在这个包内部区分多组命令。这两个 GUID 搞混是很常见的错误症状是按钮完全不出现日志里会看到命令表解析相关的告警。包类上的注册[PackageRegistration(UseManagedResourcesOnly true, AllowsBackgroundLoading true)] [InstalledProductRegistration(#110, #112, 1.0, IconResourceID 400)] [ProvideMenuResource(Menus.ctmenu, 1)] [Guid(PackageGuidString)] public sealed class MyPackage : AsyncPackage { public const string PackageGuidString a1b2c3d4-1111-2222-3333-444455556666; }ProvideMenuResource的第一个参数是资源名第二个是版本号模板默认是 1。这个资源名必须和.vsct编译后嵌入资源的名字一致一般由工程文件里的VSCTCompile项和ManifestResourceName决定。资源名对不上的症状和 GUID 对不上很相似都是按钮静默消失。命令绑定internal sealed class MyCommand { private static OleMenuCommandService commandService; public static MyCommand Instance { get; private set; } public static async Task InitializeAsync(AsyncPackage package) { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); commandService await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService; var cmdId new CommandID(new Guid(MyPackage.PackageGuidString ), 0x0100); Instance new MyCommand(commandService, cmdId); } private MyCommand(OleMenuCommandService service, CommandID id) { var command new OleMenuCommand(Execute, id); service.AddCommand(command); } private void Execute(object sender, EventArgs e) { ThreadHelper.ThrowIfNotOnUIThread(); // 业务逻辑 } }这里有个非常容易写错的细节CommandID的 GUID 用的是命令集 GUID不是包 GUID。模板里为了方便往往让两者相同导致后来想加第二组命令时才发现问题。我现在的习惯是一开始就把命令集 GUID 单独定义和包 GUID 分开。另外Execute是同步方法且运行在 UI 线程上。如果里面要做耗时操作绝不能直接.Result或者.Wait()那会死锁。正确做法是把它改成触发一个异步流程具体在下一节展开。3.3 图标、快捷键和动态可见性图标这块VS 2022 之后推荐用 ImageMoniker 而不是老的 BMP 条带。KnownMonikers里有一大堆 VS 自带的图标可以直接用省掉美工成本自定义图标则需要一份 image manifest稍微麻烦一些。老的guidImagesbmpPic1方式仍然能用但在高 DPI 下会糊新项目不建议沿用。快捷键在.vsct里通过KeyBindings节点声明KeyBindings KeyBinding guidguidMyPackageCmdSet idMyCommandId editorguidVSStd97 key1R mod1CONTROL|SHIFT / /KeyBindingseditor属性指定作用范围guidVSStd97表示全局。这里有个坑你声明的快捷键如果和其他扩展冲突VS 不会报错也不会自动帮你改用户装完之后发现按了没反应。所以发布前最好在 VS 的“工具 - 选项 - 环境 - 键盘”里搜一下自己的命令看看有没有冲突提示。动态可见性要靠命令标志。默认按钮是一直显示的如果你想让它只在特定上下文出现需要加DefaultInvisible和DynamicVisibility两个标志然后在代码里设置CommandFlagDefaultInvisible/CommandFlag CommandFlagDynamicVisibility/CommandFlagcommand.BeforeQueryStatus (s, e) { var cmd (OleMenuCommand)s; cmd.Visible /* 条件 */; cmd.Enabled /* 条件 */; };BeforeQueryStatus在菜单展开时会被频繁调用里面不要做数据库查询或者文件 IO否则菜单会肉眼可见地卡。3.4 命令相关故障的对照排查现象最可能的根因处理方式菜单里完全找不到按钮.vsct未编译、资源名不匹配、GUID 不一致检查生成目录里的 .cto 是否存在核对两处 GUID按钮存在但点了没反应AddCommand未执行或 CommandID 用了包 GUID在 Initialize 里打断点确认执行到核对 CommandID 的 GUID按钮一直是灰的未设置 QueryStatus 或 Enabled 被置 false检查BeforeQueryStatus逻辑报错“找不到命令”命令表资源和 ProvideMenuResource 版本号不一致版本号保持 1确认资源名拼写调试时正常装到正式实例就没了部署的是 Exp 实例正式实例未安装用生成的 vsix 手动装一次4. 线程模型AsyncPackage 里九成的崩溃都出在这如果说.vsct是新手的第一道坎线程模型就是第二道而且这道坎更隐蔽——代码能编译、能跑只是偶尔崩或者偶尔卡死。4.1 InitializeAsync 到底跑在哪个线程AsyncPackage.InitializeAsync这个方法有个反直觉的地方它的线程身份取决于包上有没有AllowsBackgroundLoading true。没开InitializeAsync在 UI 线程上被调用可以随便访问 UI。开了InitializeAsync在后台线程上被调用此时任何 UI 操作都会抛异常或者直接让 IDE 卡死。VS 从某个版本开始默认给模板加了AllowsBackgroundLoading true这是好事因为能让扩展加载不阻塞 IDE 启动。但代价是你要自己负责切线程。模板生成的代码骨架protected override async Task InitializeAsync( CancellationToken cancellationToken, IProgressServiceProgressData progress) { await base.InitializeAsync(cancellationToken, progress); // 后台线程这里可以做耗时初始化但不能碰 UI await TaskScheduler.Default; // 需要碰 UI 的时候切回来 await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); await MyCommand.InitializeAsync(this); }这个骨架的正确性在于AddCommand要操作 UI 线程上的服务所以必须切回主线程。很多人在这一步栽跟头直接把MyCommand.InitializeAsync(this)放在base.InitializeAsync后面就完事结果就是随机崩溃。判断自己是不是在 UI 线程上最直接的办法是在方法开头加一句ThreadHelper.ThrowIfNotOnUIThread()。如果它不抛说明你在 UI 线程如果抛了说明你在后台。这个小技巧我在排查所有线程相关问题时都会用。4.2 JoinableTaskFactory 和那句经典异常Microsoft.VisualStudio.Threading这个库提供的JoinableTaskFactory是 VS 扩展里处理线程的核心工具。它的作用是让你能安全地从后台线程“切到 UI 线程做点事再切回来”同时避免经典的死锁场景。常见的几条规则切到 UI 线程await JoinableTaskFactory.SwitchToMainThreadAsync(token)切回去做后台活await TaskScheduler.Default千万别做在 UI 线程上对返回 Task 的方法调.Result或.Wait()第三条为什么危险因为 UI 线程被阻塞住了而你等待的那个任务可能正排队等着在 UI 线程上执行续体两边互相等死锁。VS 里几乎所有异步 API 都有这个问题所以只能老老实实await一路到底。那句最经典的异常An exception occurred while calling the GetService function或者The current thread is not on the UI thread百分之九十的情况是上面某条规则被违反了。看到这个提示直接去看异常栈里最近的ThreadHelper.ThrowIfNotOnUIThread调用点基本就定位了。4.3 ProvideAutoLoad 的代价别拖慢 IDE 启动ProvideAutoLoad决定你的包在什么条件下被加载。常见的上下文有[ProvideAutoLoad(UIContextGuids80.SolutionExists, PackageAutoLoadFlags.BackgroundLoad)] [ProvideAutoLoad(UIContextGuids80.NoSolution, PackageAutoLoadFlags.BackgroundLoad)]可用的上下文包括NoSolution、SolutionExists、EmptySolution、ShellInitialized、CodeWindow、SolutionBuilding、Debugging、ProjectRetargeting等等。值得强调的是你的包如果只是提供几个命令根本不需要ProvideAutoLoad。命令的可用性和包的加载是两回事——VS 会在用户第一次点击菜单时才去加载提供该命令的包。这是 VS 为了启动性能做的惰性加载机制白白加个ProvideAutoLoad就等于把这个优化丢掉了。如果确实需要自动加载务必带上PackageAutoLoadFlags.BackgroundLoad让加载过程不占用 UI 线程。同时InitializeAsync里不要做重量级工作——读大文件、扫全盘、拉网络请求这些全部推后到真正需要的时候再做。我自己遇到过一次很典型的事故包在InitializeAsync里同步读取了一个几百 KB 的配置 JSON 并解析加了ProvideAutoLoad(UIContextGuids80.SolutionExists)但没加BackgroundLoad。结果是每次打开含解决方案的项目VS 都要卡顿两三秒用户直接把这个扩展禁用了。修复方案是把配置读取改成懒加载第一次用到的时候再读并且放到后台线程。4.4 后台线程访问 VS 服务的正确姿势在后台线程拿服务要用GetServiceAsync不能用GetService// 后台线程可用 var shell await package.GetServiceAsync(typeof(SVsShell)) as IVsShell; // 只有 UI 线程可用 var uiShell package.GetService(typeof(SVsUIShell));如果拿到的是 COM 接口IVs*系列基本都是在后台线程调用时还要留意套间Apartment问题。VS 的主线程是 STA 套间很多 COM 对象只能在 STA 里调用。跨线程直接调这些接口可能会得到一个RPC_E_WRONG_THREAD或者更隐蔽的挂起。稳妥的做法是把需要和 VS 交互的部分集中在一个方法里方法开头切到 UI 线程做完该做的立刻切回后台。而不是在后台代码里零散地穿插 UI 调用。这样代码结构清晰也不容易漏。5. 把功能真正嵌进 IDE工具窗口、选项页与编辑器扩展命令和菜单只是入口真正体现一个扩展价值的往往是工具窗口、可配置项和对编辑器的增强。5.1 ToolWindowPane 的生命周期工具窗口需要一个继承ToolWindowPane的类和一个ProvideToolWindow特性[Guid(11111111-2222-3333-4444-555555555555)] public class MyToolWindow : ToolWindowPane { public MyToolWindow() : base(null) { Caption My Tool Window; Content new MyToolWindowControl(); } }注册和停靠位置[ProvideToolWindow(typeof(MyToolWindow), Style VsDockStyle.Tabbed, Window ToolWindowGuids.SolutionExplorer)] [ProvideToolWindowVisibility(typeof(MyToolWindow), UIContextGuids80.SolutionExists)]Window参数指定和哪个已有窗口组成标签页组ToolWindowGuids里有很多现成的常量。ProvideToolWindowVisibility控制窗口在什么上下文下可见不加的话窗口会一直出现在“视图 - 其他窗口”菜单里即使当前没有解决方案。创建窗口的代码var window await package.ShowToolWindowAsync( typeof(MyToolWindow), 0, true, package.DisposalToken); if (window?.Frame null) throw new NotSupportedException(Cannot create tool window);这里第二个参数是实例 ID。如果你需要同一类工具窗口开多个实例比如每个连接一个窗口就要用不同的 ID并且注册时用ProvideToolWindow的多实例模式。单实例场景传 0 就行。工具窗口的内容一般是 WPF 控件这意味着你可以用 MVVM但要注意工具窗口的构造可能在非 UI 线程上被调用控件里不要做重活。5.2 DialogPage 选项页与配置持久化选项页继承DialogPage用公共属性暴露配置项public class MyOptionsPage : DialogPage { [Category(My Extension)] [DisplayName(服务地址)] [Description(内部服务的基地址)] public string Endpoint { get; set; } https://example.invalid; [Category(My Extension)] [DisplayName(超时秒)] public int TimeoutSeconds { get; set; } 30; }注册[ProvideOptionPage(typeof(MyOptionsPage), My Extension, 常规, 0, 0, true)]最后一个参数true表示支持自动化能被宏或者其他工具访问。读取配置var page (MyOptionsPage)package.GetDialogPage(typeof(MyOptionsPage)); var endpoint page.Endpoint;这里有个非常值得说的细节DialogPage 的属性读写必须发生在 UI 线程上。因为它是通过 COM 的自动化接口暴露给属性网格的在后台线程读取会出问题。而且GetDialogPage每次返回的是同一个实例还是新实例取决于实现细节不要缓存它也不要假设属性值不会变——用户可以随时在选项对话框里改。另一个经验如果配置项很多或者需要更复杂的结构列表、嵌套对象DialogPage 会显得笨重。我一般会把 DialogPage 当成一个薄薄的入口真正的配置数据存在自己的 JSON 文件里DialogPage 只负责读写那个文件。这样序列化逻辑完全可控也不会被属性网格的类型限制绑死。5.3 DTE 和原生服务什么时候用哪个DTEDevelopment Tools Environment是 VS 的老自动化对象模型通过它几乎能做任何事操作解决方案、编辑文件、执行命令、访问代码模型。用GetServiceAsync(typeof(SDTE))就能拿到。但DTE的问题也很明显全 COMAPI 命名混乱Project和ProjectItem的语义混乱是出了名的线程要求严格而且在大解决方案上反射式调用性能很差。VS 这些年提供了一批新的服务接口速度更快、类型更清晰。做选择有个简单原则如果新接口能做到就优先用新接口。比如需求老方案新方案获取解决方案里的项目DTE.Solution.ProjectsIVsSolutionIAsyncServiceProvider读取编辑器文本DTE.ActiveDocumentITextBufferITextDocument显示消息框DTE.StatusBar/MessageBoxSVsUIShell的IVsUIShell.ShowMessageBox解决方案事件DTE.Events.SolutionEventsIVsSolutionEvents/IVsSolutionEvents4新接口的代价是代码量大一些需要处理更多的 COM 细节和事件解绑。事件解绑这点尤其重要——Advise之后一定要在包释放时Unadvise否则会造成内存泄漏而且可能因为回调到已释放的对象上而崩溃。5.4 编辑器层面能做哪些增强编辑器扩展用的是 MEF不是 VSPackage 那套。基本套路是导出一个接口[Export(typeof(IWpfTextViewCreationListener))] [ContentType(text)] [TextViewRole(PredefinedTextViewRoles.Document)] internal sealed class MyListener : IWpfTextViewCreationListener { public void TextViewCreated(IWpfTextView textView) { // 订阅事件、加装饰层 } }常见的编辑器扩展点有这么几类分类器IClassifierProvider做语法高亮、装饰器IAdornmentLayer画波浪线或者小图标、快速信息IQuickInfoSourceProvider悬浮提示、命令过滤器ICommandFilter拦截按键、智能提示ICompletionSourceProvider。MEF 组件的调试有个额外注意事项MEF 缓存非常顽固。改了导出之后重新生成如果行为没变先删ComponentModelCache目录。这个坑我踩过很多次每次都要愣一下才想起来。另外 MEF 组件的线程身份也不确定TextViewCreated通常在 UI 线程上被调用但不要假设一直如此。里面如果要起异步任务同样要走JoinableTaskFactory。6. 从 F5 到市场调试、打包与发布的实战细节功能写完了只是走了一半剩下的调试、打包、发布才是决定这个扩展能不能被别人用起来的部分。6.1 断点打不中时的排查顺序调试 VS 扩展最常见的困扰是断点变成空心圈提示“当前不会命中断点尚未为该文档加载任何符号”。我一般是按这个顺序排查第一步确认附加的进程是带/rootsuffix Exp的那个devenv.exe。VS 调试时会自动附加到实验实例但如果你手工附加过别的进程可能会搞混。在“调试 - 附加到进程”里看一下进程的命令行参数。第二步确认程序集真的被部署到了实验实例。检查%LocalAppData%\Microsoft\VisualStudio\17.0_xxxxxxxxExp\Extensions\你的发布者\你的扩展\下有没有最新的 DLL。没有的话检查工程属性里DeployExtension是不是 true。第三步确认代码路径真的被执行了。如果包根本没被加载断点当然不会命中。可以在InitializeAsync第一行打个断点或者干脆看ActivityLog.xml里有没有关于你这个包的记录。第四步如果前三步都正常那就是缓存问题。重置实验实例重启调试。有个更省事的做法在devenv启动参数里加上/log所有包加载失败、命令表解析错误、MEF 组合错误都会记录到 ActivityLog.xml 里。这个文件是我排查“什么都没发生”类问题的第一站比打断点还快。6.2 vsixmanifest 里的版本区间和架构source.extension.vsixmanifest里有两个字段决定了这个扩展能装到哪些 VS 上Installation InstallationTarget IdMicrosoft.VisualStudio.Community Version[17.0,18.0) ProductArchitectureamd64/ProductArchitecture /InstallationTarget /InstallationVersion是一个区间表达式[17.0,18.0)表示 17.0 及以上、18.0 以下。VS 2022 的主版本号是 17所以这个区间覆盖了整个 VS 2022 系列。如果你想让扩展同时支持更早的版本需要把范围放宽并且确认 API 兼容性。ProductArchitecture在 VS 2022 上必须是amd64因为 VS 2022 是 64 位进程。这条如果漏了或者写成x86安装器会直接拒绝安装提示架构不匹配。另外一个坑是Id字段。Id必须全局唯一通常用发布者名.扩展名.随机串的形式。如果你改过这个 ID 之后重新发布VS 会当成两个不同的扩展老用户不会收到更新。所以定下来之后就别改了。6.3 第三方依赖打包方式与冲突处理VS 扩展引用第三方库有几个必须注意的点。第一注意 .NET Framework 版本。VS 2022 的进程内扩展跑在 .NET Framework 4.7.2 上你引用的库必须兼容这个框架版本。引了net8.0的库会在运行时加载失败。第二注意版本冲突。如果两个扩展引用了同一个库的不同版本先加载的那个会赢另一个扩展可能因此报FileLoadException。常见的受害库是 JSON 序列化相关和日志库。规避办法是尽量用 VS 自己带的那份比如Newtonsoft.Json在某些 VS 版本里是内置的或者仔细核对版本区间。第三注意哪些程序集该打进 vsix。默认情况下引用会被包含进去但有些 VS SDK 程序集不应该打包因为它们由宿主提供。这些通常通过IncludeAssemblyInVSIXContainer元数据控制ItemGroup VSIXSourceItem Include$(OutputPath)MyLib.dll / /ItemGroup打包错了的典型症状是安装时报依赖冲突或者装完之后 VS 启动异常。稳妥的做法是把生成的 vsix 当成一个 zip 打开看看里面都有什么和你的预期对一遍。第四InstallationTarget和依赖的对应关系。如果你的扩展依赖特定版本的 VS 组件manifest 里的Dependencies也要写清楚安装器会在安装前做检查。6.4 发布前要确认的几件事发布到 Marketplace 需要几个前置条件顺序不能乱。首先你需要一个发布者账号并且vsixmanifest里的Publisher字段必须和账号 ID 完全一致。这个字段不匹配是发布失败最常见的原因。其次版本号要在 manifest 和AssemblyInfo里保持一致并且每次发布都要递增。VS 的自动更新是靠版本号比对来判断的版本号不变用户就永远收不到更新。第三发布推荐用命令行工具vsixPublisher.exe配合一个 publish manifest这样能放进流水线自动发布vsixPublisher.exe publish ^ -payload MyExtension.vsix ^ -publishManifest publishManifest.json ^ -personalAccessToken tokenpublishManifest.json里需要填写扩展的内部名和发布者信息格式在官方文档里有模板。把 token 放在环境变量里而不是硬编码进脚本这是基本的安全习惯。第四描述、图标、README 这些元信息虽然不影响功能但直接影响别人会不会装。我见过不少功能不错的小扩展因为没有截图、描述只有一句话下载量一直上不去。7. 几个真实踩坑案例的复盘上面讲的都是原理和规则这一节讲几个我自己实际撞过的具体问题以及完整的排查过程。7.1 扩展让 IDE 启动变慢被自动禁用现象是这样的功能本身没毛病但用户反馈每次打开解决方案都要等两三秒后来 VS 直接弹窗说“此扩展可能导致启动变慢”。一开始我以为是 VS 误报直到打开 ActivityLog 才看到自己包加载耗时 2700 毫秒。排查过程分三步。第一步看加载时机发现用了[ProvideAutoLoad(UIContextGuids80.SolutionExists)]而且没带BackgroundLoad所以整个加载是同步在 UI 线程上完成的。第二步看InitializeAsync里的内容发现有段代码在扫描解决方案目录下的所有文件并建立索引。第三步确认这活能不能推后结论是完全可以——索引只有在用户真正使用工具窗口时才需要。修复方案有两个动作。一是加上PackageAutoLoadFlags.BackgroundLoad让加载不占 UI 线程二是把索引构建改成在工具窗口第一次打开时触发并且放在后台线程执行界面上显示一个进度指示。改完之后加载耗时降到 30 毫秒以内用户的抱怨也消失了。这件事教给我的教训是扩展作者对启动性能的敏感度必须比对自己代码正确性的敏感度还高。用户能容忍功能不全但不能容忍 IDE 变卡。7.2 强命名与第三方 DLL 版本冲突有一次扩展在开发机上一切正常到测试同事机器上必崩错误是Could not load file or assembly指向一个 JSON 库。排查思路是这样的先看崩溃日志里要求的是哪个版本发现要求的是 A 版本而我打包的是 B 版本。接着看为什么会被要求 A 版本——原来是另一个已安装的扩展在宿主里先加载了这个库的 A 版本CLR 在同一个 AppDomain 里只认一个版本我的扩展去加载时就拿到了 A 版本的引用但实际文件是 B 版本的。这类问题的解法不是去改版本号硬凑而是从根上避免在宿主进程里引入容易冲突的库。有三条路一是尽量用 BCL 自带的System.Text.Json或者 VS 自带的组件二是把库改名做内部化处理ILMerge 或者 Costura 这类工具三是如果非用不可确保引用的是宿主里已经存在的那个版本并且在 manifest 里声明依赖。我最终选了第一条路把 JSON 处理换成了 BCL 方案冲突直接消失。这个坑的隐蔽性在于它只在特定机器组合上复现本地测试根本发现不了。7.3 改了代码但行为没变这个问题前面提过但值得单独说因为它太常见了。表现是改了.vsct里的菜单文案重新生成F5菜单文案还是旧的。根本原因是 VS 的 pkgdef 合并机制和 MEF 缓存都做了持久化。具体来说实验实例的私有注册表privateregistry.bin里存的是上一次合并的 pkgdef如果文件时间戳判断出错它就不会重新合并。MEF 那边更明显ComponentModelCache目录里的缓存会一直用到失效为止。处理流程是分层的从轻到重只改了 MEF 导出删掉ComponentModelCache重启调试。改了.vsct或包类上的特性删掉实验实例目录下的Extensions子目录里自己的扩展重新生成部署。以上都不行完全重置实验实例。把这三步形成肌肉记忆之后这类问题基本不会超过两分钟。我现在的习惯是只要调试结果和代码预期对不上先无条件做第 2 步再去怀疑代码。7.4 本地化和资源 ID 的那些细节多语言支持这块.vsct里的字符串和代码里的字符串走的是两套机制容易搞混。.vsct里的ButtonText可以直接写死文案也可以写成#1001这样的资源引用然后在VSPackage.resx及其本地化版本里定义。用资源引用的方式才能被本地化。如果你写死了那不管用户的 IDE 是什么语言菜单都是那句写死的话。代码里的字符串比如消息框文案、工具窗口标题则通过标准 .NET 资源机制处理就是把Strings.resx复制成Strings.zh-CN.resx之类的卫星资源。有个细节很容易踩资源 ID 必须和 resx 文件里的键对应上。InstalledProductRegistration(#110, #112, ...)里的#110和#112指的是资源 ID 而不是资源名字符串。如果你的 resx 里用的是字符串键而不是数字 ID构建时会报错或者运行时显示乱码。另外本地化资源需要在 vsixmanifest 的Assets里正确声明语言否则安装器不知道这个包支持哪些语言。这块的配置比较琐碎我的做法是先用英文跑通全流程确认发布之后再加中文资源避免一开始就被资源问题卡住。写到这里如果你已经能顺利跑起来一个带工具窗口、有选项页、能从 Marketplace 安装的扩展那这套东西的核心就掌握了。剩下的都是具体 API 的熟练度问题。如果卡在某个环节我建议的排查顺序永远是先看 ActivityLog再确认实验实例是干净的最后才去怀疑代码逻辑——因为这个平台上“什么都没发生”几乎总能归因到注册信息或者缓存而不是你的 C# 写错了。
返回列表