dotnet 职业技术学院

博客

dotnet 职业技术学院

如果不用 ReSharper,那么 Visual Studio 2019 能还原 ReSharper 多少功能呢?

dotnet 职业技术学院 发布于 2019-07-10

本文只谈论 ReSharper 的那些常用功能中,Visual Studio 2019 能还原多少,主要提供给那些正在考虑不使用 ReSharper 插件的 Visual Studio 用户作为参考。毕竟 ReSharper 如此强大的功能是建立在每年缴纳不少的费用以及噩梦般占用 Visual Studio 性能的基础之上的。然而使用 Visual Studio 2019 社区版不搭配 ReSharper 则可以免费为开源社区做贡献。


本文的内容分为三个部分:

  1. Visual Studio 能完全还原的 ReSharper 的功能
    • 可能 Visual Studio 在此功能上已经追赶上了 ReSharper
    • 可能 Visual Studio 在此功能上虽然依然不如 ReSharper 完善,但缺少的部分几乎不影响体验
    • 可能 Visual Studio 此功能比 ReSharper 更胜一筹
  2. Visual Studio 能部分还原 ReSharper 的功能
    • 可能在多数场景中 Visual Studio 能获得 ReSharper 的此功能效果,在少数场景下不如 ReSharper
    • 可能对多数人来说 Visual Studio 能获得 ReSharper 的此功能效果,对另一部分人来说无法替代 ReSharper
    • 有可能 Visual Studio 在此功能上另辟蹊径比 ReSharper 更厉害,但综合效果不如 ReSharper
    • Visual Studio 此功能依然很弱,但可以通过安装免费的插件的方式补足
  3. Visual Studio 此功能依然比不上 ReSharper
    • 可能是 Visual Studio 没有此功能
    • 可能是 Visual Studio 此功能的实现方式上不如 ReSharper 快速、高效、简单

完美还原

无处不在的智能感知提示

