选择适合的 Visual Studio 可扩展性模型

可以使用 VSSDK、社区工具包和 VisualStudio.Extensibility 这三种主要的可扩展性模型来扩展 Visual Studio。 本文将介绍每种方法的优缺点。 我们用一个简单的示例来强调这两种模式在体系结构和代码上的差异。

VSSDK

VSSDK(即 Visual Studio SDK)是 Visual Studio Marketplace 中大多数扩展所基于的模型。 Visual Studio 本身就是基于这种模型构建的。 它的功能最为完整和强大,但学习和正确使用也最为复杂。 使用 VSSDK 的扩展与 Visual Studio 本身在同一进程中运行。 在与 Visual Studio 相同的进程中加载,意味着扩展如果出现访问违规、无限循环或其他问题,就会导致 Visual Studio 崩溃或挂起,从而降低客户体验。 由于扩展与 Visual Studio 在同一进程中运行,因此只能使用 .NET Framework 来生成。 希望使用或合并使用 .NET 5 及更高版本的库的扩展人员无法使用 VSSDK。

多年来,随着 Visual Studio 自身的转变和发展,VSSDK 中的 API 也在不断聚合。 在单个扩展中,可能会发现自己需要处理来自旧式印记的基于 COM 的 API、迅速轻松地完成 DTE 的欺骗性简化,以及处理 MEF 导入和导出。 让我们以编写一个扩展为例,该扩展会从文件系统中读取文本,并将其插入编辑器中当前活动文档的起始位置。 下面的代码段显示了在基于 VSSDK 的扩展中调用命令时要编写的处理代码:

private void Execute(object sender, EventArgs e)
{
    var textManager = package.GetService<SVsTextManager, IVsTextManager>();
    textManager.GetActiveView(1, null, out IVsTextView activeTextView);

    if (activeTextView != null && activeTextView is IVsTextViewEx nativeView)
    {
        ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

        IComponentModel2 compService = package.GetService<SComponentModel, IComponentModel2>();
        IVsEditorAdaptersFactoryService editorAdapter = compService.GetService<IVsEditorAdaptersFactoryService>();
        var wpfTextView = editorAdapter?.GetWpfTextView(activeTextView);

        if (frameValue is IVsWindowFrame frame && wpfTextView != null)
        {
            var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
            wpfTextView.TextBuffer?.Insert(0, fileText);
        }
    }
}

此外,还需要提供一个 .vsct 文件,用于定义命令配置,如在 UI 中的位置、关联的文本等:

<Commands package="guidVSSDKPackage">
    <Groups>
        <Group guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS" />
        </Group>
    </Groups>

    <Buttons>
        <Button guid="guidVSSDKPackageCmdSet" id="InsertTextCommandId" priority="0x0100" type="Button">
        <Parent guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" />
        <Icon guid="guidImages" id="bmpPic1" />
        <Strings>
            <ButtonText>Invoke InsertTextCommand (Unwrapped Community Toolkit)</ButtonText>
        </Strings>
        </Button>
        <Button guid="guidVSSDKPackageCmdSet" id="cmdidVssdkInsertTextCommand" priority="0x0100" type="Button">
        <Parent guid="guidVSSDKPackageCmdSet" id="MyMenuGroup" />
        <Icon guid="guidImages1" id="bmpPic1" />
        <Strings>
            <ButtonText>Invoke InsertTextCommand (VSSDK)</ButtonText>
        </Strings>
        </Button>
    </Buttons>

    <Bitmaps>
        <Bitmap guid="guidImages" href="Resources\InsertTextCommand.png" usedList="bmpPic1, bmpPic2, bmpPicSearch, bmpPicX, bmpPicArrows, bmpPicStrikethrough" />
        <Bitmap guid="guidImages1" href="Resources\VssdkInsertTextCommand.png" usedList="bmpPic1, bmpPic2, bmpPicSearch, bmpPicX, bmpPicArrows, bmpPicStrikethrough" />
    </Bitmaps>
</Commands>

正如示例中所看到的那样,代码看起来并不直观,熟悉 .NET 的人也不可能轻松掌握。 有许多概念需要学习,而且访问活动编辑器文本的 API 模式已经过时。 对于大多数扩展人员来说,VSSDK 扩展都是通过复制和粘贴在线资源来构建的,而这可能会导致困难的调试过程、试验和错误以及挫败感。 在许多情况下,VSSDK 扩展可能不是实现扩展目标的最简单方法(尽管有时它们是唯一的选择)。