默认情况下,Visual Studio 只在你刚开始打字或者输入 .( 的时候才出现智能感知提示,但是如果你使用 ReSharper 开发,你会发现智能感知提示无处不在(所以那么卡?)。

实际上你也可以配置 Visual Studio 的智能感知在更多的情况下出现,请打开下面“工具”->“选项”->“文本编辑器”->“C#”->“IntelliSense”:

打开更多的智能感知提示时机

打开“键入字符后显示完成列表”和“删除字符后显示完成列表”。这样,你只要正在编辑,都会显示智能感知提示。

另外,如果你当前需要打开智能感知提示,默认情况下使用 Ctrl + 空格键 可以打开。当然你也可以将其修改为 ReSharper 中常见的快捷键 Alt + 右箭头。方法是修改键盘快捷键中的 “” 项。

完成列表

修改快捷键方法详见:

另外,在 IntelliCode 部分,可以选择打开更多的 IntelliSense 完成项:

IntelliCode

在输入时即自动导入需要的命名空间

ReSharper 的智能感知提示包含所依赖的各种程序集中的类型,然而 Visual Studio 的智能感知则没有包含那些,只有顶部写了 using 的几个命名空间中的类型。

Visual Studio 2019 中可以设置智能感知提示中“显示未导入命名空间中的项”。默认是没有开启的,当开启后,你将直接能在智能感知提示中看到原本 ReSharper 中才能有的编写任何类型的体验。

智能感知中包含尚未导入的类型

默认情况下输入未知类型时只能完整输入类名然后使用重构快捷键将命名空间导入:

只能通过重构导入命名空间

但开启了此选项后,只需要输入类名的一部分,哪怕此类型还没有写 using 将其导入,也能在智能感知提示中看到并且完成输入。

可以导入命名空间的智能感知提示

提取局部变量

在 ReSharper 中,选中一段代码,如果这段代码可以返回一个值,那么可以使用重构快捷键(默认 Alt+Enter)生成一个局部变量。如果同样带代码块在此方法体中有多处,那么可以同时将多处代码一并提取出来成为一个布局变量。

在 Visual Studio 中,也可以选中一段代码将其提取称一个局部变量:

重命名标识符(类名/方法名/属性名/变量名等)

ReSharper 可以使用 Ctrl + R, R 快捷键重命名一个标识符。

Visual Studio 中也是默认使用 F2 或者与 ReSharper 相同的 Ctrl + R, R 快捷键来重命名一个标识符。

重命名标识符

可以还原

正在填坑……

依然不足

大量的代码片段

ReSharper 中自带了大量方便的代码片段,而且其代码片段的可定制性非常强,有很多可以只能完成的宏;而且还有后置式代码片段。

然而 Visual Studio 自带的代码片段就弱很多,只能支持最基本的宏。

不过可以通过下面一些插件通过数量来补足功能上的一些短板:

如何安装和准备 Visual Studio 扩展/插件开发环境

dotnet 职业技术学院 发布于 2019-07-07

因为很多涉及到 Visual Studio 插件开发相关的文章/博客需要以安装 Visual Studio 插件开发环境为基础,所以本文介绍如何安装 Visual Studio 插件开发环境,以简化那些博客中的内容。


启动 Visual Studio 安装程序

请在开始菜单中找到或者搜索 Visual Studio Installer,然后启动它:

找到并且启动 Visual Studio Installer

安装 Visual Studio 插件开发工作负载

在 Visual Studio 的安装界面中选择“修改”:

修改

在工作负载中找到并勾选 Visual Studio 扩展开发(英文版是 Visual Studio extension development),然后按下右下角的“修改”:

勾选 Visual Studio 扩展开发负载

等待 Visual Studio 安装完 Visual Studio 扩展开发。如果提示重启计算机,那么就重启一下。

如果你想开发基于 Roslyn 的语法/语义分析插件

如果你想开发基于 Roslyn 的语法/语义分析插件,那么需要在选择了 Visual Studio 扩展开发工作负载之后,在右侧将可选的 .NET Compiler Platform SDK 也打上勾。

.NET Compiler Platform SDK

体验 Visual Studio 插件模板

如果你成功安装了 Visual Studio 扩展开发的工作负载,那么你在新建项目的时候就可以看到 Visual Studio 扩展开发相关的项目模板。

Visual Studio 扩展开发相关模板

基于 Roslyn 同时为 Visual Studio 插件和 NuGet 包开发 .NET/C# 源代码分析器 Analyzer 和修改器 CodeFixProvider

dotnet 职业技术学院 发布于 2019-07-07

Roslyn 是 .NET 平台下十分强大的编译器,其提供的 API 也非常丰富好用。本文将基于 Roslyn 开发一个 C# 代码分析器,你不止可以将分析器作为 Visual Studio 代码分析和重构插件发布,还可以作为 NuGet 包发布。不管哪一种,都可以让我们编写的 C# 代码分析器工作起来并真正起到代码建议和重构的作用。


本文将教大家如何从零开始开发一个基于 Roslyn 的 C# 源代码分析器 Analyzer 和修改器 CodeFixProvider。可以作为 Visual Studio 插件安装和使用,也可以作为 NuGet 包安装到项目中使用(无需安装插件)。无论哪一种,你都可以在支持 Roslyn 分析器扩展的 IDE(如 Visual Studio)中获得如下面动图所展示的效果。

本文教大家可以做到的效果

开发准备

安装 Visual Studio 扩展开发工作负载

你需要先安装 Visual Studio 的扩展开发工作负载,如果你还没有安装,那么请先阅读以下博客安装:

Visual Studio 扩展开发

创建一个分析器项目

启动 Visual Studio,新建项目,然后在项目模板中找到 “Analyzer with Code Fix (.NET Standard)”,下一步。

Analyzer with Code Fix 模板

随后,取好项目名字之后,点击“创建”,你将来到 Visual Studio 的主界面。

我为项目取的名称是 Walterlv.Demo.Analyzers,接下来都将以此名称作为示例。你如果使用了别的名称,建议你自己找到名称的对应关系。

在创建完项目之后,你可选可以更新一下项目的 .NET Standard 版本(默认是 1.3,建议更新为 2.0)以及几个 NuGet 包。

首次调试

如果你现在按下 F5,那么将会启动一个 Visual Studio 的实验实例用于调试。

Visual Studio 实验实例

由于我们是一个分析器项目,所以我们需要在第一次启动实验实例的时候新建一个专门用来测试的小型项目。

简单起见,我新建一个 .NET Core 控制台项目。新建的项目如下:

测试用的控制台项目

我们目前只是基于模板创建了一个分析器,而模板中自带的分析器功能是 “只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写”。

于是我们看到 Program 类名底下标了绿色的波浪线,我们将光标定位到 Program 类名上,可以看到出现了一个 “小灯泡” 提示。按下重构快捷键(默认是 Ctrl + .)后可以发现,我们的分析器项目提供的 “Make uppercase” 建议显示了出来。于是我们可以快速地将类名修改为全部大写。

模板中自带的分析器建议

因为我们在前面安装了 Visual Studio 扩展开发的工作负载,所以可以在 “视图”->“其他窗口” 中找到并打开 Syntax Visualizer 窗格。现在,请将它打开,因为接下来我们的代码分析会用得到这个窗格。

打开语法可视化窗格

如果体验完毕,可以关闭 Visual Studio;当然也可以在我们的分析器项目中 Shift + F5 强制结束调试。

下次调试的时候,我们不需要再次新建项目了,因为我们刚刚新建的项目还在我们新建的文件夹下。下次调试只要像下面那样再次打开这个项目测试就好了。

打开历史记录中的项目

解读模板自带的分析器项目

项目和解决方案

在创建完项目之后,你会发现解决方案中有三个项目:

Visual Studio 分析器解决方案

  • Walterlv.Demo.Analyzers
    • 分析器主项目,我们接下来分析器的主要逻辑代码都在这个项目中
    • 这个项目在编译成功之后会生成一个 NuGet 包,安装了此包的项目将会运行我们的分析器
  • Walterlv.Demo.Analyzers.Vsix
    • Visual Studio 扩展项目,我们会在这里 Visual Studio 插件相关的信息
    • 这个项目在便已成功之后会生成一个 Visual Studio 插件安装包,Visual Studio 安装了此插件后将会对所有正在编辑的项目运行我们的分析器
    • 这个项目在默认情况下是启动项目(按下 F5 会启动这个项目调试),调试时会启动一个 Visual Studio 的实验实例
  • Walterlv.Demo.Analyzers.Test
    • 单元测试项目
    • 模板为我们生成了比较多的辅助代码帮助我们快速编写用于测试我们分析器可用性的单元测试,我们接下来的代码质量也靠这个来保证

在项目内部:

  • WalterlvDemoAnalyzersAnalyzer.cs
    • 模板中自带的分析器(Analyzer)的主要代码
    • 我们什么都还没有写的时候,里面已经包含一份示例用的分析器,其功能是找到包含小写的类名。
  • WalterlvDemoAnalyzersCodeFixProvider.cs
    • 模板中自带的代码修改器(CodeFixProvider)的主要代码
    • 我们什么都还没有写的时候,里面已经包含一份示例用的代码修改器,根据前面分析器中找到的诊断信息,给出修改建议,即只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写
  • Resources.resx
    • 这里包含分析器建议使用的多语言信息

多语言资源文件

分析器代码(Analyzer)

别看我们分析器文件中的代码很长,但实际上关键的信息并不多。

我们现在还没有自行修改 WalterlvDemoAnalyzersAnalyzer 类中的任何内容,而到目前位置这个类里面包含的最关键代码我提取出来之后是下面这些。为了避免你吐槽这些代码编译不通过,我将一部分的实现替换成了 NotImplementedException

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class WalterlvDemoAnalyzersAnalyzer : DiagnosticAnalyzer
{
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => throw new NotImplementedException();

    public override void Initialize(AnalysisContext context)
        => throw new NotImplementedException();
}

最关键的点:

  1. [DiagnosticAnalyzer(LanguageNames.CSharp)]
    • 为 C# 语言提供诊断分析器
  2. override SupportedDiagnostics
    • 返回此分析器支持的诊断规则
  3. override Initialize
    • 在此分析器初始化的时候执行某些代码

现在我们分别细化这些关键代码。为了简化理解,我将多语言全部替换成了实际的字符串值。

重写 SupportedDiagnostics 的部分,创建并返回了一个 DiagnosticDescriptor 类型的只读集合。目前只有一个 DiagnosticDescriptor,名字是 Rule,构造它的时候传入了一大堆字符串,包括分析器 Id、标题、消息提示、类型、级别、默认开启、描述信息。

可以很容易看出,如果我们这个分析器带有多个诊断建议,那么在只读集合中返回多个 DiagnosticDescriptor 的实例。

public const string DiagnosticId = "WalterlvDemoAnalyzers";

private static readonly LocalizableString Title = "Type name contains lowercase letters";
private static readonly LocalizableString MessageFormat = "Type name '{0}' contains lowercase letters";
private static readonly LocalizableString Description = "Type names should be all uppercase.";
private const string Category = "Naming";

private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

重写 Initialize 的部分,模板中注册了一个类名分析器,其实就是下面那个静态方法 AnalyzeSymbol

public override void Initialize(AnalysisContext context)
{
    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    // 省略实现。
    // 在模板自带的实现中,这里判断类名是否包含小写字母,如果包含则创建一个新的诊断建议以改为大写字母。
}

代码修改器(CodeFixProvider)

代码修改器文件中的代码更长,但关键信息也没有增加多少。

我们现在也没有自行修改 WalterlvDemoAnalyzersCodeFixProvider 类中的任何内容,而到目前位置这个类里面包含的最关键代码我提取出来之后是下面这些。为了避免你吐槽这些代码编译不通过,我将一部分的实现替换成了 NotImplementedException

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
public class WalterlvDemoAnalyzersCodeFixProvider : CodeFixProvider
{
    public sealed override ImmutableArray<string> FixableDiagnosticIds
        => throw new NotImplementedException();

    public sealed override FixAllProvider GetFixAllProvider()
        => WellKnownFixAllProviders.BatchFixer;

    public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
        => throw new NotImplementedException();
}

最关键的点:

  1. [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
    • 为 C# 语言提供代码修改器
  2. override FixableDiagnosticIds
    • 注意到前面 WalterlvDemoAnalyzersAnalyzer 类型中有一个公共字段 DiagnosticId 吗?在这里返回,可以为那里分析器找到的代码提供修改建议
  3. override GetFixAllProvider
    • 在最简单的示例中,我们将仅仅返回 BatchFixer,其他种类的 FixAllProvider 我将通过其他博客进行说明
  4. override RegisterCodeFixesAsync
    • FixableDiagnosticIds 属性中我们返回的那些诊断建议这个方法中可以拿到,于是为每一个返回的诊断建议注册一个代码修改器(CodeFix)

在这个模板提供的例子中,FixableDiagnosticIds 返回了 WalterlvDemoAnalyzersAnalyzer 类中的公共字段 DiagnosticId

public sealed override ImmutableArray<string> FixableDiagnosticIds =>
    ImmutableArray.Create(WalterlvDemoAnalyzersAnalyzer.DiagnosticId);

RegisterCodeFixesAsync 中找到我们在 WalterlvDemoAnalyzersAnalyzer 类中找到的一个 Diagnostic,然后对这个 Diagnostic 注册一个代码修改(CodeFix)。

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
    var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

    // TODO: Replace the following code with your own analysis, generating a CodeAction for each fix to suggest
    var diagnostic = context.Diagnostics.First();
    var diagnosticSpan = diagnostic.Location.SourceSpan;

    // Find the type declaration identified by the diagnostic.
    var declaration = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().First();

    // Register a code action that will invoke the fix.
    context.RegisterCodeFix(
        CodeAction.Create(
            title: title,
            createChangedSolution: c => MakeUppercaseAsync(context.Document, declaration, c),
            equivalenceKey: title),
        diagnostic);
}

private async Task<Solution> MakeUppercaseAsync(Document document, TypeDeclarationSyntax typeDecl, CancellationToken cancellationToken)
{
    // 省略实现。
    // 将类名改为全大写,然后返回解决方案。
}

开发自己的分析器(Analyzer)

一个简单的目标

作为示例,我们写一个属性转换分析器,将自动属性转换为可通知属性。

就是像以下上面的一种属性转换成下面的一种:

public string Foo { get; set; }
private string _foo;

public string Foo
{
    get => _foo;
    set => SetValue(ref _foo, value);
}

这里我们写了一个 SetValue 方法,有没有这个 SetValue 方法存在对我们后面写的分析器其实没有任何影响。不过你如果强迫症,可以看本文最后的“一些补充”章节,把 SetValue 方法加进来。

开始添加最基础的代码

于是,我们将 Initialize 方法中的内容改成我们期望的分析自动属性的语法节点分析。

public override void Initialize(AnalysisContext context)
    => context.RegisterSyntaxNodeAction(AnalyzeAutoProperty, SyntaxKind.PropertyDeclaration);

private void AnalyzeAutoProperty(SyntaxNodeAnalysisContext context)
{
    // 你可以在这一行打上一个断点,这样你可以观察 `context` 参数。
}

上面的 AnalyzeAutoProperty 只是我们随便取的名字,而 SyntaxKind.PropertyDeclaration 是靠智能感知提示帮我找到的。

现在我们来试着分析一个自动属性。

按下 F5 调试,在新的调试的 Visual Studio 实验实例中,我们将鼠标光标放在 public string Foo { get; set; } 行上。如果我们提前在 AnalyzeAutoProperty 方法中打了断点,那么我们可以在此时观察到 context 参数。

context 参数

  • CancellationToken 指示当前是否已取消分析
  • Node 语法节点
  • SemanticModel
  • ContainingSymbol 语义分析节点
  • Compilation
  • Options

其中,Node.KindText 属性的值为 PropertyDeclaration。还记得前面让你先提前打开 Syntax Visualizer 窗格吗?是的,我们可以在这个窗格中找到 PropertyDeclaration 节点。

我们可以借助这个语法可视化窗格,找到 PropertyDeclaration 的子节点。当我们一级一级分析其子节点的语法的时候,便可以取得这个语法节点的全部所需信息(可见性、属性类型、属性名称),也就是具备生成可通知属性的全部信息了。

在语法可视化窗格中分析属性

添加分析自动属性的代码

由于我们在前面 Initialize 方法中注册了仅在属性声明语法节点的时候才会执行 AnalyzeAutoProperty 方法,所以我们在这里可以简单的开始报告一个代码分析 Diagnostic

var propertyNode = (PropertyDeclarationSyntax)context.Node;
var diagnostic = Diagnostic.Create(_rule, propertyNode.GetLocation());
context.ReportDiagnostic(diagnostic);

现在,WalterlvDemoAnalyzersAnalyzer 类的完整代码如下:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class WalterlvDemoAnalyzersAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "WalterlvDemoAnalyzers";

    private static readonly LocalizableString _title = "自动属性";
    private static readonly LocalizableString _messageFormat = "这是一个自动属性";
    private static readonly LocalizableString _description = "可以转换为可通知属性。";
    private const string _category = "Usage";

    private static readonly DiagnosticDescriptor _rule = new DiagnosticDescriptor(
        DiagnosticId, _title, _messageFormat, _category, DiagnosticSeverity.Info,
        isEnabledByDefault: true, description: _description);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(_rule);

    public override void Initialize(AnalysisContext context) =>
        context.RegisterSyntaxNodeAction(AnalyzeAutoProperty, SyntaxKind.PropertyDeclaration);

    private void AnalyzeAutoProperty(SyntaxNodeAnalysisContext context)
    {
        var propertyNode = (PropertyDeclarationSyntax)context.Node;
        var diagnostic = Diagnostic.Create(_rule, propertyNode.GetLocation());
        context.ReportDiagnostic(diagnostic);
    }
}

可以发现代码并不多,现在运行,可以在光标落在属性声明的行时看到修改建议。如下图所示:

在属性上有修改建议

你可能会觉得有些不满,看起来似乎只有我们写的那些标题和描述在工作。但实际上你还应该注意到这些:

  1. DiagnosticId_messageFormat_description 已经工作起来了;
  2. 只有光标在属性声明的语句块时,这个提示才会出现,因此说明我们的已经找到了正确的代码块了;
  3. 不要忘了我们还有个 CodeFixProvider 没有写呢,你现在看到的依然还在修改大小写的部分代码是那个类(WalterlvDemoAnalyzersCodeFixProvider)里的。

开发自己的代码修改器(CodeFixProvider)

现在,我们开始进行代码修改,将 WalterlvDemoAnalyzersCodeFixProvider 类改成我们希望的将属性修改为可通知属性的代码。

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
public class WalterlvDemoAnalyzersCodeFixProvider : CodeFixProvider
{
    private const string _title = "转换为可通知属性";

    public sealed override ImmutableArray<string> FixableDiagnosticIds =>
        ImmutableArray.Create(WalterlvDemoAnalyzersAnalyzer.DiagnosticId);

    public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

    public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        var diagnostic = context.Diagnostics.First();
        var declaration = (PropertyDeclarationSyntax)root.FindNode(diagnostic.Location.SourceSpan);

        context.RegisterCodeFix(
            CodeAction.Create(
                title: _title,
                createChangedSolution: ct => ConvertToNotificationProperty(context.Document, declaration, ct),
                equivalenceKey: _title),
            diagnostic);
    }

    private async Task<Solution> ConvertToNotificationProperty(Document document,
        PropertyDeclarationSyntax propertyDeclarationSyntax, CancellationToken cancellationToken)
    {
        // 获取文档根语法节点。
        var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);

        // 生成可通知属性的语法节点集合。
        var type = propertyDeclarationSyntax.Type;
        var propertyName = propertyDeclarationSyntax.Identifier.ValueText;
        var fieldName = $"_{char.ToLower(propertyName[0])}{propertyName.Substring(1)}";
        var newNodes = CreateNotificationProperty(type, propertyName, fieldName);

        // 将可通知属性的语法节点插入到原文档中形成一份中间文档。
        var intermediateRoot = root
            .InsertNodesAfter(
                root.FindNode(propertyDeclarationSyntax.Span),
                newNodes);

        // 将中间文档中的自动属性移除形成一份最终文档。
        var newRoot = intermediateRoot
            .RemoveNode(intermediateRoot.FindNode(propertyDeclarationSyntax.Span), SyntaxRemoveOptions.KeepNoTrivia);

        // 将原来解决方案中的此份文档换成新文档以形成新的解决方案。
        return document.Project.Solution.WithDocumentSyntaxRoot(document.Id, newRoot);
    }

    private async Task<Solution> ConvertToNotificationProperty(Document document,
        PropertyDeclarationSyntax propertyDeclarationSyntax, CancellationToken cancellationToken)
    {
        // 这个类型暂时留空,因为这是真正的使用 Roslyn 生成语法节点的代码,虽然只会写一句话,但相当长。
    }
}

还记得我们在前面解读 WalterlvDemoAnalyzersCodeFixProvider 类型时的那些描述吗?我们现在为一个诊断 Diagnostic 注册了一个代码修改(CodeFix),并且其回调函数是 ConvertToNotificationProperty。这是我们自己编写的一个方法。

我在这个方法里面写的代码并不复杂,是获取原来的属性里的类型信息和属性名,然后修改文档,将新的文档返回。

其中,我留了一个 CreateNotificationProperty 方法为空,因为这是真正的使用 Roslyn 生成语法节点的代码,虽然只会写一句话,但相当长。

于是我将这个方法单独写在了下面。将这两个部分拼起来(用下面方法替换上面同名的方法),你就能得到一个完整的 WalterlvDemoAnalyzersCodeFixProvider 类的代码了。

private SyntaxNode[] CreateNotificationProperty(TypeSyntax type, string propertyName, string fieldName)
    => new SyntaxNode[]
    {
        SyntaxFactory.FieldDeclaration(
            new SyntaxList<AttributeListSyntax>(),
            new SyntaxTokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)),
            SyntaxFactory.VariableDeclaration(
                type,
                SyntaxFactory.SeparatedList(new []
                {
                    SyntaxFactory.VariableDeclarator(
                        SyntaxFactory.Identifier(fieldName)
                    )
                })
            ),
            SyntaxFactory.Token(SyntaxKind.SemicolonToken)
        ),
        SyntaxFactory.PropertyDeclaration(
            type,
            SyntaxFactory.Identifier(propertyName)
        )
        .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
        .AddAccessorListAccessors(
            SyntaxFactory.AccessorDeclaration(
                SyntaxKind.GetAccessorDeclaration
            )
            .WithExpressionBody(
                SyntaxFactory.ArrowExpressionClause(
                    SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken),
                    SyntaxFactory.IdentifierName(fieldName)
                )
            )
            .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
            SyntaxFactory.AccessorDeclaration(
                SyntaxKind.SetAccessorDeclaration
            )
            .WithExpressionBody(
                SyntaxFactory.ArrowExpressionClause(
                    SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken),
                    SyntaxFactory.InvocationExpression(
                        SyntaxFactory.IdentifierName("SetValue"),
                        SyntaxFactory.ArgumentList(
                            SyntaxFactory.Token(SyntaxKind.OpenParenToken),
                            SyntaxFactory.SeparatedList(new []
                            {
                                SyntaxFactory.Argument(
                                    SyntaxFactory.IdentifierName(fieldName)
                                )
                                .WithRefKindKeyword(
                                    SyntaxFactory.Token(SyntaxKind.RefKeyword)
                                ),
                                SyntaxFactory.Argument(
                                    SyntaxFactory.IdentifierName("value")
                                ),
                            }),
                            SyntaxFactory.Token(SyntaxKind.CloseParenToken)
                        )
                    )
                )
            )
            .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
        ),
    };

实际上本文并不会重点介绍如何使用 Roslyn 生成新的语法节点,因此我不会解释上面我是如何写出这样的语法节点来的,但如果你对照着语法可视化窗格(Syntax Visualizer)来看的话,也是不难理解为什么我会这么写的。

在此类型完善之后,我们再 F5 启动调试,可以发现我们已经可以完成一个自动属性的修改了,可以按照预期改成一个可通知属性。

你可以再看看下面的动图:

可以修改属性

发布

发布成 NuGet 包

前往我们分析器主项目 Walterlv.Demo.Analyzers 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug 文件夹下。我们可以找到每次编译产生的 NuGet 包。

已经打出来的 NuGet 包

如果你不知道如何将此 NuGet 包发布到 nuget.org,请在文本中回复,也许我需要再写一篇博客讲解如何推送。

发布到 Visual Studio 插件商店

前往我们分析器的 Visual Studio 插件项目 Walterlv.Demo.Analyzers.Vsix 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug 文件夹下。我们可以找到每次编译产生的 Visual Studio 插件安装包。

已经打出来的 Visual Studio 插件

如果你不知道如何将此 Visual Studio 插件发布到 Visual Studio Marketplace,请在文本中回复,也许我需要再写一篇博客讲解如何推送。

一些补充

辅助源代码

前面我们提到了 SetValue 这个方法,这是为了写一个可通知对象。为了拥有这个方法,请在我们的测试项目中添加下面这两个文件:

一个可通知类文件 NotificationObject.cs:

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Walterlv.TestForAnalyzer
{
    public class NotificationObject : INotifyPropertyChanged
    {
        protected bool SetValue<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
        {
            if (Equals(field, value))
            {
                return false;
            }

            field = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            return true;
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }
}

一个用于分析器测试的类 Demo.cs:

namespace Walterlv.TestForAnalyzer
{
    class Demo : NotificationObject
    {
        public string Foo { get; set; }
    }
}

示例代码仓库

代码仓库在我的 Demo 项目中,注意协议是 996.ICU 哟!

别忘了单元测试

别忘了我们一开始创建仓库的时候有一个单元测试项目,而我们全文都没有讨论如何充分利用其中的单元测试。我将在其他的博客中说明如何编写和使用分析器项目的单元测试。


参考资料

如何快速创建 Visual Studio 代码片段?

dotnet 职业技术学院 发布于 2019-07-05

使用 Visual Studio 的代码片段功能,我们可以快速根据已有模板创建出大量常用的代码出来。ReSharper 已经自带了一份非常好用的代码片段工具,不过使用 ReSharper 创建出来的代码片段只能用在 ReSharper 插件中。如果团队当中有一些小伙伴没有 ReSharper(毕竟很贵),那么也可以使用到 Visual Studio 原生的代码片段。

Visual Studio 的官方文档有演示如何创建 Visual Studio 的代码片段,不过上手成本真的很高。本文介绍如何快速创建 Visual Studio 代码片段,并不需要那么麻烦。


Visual Studio 的代码片段管理器

Visual Studio 中代码片段管理器的入口在“工具”中。你可以参照下图找到代码片段管理器的入口。

代码片段管理器入口

在打开代码片段管理器之后,你可以选择自己熟悉的语言。里面会列出当前语言中可以插入的各种代码片段的源。

不过,Visual Studio 并没有提供创建代码片段的方法。在这个管理器里面,你只能导入已经存在的代码片段,并不能直接进行编辑。

官方文档提供了创建代码片段的方法,就在这里:

你只需要看一看就知道这其实是非常繁琐的创建方式,你几乎在手工编写本来是给机器阅读的代码。

我们创建代码片段其实只是关注代码片段本身,那么有什么更快速的方法呢?

方法是安装插件。

Snippet Designer 插件

请去 Visual Studio 的扩展管理器中安装插件,或者去 Visual Studio 的插件市场中下载安装插件:

在扩展管理器中安装插件

在安装完插件之后(需要重新启动 Visual Studio 以完成安装),你就可以直接在 Visual Studio 中创建和编辑代码片段了。

创建代码片段

你需要去 Visual Studio 的“文件”->“新建”->“新建文件”中打开的模板选择列表中选择“Code Snippet”。

新建代码片段文件

下面,我演示创建一个 Debug.WriteLine 代码片段的创建方法。

编写一段代码

我将一段最简单的代码编写到了代码编辑窗格中:

Debug.WriteLine("[section] text");

插入占位符

实际上,这段代码中的 sectiontext 应该是占位符。那么如何插入占位符呢?

选中需要成为占位符的文本,在这里是 section ,然后鼠标右键,选择“Make Replacement”。

插入占位符

这样,在下方的列表中就会出现一个新的占位符。

列表中出现占位符

设置文本占位符

现在我们设置这个占位符的更多细节。比如在下图中,我设置了工具提示(即我们使用此代码片段的时候 Visual Studio 如何提示我们编写这个代码片段),设置了默认值(即没有写时应该是什么值)。设置了这只是一个文本文字,没有其他特别含义。设置这是可以编辑的。

设置更多信息

用通常的方法,设置 text 也是一个占位符。

设置类型占位符

如果我们只是这样创建一个代码片段,而目标代码可能没有引用 System.Diagnostics 命名空间,那么插入完之后手动引用这个命名空间体验可不好。那么如何让 Debug 类可以带命名空间地插入呢?

我们需要将 Debug 也设置成占位符。

将 Debug 也设置成占位符

但是这是可以自动生成的占位符,不需要用户输入,于是我们将其设置为不可编辑。同时,在“Function”一栏填写这是一个类型名称:

SimpleTypeName(global::System.Diagnostics.Debug)

设置 Debug 占位符

转义 $ 符号

实际上用于调试的话,代码越简单功能越全越好。于是我希望 Debug.WriteLine 上能够有一个字符串内插符号 $

那么问题来了,$ 符号是表示代码片段中占位符的符号,那么如何输入呢?

方法是——写两遍 $。于是我们的代码片段现在是这样的:

Debug.WriteLine($$"[$section$] $text$");

保存代码片段

你可以随时按下 Ctrl+S 保存这个新建的代码片段。插件一个很棒的设计是,默认所在的文件夹就是 Visual Studio 中用来存放代码片段的文件夹。于是,你刚刚保存完就可以立刻在 Visual Studio 中看到效果了。

保存代码片段

导入代码片段

如果你将代码片段保存在插件给你的默认的位置,那么你根本不需要导入任何代码片段。但如果你曾经导出过代码片段或者保存在了其他的地方,那么就需要在代码片段管理器中导入这些代码片段文件了。

使用代码片段

如果你前面使用了默认的保存路径,那么现在直接就可以开始使用了。

使用我们在 Shortcut 中设置的字母组合可以插入代码片段:

插入代码片段

在插入完成之后,我们注意到此类型可以使用导入的命名空间前缀 System.Diagnostics。如果没有导入此命名空间前缀,代码片段会自动加入。

按下 Tab 键可以在多个占位符之间跳转,而使用回车键可以确认这个代码片段。

插入后编辑的代码片段

管理代码片段

在 Visual Studio 视图菜单的其他窗口中,可以找到“Snippet Explorer”,打开它可以管理已有的代码片段,包括 Visual Studio 中内置的那些片段。

代码片段管理器

推荐 C# 代码片段

推荐另一款插件 Snippetica:

前者适用于 Visual Studio,后者适用于 Visual Studio Code。

它自带了很多的 C# 代码片段,可以很大程度补充 Visual Studio 原生代码片段存在感低的问题。


参考资料

如何在 Windows 10 中安装 WSL2 的 Linux 子系统

dotnet 职业技术学院 发布于 2019-07-05

本文介绍如何在 Windows 10 中安装 WSL2 的 Linux 子系统


第一步:启用虚拟机平台和 Linux 子系统功能

以管理员权限启动 PowerShell,然后输入以下命令启用虚拟机平台:

Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform

以管理员权限启动 PowerShell,然后输入以下命令启用 Linux 子系统功能:

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

在以上每一步命令执行完之后,PowerShell 中可能会提示你重新启动计算机。按“Y”可以重新启动。

启用 VirtualMachinePlatform

启用 Microsoft-Windows-Subsystem-Linux

正在启用 Linux 子系统

当然,这个命令跟你在控制面板中启用“适用于 Windows 的 Linux 子系统”功能是一样的。

在控制面板中启用虚拟机平台和 Linux 子系统

第二步:安装一个 Linux 发行版

打开微软商店应用,在搜索框中输入“Linux”然后搜索,你可以看到搜索结果中有很多的 Linux 发行版可以选择。选择一个你喜欢的 Linux 发行版本然后安装:

搜索 Linux

选择一个 Linux 发行版本然后安装:

安装一个 Linux 发行版

需要注意,在商店中的安装并没有实际上完成 Linux 子系统的安装,你还需要运行一次已安装的 Linux 发行版以执行真正的安装操作。

安装 Linux

第三步:启用 WSL2

重要:你的操作系统版本必须至少大于或等于 Windows 10.0.18917 !

使用 wsl -l 可以列出当前系统上已经安装的 Linux 子系统名称。注意这里的 -l 是列表“list”的缩写,是字母 l 不是其他字符。

wsl -l

如果提示 wsl 不是内部或外部命令,说明你没有启用“适用于 Windows 的 Linux 子系统”,请先完成本文第一步。

如果提示没有发现任何已安装的 Linux,说明你没有安装 Linux 发行版,或者只是去商店下载了,没有运行它执行真正的安装,请先完成本文第二步。

使用 wsl --set-version <Distro> 2 命令可以设置一个 Linux 发行版的 WSL 版本。命令中 <Distro> 替换为你安装的 Linux 发型版本的名称,也就是前面通过 wsl -l 查询到的名称。

本文的示例使用的是小白门喜欢的 Ubuntu 发行版。

wsl --set-version Ubuntu> 2

设置 WSL2

当然,使用以下命令可以在以后安装 Linux 的时候默认启用 WSL2:

wsl --set-default-version 2

参考资料

.NET/C# 中设置当发生某个特定异常时进入断点(不借助 Visual Studio 的纯代码实现)

dotnet 职业技术学院 发布于 2019-07-04

使用 Visual Studio 可以帮助我们在发生异常的时候中断,便于我们调试程序出现异常那一时刻的状态。如果没有 Visual Studio 的帮助(例如运行已发布的程序),当出现某个或某些特定异常的时候如何能够迅速进入中断的环境来调试呢?

本文介绍如何实现在发生特定异常时中断,以便调查此时程序的状态的纯代码实现。


第一次机会异常

.NET 程序代码中的任何一段代码,在刚刚抛出异常,还没有被任何处理的那一时刻,AppDomain 的实例会引发一个 FirstChanceException 事件,用于通知此时刚刚开始发生了一个异常。

于是我们可以通过监听第一次机会异常来获取到异常刚刚发生那一刻而还没有被 catch 的状态:

using System;
using System.IO;
using System.Runtime.ExceptionServices;

namespace Walterlv.Demo.DoubiBlogs
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;

            // 这里是程序的其他代码。
        }

        private static void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
        {
            // 在这里,可以通过 e.Exception 来获取到这个异常。
        }
    }
}

在第一次机会异常处中断

我在这篇博客中举了一个例子来说明如何在发生异常的时候中断,不过是使用 Visual Studio:

那么现在我们使用第一次机会异常来完善一下其中的代码:

using System;
using System.IO;
using System.Runtime.ExceptionServices;