社区工具包

社区工具包是 Visual Studio 基于社区的开源扩展模型,它封装了 VSSDK,使开发体验更加轻松。 由于它基于 VSSDK,因此受到与 VSSDK 相同的限制(即只能使用 .NET Framework,不能与 Visual Studio 的其他部分隔离等)。 继续以编写插入从文件系统读取的文本的扩展为例,通过使用“社区工具包”,命令处理程序的扩展编写如下:

protected override async Task ExecuteAsync(OleMenuCmdEventArgs e)
{
    DocumentView docView = await VS.Documents.GetActiveDocumentViewAsync();
    if (docView?.TextView == null) return;
    var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
    docView.TextBuffer?.Insert(0, fileText);
}

由此产生的代码与 VSSDK 相比,在简洁性和直观性方面都有很大改进! 我们不仅大大减少了行数,而且生成的代码看起来也很合理。 无需了解 SVsTextManagerIVsTextManager 之间的区别。 这些 API 在外观和感觉上更适合 .NET,采用了常见的命名和异步模式,并对常见操作进行了优先级排序。 但是,社区工具包仍然建立在现有的 VSSDK 模型之上,因此,底层结构的残余会渗透进来。 例如,.vsct 文件仍然必不可少。 虽然社区工具包在简化 API 方面表现出色,但它会受到 VSSDK 的限制,并且无法简化扩展配置。

VisualStudio.Extensibility

VisualStudio.Extensibility 是一种新的可扩展性模型,其中扩展在 Visual Studio 主进程之外运行。 由于这一根本性的体系结构转变,扩展现在可以使用 VSSDK 或社区工具包无法实现的新模式和功能。 VisualStudio.Extensibility提供了一套全新的 API,这些 API 具有一致性且易于使用,允许扩展以 .NET 为目标,将扩展产生的错误与 Visual Studio 的其他部分隔离开来,并让用户无需重启 Visual Studio 即可安装扩展。 但由于新模式是建立在新的基础体系结构上的,因此它还不具备 VSSDK 和社区工具包所具有的广度。 为了弥补这一差距,可以在进程中运行 VisualStudio.Extensibility 扩展,这样就可以继续使用 VSSDK API。 但是,这样做意味着扩展只能以 .NET Framework 为目标,因为它会与基于 .NET Framework 的 Visual Studio 共享相同的进程。

继续以编写从文件中插入文本的扩展为例,使用 VisualStudio.Extensibility,该扩展的命令处理将编写如下:

public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken)
{
    var activeTextView = await context.GetActiveTextViewAsync(cancellationToken);
    if (activeTextView is not null)
    {
        var editResult = await Extensibility.Editor().EditAsync(batch =>
        {
            var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));

            ITextDocumentEditor editor = activeTextView.Document.AsEditable(batch);
            editor.Insert(0, fileText);
        }, cancellationToken);
                
    }
}

要为位置、文本等配置命令,不再需要提供 .vsct 文件。 它已改为通过代码来完成:

public override CommandConfiguration CommandConfiguration => new("%VisualStudio.Extensibility.Command1.DisplayName%")
{
    Icon = new(ImageMoniker.KnownValues.Extension, IconSettings.IconAndText),
    Placements = [CommandPlacement.KnownPlacements.ExtensionsMenu],
};

代码更容易理解和遵循。 在大多数情况下,可以借助 IntelliSense 通过编辑器来编写扩展,甚至是命令配置。

比较不同的 Visual Studio 可扩展性模型

从示例中,你可能会注意到,使用 VisualStudio.Extensibility 命令处理程序中的代码行数要多于社区工具包。 社区工具包是在使用 VSSDK 构建扩展功能的基础上开发的一款非常易于使用的包装工具;但是,它也存在一些不易察觉的缺陷,而这也是开发 VisualStudio.Extensibility 的初衷。 为了理解这种转变和需求,尤其是当社区工具包似乎也能产生易于编写和理解的代码时,让我们回顾一下示例,比较一下代码深层发生了什么。

我们可以快速解包此示例中的代码,看看 VSSDK 方面实际调用了哪些代码。 我们将只关注命令执行代码段,因为 VSSDK 需要许多详细信息,而社区工具包很好地隐藏了这些细节。 但是,一旦我们看了基础代码,就会明白为什么这里的简化是一种权衡。 这种简化隐藏了一些基础详细信息,可能会导致意想不到的行为、bug,甚至性能问题和崩溃。 以下代码段显示了社区工具包代码的封装,以显示 VSSDK 调用:

private void Execute(object sender, EventArgs e)
{
    package.JoinableTaskFactory.RunAsync(async delegate
    {
        var textManager = await package.GetServiceAsync<SVsTextManager, IVsTextManager>();
        textManager.GetActiveView(1, null, out IVsTextView activeTextView);

        if (activeTextView != null && activeTextView is IVsTextViewEx nativeView)
        {
            await package.JoinableTaskFactory.SwitchToMainThreadAsync();
            ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

            IComponentModel2 compService = package.GetService<SComponentModel, IComponentModel2>();
            IVsEditorAdaptersFactoryService editorAdapter = compService.GetService<IVsEditorAdaptersFactoryService>();
            var wpfTextView = editorAdapter?.GetWpfTextView(activeTextView);

            if (frameValue is IVsWindowFrame frame && wpfTextView != null)
            {
                var fileText = File.ReadAllText(Path.Combine(Path.GetTempPath(), "test.txt"));
                wpfTextView.TextBuffer?.Insert(0, fileText);    
            }
        }
    });
}

这里要讨论的问题不多,都围绕着线程处理和异步代码。 我们将逐一详细介绍。

异步 API 与异步代码执行

首先要注意的是,社区工具包中的 ExecuteAsync 方法是 VSSDK 中的封装异步“即发即弃”调用:

package.JoinableTaskFactory.RunAsync(async delegate
{
  …
});

从核心 API 的角度来看,VSSDK 本身并不支持异步命令执行。 也就是说,当命令被执行时,VSSDK 没有办法在后台线程上执行命令处理程序代码,等待其完成,并将执行结果返回给用户原始调用上下文。 因此,尽管社区工具包中的 ExecuteAsync API 在语法上是异步的,但它并不是真正的异步执行。 由于这是一种“即发即弃”的异步执行方式,因此可以反复调用 ExecuteAsync,而无需等待前一次调用完成。 虽然社区工具包在帮助扩展人员发现如何实现常见方案方面提供了更好的体验,但它无法最终解决 VSSDK 的根本问题。 在这种情况下,基础 VSSDK API 并非异步,而社区工具包提供的“即发即弃”帮助程序方法无法正确处理异步生成和客户端状态工作;它可能会隐藏一些难以调试的潜在问题。

UI 线程与后台线程

社区工具包的这种封装异步调用的另一个后果是,代码本身仍在 UI 线程中执行,如果不想冒冻结 UI 的风险,扩展开发人员就必须找出如何正确切换到后台线程的方法。 尽管社区工具包可以隐藏 VSSDK 的干扰和额外代码,但它仍然要求你了解 Visual Studio 中线程的复杂性。 学习 VS 线程的第一课就是,并不是所有东西都能在后台线程中运行。 换句话说,并非所有内容都是线程安全的,特别是进入 COM 组件的调用。 因此,在上面的示例中,可以看到有一个切换到主 (UI) 线程的调用:

await package.JoinableTaskFactory.SwitchToMainThreadAsync();
ErrorHandler.ThrowOnFailure(nativeView.GetWindowFrame(out object frameValue));

当然,也可以在调用后切换回后台线程。 但是,作为使用社区工具包的扩展人员,需要密切关注代码所在的线程,并确定它是否有冻结 UI 的风险。 Visual Studio 中的线程很难正确使用,需要正确使用 JoinableTaskFactory 以避免死锁。 即使是我们内部的 Visual Studio 工程师,也很难编写出正确处理线程的代码,这一直是 bug 层出不穷的根源。 另一方面,VisualStudio.Extensibility 通过在进程外运行扩展和端到端依赖异步 API,完全避免了这一问题。

简单 API 与简单概念