namespace Walterlv.Demo.DoubiBlogs
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;

            try
            {
                File.ReadAllText(@"C:\walterlv\逗比博客\不存在的文件.txt");
            }
            catch (IOException)
            {
                Console.WriteLine("出现了异常");
            }
        }

        private static void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
        {
            // 现在,我们使用 Debugger.Break() 来中断程序。
            Debugger.Break();
        }
    }
}

保持 Visual Studio 异常设置窗格中的异常设置处于默认状态(意味着被 catch 的异常不会在 Visual Studio 中中断)。

现在运行这个程序,你会发现程序发生了中断,在我们写下了 Debugger.Break() 的那段代码上。

程序发生中断

而在这个时候查看 Visual Studio 中程序的堆栈,可以发现其实调用堆栈是接在一开始发生异常的那一个方法的后面的,而且是除了非托管代码之外帧都是相邻的。

应用程序堆栈

双击 Visual Studio 堆栈中亮色的帧,即可定位到我们自己写的代码。因此,双击第一个亮色的帧可以转到我们自己写的代码中第一个引发异常的代码块。这个时候可以查看应用程序中各处的状态,这正好是发生此熠时的状态(而不是 catch 之后的状态)。

优化代码和提示

为了让这段代码包装得更加“魔性”,我们可以对第一次机会异常的事件加以处理。现在,我们这么写:

[DebuggerStepThrough, DebuggerNonUserCode]
private static void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
    => ExceptionDebugger.Break();

用到的 ExceptionDebugger 类型如下:

using System.Diagnostics;

namespace Walterlv.Demo.DoubiBlogs
{
    internal class ExceptionDebugger
    {
        // 现在请查看 Visual Studio 中的堆栈以迅速定位刚刚发生异常时的程序状态。
        // 如果你按下 F10,可以立刻但不跳转到你第一个出现异常的代码块中。
        private static void BreakCore() => Debugger.Break();




        // 现在请查看 Visual Studio 中的堆栈以迅速定位刚刚发生异常时的程序状态。
        // 如果你按下 F10,可以立刻但不跳转到你第一个出现异常的代码块中。
        private static void LaunchCore() => Debugger.Launch();




        [DebuggerStepThrough, DebuggerNonUserCode]
        internal static void Break()
        {
            if (Debugger.IsAttached)
            {
                BreakCore();
            }
            else
            {
                LaunchCore();
            }
        }
    }
}

现在,发生了第一次机会异常的时候,会断点在我们写的 BreakCore 方法上。这里的代码很少,因此开发者看到这里的时候可以很容易地注意到上面的注释以了解到如何操作。

自己设的断点

现在再看堆栈,依然像前面一样,找到第一个亮色的帧可以找到第一个抛出异常的我们的代码。

调用堆栈

注意,我们在从第一次机会异常到后面中断的代码中,都设置了这两个特性:

  • DebuggerStepThrough 设置此属性可以让断点不会出现在写的这几个方法中
    • 于是,当你按下 F10 的时候,会跳过所有标记了此特性的方法,这可以直接跳转到最终发生异常的那段代码中去。
  • DebuggerNonUserCode 设置此代码非用户编写的代码
    • 于是,在 Visual Studio 的堆栈中,我们会发现这几个方法会变成暗色的,Visual Studio 不会优先显式这部分的源代码,这可以让错误在最关键的代码中显示而不会被我们刚刚写的这些代码中污染。

附加调试器

前面的代码中,我们做了一个判断 Debugger.IsAttached。这是在判断,如果当前没有附加调试器,那么就附加一个。

于是这段代码可以运行在非 Visual Studio 的环境中,当出现了异常的时候,还可以补救选择一个调试器。

附加调试器

当然,实际上附加到 Visual Studio 进行调试也是最佳的方法。只不过,我们不需要一定通过 Visual Studio,我们可以在一般测试代码的时候也能获得出现特定异常时立刻开始断点调查异常的特性。

在 Visual Studio 中设置当发生某个特定异常或所有异常时中断

dotnet 职业技术学院 发布于 2019-07-04

当使用 Visual Studio 调试的时候,如果我们的代码中出现了异常,那么 Visual Studio 会让我们的程序中断,然后我们就能知道程序中出现了异常。但是,如果这个异常已经被 catch 了,那么默认情况下 Visual Studio 是不会帮我们中断的。

能否在这个异常发生的第一时间让 Visual Studio 中断程序以便于我们调试呢?本文将介绍方法。


会中断的异常

看下面这一段代码,读取一个根本不存在的文件。我们都知道这会抛出 FileNotFoundException,随后 Visual Studio 会中断,然后告诉我们这句话发生了异常。

using System;
using System.IO;

namespace Walterlv.Demo.DoubiBlogs
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            File.ReadAllText(@"C:\walterlv\逗比博客\不存在的文件.txt");
        }
    }
}

Visual Studio 异常中断

不会中断的异常

现在,我们为这段会出异常的代码加上 try-catch

using System;
using System.IO;

namespace Walterlv.Demo.DoubiBlogs
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            try
            {
                File.ReadAllText(@"C:\walterlv\逗比博客\不存在的文件.txt");
            }
            catch (IOException)
            {
                Console.WriteLine("出现了异常");
            }
        }
    }
}

现在再运行,会发现 Visual Studio 并没有在出现此异常的时候中断,而是完成了程序最终的输出,随后结束程序。

程序正常结束,没有中断

设置发生所有异常时中断

有时我们会发现已经 catch 过的代码在后来也可能被证明有问题,于是希望即便被 catch 也要发生中断,以便在异常发生的第一时刻定位问题。

Visual Studio 提供了一个异常窗格,可以用来设置在发生哪些异常的时候一定会中断并及时给出提示。

异常窗格可以在“调试”->“窗口”->“异常设置”中打开:

异常设置窗口的打开方法

在异常设置窗格中,我们可以将 Common Language Runtime Exceptions 选项打勾,这样任何 CLR 异常引发的时候 Visual Studio 都会中断而无论是否有 catch 块处理掉了此异常。

将 CLR 异常打勾

如果需要恢复设置,点击上面的恢复成默认的按钮即可。

设置发生特定异常时中断或不中断

当然,你也可以不需要全部打勾,而是只勾选你期望诊断问题的那几个异常。你可以试试,这其实是一个非常繁琐的工作,你会在大量的异常名称中失去眼神而再也无法直视任何异常了。

只勾选期望诊断问题的几个异常

所以更推荐的做法不是仅设置特定异常时中断,而是反过来设置——设置发生所有异常时中断,除了特定的一些异常之外。

方法是:

  1. 将整个 Common Language Runtime Exceptions 打勾
  2. 在实际运行程序之后,如果发生了一些不感兴趣的异常,那么就在下面的框中将此异常取消勾选即可

设置发生此异常时中断

脱离 Visual Studio 设置

如果程序并不是在 Visual Studio 中运行,那么有没有方法进行中断呢?

一个做法是调用 Debugger.Launch(),但这样的话中断的地方就是在 Debugger.Launch() 所在的代码处,可能异常还没发生或者已经发生过了。

有没有方法可以在异常发生的那一刻中断呢?请阅读我的另一篇博客:

.NET/MSBuild 中的发布路径在哪里呢?如何在扩展编译的时候修改发布路径中的文件呢?

dotnet 职业技术学院 发布于 2019-07-04

在扩展 MSBuild 编译的时候,我们一般的处理的路径都是临时路径或者输出路径,那么发布路径在哪里呢?


我曾经在下面这一篇博客中说到可以通过阅读 Microsoft.NET.Sdk 的源码来探索我们想得知的扩展编译的答案:

于是,我们可以搜索 "Publish" 这样的关键字找到我们希望找到的编译目标,于是找到在 Microsoft.NET.Sdk.Publish.targets 文件中,有很多的 PublishDir 属性存在,这可以很大概率猜测这个就是发布路径。不过我只能在这个文件中找到这个路径的再次赋值,找不到初值。

如果全 Sdk 查找,可以找到更多赋初值和使用它复制和生成文件的地方。

PublishDir 全文查找

于是可以确认,这个就是最终的发布路径,只不过不同类型的项目,其发布路径都是不同的。

比如默认是:

<PublishDir Condition="'$(PublishDir)'==''">$(OutputPath)app.publish\</PublishDir>

还有:

<_DeploymentApplicationDir>$(PublishDir)$(_DeploymentApplicationFolderName)\</_DeploymentApplicationDir>

和其他。

如何给 Windows Terminal 增加一个新的终端(以 Bash 为例)

dotnet 职业技术学院 发布于 2019-07-03

Windows Terminal 的预览版本可以在微软应用商店下载,下载完后它原生就可以打开三个不同的终端 PowerShell Core、CMD 和 PowerShell。然而我的计算机上还安装了一个 Bash 可以如何添加到 Windows Terminal 里呢?

本文将介绍添加一个新终端应该如何修改配置。


下载安装 Windows Terminal

Windows Terminal 预览版已上架微软应用商店,你可以前往下载:

随后,在开始菜单中启动 Windows Terminal。

Windows Terminal

打开配置文件

在界面的右上角点按下拉按钮,点击“Settings”可以打开配置文件。

Settings

这个配置文件虽然看起来有 300+ 行,但实际上结构非常简单。我把它折叠起来加上一点点注释你应该很容易看出其配置文件的结构。

配置文件的结构

新增一个 profile

我们把原来的一个 profile 复制一份出来,这样我们就能够写一份自己的终端配置了。

新复制出来一个 profile

下面是我添加的 Bash 的配置。如果你是通过安装 Git for Windows 而安装的 Git Bash,那么默认路径就是 C:\Program Files\Git\bin\bash.exe

{
    "acrylicOpacity" : 0.5,
    "closeOnExit" : true,
    "colorScheme" : "Campbell",
    "commandline" : "C:\\Program Files\\Git\\bin\\bash.exe",
    "cursorColor" : "#FFFFFF",
    "cursorShape" : "bar",
    "fontFace" : "Monaco",
    "fontSize" : 12,
    "guid" : "{1d4e097e-fe87-4164-97d7-3ca794c316fd}",
    "historySize" : 9001,
    "icon" : "C:\\Users\\walterlv\\Resources\\Icons\\git-bash.png",
    "name" : "Bash",
    "padding" : "0, 0, 0, 0",
    "snapOnInput" : true,
    "startingDirectory" : "%USERPROFILE%",
    "useAcrylic" : true
},

注意,必须要改的有这些项:

  1. commandline 你需要改成你的新的终端的路径;
  2. guid 必须使用新的跟其他终端不重复的 guid;
  3. name 改为终端的名称(本例中是 Bash,虽然不是必须,但强烈建议修改)

Visual Studio 自带了一个 guid 生成工具,你可以在菜单的工具中找到:

Visual Studio 自带的 GUID 生成工具

你也可以在网上搜索 GUID 生成器得到很多在线的 GUID 生成工具。

另外,还有一些可选的参数:

  • useAcrylic 使用亚克力效果
  • acrylicOpacity 亚克力效果透明度
  • colorScheme 配色方案(配置文件后面自带了五种配色方案,你也可以额外再添加新的配色方案)
  • fontFace 字体名称
  • fontSize 字号大小
  • icon 图标
  • startingDirectory 初始路径

其中,你可能需要一个 icon 文件,下面有一个 Git Bash 的图标,有需要自取:

Git Bash 图标

最终效果

在你按下 Ctrl+S 保存这个配置文件之后,配置将会立刻生效。你可以在你的 Windows Terminal 中看到你新增的 Bash 终端了。

最终效果

App will crash when using the when keyword in a catch expression

dotnet 职业技术学院 发布于 2019-07-02

We know that we can add a when keyword after a catch filter. But if there is another exception happened in the when expression, the app will totally crash.

This happens in .NET Framework 4.8 but in .NET Core 3.0, it works correctly as the document says.

Maybe this is a bug in the .NET Framework 4.8 CLR.


本文使用 多种语言 编写,请选择你想阅读的语言:

The when in the official document

You can view the official document here:

There is such a sentence here:

The expression of the user-filtered clause is not restricted in any way. If an exception occurs during execution of the user-filtered expression, that exception is discarded and the filter expression is considered to have evaluated to false. In this case, the common language runtime continues the search for a handler for the current exception.

When there is an exception occurred in the when expression the exception will be ignored and the expression will return false.

A demo

We can write a demo to verify this behavior of the official document.

using System;
using System.IO;

namespace Walterlv.Demo.CatchWhenCrash
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            try
            {
                try
                {
                    Console.WriteLine("Try");
                    throw new FileNotFoundException();
                }
                catch (FileNotFoundException ex) when (ex.FileName.EndsWith(".png"))
                {
                    Console.WriteLine("Catch 1");
                }
                catch (FileNotFoundException)
                {
                    Console.WriteLine("Catch 2");
                }
            }
            catch (Exception)
            {
                Console.WriteLine("Catch 3");
            }
            Console.WriteLine("End");
        }
    }
}

Obviously, the FileName property will keep null in the first when expression and will cause a NullReferenceException. It is not recommended to write such the code but it can help us verify the behavior of the catch-when blocks.

If the official document is correct then we can get the output as Try-Catch 2-End because the exception in the when will be ignored and the outer catch will not catch it and then the when expression returns false so that the exception handling goes into the second one.

In .NET Core 3.0 and in .NET Framework 4.8

The pictures below show the actual output of the demo code above in .NET Core 3.0 and in .NET Framework 4.8.

.NET Core 3.0

.NET Framework 4.8

Only in the .NET Core 3.0, the output behaves the same as the official document says. But in .NET Framework 4.8, the End even not appear in the output. We can definitely sure that the app crashes in .NET Framework 4.8.

If we run the app step by step in Visual Studio, we can see that a CLR exception happens.

CLR error

This animated picture below shows how the code goes step by step.

Step debugging

.NET / MSBuild 扩展编译时什么时候用 BeforeTargets / AfterTargets 什么时候用 DependsOnTargets?

dotnet 职业技术学院 发布于 2019-07-01

在为 .NET 项目扩展 MSBuild 编译而编写编译目标(Target)时,我们会遇到用于扩展编译目标用的属性 BeforeTargets AfterTargetsDependsOnTargets

这三个应该分别在什么情况下用呢?本文将介绍其用法。


BeforeTargets / AfterTargets

BeforeTargetsAfterTargets 是用来扩展编译用的。

如果你希望在某个编译任务开始执行一定要执行你的编译目标,那么请使用 BeforeTargets。例如我想多添加一个文件加入编译,那么写:

<Target Name="_WalterlvIncludeSourceFiles"
        BeforeTargets="CoreCompile">
  <ItemGroup>
    <Compile Include="$(MSBuildThisFileFullPath)..\src\Foo.cs" />
  </ItemGroup>
</Target>

这样,一个 Foo.cs 就会在编译时加入到被编译的文件列表中,里面的 Foo 类就可以被使用了。这也是 NuGet 源代码包的核心原理部分。关于 NuGet 源代码包的制作方法,可以扩展阅读:

如果你希望一旦执行完某个编译任务之后执行某个操作,那么请使用 AfterTargets。例如我想在编译完成生成了输出文件之后,将这些输出文件拷贝到另一个调试目录,那么写:

<Target Name="CopyOutputLibToFastDebug" AfterTargets="AfterBuild">
  <ItemGroup>
    <OutputFileToCopy Include="$(OutputPath)$(AssemblyName).dll"></OutputFileToCopy>
    <OutputFileToCopy Include="$(OutputPath)$(AssemblyName).pdb"></OutputFileToCopy>
  </ItemGroup>
  <Copy SourceFiles="@(OutputFileToCopy)" DestinationFolder="$(MainProjectPath)"></Copy>
</Target>

这种写法可以进行快速的组件调试。下面这篇博客就是用到了 AfterTargets 带来的此机制来实现的:

如果 BeforeTargetsAfterTargets 中写了多个 Target 的名称(用分号分隔),那么只要任何一个准备执行或者执行完毕,就会触发此 Target 的执行。

DependsOnTargets

DependsOnTargets 是用来指定依赖的。

DependsOnTargets 并不会直接帮助你扩展一个编译目标,也就是说如果你只为你的 Target 写了一个名字,然后添加了 DependsOnTargets 属性,那么你的 Target 在编译期间根本都不会执行。

但是,使用 DependsOnTargets,你可以更好地控制执行流程和其依赖关系。

例如上面的 CopyOutputLibToFastDebug 这个将输出文件复制到另一个目录的编译目标(Target),依赖于一个 MainProjectPath 属性,因此计算这个属性值的编译目标(Target)应该设成此 Target 的依赖。

当 A 的 DependsOnTargets 设置为 B;C;D 时,那么一旦准备执行 A 时将会发生:

  • 如果 B C D 中任何一个曾经已经执行过,那么就忽略(因为已经执行过了)
  • 如果 B C D 中还有没有执行的,就立刻执行

实践

当我们实际上在扩展编译的时候,我们会用到不止一个编译目标,因此这几个属性都是混合使用的。但是,你应该在合适的地方编写合适的属性设置。

例如我们做一个 NuGet 包,这个 NuGet 包的 .targets 文件中写了下面几个 Target:

  1. _WalterlvEvaluateProperties
    • 用于初始化一些属性和参数,其他所有的 Target 都依赖于这些参数
  2. _WalterlvGenerateStartupObject
    • 生成一个类,包含 Main 入口点函数,然后将入口点设置成这个类
  3. _WalterlvIncludeSourceFiles
    • 为目标项目添加一些源代码,这就包含刚刚新生成的入口点类
  4. _WalterlvPackOutput
    • 将目标项目中生成的文件进行自定义打包

那么我们改如何为每一个 Target 设置正确的属性呢?

第一步:找出哪些编译目标是真正完成编译任务的,这些编译目标需要通过 BeforeTargetsAfterTarget 设置扩展编译。

于是我们可以找到 _WalterlvIncludeSourceFiles_WalterlvPackOutput

  • _WalterlvIncludeSourceFiles 需要添加参与编译的源代码文件,因此我们需要将 BeforeTargets 设置为 CoreCompile
  • _WalterlvPackOutput 需要在编译完成后进行自定义打包,因此我们将 AfterTargets 设置为 AfterBuild。这个时候可以确保文件已经生成完毕可以使用了。

第二步:找到依赖关系,这些依赖关系需要通过 DependsOnTargets 来执行。

于是我们可以找到 _WalterlvEvaluateProperties_WalterlvGenerateStartupObject

  • _WalterlvEvaluateProperties 被其他所有的编译目标使用了,因此,我们需要将后面所有的 DependsOnTargets 属性设置为 _WalterlvEvaluateProperties
  • _WalterlvGenerateStartupObject 生成的入口点函数被 _WalterlvIncludeSourceFiles 加入到编译中,因此 _WalterlvIncludeSourceFilesDependsOnTargets 属性需要添加 _WalterlvGenerateStartupObject(添加方法是使用分号“;”分隔)。