由于社区工具包隐藏了 VSSDK 的许多复杂功能,它可能会给扩展人员带来一种虚假的简化。 我们继续来学习相同的示例代码。 如果扩展人员不了解 Visual Studio 开发的线程要求,他们可能会认为自己的代码一直在后台线程中运行。 他们不会对从文本读取文件的调用是同步的这一事实有任何异议。 如果是在后台线程上运行,如果相关文件较大,就不会冻结 UI。 但是,当把代码解包到 VSSDK 时,他们就会发现情况并非如此。 因此,虽然社区工具包的 API 看起来更容易理解,编写起来也更连贯,但由于它与 VSSDK 绑定,因此会受到 VSSDK 的限制。 简化会掩盖一些重要的概念,如果扩展人员不理解这些概念,就会造成更大的损害。 VisualStudio.Extensibility 以进程外模型和异步 API 为基础,避免了主线程依赖性带来的诸多问题。 虽然在进程外运行会最大程度地简化线程,但其中许多优点也同样适用于在进程内运行的扩展。 例如,VisualStudio.Extensibility 命令总是在后台线程上执行。 与 VSSDK API 交互仍然需要深入了解线程的工作原理,但至少不会像本例中那样付出意外挂起的代价。

比较图表

为了总结上一节的详细内容,下表进行了快速对比:

VSSDK 社区工具包 VisualStudio.Extensibility
运行时支持 .NET Framework .NET Framework .NET
与 Visual Studio 隔离
简单 API
异步执行和 API
VS 方案广度
无需重启即可安装
支持 VS 2019 及更低版本

为了帮助你将比较结果应用于 Visual Studio 可扩展性需求,以下是一些示例方案和我们关于使用哪种模型的建议:

  • 我是 Visual Studio 扩展开发的新手,我希望获得最简单的上手体验来创建高质量的扩展,而且我只 需要 支持 Visual Studio 2022 或更高版本。
    • 在这种情况下,我们建议使用 VisualStudio.Extensibility。
  • 我想编写一个针对 Visual Studio 2022 及以上版本的扩展。但是, VisualStudio.Extensibility 并不支持我需要的所有功能。
    • 我们建议,在这种情况下应采用将 VisualStudio.Extensibility 和 VSSDK 相结合的混合方法。 可以创建一个 VisualStudio.Extensibility 扩展,该扩展在进程中运行,允许你访问 VSSDK 或社区工具包 API。
  • 我有一个现有扩展,希望对其进行更新以支持更新的版本。我希望扩展支持尽可能多的 Visual Studio 版本。
    • 由于 VisualStudio.Extensibility 仅支持 Visual Studio 2022 及以上版本,因此 VSSDK 或社区工具包是这种情况下的最佳选择。
  • 我有一个现有的扩展,我想将它迁移到 VisualStudio.Extensibility,以利用 .NET 的优势,并且无需重新启动即可安装。
    • 由于 VisualStudio.Extensibility 不支持 Visual Studio 的低级版本,因此这种情况就比较微妙。
      • 如果你现有的扩展程序只支持 Visual Studio 2022,并且拥有你所需的全部 API,那么我们建议你重写扩展,以便使用 VisualStudio.Extensibility。 但是,如果扩展需要 VisualStudio.Extensibility 尚不具备的 API,那么请继续创建一个在进程中运行的 VisualStudio.Extensibility 扩展,这样就可以访问 VSSDK API。 随着 VisualStudio.Extensibility 增加支持,你可以逐渐取消 VSSDK API 的使用,并将扩展移至进程外运行。
      • 如果你的扩展需要支持不支持 VisualStudio.Extensibility 的 Visual Studio 低级版本,我们建议对代码库进行一些重构。 将所有可跨 Visual Studio 版本共享的通用代码提取到自己的库中,并创建针对不同扩展性模型的独立 VSIX 项目。 例如,如果扩展需要支持 Visual Studio 2019 和 Visual Studio 2022,则可以在解决方案中采用以下项目结构:
        • MyExtension-VS2019(这是基于 VSSDK 的 VSIX 容器项目,以 Visual Studio 2019 为目标)
        • MyExtension-VS2022(这是基于 VSSDK+VisualStudio.Extensibility 的 VSIX 容器项目,以 Visual Studio 2022 为目标)
        • VSSDK-CommonCode(这是一个通用库,用于通过 VSSDK 调用 Visual Studio API。你的两个 VSIX 项目都可以引用该库来共享代码。)
        • MyExtension-BusinessLogic(这是一个通用库,包含与扩展业务逻辑相关的所有代码。你的两个 VSIX 项目都可以引用该库来共享代码。)

后续步骤

建议扩展人员在创建新扩展或增强现有扩展时从 VisualStudio.Extensibility 开始,如果遇到不支持的情况,则使用 VSSDK 或社区工具包。 要开始使用 VisualStudio.Extensibility,请浏览本部分介绍的文档。 还可以参考 VSExtensibility GitHub 存储库以获取示例或文件问题