将所有的这些编译任务合在一起写,将是下面这样:

<Target Name="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvGenerateStartupObject"
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvIncludeSourceFiles"
        BeforeTargets="CoreCompile"
        DependsOnTargets="_WalterlvEvaluateProperties;_WalterlvGenerateStartupObject">
</Target>
<Target Name="_WalterlvPackOutput"
        AfterTargets="AfterBuild"
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>

具体依赖于抽象

我们平时在编写代码时会考虑面向对象的六个原则,其中有一个是依赖倒置原则,即具体依赖于抽象。

你不这么写代码当然不会带来错误,但会带来维护性困难。在编写扩展编译目标的时候,这一条同样适用。

假如我们要写的编译目标不止上面这些,还有更多:

  • _WalterlvConvertTemplateCompileToRealCompile
    • 包里有一些模板代码,会在编译期间转换为真实代码并加入编译
  • _WalterlvConditionalImportedSourceCode
    • 会根据 NuGet 包用户的设置有条件地引入一些额外的源代码

那么这个时候我们前面写的用于引入源代码的 _WalterlvIncludeSourceFiles 编译目标其依赖的 Target 会更多。似乎看起来应该这么写了:

<Target Name="_WalterlvIncludeSourceFiles"
        BeforeTargets="CoreCompile"
        DependsOnTargets="_WalterlvEvaluateProperties;_WalterlvGenerateStartupObject;_WalterlvConvertTemplateCompileToRealCompile;_WalterlvConditionalImportedSourceCode">
</Target>

但你小心:

  1. 这个列表会越来越长,而且指不定还会增加一些边边角角的引入的新的源代码呢
  2. _WalterlvConditionalImportedSourceCode 是有条件的,而我们 DependsOnTargets 这样的写法会导致这个 Target 的条件失效

这里更抽象的编译目标是 _WalterlvIncludeSourceFiles,我们的依赖关系倒置了!

为了解决这样的问题,我们引入一个新的属性 _WalterlvIncludeSourceFilesDependsOn,如果有编译目标在编译过程中生成了新的源代码,那么就需要将自己加入到此属性中。

现在的源代码看起来是这样的:

<!-- 这里是一个文件 -->

<PropertyGroup>
  <_WalterlvIncludeSourceFilesDependsOn>
    $(_WalterlvIncludeSourceFilesDependsOn);
    _WalterlvGenerateStartupObject
  </_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>

<Target Name="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvGenerateStartupObject"
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvIncludeSourceFiles"
        BeforeTargets="CoreCompile"
        DependsOnTargets="$(_WalterlvIncludeSourceFilesDependsOn)">
</Target>
<Target Name="_WalterlvPackOutput"
        AfterTargets="AfterBuild"
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<!-- 这里是另一个文件 -->

<PropertyGroup>
  <_WalterlvIncludeSourceFilesDependsOn>
    $(_WalterlvIncludeSourceFilesDependsOn);
    _WalterlvConvertTemplateCompileToRealCompile;
    _WalterlvConditionalImportedSourceCode
  </_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>

<PropertyGroup Condition=" '$(UseWalterlvDemoCode)' == 'True' ">
  <_WalterlvIncludeSourceFilesDependsOn>
    $(_WalterlvIncludeSourceFilesDependsOn);
    _WalterlvConditionalImportedSourceCode
  </_WalterlvIncludeSourceFilesDependsOn>
</PropertyGroup>

<Target Name="_WalterlvConvertTemplateCompileToRealCompile"
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>
<Target Name="_WalterlvConditionalImportedSourceCode"
        Condition=" '$(UseWalterlvDemoCode)' == 'True' "
        DependsOnTargets="_WalterlvEvaluateProperties">
</Target>

实际上,Microsoft.NET.Sdk 内部有很多的编译任务是通过这种方式提供的扩展,例如:

  • BuildDependsOn
  • CleanDependsOn
  • CompileDependsOn

你可以阅读我的另一篇博客了解更多:

在项目文件 / MSBuild / NuGet 包中编写扩展编译的时候,正确使用 props 文件和 targets 文件

dotnet 职业技术学院 发布于 2019-07-01

.NET 扩展编译用的文件有 .props 文件和 .targets 文件。不给我选择还好,给了我选择之后我应该使用哪个文件来编写扩展编译的代码呢?


如果你不了解 .props 文件或者 .targets 文件,可以阅读下面的博客:

具体的例子有下面这些博客。不过大概阅读一下就好,这只是 .props 和 .targets 文件的一些应用。文章比较长,你可以考虑稍后阅读。

当我们创建的 NuGet 包中包含 .props 和 .targets 文件的时候,我们相当于在项目文件 csproj 的两个地方添加了 Import 这些文件的代码。

<Project Sdk="Microsoft.NET.Sdk">

  <!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.props')" />

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp3.0</TargetFrameworks>
  </PropertyGroup>

  <!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage\0.8.3-alpha\build\Walterlv.SamplePackage.targets')" />

</Project>

如果你安装的多份 NuGet 包都带有 .props 和 .targets 文件,那么就相当于帮助你 Import 了多个:

<Project Sdk="Microsoft.NET.Sdk">

  <!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.props')" />
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.props" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.props')" />

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp3.0</TargetFrameworks>
  </PropertyGroup>

  <!-- 本来是没有下面这一行的,我只是为了说明 NuGet 相当于帮我们添加了这一行才假装写到了这里。 -->
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage1\0.8.3-alpha\build\Walterlv.SamplePackage1.targets')" />
  <Import Project="$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.targets" Condition="Exists('$(NuGetPackageRoot)walterlv.samplepackage2\0.5.1-beta\build\Walterlv.SamplePackage2.targets')" />

</Project>

于是,什么代码写到 .props 里而什么代码写到 .targets 里就一目了然了:

  1. 如果你是定义属性或者为属性设置初值,那么请写到 .props 里面
    • 这样,所有的 NuGet 包或者扩展的编译流程都将可以访问到你设置的属性的值
  2. 如果你是使用属性,或者按条件设置属性,那么请写到 .targets 里面
    • 因为这个时候多数的属性已经初始化完毕,你可以使用到属性的值了
  3. 如果你写的是编译目标(Target),那么请写到 .targets 里面
    • 编译目标是扩展编译的,通常都是使用属性
    • 也会有一些产生属性的,但那都是需要在编译期间产生的属性,其他依赖需要使用 DependsOn 等属性来获取

例如下面的属性适合写到 .props 里面。这是一个设置属性初始值的地方:

<Project>

  <PropertyGroup>
    <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>

    <!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
       然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
       WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
       所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
    <ShouldFixNuGetImportingBugForWpfProjects Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == '' ">True</ShouldFixNuGetImportingBugForWpfProjects>

  </PropertyGroup>

</Project>

而下面的属性适合写到 .targets 里面,因为这里使用到了其他的属性:


<Project>

  <PropertyGroup>
    <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>

    <!-- 因为这里使用到了 `Configuration` 属性,需要先等到此属性已经初始化完成再使用,否则我们会拿到非预期的值。 -->
    <ShouldOptimizeDebugging> Condition=" '$(Configuration)' == 'Debug' ">True</ShouldOptimizeDebugging>

  </PropertyGroup>

</Project>

使用 Visual Studio 编译时,让错误一开始发生时就停止编译(以便及早排查编译错误节省时间)

dotnet 职业技术学院 发布于 2019-06-30

对于稍微大一点的 .NET 解决方案来说,编译时间通常都会长一些。如果项目结构和差量编译优化的好,可能编译完也就 5~30 秒,但如果没有优化好,那么出现 1~3 分钟都是可能的。

如果能够在编译出错的第一时间停止编译,那么我们能够更快地去找编译错误的原因,也能从更少的编译错误列表中找到出错的关键原因。


如果你只是觉得你的项目或解决方案编译很慢而不知道原因,我推荐你安装 Parallel Builds Monitor 插件来调查一下。你可以阅读我的一篇博客来了解它:

一个优化比较差的解决方案可能是下面这个样子的:

优化比较差的解决方案的编译甘特图

明明没有多少个项目,但是项目之间的依赖几乎是一条直线,于是不可能开启项目的并行编译。

图中这个项目的编译时长有 1 分 30 秒。可想而知,如果你的改动导致非常靠前的项目编译错误,而默认情况下编译的时候会继续尝试编译下去,于是你需要花非常长的时间才能等待编译完毕,然后从一大堆项目中出现的编译错误中找到最开始出现错误的那个(通常也是编译失败的本质原因)。

现在,推荐使用插件 VSColorOutput

它的主要功能是给你的输出窗格加上颜色,可以让你更快速地区分调试信息、输出、警告和错误。

不过,也正是因为它是通过匹配输出来上色的,于是它可以得知你的项目出现了编译错误,可以采取措施。

在你安装了这款插件之后,你可以在 Visual Studio 的“工具”->“设置”中找到 VSColorOutput 的设置。其中有一项是“Stop Build on First Error”,打开之后,再出现了错误的话,将第一时间会停止。你也可以发现你的 Visual Studio 错误列表中的错误数量非常少了,这些错误都是导致编译失败的最早出现的错误,利于你定位问题。

VSColorOutput 的设置

C#/.NET 移动或重命名一个文件夹(如果存在,则合并而不是出现异常报错)

dotnet 职业技术学院 发布于 2019-06-29

.NET 提供了一个简单的 API 来移动一个文件夹 Directory.Move(string sourceDirName, string destDirName)。不过如果你稍微尝试一下这个 API 就会发现其实相当不实用。


在使用 Directory.Move(string sourceDirName, string destDirName) 这个 API 来移动文件夹的时候,比如我们需要将 A 文件夹移动成 B 文件夹(也可以理解成重命名成 B)。

一旦 B 文件夹是存在的,那么这个时候会抛出异常。

抛出了异常

然而实际上我们可能希望这两个文件夹能够合并。

.NET 的 API 没有原生提供合并两个文件夹的方法,所以我们需要自己实现。

方法是递归遍历里面的所有文件,然后将源文件夹中的文件依次移动到目标文件夹中。为了应对复杂的文件夹层次结构,我写的方法中也包含了递归。

private static void MoveDirectory(string sourceDirectory, string targetDirectory)
{
    MoveDirectory(sourceDirectory, targetDirectory, 0);

    void MoveDirectory(string source, string target, int depth)
    {
        if (!Directory.Exists(source))
        {
            return;
        }

        if (!Directory.Exists(target))
        {
            Directory.CreateDirectory(target);
        }

        var sourceFolder = new DirectoryInfo(source);
        foreach (var fileInfo in sourceFolder.EnumerateFiles("*", SearchOption.TopDirectoryOnly))
        {
            var targetFile = Path.Combine(target, fileInfo.Name);

            if (File.Exists(targetFile))
            {
                File.Delete(targetFile);
            }

            File.Move(fileInfo.FullName, targetFile);
        }

        foreach (var directoryInfo in sourceFolder.EnumerateDirectories("*", SearchOption.TopDirectoryOnly))
        {
            var back = string.Join("\\", Enumerable.Repeat("..", depth));
            MoveDirectory(directoryInfo.FullName,
                Path.GetFullPath(Path.Combine(target, back, directoryInfo.Name)), depth + 1);
        }

        Directory.Delete(source);
    }
}

depth 是一个整型,表示递归深度。我在计算文件需要移动到的新文件夹的路径的时候,需要使用到这个递归深度,以便回溯到最开始需要移动的那个文件夹上。

如何追踪 WPF 程序中当前获得键盘焦点的元素并显示出来

dotnet 职业技术学院 发布于 2019-06-29

我们有很多的调试工具可以帮助我们查看 WPF 窗口中当前获得键盘焦点的元素。本文介绍监控当前键盘焦点元素的方法,并且提供一个不需要任何调试工具的自己绘制键盘焦点元素的方法。


使用调试工具查看当前获得键盘焦点的元素

Visual Studio 带有实时可视化树的功能,使用此功能调试 WPF 程序的 UI 非常方便。

打开实时可视化树

在打开实时可视化树后,我们可以略微认识一下这里的几个常用按钮:

实时可视化树中的常用按钮

这里,我们需要打开两个按钮:

  • 为当前选中的元素显示外框
  • 追踪具有焦点的元素

这样,只要你的应用程序当前获得焦点的元素发生了变化,就会有一个表示这个元素所在位置和边距的叠加层显示在窗口之上。

实时可视化树中的焦点追踪

你可能已经注意到了,Visual Studio 附带的这一叠加层会导致鼠标无法穿透操作真正具有焦点的元素。这显然不能让这一功能一直打开使用,这是非常不方便的。

使用代码查看当前获得键盘焦点的元素

我们打算在代码中编写追踪焦点的逻辑。这可以规避 Visual Studio 中叠加层中的一些问题,同时还可以在任何环境下使用,而不用担心有没有装 Visual Studio。

获取当前获得键盘焦点的元素:

var focusedElement = Keyboard.FocusedElement;

不过只是拿到这个值并没有多少意义,我们需要:

  1. 能够实时刷新这个值;
  2. 能够将这个控件在界面上显示出来。

实时刷新

Keyboard 有路由事件可以监听,得知元素已获得键盘焦点。

Keyboard.AddGotKeyboardFocusHandler(xxx, OnGotFocus);

这里的 xxx 需要替换成监听键盘焦点的根元素。实际上,对于窗口来说,这个根元素可以唯一确定,就是窗口的根元素。于是我可以写一个辅助方法,用于找到这个窗口的根元素:

// 用于存储当前已经获取过的窗口根元素。
private FrameworkElement _root;

// 获取当前窗口的根元素。
private FrameworkElement Root => _root ?? (_root = FindRootVisual(this));

// 一个辅助方法,用于根据某个元素为起点查找当前窗口的根元素。
private static FrameworkElement FindRootVisual(FrameworkElement source) =>
    (FrameworkElement)((HwndSource)PresentationSource.FromVisual(source)).RootVisual;

于是,监听键盘焦点的代码就可以变成:

Keyboard.AddGotKeyboardFocusHandler(Root, OnGotFocus);

void OnGotFocus(object sender, KeyboardFocusChangedEventArgs e)
{
    if (e.NewFocus is FrameworkElement fe)
    {
        // 在这里可以输出或者显示这个获得了键盘焦点的元素。
    }
}

显示

为了显示一个跟踪焦点的控件,我写了一个 UserControl,里面的主要代码是:

<Canvas IsHitTestVisible="False">
    <Border x:Name="FocusBorder" BorderBrush="#80159f5c" BorderThickness="4"
            HorizontalAlignment="Left" VerticalAlignment="Top"
            IsHitTestVisible="False" SnapsToDevicePixels="True">
        <Border x:Name="OffsetBorder" Background="#80159f5c"
                Margin="-200 -4 -200 -4" Padding="12 0"
                HorizontalAlignment="Center" VerticalAlignment="Bottom"
                SnapsToDevicePixels="True">
            <Border.RenderTransform>
                <TranslateTransform x:Name="OffsetTransform" Y="16" />
            </Border.RenderTransform>
            <TextBlock x:Name="FocusDescriptionTextBlock" Foreground="White" HorizontalAlignment="Center" />
        </Border>
    </Border>
</Canvas>
using System;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Threading;

namespace Walterlv.Windows
{
    public partial class KeyboardFocusView : UserControl
    {
        public KeyboardFocusView()
        {
            InitializeComponent();
            Loaded += OnLoaded;
            Unloaded += OnUnloaded;
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            if (Keyboard.FocusedElement is FrameworkElement fe)
            {
                SetFocusVisual(fe);
            }
            Keyboard.AddGotKeyboardFocusHandler(Root, OnGotFocus);
        }

        private void OnUnloaded(object sender, RoutedEventArgs e)
        {
            Keyboard.RemoveGotKeyboardFocusHandler(Root, OnGotFocus);
            _root = null;
        }

        private void OnGotFocus(object sender, KeyboardFocusChangedEventArgs e)
        {
            if (e.NewFocus is FrameworkElement fe)
            {
                SetFocusVisual(fe);
            }
        }

        private void SetFocusVisual(FrameworkElement fe)
        {
            var topLeft = fe.TranslatePoint(new Point(), Root);
            var bottomRight = fe.TranslatePoint(new Point(fe.ActualWidth, fe.ActualHeight), Root);
            var isOnTop = topLeft.Y < 16;
            var isOnBottom = bottomRight.Y > Root.ActualHeight - 16;

            var bounds = new Rect(topLeft, bottomRight);
            Canvas.SetLeft(FocusBorder, bounds.X);
            Canvas.SetTop(FocusBorder, bounds.Y);
            FocusBorder.Width = bounds.Width;
            FocusBorder.Height = bounds.Height;

            FocusDescriptionTextBlock.Text = string.IsNullOrWhiteSpace(fe.Name)
                ? $"{fe.GetType().Name}"
                : $"{fe.Name}({fe.GetType().Name})";
        }

        private FrameworkElement _root;

        private FrameworkElement Root => _root ?? (_root = FindRootVisual(this));

        private static FrameworkElement FindRootVisual(FrameworkElement source) =>
            (FrameworkElement)((HwndSource)PresentationSource.FromVisual(source)).RootVisual;
    }
}

这样,只要将这个控件放到窗口中,这个控件就会一直跟踪窗口中的当前获得了键盘焦点的元素。当然,为了最好的显示效果,你需要将这个控件放到最顶层。

实时可视化树中的焦点追踪

绘制并实时显示 WPF 程序中当前键盘焦点的元素

如果我们需要监听应用程序中所有窗口中的当前获得键盘焦点的元素怎么办呢?我们需要给所有当前激活的窗口监听 GotKeyboardFocus 事件。

于是,你需要我在另一篇博客中写的方法来监视整个 WPF 应用程序中的所有窗口:

里面有一段对 ApplicationWindowMonitor 类的使用:

var app = Application.Current;
var monitor = new ApplicationWindowMonitor(app);
monitor.ActiveWindowChanged += OnActiveWindowChanged;

void OnActiveWindowChanged(object sender, ActiveWindowEventArgs e)
{
    var newWindow = e.NewWindow;
    // 一旦有一个新的获得焦点的窗口出现,就可以在这里执行一些代码。
}

于是,我们只需要在 OnActiveWindowChanged 事件中,将我面前面写的控件 KeyboardFocusView 从原来的窗口中移除,然后放到新的窗口中即可监视新的窗口中的键盘焦点。

由于每一次的窗口激活状态的切换都会更新当前激活的窗口,所以,我们可以监听整个 WPF 应用程序中所有窗口中的键盘焦点。