dotnet 职业技术学院

C#

dotnet 职业技术学院

| csharp

按类别查找文章:C#


uwp dotnet dotnet-core dotnet-standard 技术 gif解析 wpf windows C# WPF D2D DirectX UWP DirectComposition win2d SharpDX VisualStudio dotnetremoting rpc visualstudio Win10 编程 slack web team msbuild linux xamarin ios resharper git nuget xaml algorithm powershell ime vscode directx roslyn markdown ui ux miscellaneous cpp sharpdx algorithms JavaScript Unicode c# 工具 系统 架构 编译 数学 几何 C#入门 原理 Powershell 性能测试 命令行 asp aspdotnetcore dotnetcore 控制台 WMI .net framework .net源代码 源代码分析 Latex C 算法 Emit Roslyn MSBuild 编译器 调试 渲染 Resharper 打包 Pandoc PowerShell VisualStudio插件 TotalCommander 软件 Jenkins gif await 安装包 InnoSetup 触摸 WPF调试 图片处理 黑科技 UI sublimetext usb 笔迹 输入法 数据库 sqlite Framework dotnetframework remoting 布局 mvvm frp Avalonia 设计规范 规范 反射 jekyll DevOps AzureDevOps 源代码 多线程 VisualStudio调试 doevents 性能优化 水印 uwp文件 pip python 软件设计 文档 docfx 资源分享 p2p 爬虫 SublimeText AE dotpeek 反编译 btsync pandoc Telegram 聊天软件 微信 P2P PPT v8 .NET JVM Direct2D MobaXterm 代理 ssh vps 代理服务器 mock 单元测试 NuGet dnc 进程通信 IPC pipe ScrollViewer WPF源代码 ink dotnettool tool Github GithubAction Diagnostics DUMP Xamarin GTK
2020
03-23 2020

推荐 .NET/C# 开发者安装的几款代码分析插件或对应的代码分析 NuGet 包

如果你使用的是旧版本的 Visual Studio,那么默认的代码分析规则集是“最小建议规则集”。基于这个,写出来的代码其实只能说是能跑通过而已。随着 Roslyn 的发布,带来了越来越多更强大的代码分析器,可以为编写高质量的代码带来更多的帮助。

作为 .NET/C# 开发者,强烈建议安装本文推荐的几款代码分析器。


推荐

  1. Visual Studio 2019 自带的分析器
  2. Microsoft Code Analysis
  3. Roslynator
  4. Code Cracker
  5. Meziantou.Analyzer

类型

这里的分析器分为 Visual Studio 扩展形式的分析器和 NuGet 包形式的分析器。

Visual Studio 扩展形式的分析器可以让你一次安装对所有项目生效,但缺点是不能影响编译过程,只能作为在 Visual Studio 中编写代码时给出提示。

NuGet 包形式的分析器可以让某个项目中的所有成员享受到同样的代码分析提示(无论是否安装插件),但缺点是仅针对单个项目生效。

简介

Visual Studio 2019 自带的分析器

重构提示

IDE0051

上图生效的分析器就是 Visual Studio 2019 自带的分析器。在可能有问题的代码上,Visual Studio 的代码编辑器会显示一些文字效果来提醒你代码问题。比如这张图就是提示私有成员 Foo 未使用。

Visual Studio 2019 自带的分析器的诊断 ID 都是以 IDE 开头,因此你可以通过这个前缀来区分是否是 Visual Studio 2019 自带的分析器提示的。

另外,自带的分析器可谓非常强大,除了以上这种提示之外,还可以提示一些重复代码的修改。比如你修改了某段代码,它会提示你相似的代码也可能需要修改。

Microsoft Code Analysis

Microsoft Code Analysis 分为两种用法,一个是 Visual Studio 扩展的形式,你可以去这里下载安装或者去 Visual Studio 的扩展管理界面搜索安装;另一个是 NuGet 包的形式,你可以直接在项目的 NuGet 管理界面安装 Microsoft.CodeAnalysis.FxCopAnalyzers

这款分析器也是微软主推的代码分析器,可以分析 API 设计问题、全球化与本地化问题、稳定性问题、性能问题、安全性问题、代码使用问题等非常多的种类。

比如下图是稳定性的一个问题,直接 catch 了一个 Exception 基类:

catch

配置提示

虽然你可以通过配置规则严重性来消除提示,但是这样写通常代码也比较容易出现一些诡异的问题而难以定位。

Microsoft Code Analysis 分析器的诊断 ID 都是以 CA 开头,因此你可以通过这个前缀来区分是否是 Microsoft Code Analysis 分析器提示的。

Microsoft.CodeAnalysis.FxCopAnalyzers 的 NuGet 包实际上是一组分析器的合集,包括:

如果你想安装这款 NuGet 包,并不需要特别去 NuGet 包管理器中安装,也不需要命令行,只需要去项目的属性页面,选择“安装”就好了。如下图:

安装分析器

Roslynator

是第三方开发者开发的,代码已在 GitHub 上开源,社区非常活跃:

提供了 500 多个代码分析和重构。更值得推荐的一个原因是他为 Visual Studio 原本的很多报告了问题的代码提供了生成解决问题代码的能力。

Code Cracker

Code Cracker 是第三方开发者开发的,代码已在 GitHub 上开源:

由于这款分析器的出现比 Visual Studio 2019 早很多,所以待 Visual Studio 2019 出现的时候,他们已经出现了一些规则的重复(意味着你可能同一个问题会被 Visual Studio 报一次,又被 Code Cracker 报一次)。

虽然部分重复,但 Code Cracker 依然提供了很多 Visual Studio 2019 和 Microsoft Code Analysis 都没有带的代码质量提示。

比如,如果你代码中的文档注释缺少了某个参数的注释,那么它会给出提示:

CC0097

Code Cracker 支持的所有种类的代码分析都可以在这里查得到:

Meziantou.Analyzer

这款插件是对其他几款分析器的重要补充。如果说其他几款分析器可以帮你解决一些基本设计问题或者 Bug 的话,这款分析器可以帮你发现更大范围的问题。

最典型的,也是我推荐这款分析器的最大原因是 —— 区域和本地化!

你的每一个 ToString(),每一个字符串比较,每一个字典的构造……他都提醒你需要考虑区域问题,然后提供给你区域问题的推荐代码!

提醒需要考虑区域问题

提供的建议

配置代码分析严重程度

你的项目中对于某项规则严重性的看法也许跟微软或其他第三方分析器不一样,因此你需要自己配置规则集的严重性。

关于如何配置代码分析严重程度,你可以阅读:

03-05 2020

dotnet 使用 Qpush 快速从电脑到手机推送文字

在手机打字总不是方便,于是就有了 Qpush 这个工具,通过这个工具可以快速从电脑到手机推送文字。 但是这个工具没有找到客户端,于是我就给他写了一个库,通过这个库可以快速进行开发

01-10 2020

.NET 将多个程序集合并成单一程序集的 4+3 种方法

编写 .NET 程序的时候,我们经常会在项目的输出目录下发现一大堆的文件。除了我们项目自己生成的程序集之外,还能找到这个项目所依赖的一大堆依赖程序集。有没有什么方法可以把这些依赖和我们的程序集合并到一起呢?

本文介绍四种将程序集和依赖打包合并到一起的方法,每一种方法都有其不同的原理和优缺点。我将介绍这些方法的原理并帮助你决定哪种方法最适合你想要使用的场景。


四种方法

目前我已知的将 .NET 程序集与依赖合并到一起的方法有下面四种:

  1. 使用 .NET Core 3.0 自带的 PublishSingleFile 属性合并依赖
  2. 使用 Fody
  3. 使用 SourceYard 源代码包
  4. 使用 ILMerge(微软所写)或者 ILRepack(基于 Mono.Ceil)
  5. 其他方法

如果你还知道有其他的方法,欢迎评论指出,非常感谢!

上面的第五种方法我也会做一些介绍,要么是因为无法真正完成任务或者适用场景非常有限,要么是其原理我还不理解,因此只进行简单介绍。

使用 .NET Core 3.0 自带的 PublishSingleFile 属性合并依赖

.NET Core 3.0 自 Preview 5 开始,增加了发布成单一 exe 文件的功能。

在你的项目文件中增加下面的两行可以开启此功能:

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
++      <RuntimeIdentifier>win10-x64</RuntimeIdentifier>
++      <PublishSingleFile>true</PublishSingleFile>
      </PropertyGroup>
    
    </Project>

第一行 RuntimeIdentifier 一定需要指定,因为发布的单一文件是特定于架构的。这里,我们指定了 win10-x64,你也可以指定为其他的值。可以使用的值你可以在这篇文章中查询到:

第二行 PublishSingleFile 即开启发布时单一文件的功能。这样,你在发布你的程序的时候可以得到一个单一的可执行程序。发布一个 .NET Core 项目的方法是在命令行中输入:

dotnet publish

当然,如果你没有更改任何你的项目文件(没有增加上面的那两行),那么你在使用发布命令的时候就需要把这两个属性再增加上。因此完整的发布命令是下面这样的:

dotnet publish -r win10-x64 /p:PublishSingleFile=true

这里的 -r 就等同于在项目中指定 RuntimeIdentifier 持续。这里的 /p 是在项目中增加一个属性,而增加的属性名是 PublishSingleFile,增加的属性值是 true

使用 .NET Core 3.0 这种自带的发布单一 exe 的方法会将你的程序的全部文件(包括所有依赖文件,包括非托管程序集,包括各种资源文件)全部打包到一个 exe 中。当运行这个 exe 的时候,会首先将所有这些文件生成到本地计算机中一个临时目录下。只有第一次运行这个 exe 的时候才会生成这个目录和其中的文件,之后的运行是不会再次生成的。

下面说一些 .NET Core 3.0 发布程序集的一点扩展——.NET Core 3.0 中对于发布程序集的三种处理方式可以放在一起使用:

  • 裁剪程序集(Assembly Trimmer)
  • 提前编译(Ahead-of-Time compilation,通过 crossgen)后面马上会说到 Microsoft.DotNet.ILCompiler
  • 单一文件打包(Single File Bundling)本小节

关于 .NET Core 3.0 中发布仅一个 exe 的方法、原理和实践,可以参见林德熙的博客:

.NET Core 在 GitHub 上开源:

使用 Fody

在你的项目中安装一个 NuGet 包 Costura.Fody。一般来说,安装完之后,你编译的时候就会生成仅有一个 exe 的程序集了。

如果你继续留意,可以发现项目中多了一个 Fody 的专属配置文件 FodyWeavers.xml,内容如下:

<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
    <Costura/>
</Weavers>

仅仅到此为止你已经足够利用 Fody 完成程序集的合并了。

但是,如果希望对 Fody 进行更精细化的配置,可以阅读叶洪的博客:

Fody 在 GitHub 上开源:

使用 SourceYard 源代码包

SourceYard 源代码包在程序集合并上是另辟蹊径的一种合并方式。它不能帮助你将所有的依赖全部合并,但足以让你在发布一些简单应用的时候不至于引入大量的依赖。

例如,你可以考虑新建一个项目,然后安装下面的 NuGet 包:

安装完成之后,你就可以在你的项目中使用到此 NuGet 包为你带来的获取 MAC 地址的工具类了。

using System;
using lindexi.src;

namespace Walterlv.Demo
{
    internal static class Program
    {
        static void Main()
        {
            var macList = MacAddress.GetActiveMacAddress();
            foreach (var mac in macList)
            {
                Console.WriteLine(mac);
            }
        }
    }
}

编译完你的项目,你会发现你的项目没有携带任何依赖。你安装的 NuGet 包并没有成为你的依赖,反而成为你正在编译的程序集的一部分。

如果你要制作一个像上面那样的源代码包,只需要在你要制作 NuGet 包的项目安装上 dotnetCampus.SourceYard,在你打包成 NuGet 包的时候,就会生成一个普通的 NuGet 包以及一个 *.Source.nupkg 的源代码包。将源代码包上传到 nuget.org 上,其他人便可以安装你制作的源代码包了。

关于如何使用 SourceYard 制作一个源代码包的方法可以阅读林德熙的博客:

关于能够做出源代码包的原理,可以阅读我的博客:

SourceYard 在 GitHub 上开源:

使用 ILMerge 或者 ILRepack 等工具

ILMerge 和 ILRepack 的合并就更加富有技术含量——当然坑也更多。

这两个都是工具,因此,你需要将工具下载下来使用。你有很多种方法下载到工具使用,因此我会推荐不同的人群使用不同的工具。

ILMerge

ILMerge 命令行工具是微软官方出品,下载地址:

其使用方法请参见我的博客:

ILRepack

ILRepack 基于 Mono.Ceil 来进行 IL 合并,其使用方法可以参见我的博客:

ILMerge-GUI 工具(已过时,但适合新手随便玩玩)

你可以在以下网址中找到 ILMerge-GUI 的下载链接:

ILMerge-GUI 工具在 Bitbucket 上开源:

其他方法

使用 Microsoft.DotNet.ILCompiler

可以将 .NET Core 编译为单个无依赖的 Native 程序。

你需要先安装一个预览版的 NuGet 包 Microsoft.DotNet.ILCompiler

关于 Microsoft.DotNet.ILCompiler 的使用,你可以阅读林德熙的博客:

使用 dnSpy

dnSpy 支持添加一个模块到程序集,也可以创建模块,还可以将程序集转换为模块。因此,一个程序集可以包含多个模块的功能就可以被充分利用起来。

添加模块到程序集

使用 Warp

Warp 在 GitHub 上开源:

其使用可以参见林德熙的博客:

各种方法的原理和使用场景比较

原理

使用 .NET Core 3.0 自带的 PublishSingleFile 属性合并依赖,其原理是生成一个启动器容器程序。最终没有对程序进行任何修改,只是单纯的打包而已。

使用 Fody,是将程序集依赖放到了资源里面。当要加载程序集的时候,会直接将资源中的程序集流加载到内存中。

使用 SourceYard 源代码包,是直接将源代码合并到了目标项目里面。

使用 ILMerge / ILRepack,是在 IL 级别对程序集进行了合并。

我们可以通过下面一张图来感受一下后三种原理上的不同。

这是一个分别通过 Fody、SourceYard 和 ILMerge / ILRepack 生成的程序集的反编译图。可以看到,对于 ILRepack / ILMerge 和 SourceYard,反编译后看到的源代码都在目标程序集中,而对于 Fody,依赖仅仅出现在资源中。

原理差别

适用范围

由于其原理不同,所以其适用范围和造成的副作用也不同。

如果你基于 .NET Core 3.0 开发,并且也不在意在目标计算机上生成的临时文件夹,那么可以考虑使用 PublishSingleFile 属性合并依赖。

如果你不在乎启动性能以及内存消耗,那么可以考虑 Fody(这意味着小型程序比较适合采用)。

如果你的程序非常在乎启动性能,那么就需要考虑 SourceYard、ILMerge / ILRepack 了。

对于 ILMerge / ILRepack 和 SourceYard 的比较,可以看下面这张表格:

方案 ILRepack / ILMerge SourceYard
适用于 任意 .NET 程序集 通过 SourceYard 发布的 NuGet 包
WPF ILRepack 支持,ILMerge 不支持 支持
调试(支持) 仅支持一般方法的调试 支持一般程序集支持的所有调试方法
调试(不支持) 不支持异步方法调试,不支持显示局部变量 没有不支持的
隐藏 API internal 的类型和成员可以隐藏 必须是 private 类型和成员才可隐藏

可以发现,如果我们能够充分将我们需要的包通过 SourceYard 发布成 NuGet,那么我们将可以获得比 ILRepack / ILMerge 更好的编写和调试体验。

表格之外还有一些特别需要说明的:

  1. ILRepack 额外支持修改 WPF 编译生成的 Baml 文件,将资源的引用路径修改成新程序集的路径。
  2. SourceYard 的类型需要写成 private 才可以隐藏,但是只有内部类才可以写 private,因此如果特别需要隐藏,请首先写一个内部类。(因此,你可能会发现有一个类型有很多个分部类,每一个分部类中都是一个私有的内部类)

开源社区

最后说一下,以上所说的所有方法全部是开源的,有问题欢迎在社区讨论一起解决:

2019
12-26 2019

如何在 .NET/C# 代码中安全地结束掉一个控制台应用程序?通过发送 Ctrl+C 信号来结束

我的电脑上每天会跑一大堆控制台程序,于是管理这些程序的运行就成了一个问题。或者说你可能也在考虑启动一个控制台程序来完成某些特定的任务。

如果我们需要结束掉这个控制台程序怎么做呢?直接杀进程吗?这样很容易出问题。我正在使用的一个控制台程序会写文件,如果直接杀进程可能导致数据没能写入到文件。所以本文介绍如何使用 .NET/C# 代码向控制台程序发送 Ctrl+C 来安全地结束掉程序。


用 Ctrl+C 结束控制台程序

如果直接用 Process.Kill 杀掉进程,进程可能来不及保存数据。所以无论是窗口程序还是控制台程序,最好都让控制台程序自己去关闭。

Process.Kill 结束控制台程序

▲ 使用 Process.Kill 结束程序,程序退出代码是 -1

Ctrl+C 结束控制台程序

▲ 使用 Ctrl+C 结束程序,程序退出代码是 0

Ctrl+C 信号

Windows API 提供了方法可以将当前进程与目标控制台进程关联起来,这样我们便可以向自己发送 Ctrl+C 信号来结束掉关联的另一个控制台进程。

关联和取消关联的方法是下面这两个,AttachConsoleFreeConsole

[DllImport("kernel32.dll")]
private static extern bool AttachConsole(uint dwProcessId);

[DllImport("kernel32.dll")]
private static extern bool FreeConsole();

不过,当发送 Ctrl+C 信号的时候,不止我们希望关闭的控制台程序退出了,我们自己程序也是会退出的(即便我们自己是一个 GUI 程序)。所以我们必须先组织自己响应 Ctrl+C 信号。

需要用到另外一个 API:

[DllImport("kernel32.dll")]
private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? HandlerRoutine, bool Add);

enum CtrlTypes : uint
{
    CTRL_C_EVENT = 0,
    CTRL_BREAK_EVENT,
    CTRL_CLOSE_EVENT,
    CTRL_LOGOFF_EVENT = 5,
    CTRL_SHUTDOWN_EVENT
}

private delegate bool ConsoleCtrlDelegate(CtrlTypes CtrlType);

不过,因为我们实际上并不需要真的对 Ctrl+C 进行响应,只是单纯临时禁用以下,所以我们归这个委托传入 null 就好了。

最后,也是最关键的,就是发送 Ctrl+C 信号了:

[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId);

下面,我将完整的代码贴出来。

全部源代码

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace Walterlv.Fracture.Utils
{
    /// <summary>
    /// 提供与控制台程序的交互。
    /// </summary>
    public class ConsoleInterop
    {
        /// <summary>
        /// 关闭控制台程序。
        /// </summary>
        /// <param name="process">要关闭的控制台程序的进程实例。</param>
        /// <param name="timeoutInMilliseconds">如果不希望一直等待进程自己退出,则可以在此参数中设置超时。你可以在超时未推出候采取强制杀掉进程的策略。</param>
        /// <returns>如果进程成功退出,则返回 true;否则返回 false。</returns>
        public static bool StopConsoleProgram(Process process, int? timeoutInMilliseconds = null)
        {
            if (process is null)
            {
                throw new ArgumentNullException(nameof(process));
            }

            if (process.HasExited)
            {
                return true;
            }

            // 尝试将我们自己的进程附加到指定进程的控制台(如果有的话)。
            if (AttachConsole((uint)process.Id))
            {
                // 我们自己的进程需要忽略掉 Ctrl+C 信号,否则自己也会退出。
                SetConsoleCtrlHandler(null, true);

                // 将 Ctrl+C 信号发送到前面已关联(附加)的控制台进程中。
                GenerateConsoleCtrlEvent(CtrlTypes.CTRL_C_EVENT, 0);

                // 拾前面已经附加的控制台。
                FreeConsole();

                bool hasExited;
                // 由于 Ctrl+C 信号只是通知程序关闭,并不一定真的关闭。所以我们等待一定时间,如果仍未关闭,则超时不处理。
                // 业务可以通过判断返回值来角是否进行后续处理(例如强制杀掉)。
                if (timeoutInMilliseconds == null)
                {
                    // 如果没有超时处理,则一直等待,直到最终进程停止。
                    process.WaitForExit();
                    hasExited = true;
                }
                else
                {
                    // 如果有超时处理,则超时候返回。
                    hasExited = process.WaitForExit(timeoutInMilliseconds.Value);
                }

                // 重新恢复我们自己的进程对 Ctrl+C 信号的响应。
                SetConsoleCtrlHandler(null, false);

                return hasExited;
            }
            else
            {
                return false;
            }
        }

        [DllImport("kernel32.dll")]
        private static extern bool AttachConsole(uint dwProcessId);

        [DllImport("kernel32.dll")]
        private static extern bool FreeConsole();

        [DllImport("kernel32.dll")]
        private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? HandlerRoutine, bool Add);

        [DllImport("kernel32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool GenerateConsoleCtrlEvent(CtrlTypes dwCtrlEvent, uint dwProcessGroupId);

        enum CtrlTypes : uint
        {
            CTRL_C_EVENT = 0,
            CTRL_BREAK_EVENT,
            CTRL_CLOSE_EVENT,
            CTRL_LOGOFF_EVENT = 5,
            CTRL_SHUTDOWN_EVENT
        }

        private delegate bool ConsoleCtrlDelegate(CtrlTypes CtrlType);
    }
}

如何使用

现在,我们可以通过调用 ConsoleInterop.StopConsoleProgram(process) 来安全地结束掉一个控制台程序。

当然,为了处理一些意外的情况,我把超时也加上了。下面的用法演示超时 2 秒候程序还没有退出,则强杀。

if (!ConsoleInterop.StopConsoleProgram(process, 2000))
{
    try
    {
        process.Kill();
    }
    catch (InvalidOperationException e)
    {
    }
}

Ctrl+C 结束控制台程序


参考资料

12-23 2019

如何将一个 .NET 对象序列化为 HTTP GET 的请求字符串

HTTP GET 请求时携带的参数直接在 URL 中,形式如 ?key1=value&key2=value&key3=value。如果是 POST 请求时,我们可以使用一些库序列化为 json 格式作为 BODY 发送,那么 GET 请求呢?有可以直接将其序列化为 HTTP GET 请求的 query 字符串的吗?


HTTP GET 请求

一个典型的 HTTP GET 请求带参数的话大概是这样的:

https://s.blog.walterlv.com/api/example?key1=value&key2=value&key3=value

于是我们将一个类型序列化为后面的参数:

[DataContract]
public class Foo
{
    [DataMember(Name = "key1")]
    public string? Key1 { get; set; }

    [DataMember(Name = "key2")]
    public string? Key2 { get; set; }

    [DataMember(Name = "key3")]
    public string? Key3 { get; set; }
}

库?

可能是这个需求太简单了,所以并没有找到单独的库。所以我就写了一个源代码包放到了 nuget.org 上。

在这里下载源代码包:

你不需要担心引入额外的依赖,因为这是一个源代码包。关于源代码包不引入额外依赖 dll 的原理,可以参见:

方法

我们需要做的是,将一个对象序列化为 query 字符串。假设这个对象的局部变量名称是 query,于是我们需要:

  1. 取得此对象所有可获取值的属性
    • query.GetType().GetProperties()
  2. 获取此属性值的方法
    • property.GetValue(query, null)
  3. 将属性和值拼接起来
    • string.Join("&", properties)

然而真实场景可能比这个稍微复杂一点:

  1. 我们需要像 Newtonsoft.Json 一样,对于标记了 DataContract 的类,按照 DataMember 来序列化
  2. URL 中的值需要进行转义

所以,我写出了下面的方法:

var isContractedType = query.GetType().IsDefined(typeof(DataContractAttribute));
var properties = from property in query.GetType().GetProperties()
                    where property.CanRead && (isContractedType ? property.IsDefined(typeof(DataMemberAttribute)) : true)
                    let memberName = isContractedType ? property.GetCustomAttribute<DataMemberAttribute>().Name : property.Name
                    let value = property.GetValue(query, null)
                    where value != null && !string.IsNullOrWhiteSpace(value.ToString())
                    select memberName + "=" + HttpUtility.UrlEncode(value.ToString());
var queryString = string.Join("&", properties);
return string.IsNullOrWhiteSpace(queryString) ? "" : prefix + queryString;

完整的代码如下:

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Web;

namespace Walterlv.Web.Core
{
    internal class QueryString
    {
        [return: NotNullIfNotNull("query")]
        public static string? Serialize(object? query, string? prefix = "?")
        {
            if (query is null)
            {
                return null;
            }

            var isContractedType = query.GetType().IsDefined(typeof(DataContractAttribute));
            var properties = from property in query.GetType().GetProperties()
                             where property.CanRead && (isContractedType ? property.IsDefined(typeof(DataMemberAttribute)) : true)
                             let memberName = isContractedType ? property.GetCustomAttribute<DataMemberAttribute>().Name : property.Name
                             let value = property.GetValue(query, null)
                             where value != null && !string.IsNullOrWhiteSpace(value.ToString())
                             select memberName + "=" + HttpUtility.UrlEncode(value.ToString());
            var queryString = string.Join("&", properties);
            return string.IsNullOrWhiteSpace(queryString) ? "" : prefix + queryString;
        }
    }
}

你可能会遇到 [return: NotNullIfNotNull("query")] 这一行编译不通过的情况,这个是 C# 8.0 带的可空引用类型所需要的契约类。

你可以将它删除,或者安装我的另一个 NuGet 包来获得更多可空引用类型契约的支持,详见:

12-09 2019

使用正则表达式尽可能准确匹配域名/网址

你可能需要准确地知道一段字符串是否是域名/网址/URL。虽然可以使用 ./ 这些来模糊匹配,但会造成误判。

实际上单纯使用正则表达式来精确匹配也是非常复杂的,通过代码来判断会简单很多。不过本文依然从域名的定义出发来尽可能匹配一段字符串是否是域名或者网址,在要求不怎么高的场合,使用本文的正则表达式写的代码会比较简单。


网址

网址实际上是 URL(统一资源定位符),它是由协议、主机名和路径组成。不过我们通常所说的网址中的主机名通常是域名,因此我们在匹配的时候主要考虑域名。

域名

维基百科 中关于域名的描述:

  1. 域名由一或多个部分组成,这些部分通常连接在一起,并由点分隔。最右边的一个标签是顶级域名,例如zh.wikipedia.org的顶级域名是org。一个域名的层次结构,从右侧到左侧隔一个点依次下降一层。每个标签可以包含1到63个八字节。域名的结尾有时候还有一点,这是保留给根节点的,书写时通常省略,在查询时由软件内部补上。
  2. 域名里的英文字母不区分大小写。
  3. 完整域名的所有字符加起来不得超过253个ASCII字符的总长度。因此,当每一级都使用单个字符时,限制为127个级别:127个字符加上126个点的总长度为253。但实际上,某些域名可能具有其他限制;也没有只有一个字符的域名后缀。

后面关于非 ASCII 字符的描述我没有贴出来。这种域名例如“.中国”。

中国电信网站备案自助管理系统 中,我们可以找到关于域名的描述:

域名中的标号都由英文字母和数字组成,每一个标号不超过63个字符,也不区分大小写字母。标号中除连字符(-)外不能使用其他的标点符号。级别最低的域名写在最左边,而级别最高的域名写在最右边。由多个标号组成的完整域名总共不超过255个字符。

路径

路径是使用 / 分隔的一段一段字符串。

正则表达式匹配

在确认了完整的网址 URL 的规范之后,使用正则表达式来匹配就会比较精确了。

域名

现在,我们来尝试匹配一下域名

  1. 每个标签可组成的字符是 - a-z A-Z 0-9,但是 - 不可作为开头,标签总长度 1-63 个字符,于是
    • [a-zA-Z0-9][-a-zA-Z0-9]{0,62}
    • 即首字不含 -,后面的字可以包含 -
  2. 允许多个标签,于是
    • (\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+
    • 除了标签内容和前面一样,但我们加了个 .

别忘了,我们还有总长度限制,于是考虑加上零宽断言 ^.{3,255}$,匹配开头和结尾,中间任意字符但长度在 3-255 之间。通过零宽断言,我们可以在不捕获匹配字符串的情况下对后面的字符串增加限制条件。

现在,把整个正则表达式拼出来:

^(?=^.{3,255}$)[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$

URL

对于不同的业务需求,可能有严格匹配或者宽松的匹配方式。

比如你要做一些比较精准的检查时需要进行严格的检查,那么选择严格匹配;这时,稍微出现一些不符合要求的字符都将认定为不是 URL。

如果你只是打算做一些简单的检查(例如只是语法高亮),那么简单匹配即可;因为当你使用 Chrome 浏览器访问这些 URL 的时候,依然可以正常访问,Chrome 会帮你格式化一下这个 URL。

URL(严格)

匹配 URL 跟匹配域名不一样,URL 复杂得多。严格匹配的要求是准确反应出 URL 的标准,但实际上如实反应标准编写的正则表达式会非常复杂,因此相比于 100% 准确匹配,我们还是从简了。

所以如果不是有特别要求,建议还是跳到后面的“宽松”部分来阅读吧!

我们以下面这个网址为例说明。

https://blog.walterlv.com/post/read-32bit-registry-from-x64-process.html

  1. 前面是可选的协议名,于是
    • (http(s)?:\/\/)
    • 然而既然可选,而且是行首,那么加一个 ? 和什么都不加的效果是一样的
  2. 随后是域名,于是
    • [a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+
    • 这里我们没有把总长度限制算上去
  3. 别忘了有个可选的端口号
    • (:[0-9]{1,5})?
    • 端口号的范围是 0-65535,但 0 是保留端口,49152 到 65535 也是保留端口,因此可以作为网址访问的范围也就是 1-49151,因此我们限制 1-5 位长度。
  4. 接下来是资源路径
    • 资源路径可以使用的字符也是有限制的,我们接下来详细说明。

组合整个正则表达式:

^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+(:[0-9]{1,5})?[-a-zA-Z0-9()@:%_\\\+\.~#?&//=]*$

顺便一提,不同于域名,我们这里去掉了长度限制,因为 URL 真的可以“很长”。另外,这里的

现在,我们补充说明一下资源路径可以使用的字符问题。

; / ? : @ & = + $ , 这些字符应该被转义。转义使用的字符是 &xxx;,因此在转义之后,依然还可能在网址中看到 &;,不过没有其他字符了。

- _ . ! ~ * ' ( ) 这些字符可以不进行转义,但也不建议在 URL 中使用。对于这部分,我们考虑将其匹配。

{ } | \ ^ [ ] ` 这部分字符可能被网关当作分隔符使用,因此不建议出现在 URL 中。对于这部分,我们考虑将其匹配。

< > # % " 控制字符。使用 % 可以组成其他 Unicode 字符,使用 # 用来指代网址中的某个部分。

因此,我们最终总结应该匹配的特殊字符有 @ : % _ \ + . ~ # ? & / =

URL(宽松)

宽松一点的话,正则表达式就好写多了。

这个正则表达式可以不写 https 协议前缀:

^\w+[^\s]+(\.[^\s]+){1,}$

如果上下文中要求必须匹配 https,则可以写:

^(http(s)?:\/\/)\w+[^\s]+(\.[^\s]+){1,}$
  • https://blog.walterlv.com/post/read-32bit-registry-from-x64-process.html#content)
    • 期望不匹配(主要是不能匹配末尾的括号),实际匹配
    • 在 URL 中,如果括号是成对的,则此 URL 允许以 ) 结尾,如果 URL 中括号不成对,则此 URL 不能以 ) 结尾;> 同理
  • https://blog.walterlv.com/post/read-32bit -registry-from-x64-process.html
    • 期望不匹配,实际不匹配
  • https://blog.lindexi.com/post/dotnet-配置-github-自动打包上传-nuget-文件.html
    • 期望匹配,实际匹配
  • https://域名.中国
    • 期望匹配,实际匹配
  • blog.walterlv.com/post/read-32bit-registry-from-x64-process.html
    • 期望匹配,实际匹配
  • x<blog.walterlv.com/post/read-32bit-registry-from-x64-process.html
    • 期望不匹配,实际匹配

这里的宽松正则表达式请小心!此正则表达式会将一段话中 URL 后面非空格的部分都算作 URL 的一部分。

更多大牛匹配 URL 的正则表达式

在 GitHub 上还有很多大牛们在写各种匹配 URL 的正则表达式:

最长的一个写了 1347 个字符,最短的有 38 个字符。

有人将其整理成一张表格,一图说明各种正则表达式能匹配到什么程度:


参考资料

12-08 2019

C# 8.0 的可空引用类型,不止是加个问号哦!你还有很多种不同的可空玩法

C# 8.0 引入了可空引用类型,你可以通过 ? 为字段、属性、方法参数、返回值等添加是否可为 null 的特性。

但是如果你真的在把你原有的旧项目迁移到可空类型的时候,你就会发现情况远比你想象当中复杂,因为你写的代码可能只在部分情况下可空,部分情况下不可空;或者传入空时才可为空,传入非空时则不可为空。


C# 8.0 可空特性

在开始迁移你的项目之前,你可能需要了解如何开启项目的可空类型支持:

可空引用类型是 C# 8.0 带来的新特性。

你可能会好奇,C# 语言的可空特性为什么在编译成类库之后,依然可以被引用它的程序集识别。也许你可以理解为有什么特性 Attribute 标记了字段、属性、方法参数、返回值的可空特性,于是可空特性就被编译到程序集中了。

确实,可空特性是通过 NullableAttributeNullableContextAttribute 这两个特性标记的。

但你是否好奇,即使在古老的 .NET Framework 4.5 或者 .NET Standard 2.0 中开发的时候,你也可以编译出支持可空信息的程序集出来。这些古老的框架中没有这些新出来的类型,为什么也可以携带类型的可空特性呢?

实际上反编译一下编译出来的程序集就能立刻看到结果了。

看下图,在早期版本的 .NET 框架中,可空特性实际上是被编译到程序集里面,作为 internalAttribute 类型了。

反编译

所以,放心使用可空类型吧!旧版本的框架也是可以用的。

更灵活控制的可空特性

阻碍你将老项目迁移到可空类型的原因,可能还有你原来代码逻辑的问题。因为有些情况下你无法完完全全将类型迁移到可空。

例如:

  1. 有些时候你不得不为非空的类型赋值为 null 或者获取可空类型时你能确保此时一定不为 null(待会儿我会解释到底是什么情况);
  2. 一个方法,可能这种情况下返回的是 null 那种情况下返回的是非 null
  3. 可能调用者传入 null 的时候才返回 null,传入非 null 的时候返回非 null

为了解决这些情况,C# 8.0 还同时引入了下面这些 Attribute

  • AllowNull: 标记一个不可空的输入实际上是可以传入 null 的。
  • DisallowNull: 标记一个可空的输入实际上不应该传入 null。
  • MaybeNull: 标记一个非空的返回值实际上可能会返回 null,返回值包括输出参数。
  • NotNull: 标记一个可空的返回值实际上是不可能为 null 的。
  • MaybeNullWhen: 当返回指定的 true/false 时某个输出参数才可能为 null,而返回相反的值时那个输出参数则不可为 null。
  • NotNullWhen: 当返回指定的 true/false 时,某个输出参数不可为 null,而返回相反的值时那个输出参数则可能为 null。
  • NotNullIfNotNull: 指定的参数传入 null 时才可能返回 null,指定的参数传入非 null 时就不可能返回 null。
  • DoesNotReturn: 指定一个方法是不可能返回的。
  • DoesNotReturnIf: 在方法的输入参数上指定一个条件,当这个参数传入了指定的 true/false 时方法不可能返回。

想必有了这些描述后,你在具体遇到问题的时候应该能知道选用那个特性。但单单看到这些特性的时候你可能不一定知道什么情况下会用得着,于是我可以为你举一些典型的例子。

输入:AllowNull

设想一下你需要写一个属性:

public string Text
{
    get => GetValue() ?? "";
    set => SetValue(value ?? "");
}

当你获取这个属性的值的时候,你一定不会获取到 null,因为我们在 get 里面指定了非 null 的默认值。然而我是允许你设置 null 到这个属性的,因为我处理好了 null 的情况。

于是,请为这个属性加上 AllowNull。这样,获取此属性的时候会得到非 null 的值,而设置的时候却可以设置成 null

++  [AllowNull]
    public string Text
    {
        get => GetValue() ?? "";
        set => SetValue(value ?? "");
    }

输入:DisallowNull

与以上场景相反的一个场景:

private string? _text;

public string? Text
{
    get => _text;
    set => _text = value ?? throw new ArgumentNullException(nameof(value), "不允许将这个值设置为 null");
}

当你获取这个属性的时候,这个属性可能还没有初始化,于是我们获取到 null。然而我却并不允许你将这个属性赋值为 null,因为这是个不合理的值。

于是,请为这个属性加上 DisallowNull。这样,获取此属性的时候会得到可能为 null 的值,而设置的时候却不允许为 null

输出:MaybeNull

如果你有尝试过迁移代码到可空类型,基本上一定会遇到泛型方法的迁移问题:

public T Find<T>(int index)
{
}

比如以上这个方法,找到了就返回找到的值,找不到就返回 T 的默认值。那么问题来了,T 没有指定这是值类型还是引用类型。

如果 T 是引用类型,那么默认值 default(T) 就会引入 null。但是泛型 T 并没有写成 T?,因此它是不可为 null 的。然而值类型和引用类型的 T? 代表的是不同的含义。这种矛盾应该怎么办?

这个时候,请给返回值标记 MaybeNull

++  [return: MaybeNull]
    public T Find<T>(int index)
    {
    }

这表示此方法应该返回一个不可为 null 的类型,但在某些情况下可能会返回 null

实际上这样的写法并没有从本质上解决掉泛型 T 的问题,不过可以用来给旧项目迁移时用来兼容 API 使用。

如果你可以不用考虑 API 的兼容性,那么可以使用新的泛型契约 where T : notnull

public T Find<T>(int index) where T : notnull
{
}

输出:NotNull

设想你有一个方法,方法参数是可以传入 null 的:

public void EnsureInitialized(ref string? text)
{
}

然而这个方法的语义是确保此字段初始化。于是可以传入 null 但不会返回 null 的。这个时候请标记 NotNull

--  public void EnsureInitialized(ref string? text)
++  public void EnsureInitialized([NotNull] ref string? text)
    {
    }

NotNullWhen, MaybeNullWhen

string.IsNullOrEmpty 的实现就使用到了 NotNullWhen

bool IsNullOrEmpty([NotNullWhen(false)] string? value);

它表示当返回 false 的时候,value 参数是不可为 null 的。

这样,你在这个方法返回的 false 判断分支里面,是不需要对变量进行判空的。

当然,更典型的还有 TryDo 模式。比如下面是 Version 类的 TryParse

bool TryParse(string? input, [NotNullWhen(true)] out Version? result)

当返回 true 的时候,result 一定不为 null

NotNullIfNotNull

典型的情况比如指定默认值:

[return: NotNullIfNotNull("defaultValue")]
public string? GetValue(string key, string? defaultValue)
{
}

这段代码里面,如果指定的默认值(defaultValue)是 null 那么返回值也就是 null;而如果指定的默认值是非 null,那么返回值也就不可为 null 了。

在早期 .NET Framework 或者早期版本的 .NET Core 中使用

在本文第一小节里面,我们说 Nullable 是编译到目标程序集中的,所以不需要引用什么特别的程序集就能够使用到可空引用的特性。

那么上面这些特性呢?它们并没有编译到目标程序集中怎么办?

实际上,你只需要有一个命名空间、名字和实现都相同的类型就够了。你可以写一个放到你自己的程序集中,也可以把这些类型写到一个自己公共的库中,然后引用它。当然,你也可以用我已经写好的 NuGet 包 Walterlv.NullableAttributes。

Walterlv.NullableAttributes

微软 .NET 官方的可空特性在这里:

我将其注释翻译成中文之后,也写了一份在这里:

如果你想简单一点,可以直接引用我的 NuGet 包:

源代码包可以在不用引入其他 dll 依赖的情况下完成引用。最终你输出的程序集是不带对此包的依赖的,详见:


参考资料

12-08 2019

一个简单的方法:截取子类名称中不包含基类后缀的部分

基类是 MenuItem,子类是 WalterlvMenuItemFooMenuItem。基类是 Configuration,子类是 WalterlvConfigurationExtensionConfiguration。在代码中,我们可能会为了能够一眼看清类之间的继承(从属)关系而在子类名称后缀中带上基类的名称。但是由于这种情况下的基类不参与实际的业务,所以对外(文件/网络)的名称通常不需要带上这个后缀。

本文提供一个简单的方法,让子类中基类的后缀删掉,只取得前面的那部分。


在这段代码中,我们至少需要获得两个传入的参数,一个是基类的名称,一个是子类的名称。但是考虑到让开发者就这样传入两者名称的话会比较容易出问题,因为开发者可能根本就不会按照要求去获取类型的名称。所以我们需要自己通过类型对象来获取名称。

另外,我们还需要有一些约束,必须有一个类型是另外一个类型的子类。于是我们可能必须来使用泛型做这样的约束。

于是,我们可以写出下面的方法:

using System;

namespace Walterlv.Utils
{
    /// <summary>
    /// 包含类名相关的处理方法。
    /// </summary>
    internal static class ClassNameUtils
    {
        /// <summary>
        /// 当某个类型的派生类都以基类(<typeparamref name="T"/>)名称作为后缀时,去掉后缀取派生类名称的前面部分。
        /// </summary>
        /// <typeparam name="T">名称统一的基类名称。</typeparam>
        /// <param name="this">派生类的实例。</param>
        /// <returns>去掉后缀的派生类名称。</returns>
        internal static string GetClassNameWithoutSuffix<T>(this T @this)
        {
            if (@this is null)
            {
                throw new ArgumentNullException(nameof(@this));
            }

            var derivedTypeName = @this.GetType().Name;
            var baseTypeName = typeof(T).Name;
            // 截取子类名称中去掉基类后缀的部分。
            var name = derivedTypeName.EndsWith(baseTypeName, StringComparison.Ordinal)
                ? derivedTypeName.Substring(0, derivedTypeName.Length - baseTypeName.Length)
                : derivedTypeName;
            // 如果子类名称和基类完全一样,则直接返回子类名称。
            return string.IsNullOrWhiteSpace(name) ? derivedTypeName : name;
        }
    }
}

我们通过判断子类是否以基类名称作为后缀来决定是否截取子字符串。

在截取完子串之后,我们还需要验证截取的字符串是否已经是空串了,因为父子类的名称可能是完全一样的(虽然这样的做法真的很逗比)。

于是使用起来只需要简单调用一下:

class Program
{
    static void Main(string[] args)
    {
        var name = ClassNameUtils.GetClassNameWithoutSuffix<Foo>(new XFoo());
    }
}

internal class Foo
{

}

internal class XFoo : Foo
{

}

于是我们可以得到 name 局部变量的值为 X。如果这个时候我们对 XFoo 类型改名,例如改成 XFoo1,那么就不会截取,而是直接得到名称 XFoo1

11-29 2019

C# 可空引用类型 Nullable 更强制的约束:将警告改为错误 WarningsAsErrors

程序员不看警告!

于是 C# 8.0 带来的可空引用类型由于默认以警告的形式出现,所以实际上约束力非常弱。

本文将把 C# 8.0 的可空引用类型警告提升为错误,以提高约束力。


启用可空引用类型

你需要先在你的项目中启用可空引用类型的支持,才能修改警告到错误:

项目属性

在项目属性中设置是比较快捷直观的方法。

在项目上右键属性,打开“生成”标签。

项目属性

在这里,可以看到“将警告视为错误”一栏:

  • 所有
  • 特定警告

可以看到默认选中的是“特定警告”且值是 NU1605

NU 是 NuGet 中发生的错误或者警告的前缀,NU1605 是大家可能平时经常见到的一个编译错误“检测到包降级”。关于这个错误的信息可以阅读官网:NuGet Warning NU1605 - Microsoft Docs,本文不需要说明。

于是,我们将我们需要视为错误的错误代码补充到后面就可以,以分号分隔。

NU1605;CS8600;CS8602;CS8603;CS8604;CS8618;CS8625

这些值的含义可以参考我的另一篇博客:

记得在改之前,把前面的配置从“活动”改为“所有配置”,这样你就不用改完之后仅在 Debug 生效,完了还要去 Release 配置再改一遍。

改为所有配置

WarningsAsErrors

前面使用属性面板指定时,有一个奇怪的默认值。实际上我们直接修改将固化这个默认值,这不利于将来项目跟随 Sdk 或者 NuGet 包的升级。

所以,最好我们能直接修改到项目文件,以便更精细地控制这个属性的值。

在上一节界面中设置实际上是生成了一个属性 WarningsAsErrors。那么我们现在修改 WarningsAsErrors 属性的值,使其拼接之前的值:

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

      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <LangVersion>latest</LangVersion>
        <Nullable>enable</Nullable>
++      <WarningsAsErrors>$(WarningsAsErrors);CS8600;CS8601;CS8602;CS8603;CS8604;CS8609;CS8610;CS8616;CS8618;CS8619;CS8622;CS8625</WarningsAsErrors>
      </PropertyGroup>

    </Project>

这句话的含义是先获取之前的值,将其放到我们要设置的值的前面。这样可以跟随 Sdk 或者 NuGet 包的升级而更新此默认值。

这些值的含义可以参考我的另一篇博客:


参考资料

11-29 2019

C# 8.0 如何在项目中开启可空引用类型的支持

C# 8.0 引入了可为空引用类型和不可为空引用类型。由于这是语法级别的支持,所以比传统的契约式编程具有更强的约束力。更容易帮助我们消灭 null 异常。

本文将介绍如何在项目中开启 C# 8.0 的可空引用类型的支持。


使用 Sdk 风格的项目文件

如果你还在使用旧的项目文件,请先升级成 Sdk 风格的项目文件:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - 吕毅

本文会示例一个项目文件。

由于现在 C# 8.0 还没有正式发布,所以如果要启用 C# 8.0 的语法支持,需要在项目文件中设置 LangVersion 属性为 8.0 而不能指定为 latest 等正式版本才能使用的值。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
  </PropertyGroup>

</Project>

在项目文件中开启可空引用类型的支持

在项目属性中添加一个属性 NullableContextOptions

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

      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <LangVersion>latest</LangVersion>
++      <Nullable>enable</Nullable>
      </PropertyGroup>

    </Project>

此属性可被指定为以下四个值之一:

  • enable
    • 所有引用类型均被视为不可为空,启用所有 null 相关的警告。
  • warnings
    • 不会判定类型是否可空或不可为空,但启用局部范围内的 null 相关的警告。
  • annotations
    • 所有引用类型均被视为不可为空,但关闭 null 相关的警告。
  • disable
    • 与 8.0 之前的 C# 行为相同,即既不认为类型不可为空,也不启用 null 相关的警告。

这五个值其实是两个不同维度的设置排列组合之后的结果:

  • 可为空注释上下文
    • 用于告知编译器是否要识别一个类型的引用可为空或者不可为空。
  • 可为空警告上下文
    • 用于告知编译器是否要启用 null 相关的警告,以及警告的级别。

当仅仅启用警告上下文而不开启可为空注释上下文,那么编译器将仅仅识别局部变量中明显可以判定出对 null 解引用的代码,而不会对包括变量或者参数定义部分进行分析。

将警告视为错误

以上只是警告,如果你希望更严格地执行可空引用的建议,可以考虑使用编译错误:

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

      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
++      <LangVersion>latest</LangVersion>
++      <Nullable>enable</Nullable>
++      <WarningsAsErrors>$(WarningsAsErrors);CS8600;CS8601;CS8602;CS8603;CS8604;CS8609;CS8610;CS8616;CS8618;CS8619;CS8622;CS8625</WarningsAsErrors>
      </PropertyGroup>

    </Project>

详见:

可为空注释(Annotation)上下文

当启动可为空注释上下文后,C# 编译器会将所有的类型引用变量识别为以下种类:

  • 不可为空
  • 可为空
  • 未知

于是,当你写出 string walterlv 的变量定义,那么 walterlv 就是不可为空的引用类型;当写出 string? walterlv 的变量定义,那么 walterlv 就是可为空的引用类型。

对于类型参数来说,可能不能确定是否是可空引用类型,那么将视为“未知”。

当关闭可为空注释上下文后,C# 编译器会将所有类型引用变量识别为以下种类:

  • 无视

于是,无论你使用什么方式顶一个一个引用类型的变量,C# 编译器都不会判定这到底是不是一个可为空还是不可为空的引用类型。

可为空警告上下文

例如以下代码:

string walterlv = null;
var value = walterlv.ToString();

在将 null 赋值给 walterlv 变量时,是不会引发程序异常的;而在后面调用了 ToString() 方法则会引发程序异常。

安全性区别就在这里。安全性警告仅会将编译期间可识别到可能运行时异常的代码进行警告(即下面的 walterlv.ToString()),而不会对没有异常的代码进行警告。如果是 enable,那么将 null 赋值给 walterlv 变量的那一句也会警告。

在源代码文件中开启可空引用类型的支持

除了在项目文件中全局开启可空引用类型的支持,也可以在 C# 源代码文件中覆盖全局的设定。

  • #nullable enable: 在源代码中启用可空引用类型并给出警告。
  • #nullable disable: 在源代码中禁用可空引用类型并关闭警告。
  • #nullable restore: 还原这段代码中可空引用类型和可空警告。
  • #nullable disable warnings: 在源代码中禁用可空警告。
  • #nullable enable warnings: 在源代码中启用可空警告。
  • #nullable restore warnings: 还原这段代码中可空警告。
  • #nullable disable annotations: 在源代码中禁用可空引用类型。
  • #nullable enable annotations: 在源代码中启用用可空引用类型。
  • #nullable restore annotations: 还原这段代码中可空引用类型。

早期版本的属性

在接近正式版的时候,开关才是 Nullable,而之前是 NullableContextOptions,但在 Visual Studio 2019 Preview 2 之前,则是 NullableReferenceTypes。现在,这些旧的属性已经废弃。

ReSharper 支持

ReSharper 从 2019.1.1 版本开始支持 C# 8.0,如果使用早期版本,就会到处报错。

但是,由于 C# 8.0 可空引用类型的特性总在变,所以建议使用 2019.2.3 或以上版本,这是 C# 8.0 正式版本发布之后的 ReSharper。


参考资料

11-27 2019

WPF 程序如何跨窗口/跨进程设置控件焦点

WPF 程序提供了 Focus 方法和 TraversalRequest 来在 WPF 焦点范围内转移焦点。但如果 WPF 窗口中嵌入了其他框架的 UI(比如另一个子窗口),那么就需要使用其他的方法来设置焦点了。


一个粗略的设置方法是,使用 Win32 API:

SetFocus(hwnd);

传入的是要设置焦点的窗口的句柄。


参考资料

11-24 2019

C# 8.0 可空引用类型中的各项警告/错误的含义和示例代码

C# 8.0 引入了可为空引用类型和不可为空引用类型。当你需要给你或者团队更严格的要求时,可能需要定义这部分的警告和错误级别。

本文将介绍 C# 可空引用类型部分的警告和错误提示,便于进行个人项目或者团队项目的配置。


开启可空引用类型以及配置警告和错误

本文的内容本身没什么意义,但如果你试图进行一些团队配置,那么本文的示例可能能带来一些帮助。

警告和错误

CS8600

将 null 文本或可能的 null 值转换为非 null 类型。

string walterlv = null;

CS8600

CS8601

可能的 null 引用赋值。

string Text { get; set; }

void Foo(string? text)
{
    // 将可能为 null 的文本向不可为 null 的类型赋值。
    Text = text;
}

CS8602

null 引用可能的取消引用。

// 当编译器判定 walterlv 可能为 null 时才会有此警告。
var value = walterlv.ToString();

CS8602

CS8603

可能的 null 引用返回。

string Foo()
{
    return null;
}

CS8603

CS8604

将可能为 null 的引用作为参数传递到不可为 null 的方法中:

void Foo()
{
    string text = GetText();;
    Bar(text);
}

string? GetText()
{
    return null;
}

CS8609

返回类型中引用类型的为 Null 性与重写成员不匹配。

比如你的基类中返回值不允许为 null,但是实现中返回值却允许为 null。

protected virtual async Task<string> FooAsync()
{
}
protected override async Task<string?> FooAsync()
{
}

CS8610

参数中引用类型的为 Null 性与重写成员不匹配。

比如你的基类中方法参数值不允许为 null,但是实现中方法参数却允许为 null。

protected virtual void FooAsync(string value)
{
}
protected override void FooAsync(string? value)
{
}

CS8616

接口中定义的成员中的 null 性与实现中成员的 null 型不匹配。

比如你的接口中不允许为 null,但是实现中却允许为 null。

CS8618

未初始化不可以为 null 的字段 “_walterlv”。

如果一个类型中存在不可以为 null 的字段,那么需要在构造函数中初始化,如果没有初始化,则会发出警告或者异常。

CS8619

一个类型与构造这个类型的 null 性不匹配。

例如:

Task<object?> foo = new Task<object>(() => new object());

CS8622

委托定义的参数中引用类型的为 null 性与目标委托不匹配。

比如你定义了一个委托:

void Foo(object? sender, EventArgs e);

然而在订阅事件的时候,使用的函数 null 性不匹配,则会出现警告:

void OnFoo(object sender, EventArgs e)
{
    // 注意到这里的 object 本应该写作 object?
}

CS8625

无法将 null 文本转换为非 null 引用或无约束类型参数。

void Foo(string walterlv = null)
{
}

CS8625

CS8653

对于泛型 T,使用 default 设置其值。如果 T 是引用类型,那么 default 就会将这个泛型类型赋值为 null。然而并没有将泛型 T 的使用写为 T?。

10-29 2019

C#/.NET 当我们在写事件 += 和 -= 的时候,方法是如何转换成事件处理器的

当我们在写 +=-= 事件的时候,我们会在 +=-= 的右边写上事件处理函数。我们可以写很多种不同的事件处理函数的形式,那么这些形式都是一样的吗?如果你不注意,可能出现内存泄漏问题。

本文将讲解事件处理函数的不同形式,理解了这些可以避免编写代码的时候出现内存相关的问题。


典型的事件处理函数

事件处理函数本质上是一个委托,比如 FileSystemWatcherChanged 事件是这样定义的:

// 这是简化的代码。
public event FileSystemEventHandler Changed;

这里的 FileSystemEventHandler 是一个委托类型:

public delegate void FileSystemEventHandler(object sender, FileSystemEventArgs e);

一个典型的事件的 += 会像下面这样:

void Subscribe(FileSystemWatcher watcher)
{
    watcher.Changed += new FileSystemEventHandler(OnChanged);
}

void OnChanged(object sender, FileSystemEventArgs e)
{
}

+= 的右边传入的是一个 new 出来的委托实例。

变种事件处理函数

除了上面直接创建的目标类型的委托之外,还有其他类型可以放到 += 的右边:

// 方法组。
watcher.Changed += OnChanged;
// Lambda 表达式。
watcher.Changed += (sender, e) => Console.WriteLine(e.ChangeType);
// Lambda 表达式。
watcher.Changed += (sender, e) =>
{
    // 事件引发时,代码会在这里执行。
};
// 匿名方法。
watcher.Changed += delegate (object sender, FileSystemEventArgs e)
{
    // 事件引发时,代码会在这里执行。
};
// 委托类型的局部变量(或者字段)。
FileSystemEventHandler onChanged = (sender, e) => Console.WriteLine(e.ChangeType);
watcher.Changed += onChanged;
// 局部方法(或者局部静态方法)。
watcher.Changed += OnChanged;
void OnChanged(object sender, FileSystemEventArgs e)
{
}

因为我们可以通过编写事件的 addremove 方法来观察事件 += -= 传入的 value 是什么类型的什么实例,所以可以很容易验证以上每一种实例最终被加入到事件中的真实实例。

实际上我们发现,无论哪一个,最终传入的都是 FileSystemEventHandler 类型的实例。

都是同一类型的实例

然而我们知道,只有直接 new 出来的那个和局部变量那个真正是 FileSystemEventHandler 类型的实例,其他都不是。

那么中间发生了什么样的转换使得我们所有种类的写法最终都可以 += 呢?

编译器类型转换

具有相同签名的不同委托类型,彼此之前并没有继承关系,因此在运行时是不可以进行类型转换的。

比如:

FileSystemEventHandler onChanged1 = (sender, e) => Console.WriteLine(e.ChangeType);
Action<object, FileSystemEventArgs> onChanged2 = (sender, e) => Console.WriteLine(e.ChangeType);

这里,onChanged1 的实例不可以赋值给 onChanged2,反过来 onChanged2 的实例也不可以赋值给 onChanged1。于是这里只有 onChanged1 才可以作为 Changed 事件 += 的右边,而 onChanged2 放到 += 右边是会出现编译错误的。

不能转换

然而,我们可以放 Lambda 表达式,可以放匿名函数,可以放方法组,也可以放局部函数。因为这些类型可以在编译期间,由编译器帮助进行类型转换。而转换的效果就类似于我们自己编写 new FileSystemEventHandler(xxx) 一样。

不是同一个委托实例

看下面这一段代码,你认为可以 -= 成功吗?

void Subscribe(FileSystemWatcher watcher)
{
    watcher.Changed += new FileSystemEventHandler(OnChanged);
    watcher.Changed -= new FileSystemEventHandler(OnChanged);
}

void OnChanged(object sender, FileSystemEventArgs e)
{
}

实际上这是可以 -= 成功的。

我们平时编写代码的时候,下面的情况可能会多一些,于是自然而然以为 +=-= 可以成功,因为他们“看起来”是同一个实例:

watcher.Changed += OnChanged;
watcher.Changed -= OnChanged;

在读完刚刚那一段之后,我们就可以知道,实际上这一段和上面 new 出来委托的写法在运行时是一模一样的。

如果你想测试,那么在 += 的时候为对象加上一个 Id,在 -= 的时候你就会发现这是一个新对象(因为没有 Id)。

不是同一个对象

然而,你平时众多的编码经验会告诉你,这里的 -= 是一定可以成功的。也就是说,+=-= 时传入的委托实例即便不是同一个,也是可以成功 +=-= 的。

+= -= 是怎么做的

+=-= 到底是怎么做的,可以在不同实例时也能 +=-= 成功呢?

+=-= 实际上是调用了 DelegateCombineRemove 方法,并生成一个新的委托实例赋值给 += -= 的左边。

public event FileSystemEventHandler Changed
{
    add
    {
        onChangedHandler = (FileSystemEventHandler)Delegate.Combine(onChangedHandler, value);
    }
    remove
    {
        onChangedHandler = (FileSystemEventHandler)Delegate.Remove(onChangedHandler, value);
    }
}

而最终的判断也是通过 DelegateEquals 方法来比较委托的实例是否相等的(==!= 也是调用的 Equals):

public override bool Equals(object? obj)
{
    if (obj == null || !InternalEqualTypes(this, obj))
        return false;

    Delegate d = (Delegate)obj;

    // do an optimistic check first. This is hopefully cheap enough to be worth
    if (_target == d._target && _methodPtr == d._methodPtr && _methodPtrAux == d._methodPtrAux)
        return true;

    // even though the fields were not all equals the delegates may still match
    // When target carries the delegate itself the 2 targets (delegates) may be different instances
    // but the delegates are logically the same
    // It may also happen that the method pointer was not jitted when creating one delegate and jitted in the other
    // if that's the case the delegates may still be equals but we need to make a more complicated check

    if (_methodPtrAux == IntPtr.Zero)
    {
        if (d._methodPtrAux != IntPtr.Zero)
            return false; // different delegate kind
        // they are both closed over the first arg
        if (_target != d._target)
            return false;
        // fall through method handle check
    }
    else
    {
        if (d._methodPtrAux == IntPtr.Zero)
            return false; // different delegate kind

        // Ignore the target as it will be the delegate instance, though it may be a different one
        /*
        if (_methodPtr != d._methodPtr)
            return false;
            */

        if (_methodPtrAux == d._methodPtrAux)
            return true;
        // fall through method handle check
    }

    // method ptrs don't match, go down long path
    //
    if (_methodBase == null || d._methodBase == null || !(_methodBase is MethodInfo) || !(d._methodBase is MethodInfo))
        return Delegate.InternalEqualMethodHandles(this, d);
    else
        return _methodBase.Equals(d._methodBase);
}

于是可以看出来,判断相等就是两个关键对象的判断相等:

  1. 方法所在的对象
  2. 方法信息(对应到反射里的 MethodInfo)

继续回到这段代码:

void Subscribe(FileSystemWatcher watcher)
{
    watcher.Changed += new FileSystemEventHandler(OnChanged);
    watcher.Changed -= new FileSystemEventHandler(OnChanged);
}

void OnChanged(object sender, FileSystemEventArgs e)
{
}

这里的对象就是 this,方法信息就是 OnChanged 的信息,也就是:

// this 就是对象,OnChanged 就是方法信息。
this.OnChanged

-=

于是什么样的 -= 才可以把 += 加进去的事件处理函数减掉呢?

  • 必须是同一个对象的同一个方法

所以:

  1. 使用方法组、静态局部函数、委托字段的方式创建的委托实例,在 +=-= 的时候无视哪个委托实例,都是可以减掉的;
  2. 使用局部函数、委托变量,在同一个上下文中,是可以减掉的,如果调用是再次进入此函数,则不能减掉(因为委托方法所在的对象实例不同)
  3. 使用 Lambda 表达式、匿名函数是不能减掉的,因为每次编写的 Lambda 表达式和匿名函数都会创建新的包含此对象的实例。

10-29 2019

如何在 .NET 项目中开启不安全代码(以便启用 unsafe fixed 等关键字)

有小伙伴希望在 .NET 代码中使用指针,操作非托管资源,于是可能使用到 unsafe fixed 关键字。但使用此关键字的前提是需要在项目中开启不安全代码。

本文介绍如何在项目中开启不安全代码。


入门方法

第一步:在你需要启用不安全代码的项目上点击右键,然后选择属性:

项目 - 属性

第二步:在“生成”标签下,勾选上“允许不安全代码”:

允许不安全代码

第三步:切换到 Release 配置,再勾上一次“允许不安全代码”(确保 Debug 和 Release 都打开)

在 Release 允许不安全代码

方法结束。

如果你一开始选择了“所有配置”,那么就不需要分别在 Debug 和 Release 下打开了,一次打开即可。

高级方法

推荐

如果你使用 .NET Core / .NET Standard 项目,那么你可以修改项目文件来实现,这样项目文件会更加清真。

第一步:在你需要启用不安全代码的项目上点击右键,然后选择编辑项目文件:

编辑项目文件

第二步:在你的项目文件的属性组中添加一行 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

我已经把需要新增的行高亮出来了

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

      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
++      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
      </PropertyGroup>

    </Project>

临时方法

不推荐

如果你只是临时希望加上不安全代码开关,则可以在编译的时候加入 -unsafe 命令行参数:

csc -unsafe walterlv.cs

注意,不能给 msbuild 或者 dotnet build 加上 -unsafe 参数来编译项目,只能使用 csc 加上 -unsafe 来编译文件。因此使用场景非常受限,不推荐使用。

其他说明

第一种方法(入门方法)和第二种方法(高级方法)最终的修改是有一些区别的。入门方法会使得项目文件中有针对于 Debug 和 Release 的不同配置,代码会显得冗余;而高级方法中只增加了一行,对任何配置均生效。

因此如果可能,尽量使用高级方法呗。

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

      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
++      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
      </PropertyGroup>

--    <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
--      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
--    </PropertyGroup>

--    <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
--      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
--    </PropertyGroup>

    </Project>

即使是 .NET Framework 也是可以使用 SDK 风格的项目文件的,详情请阅读:

10-29 2019

不要在 C# 代码中写部分命名空间(要么不写,要么写全),否则会有源码兼容性问题

我只是增加库的一个 API,比如增加几个类而已,应该不会造成兼容性问题吧。对于编译好的二进制文件来说,不会造成兼容性问题;但——可能造成源码不兼容。

本文介绍可能的源码不兼容问题。


This post is written in multiple languages. Please select yours:

比如我有一个项目 P 引用 A 和 B 两个库。其中使用到了 A 库中的 Walterlv.A.Diagnostics.Foo 类型。

using Walterlv.A;
using Walterlv.B;

namespace Walterlv.Demo
{
    class Hello
    {
        Run(Diagnostics.Foo foo)
        {
        }
    }
}

现在,我们在 B 库中新增一个类型 Walterlv.B.Diagnostics.Bar 类型。

那么上面的代码将无法完成编译,因为 Diagnosis 命名空间将具有不确定的含义,其中的 Foo 类型也将无法在不确定的命名空间中找到。

因此:

  1. 强烈建议遵守 使用类型的时候,要么不写命名空间(完全留给 using),要么写全命名空间(从第一段开始写,不要省略任何部分),否则就容易与其他命名空间冲突;
  2. 可选遵守 在库中新增 API 的时候,可能需要考虑避免将部分命名空间写成过于通用的名称。

是的,即使是单纯的新增 API 也可能会导致使用库的一方在源码级不兼容。当然二进制还是兼容的。

另外,OpportunityLiu 提醒,如果命名空间是 Walterlv.B.Walterlv.A.Diagnostics.Bar,一样可以让写全了的命名空间炸掉。呃……还是不要在库里面折腾这样的命名空间好……不然代码当中到处充斥着 global:: 可是非常难受的。

10-22 2019

.NET/C# 在 64 位进程中读取 32 位进程重定向后的注册表

我们知道,32 位程序在读取注册表的时候,会自动将注册表的路径映射到 32 位路径下,即在 Wow6432Node 子节点下。但是 64 位程序不会映射到 32 位路径下。那么 64 位程序如何读取到 32 位程序写入的注册表路径呢?


Wow6432Node

Wow6432Node

对于 32 位程序,读取注册表路径的时候,会读到 Wow6432Node 节点下的项:

32 位

这张图读取的就是前面截图中的节点。

那么怎样编译的程序是 32-bit 的程序呢?

x86

AnyCPU 32-bit preferred

对于 64 位程序,读取的时候就不会有 Wow6432Node 路径部分。由于我没有在那个路径放注册表项,所以会得到 null

null

那么怎样编译的程序是 64-bit 的程序呢?

x64

AnyCPU

如何在 64 位程序中读取 32 位注册表路径

前面我们的例子代码是这样的:

var value = RegistryHive.LocalMachine.Read(@"SOFTWARE\Walterlv");

可以看到,相同的代码,在 32 位和 64 位进程下得到的结果是不同的:

  • 32 位进程在 32 位系统上,64 位进程在 64 位系统上,读取的路径会是传入的路径;
  • 32 位进程在 64 位系统上,读取的路径会包含 Wow6432Node

那么如何在 64 位进程中读取 32 位注册表路径呢?

方法是在打开注册表项的时候,传入 RegistryView.Registry32

RegistryKey.OpenBaseKey(root, RegistryView.Registry32);

Walterlv.Win32

可以在我的 GitHub 仓库中查看完整的实现。当然,除了上面那句话,其他都不是关键代码,在哪里都可以找得到的。


参考资料

10-22 2019

解决 WPF 嵌套的子窗口在改变窗口大小的时候闪烁的问题

因为 Win32 的窗口句柄是可以跨进程传递的,所以可以用来实现跨进程 UI。不过,本文不会谈论跨进程 UI 的具体实现,只会提及其实现中的一个重要缓解,使用子窗口的方式。

你有可能在使用子窗口之后,发现拖拽改变窗口大小的时候,子窗口中的内容不断闪烁。如果你也遇到了这样的问题,那么正好可以阅读本文来解决。


问题

你可以看一下下面的这张动图,感受一下窗口的闪烁:

窗口闪烁

实际上在拖动窗口的时候,是一直都在闪的,只是每次闪烁都非常快,截取 gif 的时候截不到。

如果你希望实际跑一跑项目看看,可以使用下面的代码:

我特地提取了一个提交下的代码,如果你要尝试,不能使用 master 分支,因为 master 分支修复了闪烁的问题。

后来使用 CreateWindowEx 创建了一个纯 Win32 窗口,这种闪烁现象更容易被截图:

Win32 窗口闪烁

Win32 窗口闪烁 - 动图

解决

    public class HwndWrapper : HwndHost
    {
        protected override HandleRef BuildWindowCore(HandleRef hwndParent)
        {
            const int WS_CHILD = 0x40000000;
++          const int WS_CLIPCHILDREN = 0x02000000;
            var owner = ((HwndSource)PresentationSource.FromVisual(this)).Handle;

            var parameters = new HwndSourceParameters("demo")
            {
                ParentWindow = owner,
--              WindowStyle = (int)(WS_CHILD),
++              WindowStyle = (int)(WS_CHILD | WS_CLIPCHILDREN),
            };
            var source = new HwndSource(parameters);
            source.RootVisual = new ChildPage();
            return new HandleRef(this, source.Handle);
        }

        protected override void DestroyWindowCore(HandleRef hwnd)
        {
        }
    }

原因

正在探索……


参考资料

10-22 2019

System.ComponentModel.Win32Exception (0x80004005): 无效的窗口句柄。

在 WPF 获取鼠标当前坐标的时候,可能会得到一个异常:System.ComponentModel.Win32Exception:“无效的窗口句柄。”

本文解释此异常的原因和解决方法。


异常

获取鼠标当前相对于元素 element 的坐标的代码:

var point = Mouse.GetPosition(element);

或者,还有其他的代码:

var point1 = e.PointFromScreen(new Point());
var point2 = e.PointToScreen(new Point());

如果在按下窗口关闭按钮的时候调用以上代码,则会引发异常:

System.ComponentModel.Win32Exception (0x80004005): 无效的窗口句柄。
   at Point MS.Internal.PointUtil.ClientToScreen(Point pointClient, PresentationSource presentationSource)
   at Point System.Windows.Input.MouseDevice.GetScreenPositionFromSystem()

原因

将窗口上的点转换到控件上的点的方法是这样的:

/// <summary>
///     Convert a point from "client" coordinate space of a window into
///     the coordinate space of the screen.
/// </summary>
/// <SecurityNote>
///     SecurityCritical: This code causes eleveation to unmanaged code via call to GetWindowLong
///     SecurityTreatAsSafe: This data is ok to give out
///     validate all code paths that lead to this.
/// </SecurityNote>
[SecurityCritical, SecurityTreatAsSafe]
public static Point ClientToScreen(Point pointClient, PresentationSource presentationSource)
{
    // For now we only know how to use HwndSource.
    HwndSource inputSource = presentationSource as HwndSource;
    if(inputSource == null)
    {
        return pointClient;
    }
    HandleRef handleRef = new HandleRef(inputSource, inputSource.CriticalHandle);

    NativeMethods.POINT ptClient            = FromPoint(pointClient);
    NativeMethods.POINT ptClientRTLAdjusted = AdjustForRightToLeft(ptClient, handleRef);

    UnsafeNativeMethods.ClientToScreen(handleRef, ptClientRTLAdjusted);

    return ToPoint(ptClientRTLAdjusted);
}

最关键的是 UnsafeNativeMethods.ClientToScreen,此方法要求窗口句柄依然有效,然而此时窗口已经关闭,句柄已经销毁。

解决

10-22 2019

将 Direct3D11 在 GPU 中的纹理(Texture2D)导出到内存(Map)或导出成图片文件

Direct3D11 的使用通常不是应用程序唯一的部分,于是使用 Direct3D11 的代码如何与其他模块正确地组合在一起就是一个需要解决的问题。

本文介绍将 Direct3D11 在 GPU 中绘制的纹理映射到内存中,这样我们可以直接观察到此纹理是否是正确的,而不用担心是否有其他模块影响了最终的渲染过程。


SharpDX

本文的代码会使用到 SharpDX 库,因此,你需要在你的项目当中安装这些 NuGet 包:

<!-- 基础,必装 -->
<PackageReference Include="SharpDX" Version="4.2.0" />
<PackageReference Include="SharpDX.D3DCompiler" Version="4.2.0" />
<PackageReference Include="SharpDX.DXGI" Version="4.2.0" />
<PackageReference Include="SharpDX.Mathematics" Version="4.2.0" />
<PackageReference Include="SharpDX.Direct3D11" Version="4.2.0" />

<!-- 其他,可选 -->
<PackageReference Include="SharpDX.Direct2D1" Version="4.2.0" />
<PackageReference Include="SharpDX.Direct3D9" Version="4.2.0" />

来自于 Direct3D11 的渲染纹理

本文不会说如何创建或者获取来自 Direct3D11 的渲染纹理,不过如果你希望了解,可以:

本文接下来的内容,是在你已经获得了 SharpDX.Direct3D11.Resource 的引用,或者 SharpDX.Direct3D11.Texture2D 的前提之下。当然,如果你获得了其中任何一个实例,可以通过 COM 组件的 QueryInterface 方法获得其他实例。

var texture = resource.QueryInterface<SharpDX.Direct3D11.Texture2D>();
var resource = texture.QueryInterface<SharpDX.Direct3D11.Resource>();

关键代码(SharpDX.DXGI.Surface.Map)

要获得 GPU 中渲染的图片,我们必须要将其映射到内存中才行。而映射到内存中的核心代码是 SharpDX.DXGI.Surface 对象的 Map 方法。

using (var surface = texture2D.QueryInterface<SharpDX.DXGI.Surface>())
{
    var map = surface.Map(SharpDX.DXGI.MapFlags.Read, out DataStream dataStream);
    for (var y = 0; y < surface.Description.Height; y++)
    {
        for (var x = 0; x < surface.Description.Width; x++)
        {
            // 在这里使用位图的像素数据,坐标为 (x, y)。
            // 得到此坐标下的像素指针:
            //     var ptr = ((byte*)map.DataPointer) + y * map.Pitch;
            // 得到此像素的颜色值:
            //     var b = *(ptr + 4 * x);
            //     var g = *(ptr + 4 * x + 1);
            //     var r = *(ptr + 4 * x + 2);
            //     var a = *(ptr + 4 * x + 3);
        }
    }
    dataStream.Dispose();
    surface.Unmap();
}

注意以上代码使用了不安全代码(指针),你需要为你的项目开启不安全代码开关,详见:

你可能需要拷贝资源

实际上,在使用上面的代码时,你可能会遇到错误,错误出现在 Map 方法的调用上,描述为“参数错误”。实际上真正检查这里的两个参数时并不能发现究竟是哪个参数出了问题。

实际上出问题的参数是 surface 的实例。

一段 GPU 中的纹理要能够被映射到内存,必须要具有 CPU 的访问权。而是否具有 CPU 访问权在创建纹理的时候就已经确定下来了。

如果前面你得到的纹理是自己创建的,那么恭喜你,你只需要改一下创建纹理的参数就好了。给 Texture2DDescriptionCpuAccessFlags 属性加上 CpuAccessFlags.Read 标识。

desc.CpuAccessFlags = CpuAccessFlags.Read

但是,如果此纹理不是由你自己创建的,那么就需要拷贝一份新的纹理了。当然,拷贝过程发生在 GPU 中,占用的也是 GPU 专用内存(即显存,如果有的话)。

拷贝需要做到两点:

  1. 创建一个新的 Texture2DDescription(一定要是新的实例,你不能影响原来的实例),然后修改其 CPU 访问权限为 Read
  2. 使用 ImmediateContext 实例的 CopyResource 方法来拷贝资源(此实例可以通过 SharpDX.Direct3D11.Device 来找到)。
var originalDesc = originalTexture.Description;
var desc = new Texture2DDescription
{
    CpuAccessFlags = CpuAccessFlags.Read,
    BindFlags = BindFlags.None,
    Usage = ResourceUsage.Staging,
    Width = originalDesc.Width,
    Height = originalDesc.Height,
    Format = originalDesc.Format,
    MipLevels = 1,
    ArraySize = 1,
    SampleDescription =
    {
        Count = 1,
        Quality = 0
    },
};

var texture2D = new Texture2D(device, desc);
device.ImmediateContext.CopyResource(originalTexture, texture2D);

需要注意,拷贝纹理会额外占用显存,一般不建议这么做,除非你真的有需求一定要 CPU 能够访问到这段纹理。

导出成图片文件

实际上,当你组合起来以上以上方法,你应该能够将纹理导出成图片了。

不过,为了理解更方便一些,我还是将导出成图片的全部代码贴出来:

public static unsafe void MapTexture2DToFile(SharpDX.Direct3D11.Texture2D texture, string fileName)
{
    // 获取 Texture2D 的相关实例。
    var device = texture.Device;
    var originDesc = texture.Description;

    // 创建新的 Texture2D 对象。
    var desc = new Texture2DDescription
    {
        CpuAccessFlags = CpuAccessFlags.Read,
        BindFlags = BindFlags.None,
        Usage = ResourceUsage.Staging,
        Width = originDesc.Width,
        Height = originDesc.Height,
        Format = originDesc.Format,
        MipLevels = 1,
        ArraySize = 1,
        SampleDescription =
        {
            Count = 1,
            Quality = 0
        },
        OptionFlags = ResourceOptionFlags.Shared
    };
    var texture2D = new Texture2D(device, desc);

    // 拷贝资源。
    device.ImmediateContext.CopyResource(texture, texture2D);

    var bitmap = new System.Drawing.Bitmap(desc.Width, desc.Height);
    using (var surface = texture2D.QueryInterface<SharpDX.DXGI.Surface>())
    {
        var map = surface.Map(SharpDX.DXGI.MapFlags.Read, out DataStream dataStream);
        var lines = (int)(dataStream.Length / map.Pitch);
        var actualWidth = surface.Description.Width * 4;
        for (var y = 0; y < desc.Height; y++)
        {
            var h = desc.Height - y;
            var ptr = ((byte*)map.DataPointer) + y * map.Pitch;

            for (var x = 0; x < desc.Width; x++)
            {
                var b = *(ptr + 4 * x);
                var g = *(ptr + 4 * x + 1);
                var r = *(ptr + 4 * x + 2);
                var a = *(ptr + 4 * x + 3);
                bitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(a, r, g, b));
            }
        }
        dataStream.Dispose();
        surface.Unmap();
        bitmap.Save(fileName);
    }
}

如果你是希望以纯软件的方式渲染到 WPF 中(WriteableBitmap),可以参考:

记得打开不安全代码开关哦!详见:


参考资料

10-22 2019

.NET 实现 NTFS 文件系统的硬链接 mklink /J(Junction)

我们知道 Windows 系统 NTFS 文件系统提供了硬连接功能,可以通过 mklink 命令开启。如果能够通过代码实现,那么我们能够做更多有趣的事情。

本文提供使用 .NET/C# 代码创建 NTFS 文件系统的硬连接功能(目录联接)。


目录联接

以管理员权限启动 CMD(命令提示符),输入 mklink 命令可以得知 mklink 的用法。

C:\WINDOWS\system32>mklink
创建符号链接。

MKLINK [[/D] | [/H] | [/J]] Link Target

        /D      创建目录符号链接。默认为文件
                符号链接。
        /H      创建硬链接而非符号链接。
        /J      创建目录联接。
        Link    指定新的符号链接名称。
        Target  指定新链接引用的路径
                (相对或绝对)

我们本次要用 .NET/C# 代码实现的是 /J 目录联接。实现的效果像这样:

目录联接

这些文件夹带有一个“快捷方式”的角标,似乎是另一些文件夹的快捷方式一样。但这些与快捷方式的区别在于,应用程序读取路径的时候,目录联接会成为路径的一部分。

比如在 D:\Walterlv\NuGet\ 中创建 debug 目录联接,目标设为 D:\Walterlv\DemoRepo\bin\Debug,那么,你在各种应用程序中使用以下两个路径将被视为同一个:

  • D:\Walterlv\NuGet\debug\DemoRepo-1.0.0.nupkg
  • D:\Walterlv\DemoRepo\bin\Debug\DemoRepo-1.0.0.nupkg

或者这种:

  • D:\Walterlv\NuGet\debug\publish\
  • D:\Walterlv\DemoRepo\bin\Debug\publish\

使用 .NET/C# 实现

本文的代码主要参考自 jeff.brownManipulating NTFS Junction Points in .NET - CodeProject 一文中所附带的源代码。

由于随时可能更新,所以你可以前往 GitHub 仓库打开此代码:

使用 JunctionPoint

如果希望在代码中创建目录联接,则直接使用:

JunctionPoint.Create("walterlv.demo", @"D:\Developments", true);

后面的 true 指定如果目录联接存在,则会覆盖掉原来的目录联接。


参考资料

10-22 2019

WPF 高性能位图渲染 WriteableBitmap 及其高性能用法示例

WPF 渲染框架并没有对外提供多少可以完全控制渲染的部分,目前可以做的有:

  • D3DImage,用来承载使用 DirectX 各个版本渲染内容的控件
  • WriteableBitmap,通过一段内存空间来指定如何渲染一个位图的图片
  • HwndHost,通过承载一个子窗口以便能叠加任何种类渲染的控件

本文将解释如何最大程度压榨 WriteableBitmap 在 WPF 下的性能。


如何使用 WriteableBitmap

创建一个新的 WPF 项目,然后我们在 MainWindow.xaml 中编写一点可以用来显示 WriteableBitmap 的代码:

<Window x:Class="Walterlv.Demo.HighPerformanceBitmap.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Walterlv.Demo.HighPerformanceBitmap"
        Title="WriteableBitmap - walterlv" SizeToContent="WidthAndHeight">
    <Grid>
        <Image x:Name="Image" Width="1280" Height="720" />
    </Grid>
</Window>

为了评估其性能,我决定绘制和渲染 4K 品质的位图,并通过以下步骤来评估:

  1. 使用 CompositionTarget.Rendering 逐帧渲染以评估其渲染帧率
  2. 使用 Benchmark 基准测试来测试内部各种不同方法的性能差异

于是,在 MainWindow.xaml.cs 中添加一些测试用的修改 WriteableBitmap 的代码:

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace Walterlv.Demo.HighPerformanceBitmap
{
    public partial class MainWindow : Window
    {
        private readonly WriteableBitmap _bitmap;

        public MainWindow()
        {
            InitializeComponent();

            _bitmap = new WriteableBitmap(3840, 2160, 96.0, 96.0, PixelFormats.Pbgra32, null);
            Image.Source = _bitmap;
            CompositionTarget.Rendering += CompositionTarget_Rendering;
        }

        private void CompositionTarget_Rendering(object sender, EventArgs e)
        {
            var width = _bitmap.PixelWidth;
            var height = _bitmap.PixelHeight;

            _bitmap.Lock();

            // 在这里添加绘制位图的逻辑。

            _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
            _bitmap.Unlock();
        }
    }
}

注意,我留了一行注释说即将添加绘制位图的逻辑,接下来我们的主要内容将从此展开。

启用不安全代码

为了获取最佳性能,我们需要开启不安全代码。为此,你需要修改一下你的项目属性。

你可以阅读我的另一篇博客了解如何启用不安全代码:

简单点说就是在你的项目文件中添加下面这一行:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>netcoreapp3.0</TargetFramework>
++      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
      </PropertyGroup>
    </Project>

启用帧率测试

接下来,我们需要添加一点点代码来评估 WriteableBitmap 的性能:

++  private readonly byte[] _empty4KBitmapArray = new byte[3840 * 2160 * 4];

--  private void CompositionTarget_Rendering(object sender, EventArgs e)
++  private unsafe void CompositionTarget_Rendering(object sender, EventArgs e)
    {
        var width = _bitmap.PixelWidth;
        var height = _bitmap.PixelHeight;

        _bitmap.Lock();

++      fixed (byte* ptr = _empty4KBitmapArray)
++      {
++          var p = new IntPtr(ptr);
++          Buffer.MemoryCopy(ptr, _bitmap.BackBuffer.ToPointer(), _empty4KBitmapArray.Length, _empty4KBitmapArray.Length);
++      }

        _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
        _bitmap.Unlock();
    }

嗯,就是将一个空的 4K 大小的数组中的内容复制到 WriteableBitmap 的位图缓存中。

4K 脏区

虽然我们看不到任何可变的修改,不过 WriteableBitmap 可不这么认为。因为我们调用了 AddDirtyRect 将整个位图空间都加入到了脏区中,这样 WPF 会重新渲染整幅位图。

Visual Studio 中看到的 CPU 占用率大约维持在 16% 左右(跟具体机器相关);并且除了一开始启动的时候之外,完全没有 GC(这点很重要),内存稳定在一个值上不再变化。

也只有本文一开始提及的三种方法才可能做到渲染任何可能的图形的时候没有 GC

CPU 占用率和内存用量

查看界面渲染帧率可以发现跑满 60 帧没有什么问题(跟具体机器相关)。

帧率

小脏区

现在,我们把脏区的区域缩小为 100*100,同样看性能数据。

--  _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
++  _bitmap.AddDirtyRect(new Int32Rect(0, 0, 100, 100));

可以发现 CPU 占用降低到一半(确实是大幅降低,但是跟像素数量并不成比例);内存没有变化(废话,4K 图像是确定的);帧率没有变化(废话,只要性能够,帧率就是满的)。

小脏区 CPU 占用率和内存用量

小脏区帧率

无脏区

现在,我们将脏区清零。

--  _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
++  _bitmap.AddDirtyRect(new Int32Rect(0, 0, 0, 0));

在完全没有脏区的时候,CPU 占用直接降为 0,这个性能提升还是非常恐怖的。

零脏区 CPU 占用率和内存用量

不渲染

如果我们不把 WriteableBitmap 设置为 ImageSource 属性,那么无论脏区多大,CPU 占用都是 0。

脏区大小与 CPU 占用率之间的关系

从前面的测试中我们可以发现,脏区的大小在 WriteableBitmap 的渲染里占了绝对的耗时。因此,我把脏区大小与 CPU 占用率之间的关系用图表的形式贴出来,这样可以直观地理解其性能差异。

需要注意,CPU 占用率与机器性能强相关,因此其绝对占用没有意义,但相对大小则有参考价值。

脏区大小 CPU 占用率 帧率
0*0 0.0% 60
1*1 5.1% 60
16*9 5.7% 60
160*90 6.0% 60
320*180 6.5% 60
640*360 6.9% 60
1280*720 7.5% 60
1920*1080 10.5% 60
2560*1440 12.3% 60
3840*2160 16.1% 60

根据这张表我么可以得出:

  • 脏区渲染是 CPU 占用的最大瓶颈(因为没有脏区仅剩内存拷贝的时候 CPU 占用为 0%)

但是有一个需要注意的信息是——虽然 CPU 占用率受脏区影响非常大,但主线程却几乎没有消耗 CPU 占用。此占用基本上全是渲染线程的事。

如果我们分析主线程的性能分布,可以发现内存拷贝现在是性能瓶颈:

内存拷贝是性能瓶颈

后面我们会提到 WriteableBitmap 的渲染原理,也会说到这一点。

启用基准测试(Benchmark)

不过,由于内存数据的拷贝和脏区渲染实际上可以分开到两个不同的线程,如果这两者不同步执行(可能执行次数还有差异)的情况下,内存拷贝也可能成为性能瓶颈的一部分。

于是我将不同的内存拷贝方法进行一个基准测试,便于大家评估使用哪种方法来为 WriteableBitmap 提供渲染数据。

使用 CopyMemory 拷贝内存

++  [Benchmark(Description = "CopyMemory")]
++  [Arguments(3840, 2160)]
++  [Arguments(100, 100)]
    public unsafe void CopyMemory(int width, int height)
    {
        _bitmap.Lock();

++      fixed (byte* ptr = _empty4KBitmapArray)
++      {
++          var p = new IntPtr(ptr);
++          CopyMemory(_bitmap.BackBuffer, new IntPtr(ptr), (uint)_empty4KBitmapArray.Length);
++      }

        _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
        _bitmap.Unlock();
    }
[DllImport("kernel32.dll")]
private static extern void CopyMemory(IntPtr destination, IntPtr source, uint length);

使用 MoveMemory 移动内存

++  [Benchmark(Description = "RtlMoveMemory")]
++  [Arguments(3840, 2160)]
++  [Arguments(100, 100)]
    public unsafe void RtlMoveMemory(int width, int height)
    {
        _bitmap.Lock();

++      fixed (byte* ptr = _empty4KBitmapArray)
++      {
++          var p = new IntPtr(ptr);
++          MoveMemory(_bitmap.BackBuffer, new IntPtr(ptr), (uint)_empty4KBitmapArray.Length);
++      }

        _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
        _bitmap.Unlock();
    }
    [DllImport("kernel32.dll", EntryPoint = "RtlMoveMemory")]
    private static extern void MoveMemory(IntPtr dest, IntPtr src, uint count);

使用 Buffer.MemoryCopy 拷贝内存

需要注意,Buffer.MemoryCopy 是 .NET Framework 4.6 才引入的 API,在 .NET Framework 后续版本以及 .NET Core 的所有版本才可以使用,更旧版本的 .NET Framework 没有这个 API。

++  [Benchmark(Baseline = true, Description = "Buffer.MemoryCopy")]
++  [Arguments(3840, 2160)]
++  [Arguments(100, 100)]
    public unsafe void BufferMemoryCopy(int width, int height)
    {
        _bitmap.Lock();

++      fixed (byte* ptr = _empty4KBitmapArray)
++      {
++          var p = new IntPtr(ptr);
++          Buffer.MemoryCopy(ptr, _bitmap.BackBuffer.ToPointer(), _empty4KBitmapArray.Length, _empty4KBitmapArray.Length);
++      }

        _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
        _bitmap.Unlock();
    }

自己写 for 循环

++  [Benchmark(Description = "for for")]
++  [Arguments(3840, 2160)]
++  [Arguments(100, 100)]
    public unsafe void ForForCopy(int width, int height)
    {
        _bitmap.Lock();

++      var buffer = (byte*)_bitmap.BackBuffer.ToPointer();
++      for (var j = 0; j < height; j++)
++      {
++          for (var i = 0; i < width; i++)
++          {
++              var pixel = buffer + j * width * 4 + i * 4;
++              *pixel = 0xff;
++              *(pixel + 1) = 0x7f;
++              *(pixel + 2) = 0x00;
++              *(pixel + 3) = 0xff;
++          }
++      }

        _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
        _bitmap.Unlock();
    }

基准测试数据

我们跑一次基准测试:

Method Mean Error StdDev Median Ratio RatioSD
CopyMemory 2.723 ms 0.0642 ms 0.1881 ms 2.677 ms 0.84 0.08
RtlMoveMemory 2.659 ms 0.0740 ms 0.2158 ms 2.633 ms 0.82 0.08
Buffer.MemoryCopy 3.246 ms 0.0776 ms 0.2250 ms 3.200 ms 1.00 0.00
‘for for’ 10.401 ms 0.1979 ms 0.4964 ms 10.396 ms 3.21 0.25
‘CopyMemory with 100*100 dirty region’ 2.446 ms 0.0757 ms 0.2207 ms 2.368 ms 0.76 0.09
‘RtlMoveMemory with 100*100 dirty region’ 2.415 ms 0.0733 ms 0.2161 ms 2.369 ms 0.75 0.08
‘Buffer.MemoryCopy with 100*100 dirty region’ 3.076 ms 0.0612 ms 0.1523 ms 3.072 ms 0.95 0.08
‘for for with 100*100 dirty region’ 10.014 ms 0.2398 ms 0.6995 ms 9.887 ms 3.10 0.29

可以发现:

  1. CopyMemoryRtMoveMemory 性能是最好的,其性能差不多;
  2. 自己写循环拷贝内存的性能是最差的;
  3. 如果 WriteableBitmap 不渲染,那么无论设置多大的脏区都不会对性能有任何影响。

结论和使用建议

综合前面两者的结论,我们可以发现:

  1. WriteableBitmap 的性能瓶颈源于对脏区的重新渲染
    • 脏区为 0 或者不在可视化树渲染,则不消耗性能
    • 只要有脏区,渲染过程就会开始成为性能瓶颈
      • CPU 占用基础值就很高了
      • 脏区越大,CPU 占用越高,但增幅不大
  2. 内存拷贝不是 WriteableBitmap 的性能瓶颈
    • 建议使用 Windows API 或者 .NET API 来拷贝内存(而不是自己写)

另外,如果你有一些特殊的应用场景,可以适当调整下自己写代码的策略:

  • 如果你希望有较大脏区的情况下降低 CPU 占用,可以考虑降低 WriteableBitmap 脏区的刷新率
  • 如果你希望 WriteableBitmap 有较低的渲染延迟,则考虑减小脏区

WriteableBitmap 渲染原理

在调用 WriteableBitmap 的 AddDirtyRect 方法的时候,实际上是调用 MILSwDoubleBufferedBitmap.AddDirtyRect,这是 WPF 专门为 WriteableBitmap 而提供的非托管代码的双缓冲位图的实现。

在 WriteableBitmap 内部数组修改完毕之后,需要调用 Unlock 来解锁内部缓冲区的访问,这时会提交所有的修改。接下来的渲染都交给了 MediaContext,用来完成双缓冲位图的渲染。

private void SubscribeToCommittingBatch()
{
    // Only subscribe the the CommittingBatch event if we are on-channel.
    if (!_isWaitingForCommit)
    {
        MediaContext mediaContext = MediaContext.From(Dispatcher);
        if (_duceResource.IsOnChannel(mediaContext.Channel))
        {
            mediaContext.CommittingBatch += CommittingBatchHandler;
            _isWaitingForCommit = true;
        }
    }
}

在上面的 CommittingBatchHandler 中,将渲染指令发送到了渲染线程。

channel.SendCommand((byte*)&command, sizeof(DUCE.MILCMD_DOUBLEBUFFEREDBITMAP_COPYFORWARD));

前面我们通过脏区大小可以得出内存拷贝不是 CPU 占用率的瓶颈,脏区大小才是,不过是渲染线程在占用这 CPU 而不是主线程。但是内存拷贝却成为了主线程的瓶颈(当然前面我们给出了数据,实际上非常小)。所以如果试图分析这么高 CPU 的占用,会发现并不能从主线程上调查得出符合预期的结论(因为即便你完全干掉了内存拷贝,CPU 占用依然是这么高)。

内存拷贝是性能瓶颈

10-17 2019

WPF 制作高性能的透明背景异形窗口(使用 WindowChrome 而不要使用 AllowsTransparency=True)

在 WPF 中,如果想做一个背景透明的异形窗口,基本上都要设置 WindowStyle="None"AllowsTransparency="True" 这两个属性。如果不想自定义窗口样式,还需要设置 Background="Transparent"。这样的设置会让窗口变成 Layered Window,WPF 在这种类型窗口上的渲染性能是非常糟糕的。

本文介绍如何使用 WindowChrome 而不设置 AllowsTransparency="True" 制作背景透明的异形窗口,这可以避免异形窗口导致的低渲染性能。


背景透明的异形窗口

如下是一个背景透明异形窗口的示例:

示例异形窗口

此窗口包含很大的圆角,还包含 DropShadowEffect 制作的阴影效果。对于非透明窗口来说,这是不可能实现的。

如何实现

要实现这种背景透明的异形窗口,需要为窗口设置以下三个属性:

  • WindowStyle="None"
  • ResizeMode="CanMinimize"ResizeMode="NoResize"
  • WindowChrome.GlassFrameThickness="-1" 或设置为其他较大的正数(可自行尝试设置之后的效果)

如下就是一个最简单的例子,最关键的三个属性我已经高亮标记出来了。

    <Window x:Class="Walterlv.Demo.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
++          WindowStyle="None" ResizeMode="CanMinimize"
            Title="walterlv demo" Height="450" Width="800">
++      <WindowChrome.WindowChrome>
++          <WindowChrome GlassFrameThickness="-1" />
++      </WindowChrome.WindowChrome>
        <Window.Template>
            <ControlTemplate TargetType="Window">
                <Border Padding="64" Background="Transparent">
                    <Border CornerRadius="16" Background="White">
                        <Border.Effect>
                            <DropShadowEffect BlurRadius="64" />
                        </Border.Effect>
                        <ContentPresenter ClipToBounds="True" />
                    </Border>
                </Border>
            </ControlTemplate>
        </Window.Template>
        <Grid>
            <TextBlock FontSize="20" Foreground="#0083d0"
                   TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="欢迎访问吕毅的博客" />
                <LineBreak />
                <Run Text="blog.walterlv.com" FontSize="64" FontWeight="Light" />
            </TextBlock>
        </Grid>
    </Window>

网上流传的主流方法

在网上流传的主流方法中,AllowsTransparency="True" 都是一个必不可少的步骤,另外也需要 WindowStyle="None"。但是我一般都会极力反对大家这么做,因为 AllowsTransparency="True" 会造成很严重的性能问题。

如果你有留意到我的其他博客,你会发现我定制窗口样式的时候都在极力避开设置此性能极差的属性:

性能对比

既然特别说到性能,那也是口说无凭,我们要拿出数据来说话。

以下是我用来测试渲染性能所使用的例子:

测试性能所用的程序

相比于上面的例子来说,主要就是加了背景动画效果,这可以用来测试帧率。

    <Window x:Class="Walterlv.Demo.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            WindowStyle="None" ResizeMode="CanMinimize"
            Title="walterlv demo" Height="450" Width="800">
        <WindowChrome.WindowChrome>
            <WindowChrome GlassFrameThickness="-1" />
        </WindowChrome.WindowChrome>
        <Window.Template>
            <ControlTemplate TargetType="Window">
                <Border Padding="64" Background="Transparent">
                    <Border CornerRadius="16" Background="White">
                        <Border.Effect>
                            <DropShadowEffect BlurRadius="64" />
                        </Border.Effect>
                        <ContentPresenter ClipToBounds="True" />
                    </Border>
                </Border>
            </ControlTemplate>
        </Window.Template>
        <Grid>
++          <Rectangle x:Name="BackgroundRectangle" Margin="0 16" Fill="#d0d1d6">
++              <Rectangle.RenderTransform>
++                  <TranslateTransform />
++              </Rectangle.RenderTransform>
++              <Rectangle.Triggers>
++                  <EventTrigger RoutedEvent="FrameworkElement.Loaded">
++                      <BeginStoryboard>
++                          <BeginStoryboard.Storyboard>
++                              <Storyboard RepeatBehavior="Forever">
++                                  <DoubleAnimation Storyboard.TargetName="BackgroundRectangle"
++                                                   Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
++                                                   From="800" To="-800" />
++                              </Storyboard>
++                          </BeginStoryboard.Storyboard>
++                      </BeginStoryboard>
++                  </EventTrigger>
++              </Rectangle.Triggers>
++          </Rectangle>
            <TextBlock FontSize="20" Foreground="#0083d0"
                   TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="欢迎访问吕毅的博客" />
                <LineBreak />
                <Run Text="blog.walterlv.com" FontSize="64" FontWeight="Light" />
            </TextBlock>
        </Grid>
    </Window>

那么性能数据表现如何呢?我们让这个窗口在 2560×1080 的屏幕上全屏渲染,得出以下数据:

方案 WindowChrome AllowsTransparency
帧率(fps)数值越大越好,60 为最好 59 19
脏区刷新率(rects/s)数值越大越好 117 38
显存占用(MB)数值越小越好 83.31 193.29
帧间目标渲染数(个)数值越大越好 2 1

另外,对于显存的使用,如果我在 7680×2160 的屏幕上全屏渲染,WindowChrome 方案依然保持在 80+MB,而 AllowsTransparency 已经达到惊人的 800+MB 了。

可见,对于渲染性能,使用 WindowChrome 制作的背景透明异形窗口性能完虐使用 AllowsTransparency 制作的背景透明异形窗口,实际上跟完全没有设置透明窗口的性能保持一致。

使用 WindowChrome 制作透明窗口的性能数据

使用 AllowsTransparency 制作透明窗口的性能数据

功能对比

既然 WindowChrome 方法在性能上完虐网上流传的设置 AllowsTransparency 方法,那么功能呢?

值得注意的是,由于在使用 WindowChrome 制作透明窗口的时候设置了 ResizeMode="None",所以你拖动窗口在屏幕顶部和左右两边的时候,Windows 不会再帮助你最大化窗口或者靠边停靠窗口,于是你需要自行处理。不过窗口的标题栏拖动功能依然保留了下来,标题栏上的右键菜单也是可以继续使用的。

方案 WindowChrome AllowsTransparency
拖拽标题栏移动窗口 保留 自行实现
最小化最大化关闭按钮 丢失 丢失
拖拽边缘调整窗口大小 丢失 丢失
移动窗口到顶部可最大化 丢失 自行实现
拖拽最大化窗口标题栏还原窗口 保留 自行实现
移动窗口到屏幕两边可侧边停靠 丢失 自行实现
拖拽摇动窗口以最小化其他窗口 保留 自行实现
窗口打开/关闭/最小化/最大化/还原动画 丢失 丢失

表格中:

  • 保留 表示此功能无需任何处理即可继续支持
  • 自行实现 表示此功能已消失,但仅需要一两行代码即可补回功能
  • 丢失 表示此功能已消失,如需实现需要编写大量代码

另外,以上表格仅针对鼠标操作窗口。如果算上使用触摸来操作窗口,那么所有标记为 自行实现 的都将变为 丢失。因为虽然你可以一句话补回功能,但在触摸操作下各种 Bug,你解不完……

这两种实现的窗口之间还有一些功能上的区别:

方案 WindowChrome AllowsTransparency 说明
点击穿透 在完全透明的部分点击依然点在自己的窗口上 在完全透明的部分点击会穿透到下面的其他窗口 感谢 nocanstillbb (huang bin bin) 提供的信息

10-12 2019

使用 .editorconfig 配置 .NET/C# 项目的代码分析规则的严重程度

随着 Visual Studio 2019 更新,在 Visual Studio 中编写代码的时候也带来了基于 Roslyn 的代码质量分析。有一些代码分析严重程度可能与团队约定的不一致,这时就需要配置规则的严重程度。另外如果是个人使用插件安装了分析器,也可以配置一些严重程度满足个人的喜好。

本文介绍使用 .editorconfig 文件来配置 .NET/C# 项目中,代码分析规则的严重性。可以是全局的,也可以每个项目有自己的配置。


生效范围与继承

.editorconfig 文件可以在你的项目中的任何地方,甚至是代码仓库之外。是按照文件夹结构来继承生效的。

比如我的项目结构是这样:

+ Walterlv.Demo
    + Core
        - .editorconfig
        - Foo.cs
    - .editorconfig
    - Program.cs

项目结构

那么 Foo.cs 文件的规则严重性将受 Core 文件夹中的 .editorconfig 文件管理,如果有些规则不在此文件夹的 .editorconfig 里面,就会受外层 .editorconfig 管理。

另外,你甚至可以在整个代码仓库的外部文件夹放一个 .editorconfig 文件,这样,如果项目中没有对应的规则,那么外面文件夹中的 .editorconfig 规则就会生效,这相当于间接做了一个全局生效的规则集。

.editorconfig 中的内容

.editorconfig 中的分析器严重性内容就像下面这样:

[*.cs]

# CC0097: You have missing/unexistent parameters in Xml Docs
dotnet_diagnostic.CC0097.severity = error

# CA1031: Do not catch general exception types
dotnet_diagnostic.CA1031.severity = suggestion

# IDE0051: 删除未使用的私有成员
dotnet_diagnostic.IDE0051.severity = none

对于 C# 语言的规则,在 [*.cs] 区,每个规则格式是 dotnet_diagnostic.{DiagnosticId}.severity = {Severity}

当然,我们不需要手工书写这个文件,了解它的格式只是为了出问题的时候不至于一脸懵逼。

配置严重程度

使用 Visual Studio 2019,配置规则严重性非常简单。当然,16.3 以上版本才这么简单,之前的版本步骤多一点。

配置规则严重性

在提示有问题的代码上按下重构快捷键(默认是 Ctrl + .),可以出现重构菜单,其中就有配置规则严重性的选项,直接选择即可自动添加到 .editorconfig 文件中。如果项目中没有 .editorconfig 文件,则会自动在解决方案同目录下创建一个新的。

对这部分快捷键不了解的话可以阅读:提高使用 Visual Studio 开发效率的键盘快捷键 - walterlv

10-10 2019

什么是模态窗口?本文带你了解模态窗口的本质

做 Windows 桌面应用开发的小伙伴们对“模态窗口”(Modal Dialog)一定不陌生。如果你希望在模态窗口之上做更多的事情,或者自己实现一套模态窗口类似的机制,那么你可能需要了解模态窗口的本质。

本文不会太深,只是从模态窗口一词出发,抵达大家都熟知的一些知识为止。


开发中的模态窗口

在各种系统、语言和框架中,只要有用户可以看见的界面,都存在模态窗口的概念。从交互层面来说,它的形式是在保留主界面作为环境来显示的情况下,打开一个新的界面,用户只能在新的界面上操作,完成或取消后才能返回主界面。从作用上来说,通常是要求用户必须提供某些信息后才能继续操作,或者单纯只是为了广告。

模态窗口的三个特点

如果你希望自己搞一套模态窗口出来,那么只需要满足这三点即可。你可以随便加戏但那都无关紧要。

  1. 保留主界面显示的同时,禁用主界面的用户交互;
  2. 显示子界面,主界面在子界面操作完成后返回;
  3. 当用户试图跳过子界面的交互的时候进行强提醒。

拿 Windows 系统中的模态对话框为例子,大概就像下面这两张图片这样:

有一个小的子界面盖住了主界面,要求用户必须进行选择。Windows 系统设置因为让背景变暗了,所以用户肯定会看得到需要进行的交互;而任务管理器没有让主界面变暗,所以用户在操作子界面的时候,模态窗口的边框和标题栏闪烁以提醒用户注意。

Windows 系统设置

任务管理器

实现模态窗口

对于 Windows 操作系统来说,模态窗口并不是一个单一的概念,你并不能仅通过一个 API 调用就完成显示模态窗口,你需要在不同的时机调用不同的 API 来完成一个模态窗口。如果要完整实现一个自己的模态窗口,则需要编写实现以上三个特点的代码。

当然,你可能会发现实际上你显示一个模态窗口仅仅一句话调用就够了,那是因为你所用的应用程序框架帮你完成了模态窗口的一系列机制。

关于 WPF 框架是如何实现模态窗口的,可以阅读:直击本质:WPF 框架是如何实现模态窗口的

关于如何自己实现一个跨越线程/进程边界的模态窗口,可以阅读:实现 Windows 系统上跨进程/跨线程的模态窗口

如果你希望定制以上第三个特点中强提醒的动画效果,可以阅读:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园

API 调用

为了在 Windows 上实现模态窗口,需要一些 Win32 API 调用(当然,框架够用的话直接用框架就好)。

禁用主窗口

我们需要使用到 BOOL EnableWindow(HWND hWnd, BOOL bEnable); 来启用与禁用某个窗口。

EnableWindow(hWnd, false);
try
{
    // 模态显示一个窗口。
}
finally
{
    EnableWindow(hWnd, true);
}
[DllImport("user32")]
private static extern bool EnableWindow(IntPtr hwnd, bool bEnable);

阻塞代码等待操作完成

因为 async/await 的出现,阻塞其实可以使用 await 来实现。虽然这不是真正的阻塞,但可以真实反应出“异步”这个过程,也就是虽然这里在等待,但实际上依然能够继续在同一个线程响应用户的操作。

UWP 中的新 API 当然已经都是使用 async/await 来实现模态等待了,不过 WPF/Windows Forms 比较早,只能使用 Dispatcher 线程模型来实现模态等待。

于是我们可以考虑直接使用现成的 Dispatcher 线程模型来完成等待,方法是调用下面两个当中的任何一个:

  • Window.ShowDialog 也就是直接使用窗口原生的模态
  • Dispatcher.PushFrame 新开一个消息循环以阻塞当前代码的同时继续响应 UI 交互

上面 Window.ShowDialog 的本质也是在调用 Dispatcher.PushFrame,详见:

关于 PushFrame 新开消息循环阻塞的原理可以参考:

当然,还有其他可以新开消息循环的方法。

进行 UI 强提醒

由于我们一开始禁用了主窗口,所以如果用户试图操作主窗口是不会有效果的。然而如果用户不知道当前显示了一个模态窗口需要操作,那么给出提醒也是必要的。

简单的在 UI 上的提醒是最简单的了,比如:

  • 将主界面变暗(UWP 应用,Web 应用喜欢这么做)
  • 将主界面变模糊(iOS 应用喜欢这么做)
  • 在模态窗口上增加一个很厚重的阴影(Android 应用喜欢这么做)

然而 Windows 和 Mac OS 这些古老的系统由于兼容性负担不能随便那么改,于是需要有其他的提醒方式。

Windows 采用的方式是让标题栏闪烁,让阴影闪烁。

而这些特效的处理,来自于子窗口需要处理一些特定的消息 WM_SETCURSOR

详见:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园

通常你不需要手工处理这些消息,但是如果你完全定制了窗口样式,则可能需要自行做一个这样的模态窗口提醒效果。

10-10 2019

直击本质:WPF 框架是如何实现模态窗口的

想知道你在 WPF 编写 Window.ShowDialog() 之后,WPF 框架是如何帮你实现模态窗口的吗?

本文就带你来了解这一些。


Window.ShowDialog

WPF 显示模态窗口的方法就是 Window.ShowDialog,因此我们直接进入这个方法查看。由于 .NET Core 版本的 WPF 已经开源,我们会使用 .NET Core 版本的 WPF 源代码。

Window.ShowDialog 的源代码可以在这里查看:

这个方法非常长,所以我只把其中与模态窗口最关键的代码和相关注释留下,其他都删除(这当然是不可编译的):

public Nullable<bool> ShowDialog()
{
    // NOTE:
    // _threadWindowHandles is created here.  This reference is nulled out in EnableThreadWindows
    // when it is called with a true parameter.  Please do not null it out anywhere else.
    // EnableThreadWindow(true) is called when dialog is going away.  Once dialog is closed and
    // thread windows have been enabled, then there no need to keep the array list around.
    // Please see BUG 929740 before making any changes to how _threadWindowHandles works.
    _threadWindowHandles = new ArrayList();
    //Get visible and enabled windows in the thread
    // If the callback function returns true for all windows in the thread, the return value is true.
    // If the callback function returns false on any enumerated window, or if there are no windows
    // found in the thread, the return value is false.
    // No need for use to actually check the return value.
    UnsafeNativeMethods.EnumThreadWindows(SafeNativeMethods.GetCurrentThreadId(),
                                            new NativeMethods.EnumThreadWindowsCallback(ThreadWindowsCallback),
                                            NativeMethods.NullHandleRef);
    //disable those windows
    EnableThreadWindows(false);

    try
    {
        _showingAsDialog = true;
        Show();
    }
    catch
    {
        // NOTE:
        // See BUG 929740.
        // _threadWindowHandles is created before calling ShowDialog and is deleted in
        // EnableThreadWindows (when it's called with true).
        //
        // Window dlg = new Window();
        // Button b = new button();
        // b.OnClick += new ClickHandler(OnClick);
        // dlg.ShowDialog();
        //
        //
        // void OnClick(...)
        // {
        //      dlg.Close();
        //      throw new Exception();
        // }
        //
        //
        // If above code is written, then we get inside this exception handler only after the dialog
        // is closed.  In that case all the windows that we disabled before showing the dialog have already
        // been enabled and _threadWindowHandles set to null in EnableThreadWindows.  Thus, we don't
        // need to do it again.
        //
        // In any other exception cases, we get in this handler before Dialog is closed and thus we do
        // need to enable all the disable windows.
        if (_threadWindowHandles != null)
        {
            // Some exception case. Re-enable the windows that were disabled
            EnableThreadWindows(true);
        }
    }
}

觉得代码还是太长?不要紧,我再简化一下:

  1. EnumThreadWindows 获取当前线程的所有窗口
  2. 把当前线程中的所有窗口都禁用掉(用的是 Win32 API 的禁用哦,这不会导致窗口内控件的样式变为禁用状态)
  3. 将窗口显示出来(如果出现异常,则还原之前禁用的窗口)

可以注意到禁用掉的窗口是“当前线程”的哦。

ShowHelper

接下来的重点方法是 Window.ShowDialog 中的那句 Show()。在 Show() 之前设置了 _showingAsDialogtrue,于是这里会调用 ShowHelper 方法并传入 true

下面的代码也是精简后的 ShowHelper 方法:

private object ShowHelper(object booleanBox)
{
    try
    {
        // tell users we're going modal
        ComponentDispatcher.PushModal();

        _dispatcherFrame = new DispatcherFrame();
        Dispatcher.PushFrame(_dispatcherFrame);
    }
    finally
    {
        // tell users we're going non-modal
        ComponentDispatcher.PopModal();
    }
}

可以看到,重点是 PushModalPopModal 以及 PushFrame

PushFrame 的效果就是让调用 ShowDialog 的代码看起来就像阻塞了一样(实际上就是阻塞了,只不过开了新的消息循环看起来 UI 不卡)。

关于 PushFrame 为什么能够“阻塞”你的代码的同时还能继续响应 UI 操作的原理,可以阅读:

那么 ComponentDispatcher.PushModalComponentDispatcher.PopModal 呢?可以在这里(ComponentDispatcherThread.cs)看它的代码,实际上是为了模态计数以及引发事件的,对模态的效果没有本质上的影响。

10-10 2019

.NET/C# 检测电脑上安装的 .NET Framework 的版本

如果你希望知道某台计算机上安装了哪些版本的 .NET Framework,那么正好本文可以帮助你解决问题。


如何找到已安装的 .NET Framework

有的电脑的 .NET Framework 是自带的,有的是操作系统自带的。这样,你就不能通过控制面板的“卸载程序”去找到到底安装了哪个版本的 .NET Framework 了。

关于各个版本 Windows 10 上自带的 .NET Framework 版本,可以阅读 各个版本 Windows 10 系统中自带的 .NET Framework 版本 - walterlv

而如果通过代码 Environment.Version 来获取 .NET 版本,实际上获取的是 CLR 的版本,详见 使用 PowerShell 获取 CLR 版本号 - walterlv

这些版本号是不同的,详见 .NET Framework 4.x 程序到底运行在哪个 CLR 版本之上 - walterlv

那么如何获取已安装的 .NET Framework 的版本呢?最靠谱的方法竟然是通过读取注册表。

注册表位置和含义

读取位置在这里:

计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full\2052

注册表位置

而唯一准确能够判定 .NET Framework 版本的,只有里面的 Release 值。但可惜的是,这个值并不能直接看出来到底是 4.5 还是 4.8。我们需要有一张对应表。

我把它整理成了字典和注释,这样会比较容易理解每个编号对应的 .NET Framework 版本代号。

/// <summary>
/// 获取 .NET Framework 4.5 及以上版本的发行号与版本名称的对应关系。
/// 4.5 及以下版本没有这样的对应关系。
/// </summary>
private static readonly Dictionary<int, string> ReleaseToNameDictionary = new Dictionary<int, string>
{
    // .NET Framework 4.5
    { 378389, "4.5" },
    // .NET Framework 4.5.1(Windows 8.1 或 Windows Server 2012 R2 自带)
    { 378675, "4.5.1" },
    // .NET Framework 4.5.1(其他系统安装)
    { 378758, "4.5.1" },
    // .NET Framework 4.5.2
    { 379893, "4.5.2" },
    // .NET Framework 4.6(Windows 10 第一个版本 1507 自带)
    { 393295, "4.6" },
    // .NET Framework 4.6(其他系统安装)
    { 393297, "4.6" },
    // .NET Framework 4.6.1(Windows 10 十一月更新 1511 自带)
    { 394254, "4.6.1" },
    // .NET Framework 4.6.1(其他系统安装)
    { 394271, "4.6.1" },
    // .NET Framework 4.6.2(Windows 10 一周年更新 1607 和 Windows Server 2016 自带)
    { 394802, "4.6.2" },
    // .NET Framework 4.6.2(其他系统安装)
    { 394806, "4.6.2" },
    // .NET Framework 4.7(Windows 10 创造者更新 1703 自带)
    { 460798, "4.7" },
    // .NET Framework 4.7(其他系统安装)
    { 460805, "4.7" },
    // .NET Framework 4.7.1(Windows 10 秋季创造者更新 1709 和 Windows Server 1709 自带)
    { 461308, "4.7.1" },
    // .NET Framework 4.7.1(其他系统安装)
    { 461310, "4.7.1" },
    // .NET Framework 4.7.2(Windows 10 2018年四月更新 1803 和 Windows Server 1803 自带)
    { 461808, "4.7.2" },
    // .NET Framework 4.7.2(其他系统安装)
    { 461814, "4.7.2" },
    // .NET Framework 4.8(Windows 10 2019年五月更新 1903 自带)
    { 528040, "4.8" },
    // .NET Framework 4.8(其他系统安装)
    { 528049, "4.8" },
};

另外,还有一些值也是有意义的(只是不那么精确):

  • 主版本
    • 也就是可以共存的版本,比如 v3.5 系列和 v4 系列就是可以共存的,它们分别是就地更新的保持兼容的版本
  • 发行版本名称
    • 完整版 Full 和精简版 Client
  • 版本号
    • 比如 3.5.30729.4926 或者 4.7.02556
  • 服务包版本
    • 古时候的微软喜欢用 SP1 SP2 来命名同一个版本的多次更新,这也就是那个年代的产物

它们分别在注册表的这些位置:

  • 主版本
    • 计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\NET Framework Setup\NDP 里项的名称
  • 发行版本名称
    • 以上项里子项的名称
  • 版本号
    • 以上项里的 Version
  • 服务包版本
    • 以上项里的 SP

读取注册表

在上面已经梳理了读取注册表的位置之后,相信你可以很容易写出读取已安装 .NET Framework 版本的代码出来。

我已经将其做成了 NuGet 源代码包(使用 SourceYard 打包),你可以安装 NuGet 包来获得读取已安装 .NET Framework 版本的功能:

或者在 GitHub 查看源代码:

只有一个类型——NdpInfo

使用方法有两种。

第一种,获取当前计算机上所有已经安装的 .NET Framework 版本:

var allVersions = await NdpInfo.ReadFromRegistryAsync();

执行完成之后看看得到的字典 allVersions 如下:

已安装的全部 .NET Framework

字典里 Key 是不能共存的主版本,Value 是这个主版本里当前已经安装的具体版本信息。

如果直接使用 ToString(),是可以生成我们平时经常在各大文档或者社区使用的 .NET Framework 的名称。

第二种,获取当前已安装的最新的 .NET Framework 版本名称:

var currentVersion = NdpInfo.GetCurrentVersionName();

这可以直接获取到一个字符串,比如 .NET Framework 4.8。对于只是简单获取一下已安装名称而不用做更多处理的程序来说会比较方便。

10-10 2019

Windows 系统上用 .NET/C# 查找所有窗口,并获得窗口的标题、位置、尺寸、最小化、可见性等各种状态

在 Windows 应用开发中,如果需要操作其他的窗口,那么可以使用 EnumWindows 这个 API 来枚举这些窗口。

你可以使用本文编写的一个类型,查找到所有窗口中你关心的信息。


需要使用的 API

枚举所有窗口仅需要使用到 EnumWindows,其中需要定义一个委托 WndEnumProc 作为传入参数的类型。

剩下的我们需要其他各种方法用于获取窗口的其他属性。

private delegate bool WndEnumProc(IntPtr hWnd, int lParam);

[DllImport("user32")]
private static extern bool EnumWindows(WndEnumProc lpEnumFunc, int lParam);

[DllImport("user32")]
private static extern IntPtr GetParent(IntPtr hWnd);

[DllImport("user32")]
private static extern bool IsWindowVisible(IntPtr hWnd);

[DllImport("user32")]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lptrString, int nMaxCount);

[DllImport("user32")]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

[DllImport("user32")]
private static extern bool GetWindowRect(IntPtr hWnd, ref LPRECT rect);

[StructLayout(LayoutKind.Sequential)]
private readonly struct LPRECT
{
    public readonly int Left;
    public readonly int Top;
    public readonly int Right;
    public readonly int Bottom;
}

枚举所有窗口

我将以上 API 封装成 FindAll 函数,并提供过滤器可以给大家过滤众多的窗口使用。

比如,我写了下面一个简单的示例,可以输出当前可见的所有窗口以及其位置和尺寸:

using System;

namespace Walterlv.WindowDetector
{
    class Program
    {
        static void Main(string[] args)
        {
            var windows = WindowEnumerator.FindAll();
            for (int i = 0; i < windows.Count; i++)
            {
                var window = windows[i];
                Console.WriteLine($@"{i.ToString().PadLeft(3, ' ')}. {window.Title}
     {window.Bounds.X}, {window.Bounds.Y}, {window.Bounds.Width}, {window.Bounds.Height}");
            }
            Console.ReadLine();
        }
    }
}

获取所有窗口以及其位置和尺寸

这里的 FindAll 方法,我提供了一个默认参数,可以指定如何过滤所有枚举到的窗口。如果不指定,则会找可见的,包含标题的,没有最小化的窗口。如果你希望找一些看不见的窗口,可以自己写过滤条件。

什么都不要过滤的话,就传入 _ => true,意味着所有的窗口都会被枚举出来。

附源码

因为源代码会经常更新,所以建议在这里查看:

无法访问的话,可以看下面:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;

namespace Walterlv.WindowDetector
{
    /// <summary>
    /// 包含枚举当前用户空间下所有窗口的方法。
    /// </summary>
    public class WindowEnumerator
    {
        /// <summary>
        /// 查找当前用户空间下所有符合条件的窗口。如果不指定条件,将仅查找可见窗口。
        /// </summary>
        /// <param name="match">过滤窗口的条件。如果设置为 null,将仅查找可见窗口。</param>
        /// <returns>找到的所有窗口信息。</returns>
        public static IReadOnlyList<WindowInfo> FindAll(Predicate<WindowInfo> match = null)
        {
            var windowList = new List<WindowInfo>();
            EnumWindows(OnWindowEnum, 0);
            return windowList.FindAll(match ?? DefaultPredicate);

            bool OnWindowEnum(IntPtr hWnd, int lparam)
            {
                // 仅查找顶层窗口。
                if (GetParent(hWnd) == IntPtr.Zero)
                {
                    // 获取窗口类名。
                    var lpString = new StringBuilder(512);
                    GetClassName(hWnd, lpString, lpString.Capacity);
                    var className = lpString.ToString();

                    // 获取窗口标题。
                    var lptrString = new StringBuilder(512);
                    GetWindowText(hWnd, lptrString, lptrString.Capacity);
                    var title = lptrString.ToString().Trim();

                    // 获取窗口可见性。
                    var isVisible = IsWindowVisible(hWnd);

                    // 获取窗口位置和尺寸。
                    LPRECT rect = default;
                    GetWindowRect(hWnd, ref rect);
                    var bounds = new Rectangle(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top);

                    // 添加到已找到的窗口列表。
                    windowList.Add(new WindowInfo(hWnd, className, title, isVisible, bounds));
                }

                return true;
            }
        }

        /// <summary>
        /// 默认的查找窗口的过滤条件。可见 + 非最小化 + 包含窗口标题。
        /// </summary>
        private static readonly Predicate<WindowInfo> DefaultPredicate = x => x.IsVisible && !x.IsMinimized && x.Title.Length > 0;

        private delegate bool WndEnumProc(IntPtr hWnd, int lParam);

        [DllImport("user32")]
        private static extern bool EnumWindows(WndEnumProc lpEnumFunc, int lParam);

        [DllImport("user32")]
        private static extern IntPtr GetParent(IntPtr hWnd);

        [DllImport("user32")]
        private static extern bool IsWindowVisible(IntPtr hWnd);

        [DllImport("user32")]
        private static extern int GetWindowText(IntPtr hWnd, StringBuilder lptrString, int nMaxCount);

        [DllImport("user32")]
        private static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

        [DllImport("user32")]
        private static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);

        [DllImport("user32")]
        private static extern bool GetWindowRect(IntPtr hWnd, ref LPRECT rect);

        [StructLayout(LayoutKind.Sequential)]
        private readonly struct LPRECT
        {
            public readonly int Left;
            public readonly int Top;
            public readonly int Right;
            public readonly int Bottom;
        }
    }

    /// <summary>
    /// 获取 Win32 窗口的一些基本信息。
    /// </summary>
    public readonly struct WindowInfo
    {
        public WindowInfo(IntPtr hWnd, string className, string title, bool isVisible, Rectangle bounds) : this()
        {
            Hwnd = hWnd;
            ClassName = className;
            Title = title;
            IsVisible = isVisible;
            Bounds = bounds;
        }

        /// <summary>
        /// 获取窗口句柄。
        /// </summary>
        public IntPtr Hwnd { get; }

        /// <summary>
        /// 获取窗口类名。
        /// </summary>
        public string ClassName { get; }

        /// <summary>
        /// 获取窗口标题。
        /// </summary>
        public string Title { get; }

        /// <summary>
        /// 获取当前窗口是否可见。
        /// </summary>
        public bool IsVisible { get; }

        /// <summary>
        /// 获取窗口当前的位置和尺寸。
        /// </summary>
        public Rectangle Bounds { get; }

        /// <summary>
        /// 获取窗口当前是否是最小化的。
        /// </summary>
        public bool IsMinimized => Bounds.Left == -32000 && Bounds.Top == -32000;
    }
}

10-10 2019

使用 SetWindowCompositionAttribute 来控制程序的窗口边框和背景(可以做 Acrylic 亚克力效果、模糊效果、主题色效果等)

Windows 系统中有一个没什么文档的 API,SetWindowCompositionAttribute,可以允许应用的开发者将自己窗口中的内容渲染与窗口进行组合。这可以实现很多系统中预设的窗口特效,比如 Windows 7 的毛玻璃特效,Windows 8/10 的前景色特效,Windows 10 的模糊特效,以及 Windows 10 1709 的亚克力(Acrylic)特效。而且这些组合都发生在 dwm 进程中,不会额外占用应用程序的渲染性能。

本文介绍 SetWindowCompositionAttribute 可以实现的所有效果。你可以通过阅读本文了解到与系统窗口可以组合渲染到哪些程度。


试验用的源代码

本文将创建一个简单的 WPF 程序来验证 SetWindowCompositionAttribute 能达到的各种效果。你也可以不使用 WPF,得到类似的效果。

简单的项目文件结构是这样的:

  • [项目] Walterlv.WindowComposition
    • App.xaml
    • App.xaml.cs
    • MainWindow.xaml
    • MainWindow.xaml.cs
    • WindowAccentCompositor

其中,App.xaml 和 App.xaml.cs 保持默认生成的不动。

为了验证此 API 的效果,我需要将 WPF 主窗口的背景色设置为纯透明或者 null,而设置 ControlTemplate 才能彻彻底底确保所有的样式一定是受我们自己控制的,我们在 ControlTemplate 中没有指定任何可以显示的内容。MainWindow.xaml 的全部代码如下:

<Window x:Class="Walterlv.WindowComposition.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="欢迎访问吕毅的博客:blog.walterlv.com" Height="450" Width="800">
    <Window.Template>
        <ControlTemplate TargetType="Window">
            <AdornerDecorator>
                <ContentPresenter />
            </AdornerDecorator>
        </ControlTemplate>
    </Window.Template>
    <!-- 我们注释掉 WindowChrome,是因为即将验证 WindowChrome 带来的影响。 -->
    <!--<WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="-1" />
    </WindowChrome.WindowChrome>-->
    <Grid>
    </Grid>
</Window>

而 MainWindow.xaml.cs 中,我们简单调用一下我们即将写的调用 SetWindowCompositionAttribute 的类型。

using System.Windows;
using System.Windows.Media;
using Walterlv.Windows.Effects;

namespace Walterlv.WindowComposition
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            var compositor = new WindowAccentCompositor(this);
            compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
        }
    }
}

还剩下一个 WindowAccentCompositor.cs 文件,因为比较长放到博客里影响阅读,所以建议前往这里查看:

而其中对我们最终渲染效果有影响的就是 AccentPolicy 类型的几个属性。其中 AccentState 属性是下面这个枚举,而 GradientColor 将决定窗口渲染时叠加的颜色。

private enum AccentState
{
    ACCENT_DISABLED = 0,
    ACCENT_ENABLE_GRADIENT = 1,
    ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
    ACCENT_ENABLE_BLURBEHIND = 3,
    ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
    ACCENT_INVALID_STATE = 5,
}

影响因素

经过试验,对最终显示效果有影响的有这些:

  • 选择的 AccentState 枚举值
  • 使用的 GradientColor 叠加色
  • 是否使用 WindowChrome 让客户区覆盖非客户区
  • 目标操作系统(Windows 7/8/8.1/10)

使用 WindowChrome,你可以用你自己的 UI 覆盖掉系统的 UI 窗口样式。关于 WindowChrome 让客户区覆盖非客户区的知识,可以阅读:

需要注意的是,WindowChromeGlassFrameThickness 属性可以设置窗口边框的粗细,设置为 0 将导致窗口没有阴影,设置为负数将使得整个窗口都是边框。

排列组合

我们依次来看看效果。

AccentState=ACCENT_DISABLED

使用 ACCENT_DISABLED 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome

<WindowChrome.WindowChrome>
    <WindowChrome />
</WindowChrome.WindowChrome>

with WindowChrome in Windows 10


在 Windows 7 上,使用 WindowChrome

with WindowChrome in Windows 7

当然,以上边框比较细,跟系统不搭,可以设置成其他值:

bold thickness WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome 并且 GlassFrameThickness 设置为 -1

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>

-1 glass frame in Windows 10


而在 Windows 7 上,这就是非常绚丽的全窗口的 Aero 毛玻璃特效:

-1 glass frame in Windows 7

AccentState=ACCENT_ENABLE_GRADIENT

使用 ACCENT_DISABLED 时,GradientColor 叠加色会影响到最终的渲染效果。

还记得我们前面叠加的颜色是什么吗?

叠加的颜色

接下来别忘了然后把它误以为是我系统的主题色哦!


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10

另外,你会注意到左、下、右三个方向上边框会深一些。那是 Windows 10 的窗口阴影效果,因为实际上 Windows 10 叠加的阴影也是窗口区域的一部分,只是一般人看不出来而已。我们叠加了颜色之后,这里就露馅儿了。

另外,这个颜色并不是我们自己的进程绘制的哦,是 dwm 绘制的颜色。

如果不指定 GradientColor 也就是保持为 0,你将看到上面绿色的部分全是黑色的;嗯,包括阴影的部分……

without WindowChrome in Windows 10 - default gradient color


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7

可以看出,在 Windows 7 上,GradientColor 被无视了。


而使用 WindowChrome 在 Windows 10 上,则可以得到整个窗口的叠加色:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>

with WindowChrome in Windows 10

可以注意到,窗口获得焦点的时候,整个窗口都是叠加色;而窗口失去焦点的时候,指定了边框的部分颜色会更深(换其他颜色叠加可以看出来是叠加了半透明黑色)。

如果你希望失去焦点的时候,边框部分不要变深,请将边框设置为 -1

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>

使用 WindowChrome 在 Windows 7 上,依然没有任何叠加色的效果:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_TRANSPARENTGRADIENT

使用 ACCENT_ENABLE_TRANSPARENTGRADIENT 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome frame in Windows 10

依然左、下、右三个方向上边框会深一些,那是 Windows 10 的窗口阴影效果。


不使用 WindowChrome 在 Windows 7 上:

without WindowChrome in Windows 7

GradientColor 也是被无视的,而且效果跟之前一样。


使用 WindowChrome 在 Windows 10 上,在获得焦点的时候整个背景是系统主题色;而失去焦点的时候是灰色,但边框部分是深色。

with WindowChrome frame in Windows 10

依然可以将边框设置为 -1 使得边框不会变深:

with WindowChrome in Windows 10


使用 WindowChrome 在 Windows 7 上,依然是老样子:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_BLURBEHIND

ACCENT_ENABLE_BLURBEHIND 可以在 Windows 10 上做出模糊效果,就跟 Windows 10 早期版本的模糊效果是一样的。你可以看我之前的一篇博客,那时亚克力效果还没出来:

使用 ACCENT_ENABLE_BLURBEHIND 时,GradientColor 叠加色没有任何影响,唯一影响渲染的是 WindowChrome 和操作系统。


在 Windows 10 上,没有使用 WindowChrome

模糊效果

你可能需要留意一下那个“诡异”的模糊范围,你会发现窗口的阴影外侧也是有模糊的!!!你能忍吗?肯定不能忍,所以还是乖乖使用 WindowChrome 吧!


在 Windows 7 上,没有使用 WindowChrome,效果跟其他值一样,依然没有变化:

without WindowChrome in Windows 7


在 Windows 10 上,使用 WindowChrome

with WindowChrome in Windows 10


使用 WindowChrome 在 Windows 7 上,依然是老样子:

with WindowChrome in Windows 7

AccentState=ACCENT_ENABLE_ACRYLICBLURBEHIND

从 Windows 10 (1803) 开始,Win32 程序也能添加亚克力效果了,因为 SetWindowCompositionAttribute 的参数枚举新增了 ACCENT_ENABLE_ACRYLICBLURBEHIND

亚克力效果相信大家不陌生,那么在 Win32 应用程序里面使用的效果是什么呢?


不使用 WindowChrome,在 Windows 10 上:

without WindowChrome in Windows 10

咦!等等!这不是跟之前一样吗?


嗯,下面就是不同了,亚克力效果支持与半透明的 GradientColor 叠加,所以我们需要将传入的颜色修改为半透明:

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x3f, 0x18, 0xa0, 0x5e));

acrylic without WindowChrome


那么如果改为全透明会怎么样呢?

不幸的是,完全没有效果!!!

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));

no acrylic without WindowChrome


接下来是使用 WindowChrome 时:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>

acrylic with WindowChrome frame

然而周围有一圈偏白色的渐变是什么呢?那个其实是 WindowChrome 设置的边框白,被亚克力效果模糊后得到的混合效果。


所以,如果要获得全窗口的亚克力效果,请将边框设置成比较小的值:

<WindowChrome.WindowChrome>
    <WindowChrome GlassFrameThickness="0 1 0 0" />
</WindowChrome.WindowChrome>

acrylic with thin WindowChrome frame


记得不要像前面的那些效果一样,如果设置成 -1,你将获得纯白色与设置的 Gradient 叠加色的亚克力特效,是个纯色:

acrylic with WindowChrome -1 frame


你可以将叠加色的透明度设置得小一些,这样可以看出叠加的颜色:

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0xa0, 0x18, 0xa0, 0x5e));

acrylic with darker gradient color


那么可以设置为全透明吗?

    var compositor = new WindowAccentCompositor(this);
--  compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++  compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));

很不幸,最终你会完全看不到亚克力效果,而变成了毫无特效的透明窗口:

acrylic with transparent gradient color

最上面那根白线,是我面前面设置边框为 0 1 0 0 导致的。


如果在这种情况下,将边框设置为 0 会怎样呢?记得前面我们说过的吗,会导致阴影消失哦!

呃……你将看到……这个……

什么都没有……

acrylic with zero WindowChrome frame thickness

是不是找到了一条新的背景透明异形窗口的方法?

还是省点心吧,亚克力效果在 Win32 应用上的性能还是比较堪忧的……

想要背景透明,请参见:


不用考虑 Windows 7,因为大家都知道不支持。实际效果会跟前面的一模一样。

AccentState=ACCENT_INVALID_STATE

这个值其实不用说了,因为 AccentState 在不同系统中可用的值不同,为了保证向后兼容性,对于新系统中设置的值,旧系统其实就视之为 ACCENT_INVALID_STATE

那么如果系统认为设置的是 ACCENT_INVALID_STATE 会显示成什么样子呢?

答案是,与 ACCENT_DISABLED 完全相同。

总结

由于 Windows 7 上所有的值都是同样的效果,所以下表仅适用于 Windows 10。

  效果
ACCENT_DISABLED 黑色(边框为纯白色)
ACCENT_ENABLE_GRADIENT GradientColor 颜色(失焦后边框为深色)
ACCENT_ENABLE_TRANSPARENTGRADIENT 主题色(失焦后边框为深色)
ACCENT_ENABLE_BLURBEHIND 模糊特效(失焦后边框为灰色)
ACCENT_ENABLE_ACRYLICBLURBEHIND 与 GradientColor 叠加颜色的亚克力特效
ACCENT_INVALID_STATE 黑色(边框为纯白色)

在以上的特效之下,WindowChrome 可以让客户区覆盖非客户区,或者让整个窗口都获得特效,而不只是标题栏。

附源代码

请参见 GitHub 地址以获得最新代码。如果不方便访问,那么就看下面的吧。

using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;

namespace Walterlv.Windows.Effects
{
    /// <summary>
    /// 为窗口提供模糊特效。
    /// </summary>
    public class WindowAccentCompositor
    {
        private readonly Window _window;

        /// <summary>
        /// 创建 <see cref="WindowAccentCompositor"/> 的一个新实例。
        /// </summary>
        /// <param name="window">要创建模糊特效的窗口实例。</param>
        public WindowAccentCompositor(Window window) => _window = window ?? throw new ArgumentNullException(nameof(window));

        public void Composite(Color color)
        {
            Window window = _window;
            var handle = new WindowInteropHelper(window).EnsureHandle();

            var gradientColor =
                // 组装红色分量。
                color.R << 0 |
                // 组装绿色分量。
                color.G << 8 |
                // 组装蓝色分量。
                color.B << 16 |
                // 组装透明分量。
                color.A << 24;

            Composite(handle, gradientColor);
        }

        private void Composite(IntPtr handle, int color)
        {
            // 创建 AccentPolicy 对象。
            var accent = new AccentPolicy
            {
                AccentState = AccentState.ACCENT_ENABLE_ACRYLICBLURBEHIND,
                GradientColor = 0,
            };

            // 将托管结构转换为非托管对象。
            var accentPolicySize = Marshal.SizeOf(accent);
            var accentPtr = Marshal.AllocHGlobal(accentPolicySize);
            Marshal.StructureToPtr(accent, accentPtr, false);

            // 设置窗口组合特性。
            try
            {
                // 设置模糊特效。
                var data = new WindowCompositionAttributeData
                {
                    Attribute = WindowCompositionAttribute.WCA_ACCENT_POLICY,
                    SizeOfData = accentPolicySize,
                    Data = accentPtr,
                };
                SetWindowCompositionAttribute(handle, ref data);
            }
            finally
            {
                // 释放非托管对象。
                Marshal.FreeHGlobal(accentPtr);
            }
        }

        [DllImport("user32.dll")]
        private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data);

        private enum AccentState
        {
            ACCENT_DISABLED = 0,
            ACCENT_ENABLE_GRADIENT = 1,
            ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
            ACCENT_ENABLE_BLURBEHIND = 3,
            ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
            ACCENT_INVALID_STATE = 5,
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct AccentPolicy
        {
            public AccentState AccentState;
            public int AccentFlags;
            public int GradientColor;
            public int AnimationId;
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct WindowCompositionAttributeData
        {
            public WindowCompositionAttribute Attribute;
            public IntPtr Data;
            public int SizeOfData;
        }

        private enum WindowCompositionAttribute
        {
            // 省略其他未使用的字段
            WCA_ACCENT_POLICY = 19,
            // 省略其他未使用的字段
        }
    }
}

09-19 2019

WPF 程序如何移动焦点到其他控件

WPF 中可以使用 UIElement.Focus() 将焦点设置到某个特定的控件,也可以使用 TraversalRequest 仅仅移动焦点。本文介绍如何在 WPF 程序中控制控件的焦点。


UIElement.Focus

仅仅需要在任何一个控件上调用 Focus() 方法即可将焦点设置到这个控件上。

但是需要注意,要使 Focus() 能够工作,这个元素必须满足两个条件:

  • Focusable 设置为 true
  • IsVisibletrue

TraversalRequest

如果你并不是将焦点设置到某个特定的控件,而是希望将焦点转移,可以考虑使用 TraversalRequest 类。

比如,以下代码是将焦点转移到下一个控件,也就是按下 Tab 键时焦点会去的控件。

var traversalRequest = new TraversalRequest(FocusNavigationDirection.Next);
// view 是可视化树中的一个控件。
view.MoveFocus(traversalRequest);

关于逻辑焦点和键盘焦点

键盘焦点就是你实际上按键输入和快捷键会生效的焦点,也就是当前正在工作的控件的焦点。

而 WPF 有多个焦点范围(Focus Scope),按下 Tab 键切换焦点的时候只会在当前焦点范围切焦点,不会跨范围。那么一旦跨范围切焦点的时候,焦点会去哪里呢?答案是逻辑焦点。

每个焦点范围内都有一个逻辑焦点,记录如果这个焦点范围一旦获得焦点后应该在哪个控件获得键盘焦点。

比如默认情况下 WPF 每个 Window 就是一个焦点范围,那么每个 Window 中的当前焦点就是逻辑焦点。而一旦这个 Window 激活,那么这个窗口中的逻辑焦点就会成为键盘焦点,另一个窗口当中的逻辑焦点保留,而键盘焦点则丢失。

跨窗口/跨进程切换焦点

参见我的另一篇博客:


参考资料

09-19 2019

使用 SetParent 制作父子窗口的时候,如何设置子窗口的窗口样式以避免抢走父窗口的焦点

制作传统 Win32 程序以及 Windows Forms 程序的时候,一个用户看起来独立的窗口本就是通过各种父子窗口嵌套完成的,有大量窗口句柄,窗口之间形成父子关系。不过,对于 WPF 程序来说,一个独立的窗口实际上只有一个窗口句柄,窗口内的所有内容都是 WPF 绘制的。

如果你不熟悉 Win32 窗口中的父子窗口关系和窗口样式,那么很有可能遇到父子窗口之间“抢夺焦点”的问题,本文介绍如何解决这样的问题。


“抢夺焦点”

下图中的上下两个部分是两个不同的窗口,他们之间通过 SetParent 建立了父子关系。

注意看下面的窗口标题栏,当我在这些不同区域间点击的时候,窗口标题栏在黑色和灰色之间切换:

抢夺焦点

这说明当子窗口获得焦点的时候,父窗口会失去焦点并显示失去焦点的样式。

你可以在这篇博客中找到一个简单的例子:

解决办法

而原因和解决方法仅有一个,就是子窗口需要有一个子窗口的样式。

具体来说,子窗口必须要有 WS_CHILD 样式。

你可以看看 Spyxx.exe 抓出来的默认普通窗口和子窗口的样式差别:

![默认普通窗口]](/static/posts/2019-09-19-10-21-31.png)

▲ 默认普通窗口

子窗口

▲ 子窗口


参考资料

09-18 2019

.NET/C# 利用 Walterlv.WeakEvents 高性能地定义和使用弱事件

弱引用是 .NET 引入的概念,可以用来协助解决内存泄漏问题。然而事件也可能带来内存泄漏问题,是否有弱事件机制可以使用呢?.NET 没有自带的弱事件机制,但其中的一个子集 WPF 带了。然而我们不是什么项目都能引用 WPF 框架类库的。

本文介绍 Walterlv.WeakEvents 库来定义和使用弱事件。


系列博客:

下载安装 Walterlv.WeakEvents

在你需要做弱事件的项目中安装 NuGet 包:

定义弱事件

现在,定义弱事件就不能直接写 event EventHandler Bar 了,要像下面这样写:

using System;
using Walterlv.WeakEvents;

namespace Walterlv.Demo
{
    public class Foo
    {
        private readonly WeakEvent<EventArgs> _bar = new WeakEvent<EventArgs>();

        public event EventHandler Bar
        {
            add => _bar.Add(value, value.Invoke);
            remove => _bar.Remove(value);
        }

        private void OnBar() => _bar.Invoke(this, EventArgs.Empty);
    }
}

使用弱事件

对于弱事件的使用,就跟以前任何其他正常事件一样了,直接 +=-=

这样,如果我有一个 A 类的实例 a,订阅了以上 FooBar 事件,那么当 a 脱离作用范围后,将可以被垃圾回收机制回收。而如果不这么做,Foo 将始终保留对 a 实例的引用,这将阻止垃圾回收。

09-18 2019

.NET 设计一套高性能的弱事件机制

弱引用是 .NET 引入的概念,可以用来协助解决内存泄漏问题。然而事件也可能带来内存泄漏问题,是否有弱事件机制可以使用呢?.NET 没有自带的弱事件机制,但其中的一个子集 WPF 带了。然而我们不是什么项目都能引用 WPF 框架类库的。网上有很多弱事件的 NuGet 包,不过仅仅支持定义事件的时候写成弱事件而不支持让任意事件变成弱事件,并且存在性能问题。

本文将设计一套弱事件机制,不止可以让任意一个 CLR 事件成为弱事件,还具有近乎原生事件的性能。


系列博客:

场景与问题

本文主要为了设计一套弱事件机制而编写,因此如果你感兴趣,应该已经理解了我试图做什么事情。

当然,如果并不理解,可以阅读这个机制的应用篇,里面有具体的应用场景:

现有设计

在我进行此设计之前,已有如下种类的弱事件机制:

  1. WPF 框架自带的 WeakEventManager
    • 功能非常有限,自己继承实现一个的难度非常高,但具有很高的性能;WPF 绑定等机制的底层实现用到了这个类型。
  2. WPF 框架自带的泛型类 WeakEventManager<TEventSource, TEventArgs>
    • 可以让你更容易地实现一个自己的弱事件,但是性能非常差
  3. 使用网上很多的 NuGet 包
    • 下载量较高的几个 NuGet 包我都有研究过其中的源代码,要么有限制必须是定义事件的时候就必须使用弱事件,要么使用反射或其他动态调用方法性能较差
  4. StackOverflow 上关于 Weak Event 的高赞回答
    • 目前还没有找到可以支持将任意事件添加弱事件支持的回答

由于我希望编写的弱事件机制尽可能减少对非预期框架的依赖,而且具有很高的性能,所以我打算自己实现一套。

设计原则

  1. 支持为任意类型的事件添加弱事件支持,而不只是自己定义新事件的时候可以使用(对标主流 NuGet 包和 StackOverflow 上的回答)
  2. 具有很高的性能(对标主流的 NuGet 包和 WPF 泛型版本的 WeakEventManager)
  3. 类的使用者只需要编写极少量的代码就能完成(对标 WPF 非泛型版本的 WeakEventManager)

这三个原则,从上到下优先级依次降低。

要支持所有类型的 CLR 事件,意味着我的设计中必须要能够直接监听到任意事件,而不能所有代码都从我自己编写的代码开始。

要有很高的性能,就意味着我几乎不能使用“反射”,也不能使用委托的 DynamicInvoke 方法,还不能生成 IL 代码(首次生成很慢),也不能使用表达式树(首次编译很慢)。那么可以使用的也就只剩下两个了,一个是纯 C#/.NET 带的编译期就能确定执行的代码,另一个是使用 Roslyn 编译期在编译期间进行特殊处理。

类的使用者要编写极少量的代码,意味着能够抽取到框架中的代码就尽量抽取到框架中。

取名

俗话说,一个好的名字是成功的一半。

因为我希望为任意 CLR 事件添加弱事件支持,所以其职责有点像“代理、中间人、中继、中转”,对应英文的 Proxy Agent Relay Transfer。最终我选择名称 Relay(中继),因为好听。

API 设计

对于 API 的设计,我有一个小原则:

  • 如果技术实现很难,那么 API 迁就技术实现;如果技术实现很容易,那么技术迁就 API

我总结了好的 API 设计的一些原则:

不得不说,此类型设计的技术难度还是挺大的。虽然我们知道有 WeakReference<T> 可用,但依然存在很多的技术难点。于是 API 的设计可能要退而求其次优先满足前两个优先级更高的目标。

我们期望 API 足够简单,因此在几个备选方案中选择:

  1. WeakEventRelay.Subscribe("Changed", OnChanged)
    • 使用字符串来表示事件,肯定会用到反射,不可取
  2. WeakEventRelay.Subscribe(o => o.Changed, OnChanged)
    • 如果使用 Action 来做,会遇到 o.Changed 必须出现在 += 左边的编译错误
    • 如果使用表达式树,也一样会遇到 o.Changed 必须出现在 += 左边的编译错误,同时还会出现少量性能问题

因此,直接一个方法就能完成事件注册是不可能的了,我们改用其他方法——继承自某个基类:

internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
{
    public event FileSystemEventHandler Changed
    {
        add => /*实现弱事件订阅*/;
        remove => /*实现弱事件注销*/;
    }
}

那么实现的难点就都在 addremove 方法里面了。

技术实现

我们究竟需要哪些信息才可以完成弱事件机制呢?

  • 事件源(也就是在使用弱事件机制之前最原始的事件引发者,经常以 object sender 的形式出现在你的代码中)
  • 要订阅的事件(比如 FileSystemWatcher.Changed 事件)
  • 新注册的事件处理函数(也就是 addremove 方法中的 value

然而事情并没有那么简单:

在框架通用代码中,我不可能获取到要订阅的事件。因为事件要求只能出现在 += 的左边,不能以任何其他形式使用(包括但不限于通过参数传递,伪装成 Lambda 表达式,伪装成表达式树)。这意味着 o.Changed += OnChanged 这样的事件订阅完全写不出来通用代码(除非牺牲性能)。

那么还能怎么做呢?只能将这段写不出来的代码留给业务编写者来编写了。

也就是说,类似于 o.Changed += OnChanged 这样的代码只能交给业务开发者来实现。与此同时也注定了 OnChanged 必须由业务开发者编写(因为无法写出通用的高性能的事件处理函数,并且还能在 +=-= 的时候保持同一个实例。

我没有办法通过抽象的办法引发一个事件。具体来说,无法在抽象的通用代码中写出 Changed.Invoke(sender, e) 这样代码。因为委托的基类 Delegate MultiCastDelegate 没有 Invoke 方法可以使用,只有耗性能的 DynamicInvoke 方法。各种不同的委托定义虽然可以有相同的参数和返回值类型,但是却不能相互转换,因此我也不能将传入的委托转换成 Action<TSender, TArgs> 这样的通用委托。

庆幸的是,C# 提供了将方法组隐式转换委托的方法,可以让两个参数和返回值类型相同的委托隐式转换。但注意,这是隐式转换,没有运行时代码可以高性能地完成这件事情。

addremove 方法中,value 参数就是使用方传入的事件处理函数,value.Invoke 就是方法组,可以隐式转换为通用的 Action<TSender, TArgs>

这意味着,我们可以将 value.Invoke 传入来以通用的方式调用事件处理函数。但是请特别注意,这会导致新创建委托实例,导致 -= 的时候实例与 += 的时候不一致,无法注销事件。因此,我们除了传入 value.Invoke 之外,还必须传入 value 本身。

API 半残品预览

internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
{
    public event FileSystemEventHandler Changed
    {
        add => Subscribe(o => o.Changed += OnChanged, value, value.Invoke);
        remove => Unsubscribe(o => o.Changed -= OnChanged, value);
    }

    private void OnChanged(object sender, FileSystemEventArgs e) => /* 引发弱事件 */;
}

这已经开始让业务方的代码变得复杂起来了。

方案完善

我们还需要能够注册、注销和引发弱事件,而这部分就没那么坑了。因为:

  1. 我们已经把最坑的 o.Changed += OnChangedvaluevalue.Invoke 都传进来了;
  2. 在类型中定义一个弱事件,目前网上各种主流弱事件 NuGet 包都有实现。

我写了一个 WeakEvent<TSender, TArgs> 泛型类专门用来定义弱事件。

不过,这让业务方的代码压力更大了:

internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
{
    private readonly WeakEvent<FileSystemEventArgs> _changed = new WeakEvent<FileSystemEventArgs>();

    public event FileSystemEventHandler Changed
    {
        add => Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));
        remove => _changed.Remove(value);
    }

    private void OnChanged(object sender, FileSystemEventArgs e) => TryInvoke(_changed, sender, e);
}

最后,订阅事件所需的实例,我认为最好不要能够让业务方直接能访问。因为弱事件的实现并不简单(看上面如此复杂的公开 API 就知道了),如果能够直接访问,势必带来更复杂的使用问题。所以我仅在部分方法和 Lambda 表达式参数中开放实例。

所以,构造函数需要传入事件源。

最后的问题

最后还留下了一个问题

  • 订阅者现在确实“弱事件”了,但这个“中继”怎么办?可是被强引用了啊?

虽然中继的类实例小得多,但这确实依然也是泄漏,因此需要回收。

于是我在任何可能执行代码的时机加上了回收检查:如果发现所有订阅者都已经被回收,那么“中继”也就可以被回收了,将注销所有事件源的订阅。(当然要允许重新开始订阅。)

所以最后业务方编写的中继代码又多了一些:

using System.IO;
using Walterlv.WeakEvents;

namespace Walterlv.Demo
{
    internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
    {
        public FileSystemWatcherWeakEventRelay(FileSystemWatcher eventSource) : base(eventSource) { }

        private readonly WeakEvent<FileSystemEventArgs> _changed = new WeakEvent<FileSystemEventArgs>();

        public event FileSystemEventHandler Changed
        {
            add => Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));
            remove => _changed.Remove(value);
        }

        private void OnChanged(object sender, FileSystemEventArgs e) => TryInvoke(_changed, sender, e);

        protected override void OnReferenceLost(FileSystemWatcher source)
        {
            source.Changed -= OnChanged;
        }
    }
}

实际使用

虽然弱事件中继的代码复杂了点,但是:

1 最终用户的使用可是非常简单的:

public class WalterlvDemo
{
    public WalterlvDemo()
    {
        _watcher = new FileSystemWatcher(@"D:\Desktop\walterlv.demo.md")
        {
            EnableRaisingEvents = true,
        };
        _watcher.Created += OnCreated;
        _watcher.Changed += OnChanged;
        _watcher.Renamed += OnRenamed;
        _watcher.Deleted += OnDeleted;
    }

    private readonly FileSystemWatcher _watcher;
    private void OnCreated(object sender, FileSystemEventArgs e) { }
    private void OnChanged(object sender, FileSystemEventArgs e) { }
    private void OnRenamed(object sender, RenamedEventArgs e) { }
    private void OnDeleted(object sender, FileSystemEventArgs e) { }
}

2 是在懒得写,我可以加上 Roslyn 编译器生成中继代码的方式,这个我将在不久的将来加入到 Walterlv.WeakEvents 库中。

相关源码

更具体的使用场景和示例代码,请阅读:

本文所涉及的全部源代码,已在 GitHub 上开源:

注意开源协议:

996.icu

LICENSE


参考资料

09-18 2019

.NET/C# 利用 Walterlv.WeakEvents 高性能地中转一个自定义的弱事件(可让任意 CLR 事件成为弱事件)

弱引用是 .NET 引入的概念,可以用来协助解决内存泄漏问题。然而事件也可能带来内存泄漏问题,是否有弱事件机制可以使用呢?.NET 没有自带的弱事件机制,但其中的一个子集 WPF 带了。然而我们不是什么项目都能引用 WPF 框架类库的。网上有很多弱事件的 NuGet 包,不过仅仅支持定义事件的时候写成弱事件而不支持让任意事件变成弱事件,并且存在性能问题。

本文介绍 Walterlv.WeakEvents 库来做弱事件。你可以借此将任何一个 CLR 事件当作弱事件来使用。


系列博客:

场景与问题

了解一下场景,你就能知道这是否是适合你的方案。

比如我正在使用 FileSystemWatcher 来监听一个文件的改变,我可能会使用到这些事件:

  • Created 在文件被创建时引发
  • Changed 在文件内容或属性发生改变时引发
  • Renamed 在文件被重命名时引发
  • Deleted 在文件被删除时引发

更具体一点的代码是这样的:

public class WalterlvDemo
{
    public WalterlvDemo()
    {
        _watcher = new FileSystemWatcher(@"D:\Desktop\walterlv.demo.md")
        {
            EnableRaisingEvents = true,
        };
        _watcher.Created += OnCreated;
        _watcher.Changed += OnChanged;
        _watcher.Renamed += OnRenamed;
        _watcher.Deleted += OnDeleted;
    }

    private readonly FileSystemWatcher _watcher;
    private void OnCreated(object sender, FileSystemEventArgs e) { }
    private void OnChanged(object sender, FileSystemEventArgs e) { }
    private void OnRenamed(object sender, RenamedEventArgs e) { }
    private void OnDeleted(object sender, FileSystemEventArgs e) { }
}
private void Foo()
{
    var demo = new WalterlvDemo();
    // 使用 demo
    // 此方法结束后,demo 将脱离作用域,本应该可以被回收的。
}

但是,一旦我们这么写,那么我们这个类型 WalterlvDemo 的实例 demo 将无法被回收,因为 FileSystemWatcher 将始终通过事件引用着这个实例。即使你已经不再引用这个类型的任何一个实例,此实例也会被 _watcher 的事件引用着,而 FileSystemWatcher 的实例也因为 EnableRaisingEvents 而一直存在。

一个可行的解决办法是调用 FileSystemWatcherDispose 方法。不过有些时候很难决定到底在什么时机调用 Dispose 合适。

现在,我们希望有一种方法,能够在 WalterlvDemo 的实例失去作用域后被回收,最好 FileSystemWatcher 也能够自动被 Dispose 释放掉。

如果你试图解决的是类似这样的问题,那么本文就可以帮到你。

总结一下:

  1. 用到了一个现有的类型(你无法修改它的源代码,本例中是 FileSystemWatcher);
  2. 你无法决定什么时候释放此类型的实例(本例中是不知道什么时候调用 Dispose);
  3. 一旦你监听此类型的事件,将产生内存泄漏,导致你自己类型的实例无法释放(本例中是 demo 变量脱离作用域。)。

目前有 WPF 自带的 WeakEventManager 机制,网上也有很多可用的 NuGet 包,但是都有限制:

  1. 只能给自己定义的类型引入弱事件机制,不能给现有类型引入弱事件;
  2. 要么用反射,要么用 IL 生成代码,性能都不高。

而 Walterlv.WeakEvents 除了解决了给任一类型引入弱事件的问题,还具有非常高的性能,几乎跟定义原生事件无异。

下载安装 Walterlv.WeakEvents

在你需要做弱事件的项目中安装 NuGet 包:

编写自定义的弱事件中继

现在,我们需要编写一个自定义的弱事件中继类 FileSystemWatcherWeakEventRelay,即专门为 FileSystemWatcher 做的弱事件中继。

下面是一个简单点的例子,为其中的 Changed 事件做了一个中继:

using System.IO;
using Walterlv.WeakEvents;

namespace Walterlv.Demo
{
    internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
    {
        public FileSystemWatcherWeakEventRelay(FileSystemWatcher eventSource) : base(eventSource) { }

        private readonly WeakEvent<FileSystemEventArgs> _changed = new WeakEvent<FileSystemEventArgs>();

        public event FileSystemEventHandler Changed
        {
            add => Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));
            remove => _changed.Remove(value);
        }

        private void OnChanged(object sender, FileSystemEventArgs e) => TryInvoke(_changed, sender, e);

        protected override void OnReferenceLost(FileSystemWatcher source)
        {
            source.Changed -= OnChanged;
        }
    }
}

你可能会看到代码有点儿多,但是我向你保证,这是除了采用 Roslyn 编译器技术以外最高性能的方案了。如果你对弱事件的性能有要求,那么还是接受这些代码会比较好。

不要紧张,我来一一解释这些代码。另外,如果你不想懂这些代码,就按照模板一个个敲就好了,都是模板化的代码(特别适合使用 Roslyn 编译器生成,我可能接下来就会做这件事情避免你写出这些代码)。

  1. 首先,我们定义了一个自定义的弱事件中继 FileSystemWatcherWeakEventRelay,继承自库 Walterlv.WeakEvents 中的 WeakEventRelay<FileSystemWatcher> 类型。带上的泛型参数表明是针对 FileSystemWatcher 类型做弱事件中继。
  2. 一个构造函数,将参数传递给基类:public FileSystemWatcherWeakEventRelay(FileSystemWatcher eventSource) : base(eventSource) { }。这个构造函数是可以用 Visual Studio 生成的,快捷键是 Ctrl + . 或者 Alt + Enter(快捷键功效详见:提高使用 Visual Studio 开发效率的键盘快捷键
  3. 定义了一个私有的 WeakEvent<FileSystemEventArgs>,名为 _changed,这个就是弱事件的核心。泛型参数是事件参数的类型(注意,为了极致的性能,这里的泛型参数是事件参数的名称,而不是大多数弱事件框架中提供的事件处理委托类型)。
  4. 定义了一个对外公开的事件 public event FileSystemEventHandler Changed
    • add 方法固定调用 Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));。其中 ChangedFileSystemWatcher 中的事件,OnChanged 是我们即将定义的事件处理函数,_changed 是前面定义好的弱事件字段,而后面的 valuevalue.Invoke 是固定写法。
    • remove 方法固定调用弱事件的 Remove 方法,即 _changed.Remove(value);
  5. 编写针对公开事件的事件处理函数 OnChanged,并在里面固定调用 TryInvoke(_changed, sender, e)
  6. 重写 OnReferenceLost 方法,用于在对象已被回收后反注册 FileSystemWatcher 中的事件。

希望看了上面这 6 点之后你还能理解这些代码都是在做啥。如果依然不能理解,可以考虑:

  1. 参考下面 FileSystemWatcherWeakEventRelay 的完整代码来理解哪些是可变部分哪些是不可变部分,自己替换就好;
  2. 等待 Walterlv.WeakEvents 库的作者更新自动生成这段代码的功能。
using System.IO;
using Walterlv.WeakEvents;

namespace Walterlv.Demo
{
    internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
    {
        public FileSystemWatcherWeakEventRelay(FileSystemWatcher eventSource) : base(eventSource) { }

        private readonly WeakEvent<FileSystemEventArgs> _created = new WeakEvent<FileSystemEventArgs>();
        private readonly WeakEvent<FileSystemEventArgs> _changed = new WeakEvent<FileSystemEventArgs>();
        private readonly WeakEvent<RenamedEventArgs> _renamed = new WeakEvent<RenamedEventArgs>();
        private readonly WeakEvent<FileSystemEventArgs> _deleted = new WeakEvent<FileSystemEventArgs>();

        public event FileSystemEventHandler Created
        {
            add => Subscribe(o => o.Created += OnCreated, () => _created.Add(value, value.Invoke));
            remove => _created.Remove(value);
        }

        public event FileSystemEventHandler Changed
        {
            add => Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));
            remove => _changed.Remove(value);
        }

        public event RenamedEventHandler Renamed
        {
            add => Subscribe(o => o.Renamed += OnRenamed, () => _renamed.Add(value, value.Invoke));
            remove => _renamed.Remove(value);
        }

        public event FileSystemEventHandler Deleted
        {
            add => Subscribe(o => o.Deleted += OnDeleted, () => _deleted.Add(value, value.Invoke));
            remove => _deleted.Remove(value);
        }

        private void OnCreated(object sender, FileSystemEventArgs e) => TryInvoke(_created, sender, e);
        private void OnChanged(object sender, FileSystemEventArgs e) => TryInvoke(_changed, sender, e);
        private void OnRenamed(object sender, RenamedEventArgs e) => TryInvoke(_renamed, sender, e);
        private void OnDeleted(object sender, FileSystemEventArgs e) => TryInvoke(_deleted, sender, e);

        protected override void OnReferenceLost(FileSystemWatcher source)
        {
            source.Created -= OnCreated;
            source.Changed -= OnChanged;
            source.Renamed -= OnRenamed;
            source.Deleted -= OnDeleted;
            source.Dispose();
        }
    }
}

使用自定义的弱事件中继

当你把上面这个自定义的弱事件中继类型写好了之后,使用它就非常简单了,对我们原有的代码改动非常小。

    public class WalterlvDemo
    {
        public WalterlvDemo()
        {
            _watcher = new FileSystemWatcher(@"D:\Desktop\walterlv.demo.md")
            {
                EnableRaisingEvents = true,
            };
++          var weakEvent = new FileSystemWatcherWeakEventRelay(_watcher);
--          _watcher.Created += OnCreated;
--          _watcher.Changed += OnChanged;
--          _watcher.Renamed += OnRenamed;
--          _watcher.Deleted += OnDeleted;
++          weakEvent.Created += OnCreated;
++          weakEvent.Changed += OnChanged;
++          weakEvent.Renamed += OnRenamed;
++          weakEvent.Deleted += OnDeleted;
        }

        private readonly FileSystemWatcher _watcher;
        private void OnCreated(object sender, FileSystemEventArgs e) { }
        private void OnChanged(object sender, FileSystemEventArgs e) { }
        private void OnRenamed(object sender, RenamedEventArgs e) { }
        private void OnDeleted(object sender, FileSystemEventArgs e) { }
    }

最终效果预览

我写了一个程序,每 1 秒修改一次文件;每 5 秒回收一次内存。然后使用 FileSystemWatcher 来监视这个文件的改变。

可以看到,在回收内存之后,将不会再监视文件的改变。当然,如果你期望一直可以监视改变,当然也不希望用到本文的弱事件。

可以回收事件

为什么弱事件中继的 API 如此设计?

一句话解答:为了高性能

请参见我的另一篇博客:


参考资料

09-17 2019

C#/.NET 中启动进程时所使用的 UseShellExecute 设置为 true 和 false 分别代表什么意思?

在 .NET 中创建进程时,可以传入 ProcessStartInfo 类的一个新实例。在此类型中,有一个 UseShellExecute 属性。

本文介绍 UseShellExecute 属性的作用,设为 truefalse 时,分别有哪些进程启动行为上的差异。


本质差异

Process.Start 本质上是启动一个新的子进程,不过这个属性的不同,使得启动进程的时候会调用不同的 Windows 的函数。

当然,如果你知道这两个函数的区别,那你自然也就了解此属性设置为 truefalse 的区别了。

效果差异

ShellExecute 的用途是打开程序或者文件或者其他任何能够打开的东西(如网址)。

也就是说,你可以在 Process.Start 的时候传入这些:

  • 一个可执行程序(exe)
  • 一个网址
  • 一个 html / mp4 / jpg / docx / enbx 等各种文件
  • PATH 环境变量中的各种程序

不过,此方法有一些值得注意的地方:

  • 不支持重定向输入和输出
  • 最终启动了哪个进程可能是不确定的,你可能需要注意潜在的安全风险

CreateProcess 则会精确查找路径来执行,不支持各种非可执行程序的打开。但是:

  • 支持重定向输入和输出

如何选择

UseShellExecute 在 .NET Framework 中的的默认值是 true,在 .NET Core 中的默认值是 false

如果有以下需求,那么建议设置此值为 false

  • 需要明确执行一个已知的程序
  • 需要重定向输入和输出

如果你有以下需求,那么建议设置此值为 true 或者保持默认:

  • 需要打开文档、媒体、网页文件等
  • 需要打开 Url
  • 需要打开脚本执行
  • 需要打开计算机上环境变量中路径中的程序

参考资料

09-12 2019

.NET Framework 的 bug?try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃

在 .NET Framework 4.8 中,try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃。而 .NET Core 3.0 中不会出现这样的问题。

本文涉及的 Bug 已经报告给了微软,并且得到了微软的回复。是 .NET Framework 4.8 为了解决一个安全性问题而强行结束了进程。


This post is written in multiple languages. Please select yours:

官方文档中 when 的行为

你可以前往官方文档:

在其中,你可以找到这样一段话:

用户筛选的子句的表达式不受任何限制。 如果在执行用户筛选的表达式期间发生异常,则将放弃该异常,并视筛选表达式的值为 false。 在这种情况下,公共语言运行时继续搜索当前异常的处理程序。

即当 when 块中出现异常时,when 表达式将视为值为 false,并且此异常将被忽略。

示例程序

鉴于官方文档中的描述,我们可以编写一些示例程序来验证这样的行为。

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");
        }
    }
}

很显然,我们直接 new 出来的 FileNotFoundExceptionFileName 属性会保持为 null。对其解引用会产生 NullReferenceException。很显然代码不应该这么写,但可以用来验证 catch-when 语句的行为。

按照官网描述,输出应该为 Try-Catch 2-End。因为 when 中的异常被忽略,因此不会进入到外层的 catch 块中;因为 when 中出现异常导致表达式值视为 false,因此进入了更合适的异常处理块 Catch 2 中。

在 .NET Core 3.0 中的行为和 .NET Framework 4.8 中的行为

下面两张图分别是这段代码在 .NET Core 3.0 和 .NET Framework 4.8 中的输出:

.NET Core 3.0 中的行为

.NET Framework 4.8 中的行为

可以注意到,只有 .NET Core 3.0 中的行为符合官方文档的描述,而 .NET Framework 4.8 中甚至连 End 都没有输出!几乎可以确定,程序在 .NET Framework 4.8 中出现了致命的崩溃!

如果我们以 Visual Studio 调试启动此程序,可以看到抛出了 CLR 异常:

抛出了 CLR 异常

以下是在 Visual Studio 中单步跟踪的步骤:

单步调试

Issue 和行为

由于本人金鱼般的记忆力,我竟然给微软报了三次这个 Bug:

此问题是 .NET Framework 4.8 为了修复一个安全性问题才强行结束了进程:

Process corrupting exceptions in exception filter (like access violation) now result in aborting the current process. [110375, clr.dll, Bug, Build:3694]

请参见:

09-12 2019

如何在 WPF 中获取所有已经显式赋过值的依赖项属性

获取 WPF 的依赖项属性的值时,会依照优先级去各个级别获取。这样,无论你什么时候去获取依赖项属性,都至少是有一个有效值的。有什么方法可以获取哪些属性被显式赋值过呢?如果是 CLR 属性,我们可以自己写判断条件,然而依赖项属性没有自己写判断条件的地方。

本文介绍如何获取以及显式赋值过的依赖项属性。


需要用到 DependencyObject.GetLocalValueEnumerator() 方法来获得一个可以遍历所有依赖项属性本地值。

public static void DoWhatYouLikeByWalterlv(DependencyObject dependencyObject)
{
    var enumerator = dependencyObject.GetLocalValueEnumerator();
    while (enumerator.MoveNext())
    {
        var entry = enumerator.Current;
        var property = entry.Property;
        var value = entry.Value;
        // 在这里使用 property 和 value。
    }
}

这里的 value 可能是 MarkupExtension 可能是 BindingExpression 还可能是其他一些可能延迟计算值的提供者。因此,你不能在这里获取到常规方法获取到的依赖项属性的真实类型的值。

但是,此枚举拿到的所有依赖项属性的值都是此依赖对象已经赋值过的依赖项属性的本地值。如果没有赋值过,将不会在这里的遍历中出现。


参考资料

09-12 2019

在 WPF 中获取一个依赖对象的所有依赖项属性

本文介绍如何在 WPF 中获取一个依赖对象的所有依赖项属性。


通过 WPF 标记获取

public static IEnumerable<DependencyProperty> EnumerateDependencyProperties(object element)
{
    if (element is null)
    {
        throw new ArgumentNullException(nameof(element));
    }

    MarkupObject markupObject = MarkupWriter.GetMarkupObjectFor(element);
    if (markupObject != null)
    {
        foreach (MarkupProperty mp in markupObject.Properties)
        {
            if (mp.DependencyProperty != null)
            {
                yield return mp.DependencyProperty;
            }
        }
    }
}

public static IEnumerable<DependencyProperty> EnumerateAttachedProperties(object element)
{
    if (element is null)
    {
        throw new ArgumentNullException(nameof(element));
    }

    MarkupObject markupObject = MarkupWriter.GetMarkupObjectFor(element);
    if (markupObject != null)
    {
        foreach (MarkupProperty mp in markupObject.Properties)
        {
            if (mp.IsAttached)
            {
                yield return mp.DependencyProperty;
            }
        }
    }
}

通过设计器专用方法获取

本来 .NET 中提供了一些专供设计器使用的类型 TypeDescriptor 可以帮助设计器找到一个类型或者组件的所有可以设置的属性,不过我们也可以通过此方法来获取所有可供使用的属性。

下面是带有重载的两个方法,一个传入类型一个传入实例。

/// <summary>
/// 获取一个对象中所有的依赖项属性。
/// </summary>
public static IEnumerable<DependencyProperty> GetDependencyProperties(object instance)
    => TypeDescriptor.GetProperties(instance, new Attribute[] { new PropertyFilterAttribute(PropertyFilterOptions.All) })
        .OfType<PropertyDescriptor>()
        .Select(x => DependencyPropertyDescriptor.FromProperty(x)?.DependencyProperty)
        .Where(x => x != null);

/// <summary>
/// 获取一个类型中所有的依赖项属性。
/// </summary>
public static IEnumerable<DependencyProperty> GetDependencyProperties(Type type)
    => TypeDescriptor.GetProperties(type, new Attribute[] { new PropertyFilterAttribute(PropertyFilterOptions.All) })
        .OfType<PropertyDescriptor>()
        .Select(x => DependencyPropertyDescriptor.FromProperty(x)?.DependencyProperty)
        .Where(x => x != null);

参考资料

09-07 2019

提高使用 Visual Studio 开发效率的键盘快捷键

Visual Studio 的功能可谓真是丰富,再配合各种各样神奇强大的插件,Visual Studio 作为太阳系最强大的 IDE 名副其实。

如果你能充分利用起 Visual Studio 启用这些功能的快捷键,那么效率也会很高。


建议记住

功能 快捷键 建议修改成
重构 Ctrl + . Alt + Enter
转到所有 Ctrl + , Ctrl + N
重命名 F2  
打开智能感知列表 Ctrl + J Alt + 右
注释 Ctrl + K, Ctrl + C  
取消注释 Ctrl + K, Ctrl + U  
保存全部文档 Ctrl + K, S  
折叠成大纲 Ctrl + M, Ctrl + O  
展开所有大纲 Ctrl + M, Ctrl + P  
加入书签 Ctrl + K, Ctrl + K  
上一书签 Ctrl + K, Ctrl + P  
下一书签 Ctrl + K, Ctrl + N  
切换自动换行 Alt + Z  

万能重构

你可以不记住本文的其他任何快捷键,但这个你一定要记住,那就是:

Ctrl + .

当然,因为中文输入法会占用这个快捷键,所以我更喜欢将这个快捷键修改一下,改成:

Alt + Enter

修改方法可以参见:如何快速自定义 Visual Studio 中部分功能的快捷键

它的功能是“快速操作和重构”。你几乎可以在任何代码上使用这个快捷键来快速修改你的代码。

比如修改命名空间:

修改命名空间

比如提取常量或变量:

提取常量

比如添加参数判空代码:

参数判空

还有更多功能都可以使用此快捷键。而且因为 Roslyn 优秀的 API,有更多扩展可以使用此快捷键生效,详见:基于 Roslyn 同时为 Visual Studio 插件和 NuGet 包开发 .NET/C# 源代码分析器 Analyzer 和修改器 CodeFixProvider

转到所有

不能每次都去解决方案里面一个个找文件,对吧!所以一个快速搜索文件和符号的快捷键也是非常能够提升效率的。

Ctrl + , 转到所有(go to all)

不过我建议将其改成:

Ctrl + N 这是 ReSharper 默认的转到所有(Goto Everything)的快捷键

这可以帮助你快速找到整个解决方案中的所有文件或符号,看下图:

转到所有

修改方法可以参见:如何快速自定义 Visual Studio 中部分功能的快捷键,下图是此功能的命令名称 编辑.转到所有Edit.GoToAll):

编辑.转到所有

有一些小技巧:

  • 你可以无需拼写完整个单词就找到你想要的符号
    • 例如输入 mw 就可以找到 MainWindow
  • 对于两个以上单词拼成的符号,建议将每个单词的首字母输入成大写,这样可以提高目标优先级,更容易找到
    • 例如 PrivateTokenManager,如果希望干扰少一些,建议输入 PTM 而不是 ptm;当然想要更少的干扰,可以打更多的字母,例如 priToM 等等

注意到上面的界面里面右上角有一些过滤器吗?这些过滤器有单独的快捷键。这样就直接搜索特定类型的符号,而不是所有了,可以提高查找效率。

Ctrl + O 查找当前文件中的所有成员(只搜一个文件,这可以大大提高命中率) Ctrl + T 转到符号(只搜类型名称、成员名称) Ctrl + G 查找当前文件的行号(比如你在代码审查中看到一行有问题的代码,得知行号,可以迅速跳转到这一行)

重构

重命名

F2

重命名

如果你在一个标识符上直接重新输入改了名字,也可以通过 Ctrl + . 或者 Alt + Enter 完成重命名。

其他

这些都可以被最上面的 Ctrl + . 或者 Alt + Enter 替代,因此都可以忘记。

Ctrl + R, Ctrl + E 封装字段
Ctrl + R, Ctrl + I 提取接口
Ctrl + R, Ctrl + V 删除参数
Ctrl + R, Ctrl + O 重新排列参数

IntelliSense 自动完成列表

智能感知

IntelliSense 以前有个漂亮的中文名字,叫做“智能感知”,不过现在大多数的翻译已经与以前的另一个平淡无奇的功能结合到了一起,叫做“自动完成列表”。Visual Studio 默认只会让智能感知列表发挥非常少量的功能,如果你不进行一些配置,使用起来会“要什么没什么”,想显示却不显示。

请通过另一篇博客中的内容把 Visual Studio 的智能感知列表功能好好配置一下,然后我们才可以再次感受到它的强大(记得要翻到最后哦):

如果还有一些时机没有打开智能感知列表,可以配置一个快捷键打开它,我这边配置的快捷键是 Alt + 右

设置打开智能感知的快捷键

参数信息

Ctrl + Shift + 空格

显示方法的参数信息。

显示参数信息

默认在输入参数的时候就已经会显示了;如果错过了,可以在输入 , 的时候继续出现;如果还错过了,可以使用此快捷键出现。

编写

代码格式化

Ctrl + K, Ctrl + E 全文代码清理(包含全文代码格式化以及其他功能) Shift + Alt + F 全文代码格式化 Ctrl + K, Ctrl + F 格式化选定的代码

关于代码清理,你可以配置做哪些事情:

配置代码清理

配置代码清理

其他

Ctrl + K, Ctrl + / 将当前行注释或取消注释
Ctrl + K, Ctrl + C 将选中的代码注释掉
Ctrl + K, Ctrl + UCtrl + Shift + / 将选定的内容取消注释

Ctrl + U 将当前选中的所有文字转换为小写(请记得配合 F2 重命名功能使用避免编译不通过)
Ctrl + ] 增加行缩进
Ctrl + [ 减少行缩进

Ctrl + S 保存文档 Ctrl + K, S 保存全部文档(注意按键,是按下 Ctrl + K 之后所有按键松开,然后单按一个 S

导航

Ctrl + F 打开搜索面板开始强大的搜索功能
Ctrl + H 打开替换面板,或展开搜索面板为替换面板
Ctrl + I 渐进式搜索(就像 Ctrl + F 一样,不过不会抢焦点,搜索完按回车键即完成搜索,适合键盘党操作)
Ctrl + Shift + F 打开搜索窗口(与 Ctrl + F 虽然功能重合,但两者互不影响,意味着你可以充分这两套搜索来执行两套不同的搜索配置)
Ctrl + Shift + H 打开替换窗口(与 Ctrl + H 虽然功能重合,但两者互不影响,意味着你可以充分这两套替换来执行两套不同的替换配置)
Alt + 下 在当前文件中,将光标定位到下一个方法
Alt + 上 在当前文件中,将光标定位到上一个方法

Ctrl + M, Ctrl + M 将光标当前所在的类/方法切换大纲的展开或折叠
Ctrl + M, Ctrl + L 将全文切换大纲的展开或折叠(如果当前有任何大纲折叠了则全部展开,否则全部折叠)
Ctrl + M, Ctrl + P 将全文的大纲全部展开
Ctrl + M, Ctrl + U 将光标当前所在的类/方法大纲展开
Ctrl + M, Ctrl + O 将全文的大纲都折叠到定义那一层

Ctrl + D 查找下一个相同的标识符,然后放一个新的脱字号或者称作输入光标)(多次点按可以在相同字符串上出很多光标,可以一起编辑,如下图) Ctrl + Insert 查找所有相同的标识符,然后全部放置脱字号(如下图)

多个脱字号

脱字号 是 Visual Studio 中对于输入光标的称呼,对应英文的 Caret。

书签

Ctrl + K, Ctrl + K 为当前行加入到书签或从书签中删除 Ctrl + K, Ctrl + P 切换到上一个书签 Ctrl + K, Ctrl + N 切换到下一个书签 Ctrl + K, Ctrl + L 删除所有书签(会有对话框提示的,不怕误按)

如果配合书签面板,那么可以在调查问题的时候很方便在找到的各种关键代码处跳转,避免每次都寻找。

配合书签面板

另外,还有个任务列表,跟书签列表差不多的功能:

Ctrl + K, Ctrl + H 将当前代码加入到任务列表中或者从列表中删除(效果类似编写 // TODO

任务列表

显示

Ctrl + R, Ctrl + W 显示空白字符
Alt + Z 切换自动换行和单行模式

显示空白字符

09-05 2019

.NET/C# 阻止屏幕关闭,阻止系统进入睡眠状态

在 Windows 系统中,一段时间不操作键盘和鼠标,屏幕便会关闭,系统会进入睡眠状态。但有些程序(比如游戏、视频和演示文稿)在运行过程中应该阻止屏幕关闭,否则屏幕总是关闭,会导致体验会非常糟糕。

本文介绍如何编写 .NET/C# 代码临时阻止屏幕关闭以及系统进入睡眠状态。


Windows API

我们需要使用到一个 Windows API:

/// <summary>
/// Enables an application to inform the system that it is in use, thereby preventing the system from entering sleep or turning off the display while the application is running.
/// </summary>
[DllImport("kernel32")]
private static extern ExecutionState SetThreadExecutionState(ExecutionState esFlags);

使用到的枚举用 C# 类型定义是:

[Flags]
private enum ExecutionState : uint
{
    /// <summary>
    /// Forces the system to be in the working state by resetting the system idle timer.
    /// </summary>
    SystemRequired = 0x01,

    /// <summary>
    /// Forces the display to be on by resetting the display idle timer.
    /// </summary>
    DisplayRequired = 0x02,

    /// <summary>
    /// This value is not supported. If <see cref="UserPresent"/> is combined with other esFlags values, the call will fail and none of the specified states will be set.
    /// </summary>
    [Obsolete("This value is not supported.")]
    UserPresent = 0x04,

    /// <summary>
    /// Enables away mode. This value must be specified with <see cref="Continuous"/>.
    /// <para />
    /// Away mode should be used only by media-recording and media-distribution applications that must perform critical background processing on desktop computers while the computer appears to be sleeping.
    /// </summary>
    AwaymodeRequired = 0x40,

    /// <summary>
    /// Informs the system that the state being set should remain in effect until the next call that uses <see cref="Continuous"/> and one of the other state flags is cleared.
    /// </summary>
    Continuous = 0x80000000,
}

以上所有的注释均照抄自微软的官方 API 文档:

API 封装

如果你擅长阅读英文,那么以上的 API 函数、枚举和注释足够你完成你的任务了。

不过,我这里提供一些封装,以应对一些常用的场景。

using System;
using System.Runtime.InteropServices;

namespace Walterlv.Windows
{
    /// <summary>
    /// 包含控制屏幕关闭以及系统休眠相关的方法。
    /// </summary>
    public static class SystemSleep
    {
        /// <summary>
        /// 设置此线程此时开始一直将处于运行状态,此时计算机不应该进入睡眠状态。
        /// 此线程退出后,设置将失效。
        /// 如果需要恢复,请调用 <see cref="RestoreForCurrentThread"/> 方法。
        /// </summary>
        /// <param name="keepDisplayOn">
        /// 表示是否应该同时保持屏幕不关闭。
        /// 对于游戏、视频和演示相关的任务需要保持屏幕不关闭;而对于后台服务、下载和监控等任务则不需要。
        /// </param>
        public static void PreventForCurrentThread(bool keepDisplayOn = true)
        {
            SetThreadExecutionState(keepDisplayOn
                ? ExecutionState.Continuous | ExecutionState.SystemRequired | ExecutionState.DisplayRequired
                : ExecutionState.Continuous | ExecutionState.SystemRequired);
        }

        /// <summary>
        /// 恢复此线程的运行状态,操作系统现在可以正常进入睡眠状态和关闭屏幕。
        /// </summary>
        public static void RestoreForCurrentThread()
        {
            SetThreadExecutionState(ExecutionState.Continuous);
        }

        /// <summary>
        /// 重置系统睡眠或者关闭屏幕的计时器,这样系统睡眠或者屏幕能够继续持续工作设定的超时时间。
        /// </summary>
        /// <param name="keepDisplayOn">
        /// 表示是否应该同时保持屏幕不关闭。
        /// 对于游戏、视频和演示相关的任务需要保持屏幕不关闭;而对于后台服务、下载和监控等任务则不需要。
        /// </param>
        public static void ResetIdle(bool keepDisplayOn = true)
        {
            SetThreadExecutionState(keepDisplayOn
                ? ExecutionState.SystemRequired | ExecutionState.DisplayRequired
                : ExecutionState.SystemRequired);
        }
    }
}

如果你对这段封装中的 keepDisplayOn 参数,也就是 ExecutionState.DisplayRequired 枚举不了解,看看下图直接就懂了。一个指的是屏幕关闭,一个指的是系统进入睡眠。

电源和睡眠

此封装后,使用则相当简单:

// 阻止系统睡眠,阻止屏幕关闭。
SystemSleep.PreventForCurrentThread();

// 恢复此线程曾经阻止的系统休眠和屏幕关闭。
SystemSleep.RestoreForCurrentThread();

或者:

// 重置系统计时器,临时性阻止系统睡眠和屏幕关闭。
// 此效果类似于手动使用鼠标或键盘控制了一下电脑。
SystemSleep.ResetIdle();

在使用 PreventForCurrentThread 这个 API 的时候,你需要避免程序对空闲时机的控制不好,导致屏幕始终不关闭。

如果你发现无论你设置了多么短的睡眠时间和屏幕关闭时间,屏幕都不会关闭,那就是有某个程序阻止了屏幕关闭,你可以:


参考资料

09-02 2019

WPF 不要给 Window 类设置变换矩阵(分析篇):System.InvalidOperationException: 转换不可逆。

最近总是收到一个异常 “System.InvalidOperationException: 转换不可逆。”,然而看其堆栈,一点点自己写的代码都没有。到底哪里除了问题呢?

虽然异常堆栈信息里面没有自己编写的代码,但是我们还是找到了问题的原因和解决方法。


异常堆栈

这就是抓到的此问题的异常堆栈:

System.InvalidOperationException: 转换不可逆。
    System.Windows.Media.Matrix.Invert()
    MS.Internal.PointUtil.TryApplyVisualTransform(Point point, Visual v, Boolean inverse, Boolean throwOnError, Boolean& success)
    MS.Internal.PointUtil.TryClientToRoot(Point point, PresentationSource presentationSource, Boolean throwOnError, Boolean& success)
    System.Windows.Input.MouseDevice.LocalHitTest(Boolean clientUnits, Point pt, PresentationSource inputSource, IInputElement& enabledHit, IInputElement& originalHit)
    System.Windows.Input.MouseDevice.GlobalHitTest(Boolean clientUnits, Point pt, PresentationSource inputSource, IInputElement& enabledHit, IInputElement& originalHit)
    System.Windows.Input.StylusWisp.WispStylusDevice.FindTarget(PresentationSource inputSource, Point position)
    System.Windows.Input.StylusWisp.WispLogic.PreNotifyInput(Object sender, NotifyInputEventArgs e)
    System.Windows.Input.InputManager.ProcessStagingArea()
    System.Windows.Input.InputManager.ProcessInput(InputEventArgs input)
    System.Windows.Input.StylusWisp.WispLogic.InputManagerProcessInput(Object oInput)
    System.Windows.Threading.ExceptionWrapper.InternalRealCall(Delegate callback, Object args, Int32 numArgs)
    System.Windows.Threading.ExceptionWrapper.TryCatchWhen(Object source, Delegate callback, Object args, Int32 numArgs, Delegate catchHandler)

可以看到,我们的堆栈结束点是 ExceptionWrapper.TryCatchWhen 可以得知此异常是通过 Dispatcher.UnhandledException 来捕获的。也就是说,此异常直接通过 Windows 消息被我们间接触发,而不是直接通过我们编写的代码触发。而最顶端是对矩阵求逆,而此异常是试图对一个不可逆的矩阵求逆。

分析过程

如果你不想看分析过程,可以直接移步至本文的最后一节看原因和解决方案。

源代码

因为 .NET Framework 版本的 WPF 是开源的,.NET Core 版本的 WPF 目前还处于按揭开源的状态,所以我们看 .NET Framework 版本的代码来分析原因。

我按照调用堆栈从顶到底的顺序,将前面三帧的代码贴到下面。

PointUtil.TryApplyVisualTransform

public static Point TryApplyVisualTransform(Point point, Visual v, bool inverse, bool throwOnError, out bool success)
{
    success = true;

    if(v != null)
    {
        Matrix m = GetVisualTransform(v);

        if (inverse)
        {
            if(throwOnError || m.HasInverse)
            {
                m.Invert();
            }
            else
            {
                success = false;
                return new Point(0,0);
            }
        }

        point = m.Transform(point);
    }

    return point;
}

PointUtil.TryClientToRoot

[SecurityCritical,SecurityTreatAsSafe]
public static Point TryClientToRoot(Point point, PresentationSource presentationSource, bool throwOnError, out bool success)
{
    if (throwOnError || (presentationSource != null && presentationSource.CompositionTarget != null && !presentationSource.CompositionTarget.IsDisposed))
    {
        point = presentationSource.CompositionTarget.TransformFromDevice.Transform(point);
        point = TryApplyVisualTransform(point, presentationSource.RootVisual, true, throwOnError, out success);
    }
    else
    {
        success = false;
        return new Point(0,0);
    }

    return point;
}

你可能会说,在调用堆栈上面看不到 PointUtil.ClientToRoot 方法。但其实如果我们看一看 MouseDevice.LocalHitTest 的代码,会发现其实调用的是 PointUtil.ClientToRoot 方法。在调用堆栈上面看不到它是因为方法足够简单,被内联了。

[SecurityCritical,SecurityTreatAsSafe]
public static Point ClientToRoot(Point point, PresentationSource presentationSource)
{
    bool success = true;
    return TryClientToRoot(point, presentationSource, true, out success);
}

求逆的矩阵

下面我们一步一步分析异常的原因。

我们先看看是什么代码在做矩阵求逆。下面截图中的方法是反编译的,就是上面我们在源代码中列出的 TryApplyVisualTransform 方法。

矩阵求逆的调用

先获取了传入 Visual 对象的变换矩阵,然后根据参数 inverse 来对其求逆。如果矩阵可以求逆,即 HasInverse 属性返回 true,那么代码可以继续执行下去而不会出现异常。但如果 HasInverse 返回 false,则根据 throwOnError 来决定是否抛出异常,在需要抛出异常的情况下会真实求逆,也就是上面截图中我们看到的异常发生处的代码。

那么接下来我们需要验证三点:

  1. 这个 Visual 是哪里来的;
  2. 这个 Visual 的变换矩阵什么情况下不可求逆;
  3. throwOnError 确定传入的是 true 吗。

于是我们继续往上层调用代码中查看。

应用变换的调用 1

应用变换的调用 2

可以很快验证上面需要验证的两个点:

  1. throwOnError 传入的是 true
  2. VisualPresentationSourceRootVisual

PresentationSourceRootVisual 是什么呢?PresentationSource 是承载 WPF 可视化树的一个对象,对于窗口 Window,是通过 HwndSourcePresentationSource 的子类)承载的;对于跨线程 WPF UI,可以通过自定义的 PresentationSource 子类来完成。这部分可以参考我之前的一些博客:

不管怎么说,这个指的就是 WPF 可视化树的根:

  • 如果你使用 Window 来显示 WPF 窗口,那么根就是 Window 类;
  • 如果你是用 Popup 来承载一个弹出框,那么根就是 PopupRoot 类;
  • 如果你使用了一些跨线程/跨进程 UI 的技术,那么根就是自己写的可视化树根元素。

对于绝大多数 WPF 开发者来说,只会碰到前面第一种情况,也就是仅仅有 Window 作为可视化树的根的情况。一般人很难直接给 PopupRoot 设置变换矩阵,一般 WPF 程序的代码也很少做跨线程或跨进程 UI。

于是我们几乎可以肯定,是有某处的代码让 Window 的变换矩阵不可逆了。

矩阵求逆

什么样的矩阵是不可逆的?

异常代码

发生异常的代码是 WPF 中 Matrix.Invert 方法,其发生异常的代码如下:

Matrix.Invert

首先判断矩阵的行列式 Determinant 是否为 0,如果为 0 则抛出矩阵不可逆的异常。

Matrix.Determinant

行列式

WPF 的 2D 变换矩阵 \(M\) 是一个 \(3\times{3}\) 的矩阵:

\[\begin{bmatrix} M11 & M12 & 0 \\ M21 & M22 & 0 \\ OffsetX & OffsetY & 1 \end{bmatrix}\]

其行列式 \(det(M)\) 是一个标量:

\[\left | A \right | = \begin{vmatrix} M11 & M12 & 0 \\ M21 & M22 & 0 \\ OffsetX & OffsetY & 1 \end{vmatrix} = M11 \times M22 - M12 \times M21\]

因为矩阵求逆的时候,行列式的值会作为分母,于是会无法计算,所以行列式的值为 0 时,矩阵不可逆。

前面我们计算 WPF 的 2D 变换矩阵的行列式的值为 \(M11 \times M22 - M12 \times M21\),因此,只要使这个式子的值为 0 即可。

那么 WPF 的 2D 变换的时候,如何使此值为 0 呢?

  • 平移?平移只会修改 \(OffsetX\) 和 \(OffsetY\),因此对结果没有影响
  • 缩放?缩放会将原矩阵点乘缩放矩阵
  • 旋转?旋转会将旋转矩阵点乘原矩阵

其中,原矩阵在我们的场景下就是恒等的矩阵,即 Matrix.Identity

\[\begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix}\]

接下来缩放和旋转我们都不考虑变换中心的问题,因为变换中心的问题都可以等价为先进行缩放和旋转后,再单纯进行平移。由于平移对行列式的值没有影响,于是我们忽略。

缩放矩阵

缩放矩阵。如果水平和垂直分量分别缩放 \(ScaleX\) 和 \(ScaleY\) 倍,则缩放矩阵为:

\[\begin{bmatrix} ScaleX & 0 & 0 \\ 0 & ScaleY & 0 \\ 0 & 0 & 1 \end{bmatrix}\]

原矩阵点乘缩放矩阵结果为:

\[\begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} ScaleX & 0 & 0 \\ 0 & ScaleY & 0 \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} ScaleX & 0 & 0 \\ 0 & ScaleY & 0 \\ 0 & 0 & 1 \end{bmatrix}\]

于是,只要 \(ScaleX\) 和 \(ScaleY\) 任何一个为 0 就可以导致新矩阵的行列式必定为 0。

旋转矩阵

旋转矩阵。假设用户设置的旋转角度为 angle,那么换算成弧度为 angle * (Math.PI/180.0),我们将弧度记为 \(\alpha\),那么旋转矩阵为:

\[\begin{bmatrix} \cos{\alpha} & \sin{\alpha} & 0 \\ -\sin{\alpha} & \cos{\alpha} & 0 \\ 0 & 0 & 1 \end{bmatrix}\]

旋转矩阵点乘原矩阵的结果为:

\[\begin{bmatrix} \cos{\alpha} & \sin{\alpha} & 0 \\ -\sin{\alpha} & \cos{\alpha} & 0 \\ 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} \cos{\alpha} & \sin{\alpha} & 0 \\ -\sin{\alpha} & \cos{\alpha} & 0 \\ 0 & 0 & 1 \end{bmatrix}\]

对此矩阵的行列式求值:

\[\cos^{2}{\alpha} + \sin^{2}{\alpha} = 1\]

也就是说其行列式的值恒等于 1,因此其矩阵必然可求逆。

WPF 2D 变换矩阵求逆小结

对于 WPF 的 2D 变换矩阵:

  1. 平移和旋转不可能导致矩阵不可逆;
  2. 缩放,只要水平和垂直方向的任何一个分量缩放量为 0,矩阵就会不可逆。

寻找问题代码

现在,我们寻找问题的方向已经非常明确了:

  • 找到设置了 ScaleTransformWindow,检查其是否给 ScaleX 或者 ScaleY 属性赋值为了 0

然而,真正写一个 demo 程序来验证这个问题的时候,就发现没有这么简单。因为:

不能给 Window 设置变换矩阵

我们发现,不止是 ScaleXScaleY 属性不能设为 0,实际上设成 0.5 或者其他值也是不行的。

唯一合理值是 1

那么为什么依然有异常呢?难道是 ScaleTransform 的值一开始正常,然后被修改?

编写 demo 验证,果然如此。而只有变换到 0 才会真的引发本文一开始我们提到的异常。一般会开始设为 1 而后设为 0 的代码通常是在做动画。

一定是有代码正在为窗口的 ScaleTransform 做动画。

结果全代码仓库搜索 ScaleTransform 真的找到了问题代码。

<Window x:Name="WalterlvDoubiWindow"
        x:Class="Walterlv.Exceptions.Unknown.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.RenderTransform>
        <ScaleTransform ScaleX="1" ScaleY="1" />
    </Window.RenderTransform>
    <Window.Resources>
        <Storyboard x:Key="Storyboard.Load">
            <DoubleAnimation Storyboard.TargetName="WalterlvDoubiWindow"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
                             From="0" To="1" />
            <DoubleAnimation Storyboard.TargetName="WalterlvDoubiWindow"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
                             From="0" To="1" />
        </Storyboard>
    </Window.Resources>
    <Grid>
        <!-- 省略的代码 -->
    </Grid>
</Window>

不过,这段代码并不会导致每次都出现异常,而是在非常多次尝试中偶尔能出现一次异常。

原因和解决方案

原因

  1. Window 类是不可以设置 RenderTransform 属性的,但允许设置恒等(Matrix.Identity)的变换;
  2. 如果让 Window 类缩放分量设置为 0,就会出现矩阵不可逆异常。

解决方案

不要给 Window 类设置变换,如果要做,请给 Window 内部的子元素设置。比如上面的例子中,我们给 Grid 设置就没有问题(而且可以做到类似的效果。

09-02 2019

WPF 不要给 Window 类设置变换矩阵(应用篇)

WPF 的 Window 类是不允许设置变换矩阵的。不过,总会有小伙伴为了能够设置一下试图绕过一些验证机制。

不要试图绕过,因为你会遇到更多问题。


试图设置变换矩阵

当你试图给 Window 类设置变换矩阵的时候,会出现异常:

System.InvalidOperationException:“转换对于 Window 无效。”

无论是缩放还是旋转,都一样会出现异常。

转换对于 Window 无效 - 缩放

转换对于 Window 无效 - 旋转

我们在 WPF 不要给 Window 类设置变换矩阵(分析篇) 一文中已经证明在 WPF 的 2D 变换中,旋转一定不会造成矩阵不可逆,因此此验证是针对此属性的强验证。

只有做设置的变换是恒等变换的时候,才可以完成设置。

this.RenderTransform = new TranslateTransform(0, 0);
this.RenderTransform = new ScaleTransform(1, 1);
this.RenderTransform = new RotateTransform(0);
this.RenderTransform = new MatrixTransform(Matrix.Identity);

绕过验证

然而你可以通过先设置变换,再修改变换值的方式绕过验证:

var scaleTransform = new ScaleTransform(1, 1);
this.RenderTransform = scaleTransform;
scaleTransform.ScaleX = 0.5;
scaleTransform.ScaleY = 0.5;

实际上,你绕过也没有关系,可是这样的设置实际上是没有任何效果的。

不过为什么还是会有小伙伴这么设置呢?

是因为小伙伴同时还设置了窗口透明 AllowsTransparency="True"WindowStyle="None"Background="Transparent",导致看起来好像这个变换生效了一样。

小心异常

此设置不仅没有效果,还会引发异常,请阅读我的另一篇博客了解:

08-29 2019

通过设置启用 Visual Studio 默认关闭的大量强大的功能提升开发效率

使用 Visual Studio 开发 C#/.NET 应用程序,以前有 ReSharper 来不足其各项功能短板,后来不断将 ReSharper 的功能一点点搬过来稍微好了一些。不过直到 Visual Studio 2019,才开始渐渐可以和 ReSharper 拼一下了。

如果你使用 Visual Studio 2019,那么像本文这样配置一下,可以大大提升你的开发效率。


工具选项

打开菜单 “工具” -> “选项”,然后你就打开了 Visual Studio 的选项窗口。接下来本文的所有内容都会在这里进行。

打开选项窗口

文本编辑器

在 “文本编辑器” -> “常规” 分类中,我们关心这些设置:

  • 使鼠标单击可执行转到定义 这样按住 Ctrl 键点击标识符的时候可以转到定义(开启此选项之后,后面有其他选项可以转到反编译后的源码)

文本编辑器 -> 常规

当然也有其他可以打开玩的:

  • 查看空白 专治强迫症,可以把空白字符都显示出来,这样你可以轻易看到对齐问题以及多于的空格了

C#

在 “文本编辑器” -> “C#” -> “IntelliSense” 分类中,我们关心这些设置:

  • 键入字符后显示完成列表 删除字符后显示完成列表 突出显示完成列表项的匹配部分 显示完成项筛选器 打开这些选项可以让智能感知列表更容易显示出来,而我们也知道智能感知列表的强大
  • 推荐 显示 unimported 命名空间中的项(实验) 这一项默认不会勾选,但强烈建议勾选上;它可以帮助我们直接输入没有 using 的命名空间中的类型,这可以避免记住大量记不住的类名

IntelliSense

C# 高级

在 “文本编辑器” -> “C#” -> “高级” 分类中,我们关心大量设置:

  • 支持导航到反编译源(实验) 前面我们说可以 Ctrl + 鼠标导航到定义,如果打开了这个就可以看反编译后的源码了
  • 启用可为 null 的引用分析 IDE 功能 这个功能可能还没有完成,暂时还是无法开启的

高级

当然也有其他可以打开玩的:

  • 启用完成解决方案分析 这是基于 Roslyn 的分析,Visual Studio 的大量重构功能都依赖于它;默认关闭也可以用,只是仅分析当前正在编辑的文件;如果打开则分析整个解决方案,你会在错误列表中看到大量的编译警告

代码样式

在 “文本编辑器” -> “C#” -> “代码样式” 分类,如果你关心代码的书写风格,那么这个分类底下的每一个子类别都可以考虑一个个检查一下。

代码样式

人工智能 IntelliCode

Visual Studio 2019 默认安装了 IntelliCode 可以充分利用微软使用 GitHub 上开源项目训练出来的模型来帮助编写代码。这些强烈建议开启。

  • C# 基础模型 微软利用 GitHub 开源项目训练的基础模型
  • XAML 基础模型 微软利用 GitHub 开源项目训练的基础模型
  • C# 参数完成
  • C# 自定义模型 如果针对单个项目训练出来了模型,那么可以使用专门针对此项目训练的模型
  • EditorConfig 推理 可以根据项目推断生成 EditorConfig 文件 可以参见在 Visual Studio 中使用 EditorConfig 统一代码风格
  • 自定义模型训练提示 如果开启,那么每个项目的规模如果达到一定程度就会提示训练一个自定义模型出来

IntelliCode

IntelliCode English

训练模型会上传一部分数据到 IntelliCode 服务器,你可以去 %TEMP%\Visual Studio IntelliCode 目录来查看到底上传了哪些数据。

快捷键

当然,设置好快捷键也是高效编码的重要一步,可以参考:

自动完成

在你点击 “确定” 关闭了以上窗口之后,我们还需要设置一项。

确保下图中的这个按钮处于 “非选中” 状态:

建议完成模式

这样,当出现智能感知列表的时候,我们直接就可以按下回车键输入这个选项了;否则你还需要按上下选中再回车。

建议完成和标准完成

08-27 2019

WPF 的 Application.Current.Dispatcher 中,为什么 Current 可能为 null

在 WPF 程序中,可能会存在 Application.Current.Dispatcher.Xxx 这样的代码让一部分逻辑回到主 UI 线程。因为发现在调用这句代码的时候出现了 NullReferenceException,于是就有三位小伙伴告诉我说 CurrentDispatcher 属性都可能为 null

然而实际上这里只可能 Currentnull 而此上下文的 Dispatcher 是绝对不会为 null 的。(当然我们这里讨论的是常规编程手段,如果非常规手段,你甚至可以让实例的 thisnull 呢……)


当你的应用程序退出时,所有 UI 线程的代码都不再会执行,因此这是安全的;但所有非 UI 线程的代码依然在继续执行,此时随时可能遇到 Application.Current 属性为 null。

由于本文所述的两个部分都略长,所以拆分成两篇博客,这样更容易理解。

Application.Current 静态属性

源代码

Application 类型的源代码会非常长,所以这里就不贴了,可以前往这里查看:

其中,Current 返回的是 _appInstance 的静态字段。因此 _appInstance 字段为 null 的时机就是 Application.Currentnull 的时机。

/// <summary>
///     The Current property enables the developer to always get to the application in
///     AppDomain in which they are running.
/// </summary>
static public Application Current
{
    get
    {
        // There is no need to take the _globalLock because reading a
        // reference is an atomic operation. Moreover taking a lock
        // also causes risk of re-entrancy because it pumps messages.

        return _appInstance;
    }
}

由于 _appInstance 字段是私有字段,所以仅需调查这个类本身即可找到所有的赋值时机。(反射等非常规手段需要排除在外,因为这意味着开发者是逗比——自己闯的祸不能怪 WPF 框架。)

赋值时机

_appInstance 的赋值时机有两处:

  1. Application 的实例构造函数(注意哦,是实例构造函数而不是静态构造函数);
  2. Application.DoShutdown 方法。

Application 的实例构造函数中:

  • _appInstance 的赋值是线程安全的,这意味着多个 Application 实例的构造不会因为线程安全问题导致 _appInstance 字段的状态不正确。
  • 如果 _appCreatedInThisAppDomaintrue 那么,将抛出异常,组织此应用程序域中创建第二个 Application 类型的实例。
/// <summary>
///     Application constructor
/// </summary>
/// <SecurityNote>
///    Critical: This code posts a work item to start dispatcher if in the browser
///    PublicOk: It is ok because the call itself is not exposed and the application object does this internally.
/// </SecurityNote>
[SecurityCritical]
public Application()
{
    // 省略了一部分代码。
    lock(_globalLock)
    {
        // set the default statics
        // DO NOT move this from the begining of this constructor
        if (_appCreatedInThisAppDomain == false)
        {
            Debug.Assert(_appInstance == null, "_appInstance must be null here.");
            _appInstance = this;
            IsShuttingDown    = false;
            _appCreatedInThisAppDomain = true;
        }
        else
        {
            //lock will be released, so no worries about throwing an exception inside the lock
            throw new InvalidOperationException(SR.Get(SRID.MultiSingleton));
        }
    }
    // 省略了一部分代码。
}

也就是说,此类型实际上是设计为单例的。在第一个实例构造出来之后,单例的实例即可开始使用。

后续赋值

此单例实例的唯一结束时机就是 Application.DoShutdown 方法。这是唯一将 _appInstance 赋值为 null 的代码。

/// <summary>
/// DO NOT USE - internal method
/// </summary>
///<SecurityNote>
///     Critical: Calls critical code: Window.InternalClose
///     Critical: Calls critical code: HwndSource.Dispose
///     Critical: Calls critical code: PreloadedPackages.Clear()
///</SecurityNote>
[SecurityCritical]
internal virtual void DoShutdown()
{
    // 省略了一部分代码。

    // Event handler exception continuality: if exception occurs in ShuttingDown event handler,
    // our cleanup action is to finish Shuttingdown.  Since Shuttingdown cannot be cancelled.
    // We don't want user to use throw exception and catch it to cancel Shuttingdown.
    try
    {
        // fire Applicaiton Exit event
        OnExit(e);
    }
    finally
    {
        SetExitCode(e._exitCode);

        // By default statics are shared across appdomains, so need to clear
        lock (_globalLock)
        {
            _appInstance = null;
        }

        _mainWindow = null;
        _htProps = null;
        NonAppWindowsInternal = null;

        // 省略了一部分代码。
    }
}

可以调用到此代码的公共 API 有:

  • Application.Shutdown 实例方法
  • 导致 Window 关闭的若干方法(InternalDispose
  • IBrowserHostServices.PostShutdown 接口方法

因此,所有直接或间接调用到以上方法的地方都会导致 Application.Current 属性被赋值为 null

对所写代码的影响

从以上的分析可以得知,只要你还能在 Application.DoShutdown 执行之后继续执行代码,那么这部分的代码都将面临着 Application.Currentnull 风险。

那么,到底有哪些时机可能遇到 Application.Currentnull 呢?这部分就与读者项目中所用的业务代码强相关了。

但是这部分业务代码会有一些公共特征帮助你判定你是否可能写出遭遇 Application.Currentnull 的代码。

此特征是:此代码与 Application.Current 不在同一线程

Application.Current 不在同一线程

对于 WPF 程序,你的多数代码可能是由用户交互产生,即便有后续代码的执行,也依然是从 UI 交互产生。这样的代码不会遇到 Application.Currentnull 的情况。

但是,如果你的代码由非 UI 线程触发,例如在 Usb 设备改变、与其他端的通信、某些异步代码的回调等等,这些代码不受 Dispatcher 是否调度影响,几乎一定会执行。因此 Application.Current 就算赋值为 null 了,它们也不知道,依然会继续执行,于是就会遭遇 Application.Currentnull

这本质上是一个线程安全问题。

使用 Invoke/BeginInvoke/InvokeAsync 的代码不会出问题

Application.DoShutdown 方法被 ShutdownImpl 包装,且所有调用均从此包装进入,因此,所有可能导致 Application.Currentnull 的代码,均会调用此方法,也就是说,会调用 Dispatcher.CriticalInvokeShutdown 实例方法。

/// <summary>
/// This method gets called on dispatch of the Shutdown DispatcherOperationCallback
/// </summary>
///<SecurityNote>
///  Critical: Calls critical code: DoShutdown, Dispatcher.CritcalInvokeShutdown()
///</SecurityNote>
[SecurityCritical]
private void ShutdownImpl()
{
    // Event handler exception continuality: if exception occurs in Exit event handler,
    // our cleanup action is to finish Shutdown since Exit cannot be cancelled. We don't
    // want user to use throw exception and catch it to cancel Shutdown.
    try
    {
        DoShutdown();
    }
    finally
    {
        // Quit the dispatcher if we ran our own.
        if (_ownDispatcherStarted == true)
        {
            Dispatcher.CriticalInvokeShutdown();
        }

        ServiceProvider = null;
    }
}

所有的关闭 Dispatcher 的调用有两类,Application 关闭时调用的是内部方法 CriticalInvokeShutdown

  1. 立即关闭 CriticalInvokeShutdown,即以 Send 优先级 Invoke 关闭方法,而 Send 优先级调用 Invoke 几乎等同于直接调用(为什么是等同而不是直接调用?因为还需要考虑回到 Dispatcher 初始化时所在的线程)。
  2. 开始关闭 BeginInvokeShutdown,即以指定的优先级 InvokeAsync 关闭方法。

而关闭 Dispatcher 意味着所有使用 Invoke/BeginInvoke/InvokeAsync 的任务将终止。

//<SecurityNote>
//  Critical - as it accesses security critical data ( window handle)
//</SecurityNote>
[SecurityCritical]
private void ShutdownImplInSecurityContext(Object state)
{
    // 省略了一部分代码。

    // Now that the queue is off-line, abort all pending operations,
    // including inactive ones.
    DispatcherOperation operation = null;
    do
    {
        lock(_instanceLock)
        {
            if(_queue.MaxPriority != DispatcherPriority.Invalid)
            {
                operation = _queue.Peek();
            }
            else
            {
                operation = null;
            }
        }

        if(operation != null)
        {
            operation.Abort();
        }
    } while(operation != null);

    // 省略了一部分代码。
}

由于此终止代码在 Dispatcher 所在的线程执行,而所有 Invoke/BeginInvoke/InvokeAsync 代码也都在此线程执行,因此这些代码均不会并发。已经执行的代码会在此终止代码之前,而在此终止代码之后也不会再执行任何 Invoke/BeginInvoke/InvokeAsync 的任务了。

  • 所有通过 Invoke/BeginInvoke/InvokeAsync 或间接通过此方法(如 WPF 控件相关事件)调用的代码,均不会遭遇 Application.Currentnull
  • 所有在 UI 线程使用 async / await 并使用默认上下文执行的代码,均不会遭遇 Application.Currentnull。(这意味着你没有使用 .ConfigureAwait(false),详见在编写异步方法时,使用 ConfigureAwait(false) 避免使用者死锁 - walterlv。)

最简示例代码

最简例子

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace Walterlv.Demo.ApplicationDispatcher
{
    class Program
    {
        [STAThread]
        static void Main(string[] args)
        {
            var app = new Application();
            Task.Delay(1000).ContinueWith(t =>
            {
                app.Dispatcher.InvokeAsync(() => app.Shutdown());
            });
            Task.Delay(2000).ContinueWith(t =>
            {
                Application.Current.Dispatcher.InvokeAsync(() => { });
            });
            app.Run();
            Thread.Sleep(2000);
        }
    }
}

结论

总结以上所有的分析:

  1. 任何与 Application 不在同一个线程的代码,都可能遭遇 Application.Currentnull
  2. 任何与 Application 在同一个线程的代码,都不可能遇到 Application.Currentnull

这其实是一个线程安全问题。用所有业务开发者都可以理解的说法描述就是:

当你的应用程序退出时,所有 UI 线程的代码都不再会执行,因此这是安全的;但所有非 UI 线程的代码依然在继续执行,此时随时可能遇到 Application.Current 属性为 null。

因此,记得所有非 UI 线程的代码,如果需要转移到 UI 线程执行,记得判空:

private void OnUsbDeviceChanged(object sender, EventArgs e)
{
    // 记得这里需要判空,因为此上下文可能在非 UI 线程。
    Application.Current?.InvokeAsync(() => { });
}

Application.Dispatcher 实例属性

关于 Application.Dispatcher 是否可能为 null 的分析,由于比较长,请参见我的另一篇博客:


参考资料

08-27 2019

WPF 的 Application.Current.Dispatcher 中,Dispatcher 属性一定不会为 null

在 WPF 程序中,可能会存在 Application.Current.Dispatcher.Xxx 这样的代码让一部分逻辑回到主 UI 线程。因为发现在调用这句代码的时候出现了 NullReferenceException,于是就有三位小伙伴告诉我说 CurrentDispatcher 属性都可能为 null

然而实际上这里只可能 Currentnull 而此上下文的 Dispatcher 是绝对不会为 null 的。(当然我们这里讨论的是常规编程手段,如果非常规手段,你甚至可以让实例的 thisnull 呢……)


由于本文所述的两个部分都略长,所以拆分成两篇博客,这样更容易理解。

Application.Dispatcher 实例属性

Application.Dispatcher 实例属性来自于 DispatcherObject

源代码

为了分析此属性是否可能为 null,我现在将 DispatcherObject 的全部代码贴在下面:

using System;
using System.Windows;
using System.Threading;
using MS.Internal.WindowsBase;               // FriendAccessAllowed
 
namespace System.Windows.Threading
{
    /// <summary>
    ///     A DispatcherObject is an object associated with a
    ///     <see cref="Dispatcher"/>.  A DispatcherObject instance should
    ///     only be access by the dispatcher's thread.
    /// </summary>
    /// <remarks>
    ///     Subclasses of <see cref="DispatcherObject"/> should enforce thread
    ///     safety by calling <see cref="VerifyAccess"/> on all their public
    ///     methods to ensure the calling thread is the appropriate thread.
    ///     <para/>
    ///     DispatcherObject cannot be independently instantiated; that is,
    ///     all constructors are protected.
    /// </remarks>
    public abstract class DispatcherObject
    {
        /// <summary>
        ///     Returns the <see cref="Dispatcher"/> that this
        ///     <see cref="DispatcherObject"/> is associated with.
        /// </summary>
        [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Advanced)]
        public Dispatcher Dispatcher
        {
            get
            {
                // This property is free-threaded.
 
                return _dispatcher;
            }
        }
 
        // This method allows certain derived classes to break the dispatcher affinity
        // of our objects.
        [FriendAccessAllowed] // Built into Base, also used by Framework.
        internal void DetachFromDispatcher()
        {
            _dispatcher = null;
        }
 
        // Make this object a "sentinel" - it can be used in equality tests, but should
        // not be used in any other way.  To enforce this and catch bugs, use a special
        // sentinel dispatcher, so that calls to CheckAccess and VerifyAccess will
        // fail;  this will catch most accidental uses of the sentinel.
        [FriendAccessAllowed] // Built into Base, also used by Framework.
        internal void MakeSentinel()
        {
            _dispatcher = EnsureSentinelDispatcher();
        }
 
        private static Dispatcher EnsureSentinelDispatcher()
        {
            if (_sentinelDispatcher == null)
            {
                // lazy creation - the first thread reaching here creates the sentinel
                // dispatcher, all other threads use it.
                Dispatcher sentinelDispatcher = new Dispatcher(isSentinel:true);
                Interlocked.CompareExchange<Dispatcher>(ref _sentinelDispatcher, sentinelDispatcher, null);
            }
 
            return _sentinelDispatcher;
        }
 
        /// <summary>
        ///     Checks that the calling thread has access to this object.
        /// </summary>
        /// <remarks>
        ///     Only the dispatcher thread may access DispatcherObjects.
        ///     <p/>
        ///     This method is public so that any thread can probe to
        ///     see if it has access to the DispatcherObject.
        /// </remarks>
        /// <returns>
        ///     True if the calling thread has access to this object.
        /// </returns>
        [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
        public bool CheckAccess()
        {
            // This method is free-threaded.
 
            bool accessAllowed = true;
            Dispatcher dispatcher = _dispatcher;
 
            // Note: a DispatcherObject that is not associated with a
            // dispatcher is considered to be free-threaded.
            if(dispatcher != null)
            {
                accessAllowed = dispatcher.CheckAccess();
            }
 
            return accessAllowed;
        }
 
        /// <summary>
        ///     Verifies that the calling thread has access to this object.
        /// </summary>
        /// <remarks>
        ///     Only the dispatcher thread may access DispatcherObjects.
        ///     <p/>
        ///     This method is public so that derived classes can probe to
        ///     see if the calling thread has access to itself.
        /// </remarks>
        [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
        public void VerifyAccess()
        {
            // This method is free-threaded.
 
            Dispatcher dispatcher = _dispatcher;
 
            // Note: a DispatcherObject that is not associated with a
            // dispatcher is considered to be free-threaded.
            if(dispatcher != null)
            {
                dispatcher.VerifyAccess();
            }
        }
 
        /// <summary>
        ///     Instantiate this object associated with the current Dispatcher.
        /// </summary>
        protected DispatcherObject()
        {
            _dispatcher = Dispatcher.CurrentDispatcher;
        }
 
        private Dispatcher _dispatcher;
        private static Dispatcher _sentinelDispatcher;
    }
}

代码来自:DispatcherObject.cs

Dispatcher 属性仅仅是在获取 _dispatcher 字段的值,因此我们只需要看 _dispatcher 字段的赋值时机,以及所有给 _dispatcher 赋值的代码。

由于 _dispatcher 字段是私有字段,所以仅需调查这个类本身即可找到所有的赋值时机。(反射等非常规手段需要排除在外,因为这意味着开发者是逗比——自己闯的祸不能怪 WPF 框架。)

赋值时机

先来看看 dispatcher 字段的赋值时机。

DispatcherObject 仅有一个构造函数,而这个构造函数中就已经给 _dispatcher 赋值了,因此其所有的子类的初始化之前,_dispatcher 就会被赋值。

protected DispatcherObject()
{
    _dispatcher = Dispatcher.CurrentDispatcher;
}

那么所赋的值是否可能为 null 呢,这就要看 Dispatcher.CurrentDispatcher 是否可能返回一个 null 了。

以下是 Dispatcher.CurrentDispatcher 的属性获取代码:

public static Dispatcher CurrentDispatcher
{
    get
    {
        // Find the dispatcher for this thread.
        Dispatcher currentDispatcher = FromThread(Thread.CurrentThread);;

        // Auto-create the dispatcher if there is no dispatcher for
        // this thread (if we are allowed to).
        if(currentDispatcher == null)
        {
            currentDispatcher = new Dispatcher();
        }

        return currentDispatcher;
    }
}

可以看到,无论前面的方法得到的值是否是 null,后面都会再给 currentDispatcher 局部变量赋值一个新创建的实例的。因此,此属性是绝对不会返回 null 的。

由此可知,DispatcherObject 自构造起便拥有一个不为 nullDispatcher 属性,其所有子类在初始化之前便会得到不为 nullDispatcher 属性。

后续赋值

现在我们来看看在初始化完成之后,后面是否有可能将 _dispatcher 赋值为 null。

_dispatcher 字段的赋值代码仅有两个:

// This method allows certain derived classes to break the dispatcher affinity
// of our objects.
[FriendAccessAllowed] // Built into Base, also used by Framework.
internal void DetachFromDispatcher()
{
    _dispatcher = null;
}

// Make this object a "sentinel" - it can be used in equality tests, but should
// not be used in any other way.  To enforce this and catch bugs, use a special
// sentinel dispatcher, so that calls to CheckAccess and VerifyAccess will
// fail;  this will catch most accidental uses of the sentinel.
[FriendAccessAllowed] // Built into Base, also used by Framework.
internal void MakeSentinel()
{
    _dispatcher = EnsureSentinelDispatcher();
}

第一个 DetachFromDispatcher 很好理解,让 DispatcherObjectDispatcher 无关。在整个 WPF 的代码中,使用此方法的仅有以下 6 处:

  • Freezable.Freeze 实例方法
  • BeginStoryboard.Seal 实例方法
  • Style.Seal 实例方法
  • TriggerBase.Seal 实例方法
  • StyleHelperSealTemplate 静态方法中对 FrameworkTemplate 类型的实例调用此方法
  • ResourceDictionary 在构造函数中为 DispatcherObject 类型的 DummyInheritanceContext 属性调用此方法

Application 类型不是以上任何一个类型的子类(Application 类的直接基类是 DispatcherObject),因此 Application 类中的 Dispatcher 属性不可能因为 DetachFromDispatcher 方法的调用而被赋值为 null

接下来看看 MakeSentinel 方法,此方法的作用不如上面方法那样直观,实际上它的作用仅仅为了验证某个方法调用时所在的线程是否是符合预期的(给 VerifyAccessCheckAccess 使用)。

使用此方法的仅有 1 处:

  • ItemsControl 所用的 ItemInfo 类的静态构造函数
internal static readonly DependencyObject SentinelContainer = new DependencyObject();
internal static readonly DependencyObject UnresolvedContainer = new DependencyObject();
internal static readonly DependencyObject KeyContainer = new DependencyObject();
internal static readonly DependencyObject RemovedContainer = new DependencyObject();

static ItemInfo()
{
    // mark the special DOs as sentinels.  This helps catch bugs involving
    // using them accidentally for anything besides equality comparison.
    SentinelContainer.MakeSentinel();
    UnresolvedContainer.MakeSentinel();
    KeyContainer.MakeSentinel();
    RemovedContainer.MakeSentinel();
}

所有这些使用都与 Application 无关。

结论

总结以上所有的分析:

  1. Application 类型的实例在初始化之前,Dispatcher 属性就已经被赋值且不为 null
  2. 所有可能改变 _dispatcher 属性的常规方法均与 Application 类型无关;

因此,所有常规手段均不会让 Application 类的 Dispatcher 属性拿到 null 值。如果你还说拿到了 null,那就检查是否有逗比程序员通过反射或其他手段将 _dispatcher 字段改为了 null 吧……

Application.Current 静态属性

关于 Application.Current 是否可能为 null 的分析,由于比较长,请参见我的另一篇博客:


参考资料

08-27 2019

使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死)

在微软的官方文档中,说 SetParent 可以在进程内设置,也可以跨进程设置。当使用跨进程设置窗口的父子关系时,你需要注意本文提到的一些问题,避免踩坑。


跨进程设置 SetParent

关于 SetParent 函数设置窗口父子关系的文档可以看这个:

在这篇文章的 DPI 感知一段中明确写明了在进程内以及跨进程设置父子关系时的一些行为。虽然没有明确说明支持跨进程设置父子窗口,不过这段文字就几乎说明 Windows 系统对于跨进程设置窗口父子关系还是支持的。

但 Raymond Chen 在 Is it legal to have a cross-process parent/child or owner/owned window relationship? 一文中有另一段文字:

If I remember correctly, the documentation for Set­Parent used to contain a stern warning that it is not supported, but that remark does not appear to be present any more. I have a customer who is reparenting windows between processes, and their application is experiencing intermittent instability.
如果我没记错的话,SetParent 的文档曾经包含一个严厉的警告表明它不受支持,但现在这段备注似乎已经不存在了。我就遇到过一个客户跨进程设置窗口之间的父子关系,然后他们的应用程序间歇性不稳定。

这里表明了 Raymond Chen 对于跨进程设置父子窗口的一些担忧,但从文档趋势来看,还是支持的。只是这种担忧几乎说明跨进程设置 SetParent 存在一些坑。

那么本文就说说跨进程设置父子窗口的一些坑。

消息循环强制同步

消息循环

我们会感觉到 Windows 中某个窗口有响应(比如鼠标点击有反应),是因为这个窗口在处理 Windows 消息。窗口进行消息循环不断地处理消息使得各种各样的用户输入可以被处理,并正确地在界面上显示。

一个典型的消息循环大概像这样:

while(GetMessage(ref msg, IntPtr.Zero, 0, 0))
{
    TranslateMessage(ref msg);
    DispatchMessage(ref msg);
}

对于显示了窗口的某个线程调用了 GetMessage 获取了消息,Windows 系统就会认为这个线程有响应。相反,如果长时间不调用 GetMessage,Windows 就会认为这个线程无响应。TranslateMessage 则是翻译一些消息(比如从按键消息翻译成字符消息)。真正处理 GetMessage 中的内容则是后面的调度消息 DispatchMessage,是这个函数的调用使得我们 UI 界面上的内容可以有可见的反映。

一般来说,每个创建了窗口的线程都有自己独立的消息循环,且不会互相影响。然而一旦这些窗口之间建立了父子关系之后就会变得麻烦起来。

强制同步

Windows 会让具有父子关系的所有窗口的消息循环强制同步。具体指的是,所有具有父子关系的窗口消息循环,其消息循环会串联成一个队列(这样才可以避免消息循环的并发)。

也就是说,如果你有 A、B、C、D 四个窗口,分属不同进程,A 是 B、C、D 窗口的父窗口,那么当 A 在处理消息的时候,B、C、D 的消息循环就会卡在 GetMessage 的调用。同样,无论是 B、C 还是 D 在处理消息的时候,其他窗口也会同样卡在 GetMessage 的调用。这样,所有进程的 UI 线程实际上会互相等待,所有通过消息循环执行的代码都不会同时执行。然而实际上 Windows GUI 应用程序的开发中基本上 UI 代码都是通过消息循环来执行的,所以这几乎等同于所有进程的 UI 线程强制同步成类似一个 UI 线程的效果了。

带来的副作用也就相当明显,任何一个进程卡了 UI,其他进程的 UI 将完全无响应。当然,不依赖消息循环的代码不会受此影响,比如 WPF 应用程序的动画和渲染。

如何解决

对于 SetParent 造成的这些问题,实际上没有官方的解决方案,你需要针对你不同的业务采用不同的解决办法。

正如 Raymond Chen 所说:

(It’s one of those “if you don’t already know what the consequences are, then you are not smart enough to do it correctly” things. You must first become the master of the rules before you can start breaking them.)
正如有些人说的“如果你不知道后果,那么你也不足以正确地完成某件事情”。在开始破坏规则之前,您必须先成为规则的主人。

你必须清楚跨进程设置父子窗口带来的各种副作用,然后针对性地给出解决方案:

  1. 比如所有窗口会强制串联成一个队列,那么可以考虑将暂时不显示的窗口断开父子关系;
  2. 比如设置窗口的位置大小等操作,必须考虑此窗口不是顶层窗口的问题,需要跨越进程到顶层窗口来操作;

参考资料

08-14 2019

System.InvalidOperationException:“BuildWindowCore 无法返回寄宿的子窗口句柄。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“BuildWindowCore 无法返回寄宿的子窗口句柄。”。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

问题

你有可能在调试嵌入窗口代码的时候遇到错误:

错误

System.InvalidOperationException:“BuildWindowCore 无法返回寄宿的子窗口句柄。”

英文是:

BuildWindowCore failed to return the hosted child window handle.

原因和解决办法

此异常的原因非常简单,是 HwndSourceBuildWindowCore 的返回值有问题。具体来说,就是子窗口的句柄返回了 0。

也就是下面这段代码中 return new HandleRef(this, IntPtr.Zero) 这句,第二个参数是 0。

protected override HandleRef BuildWindowCore(HandleRef hwndParent)
{
    const int WS_CHILD = 1073741824;
    const int WS_CLIPCHILDREN = 33554432;
    var parameters = new HwndSourceParameters("demo")
    {
        ParentWindow = hwndParent.Handle,
        WindowStyle = (int)(WS_CHILD | WS_CLIPCHILDREN),
        TreatAncestorsAsNonClientArea = true,
    };
    var source = new HwndSource(parameters);
    source.RootVisual = new Button();
    return new HandleRef(this, _handle);
}

要解决,就需要传入正确的句柄值。当然上面的代码为了示例,故意传了一个不知道哪里的 _handle,实际上应该传入 source.Handle 才是正确的。

08-14 2019

System.InvalidOperationException:“寄宿 HWND 必须是子窗口。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“System.InvalidOperationException:“寄宿 HWND 必须是子窗口。””。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


一个最简的嵌入其他窗口的例子

我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

下面是最简单的一个例子,为了简单,没有跨进程传递 Win32 窗口句柄,而是直接创建出来。

using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

namespace Walterlv.Demo.HwndWrapping
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            Content = new HwndWrapper();
        }
    }

    public class HwndWrapper : HwndHost
    {
        private HwndSource _source;

        protected override HandleRef BuildWindowCore(HandleRef hwndParent)
        {
            var parameters = new HwndSourceParameters("walterlv");
            _source = new HwndSource(parameters);
            // 这里的 ChildPage 是一个继承自 UseControl 的 WPF 控件,你可以自己创建自己的 WPF 控件。
            _source.RootVisual = new ChildPage();
            return new HandleRef(this, _source.Handle);
        }

        protected override void DestroyWindowCore(HandleRef hwnd)
        {
            _source?.Dispose();
        }
    }
}

寄宿 HWND 必须是子窗口

当运行此代码的时候,会提示错误:

System.InvalidOperationException:“寄宿 HWND 必须是子窗口。”

或者英文版:

System.InvalidOperationException:”Hosted HWND must be a child window.”

这是一个 Win32 错误,因为我们试图将一个普通的窗口嵌入到另一个窗口中,而实际上要完成嵌入需要子窗口才行。

那么如何设置一个 Win32 窗口为子窗口呢?使用 SetWindowLong 来设置 Win32 窗口的样式是可以的。不过我们因为使用了 HwndSource,所以可以通过 HwndSourceParameters 来更方便地设置窗口样式。

我们需要将 HwndSourceParameters 那一行改成这样:

++  const int WS_CHILD = 0x40000000;
--  var parameters = new HwndSourceParameters("walterlv");
++  var parameters = new HwndSourceParameters("walterlv")
++  {
++      ParentWindow = hwndParent.Handle,
++      WindowStyle = WS_CHILD,
++  };

最关键的是两点:

  1. 需要设置此窗口为子窗口,也就是设置 WindowStyleWS_CHILD
  2. 需要设置此窗口的父窗口,也就是设置 ParentWindowhwndParent.Handle(我们使用参数中传入的 hwndParent 作为父窗口)。

现在再运行,即可正常显示此嵌套窗口:

嵌套窗口

另外,WindowStyle 属性最好加上 WS_CLIPCHILDREN,详情请阅读:


参考资料

08-14 2019

System.InvalidOperationException:“寄宿的 HWND 必须是指定父级的子窗口。”

当试图在 WPF 窗口中嵌套显示 Win32 子窗口的时候,你有可能出现错误:“寄宿的 HWND 必须是指定父级的子窗口。”。

这是很典型的 Win32 错误,本文介绍如何修复此错误。


我们在 MainWindow 中嵌入一个其他的窗口来承载新的 WPF 控件。一般情况下我们当然不会这么去做,但是如果我们要跨越进程边界来完成 WPF 渲染内容的融合的时候,就需要嵌入一个新的窗口了。

WPF 中可以使用 HwndSource 来包装一个 WPF 控件到 Win32 窗口,使用自定义的继承自 HwndHost 的类可以把 Win32 窗口包装成 WPF 控件。由于窗口句柄是可以跨越进程边界传递的,所以这样的方式可以完成跨进程的 WPF 控件显示。

问题

你有可能在调试嵌入窗口代码的时候遇到错误:

错误

System.InvalidOperationException:“寄宿的 HWND 必须是指定父级的子窗口。”

英文是:

Hosted HWND must be a child window of the specified parent.

原因和解决办法

出现此错误,是因为同一个子窗口被两次设置为同一个窗口的子窗口。

具体来说,就是 A 窗口使用 HwndHost 设置成了 B 的子窗口,随后 A 又通过一个新的 HwndHost 设置成了新子窗口。

要解决,则必须确保一个窗口只能使用 HwndHost 设置一次子窗口。

08-04 2019

通过 mklink 收集本地文件系统的所有 NuGet 包输出目录来快速调试公共组件代码

我们做的公共库可能通过 nuget.org 发布,也可能是自己搭建 NuGet 服务器。但是,如果某个包正在开发中,需要快速验证其是否解决掉一些诡异的 bug 的话,除了单元测试这种间接的测试方法,还可以在本地安装未发布的 NuGet 包的方法来快速调试。

本文介绍如何本地打包发布 NuGet 包,然后通过 mklink 收集所有的本地包达到快速调试的目的。


将本地文件夹作为 NuGet 源

我有另一篇博客介绍如何将本地文件夹设置称为 NuGet 包源:

在 Visual Studio 中打开 工具 -> 选项 -> NuGet 包管理器 -> 包源 可以直接将一个本地文件夹设置称为 NuGet 包源。

管理包源

其他设置方法可以去那篇博客当中阅读。

如下图,是我通过 mklink 将散落在各处的 NuGet 包的调试输出目录收集了起来:

通过 mklink 收集的 NuGet 包源

比如,点开其中的 Walterlv.Packages 可以看到 Walterlv.Packages 仓库中输出的 NuGet 包:

其中的一个 NuGet 输出文件夹

由于我的每一个文件夹都是指向的 Visual Studio 编译后的输出目录,所以,只需要使用 Visual Studio 重新编译一下项目,文件夹中的 NuGet 包即会更新。

于是,这相当于我在一个文件夹中,包含了我整个计算机上所有库项目的 NuGet 包,只需要将这个文件夹设置称为 NuGet 包源,即可直接调试本地任何一个公共组件库打出来的 NuGet 包。

设置源并体验快速调试

如下图,是我将那个收集所有 NuGet 文件夹的目录设置成为了 NuGet 源:

设置的本地 NuGet 源

于是,我可以在 Visual Studio 的包管理器中看到所有还没有发布的,依然处于调试状态的各种库:

各种处于调试状态的各种库

基于此,我们可以在包还没有编写完的时候调试,验证速度非常快。

08-04 2019

使用 C# 中的 dynamic 关键字调用类型方法时可能遇到的各种问题

你可以使用 dynamic 来定义一个变量或者字段,随后你可以像弱类型语言一样调用这个实例的各种方法,就像你一开始就知道这个类型的所有属性和方法一样。

但是,使用不当又会遇到各种问题,本文收集使用过程中可能会遇到的各种问题,帮助你解决掉它们。


快速入门

dynamic 可以这么用:

dynamic foo = GetSomeInstance();
foo.Run("欢迎访问吕毅(lvyi)的博客:blog.walterlv.com");

object GetSomeInstance()
{
    return 诡异的东西;
}

我们的 GetSomeInstance 明明返回的是 object,我们却可以调用真实类中的方法。

接下来讲述使用 dynamic 过程中可能会遇到的问题和解决方法。

编译错误:缺少编译器要求的成员

你初次在你的项目中引入 dynamic 关键字后,会出现编译错误,提示 “缺少编译器要求的成员”。

error CS0656: 缺少编译器要求的成员“Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo.Create”

对于 .NET Core 或者 .NET Standard 项目

需要为你的项目安装以下两个 NuGet 包:

引用两个 NuGet 包

于是你的项目里面会多出两个引用:

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

      <PropertyGroup>
        <TargetFrameworks>netstandard2.0;net48</TargetFrameworks>
      </PropertyGroup>

      <ItemGroup>
++      <PackageReference Include="Microsoft.CSharp" Version="4.5.0" />
++      <PackageReference Include="System.Dynamic.Runtime" Version="4.3.0" />
      </ItemGroup>

    </Project>

对于 .NET Framework 项目

你需要引用 Microsoft.CSharp

添加引用

引用 Microsoft.CSharp

于是你的项目里面会多出一项引用:

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

      <PropertyGroup>
        <TargetFramework>net48</TargetFramework>
      </PropertyGroup>

      <ItemGroup>
++      <Reference Include="Microsoft.CSharp" />
      </ItemGroup>

    </Project>

异常:“{0}”未包含“{1}”的定义

{0} 是类型名称,而 {1} 是使用 dynamic 访问的属性或者方法的名称。

比如,我试图从某个 Attribute 中访问到 Key 属性的时候会抛出以下异常:

Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:““System.Attribute”未包含“Key”的定义”

出现此异常的原因是:

  • dynamic 所引用的对象里面,没有签名相同的 public 的属性或者方法

于是,如果你确认你的类型里面是有这个属性或者方法的话,那么就需要注意需要将此成员改成 public 才可以访问。


参考资料

08-04 2019

设计一个 .NET 可用的弱引用集合(可用来做缓存池使用)

我们有弱引用 WeakReference<T> 可以用来保存可被垃圾回收的对象,也有可以保存键值对的 ConditionalWeakTable

我们经常会考虑制作缓存池。虽然一般不推荐这么设计,但是你可以使用本文所述的方法和代码作为按垃圾回收缓存的缓存池的设计。


设计思路

既然现有 WeakReference<T>ConditionalWeakTable 可以帮助我们实现弱引用,那么我们可以考虑封装这两个类中的任何一个或者两个来帮助我们完成弱引用集合。

ConditionalWeakTable 类型仅仅在 internal 级别可以访问到集合中的所有的元素,对外开放的接口当中是无法拿到集合中的所有元素的,仅仅能根据 Key 来找到 Value 而已。所以如果要根据 ConditionalWeakTable 来实现弱引用集合那么需要自己记录集合中的所有的 Key,而这样的话我们依然需要自己实现一个用来记录所有 Key 的弱引用集合,相当于鸡生蛋蛋生鸡的问题。

所以我们考虑直接使用 WeakReference<T> 来实现弱引用集合。

自己维护一个列表 List<WeakReference<T>>,对外开放的 API 只能访问到其中未被垃圾回收到的对象。

设计原则

在设计此类型的时候,有一个非常大的需要考虑的因素,就是此类型中的元素个数是不确定的,如果设计不当,那么此类型的使用者可能写出的每一行代码都是 Bug。

你可以参考我的另一篇博客了解设计这种不确定类型的 API 的时候的一些指导:

总结起来就是:

  • 必须提供一个单一的方法,能够完成一些典型场景下某一时刻确定性状态的获取
  • 绝不能提供一些可能多次调用获取状态的方法

那么这个原则怎么体现在此弱引用集合的类型设计上呢?

设计实践

分析踩坑

IList<T>

我们来看看 IList<T> 接口是否可行:

public class WeakCollection<T> : IList<T> where T : class
{
    public T this[int index] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
    public int Count => throw new NotImplementedException();
    public bool IsReadOnly => throw new NotImplementedException();
    public void Add(T item) => throw new NotImplementedException();
    public void Clear() => throw new NotImplementedException();
    public bool Contains(T item) => throw new NotImplementedException();
    public void CopyTo(T[] array, int arrayIndex) => throw new NotImplementedException();
    public IEnumerator<T> GetEnumerator() => throw new NotImplementedException();
    public int IndexOf(T item) => throw new NotImplementedException();
    public void Insert(int index, T item) => throw new NotImplementedException();
    public bool Remove(T item) => throw new NotImplementedException();
    public void RemoveAt(int index) => throw new NotImplementedException();
    IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();
}

this[]CountIsReadOnlyContainsCopyToIndexOfGetEnumerator 这些都是在获取状态,AddClearRemove 是在修改状态,而 InsertRemoveAt 会在修改状态的同时读取状态。

这么多的获取和修改状态的方法,如果提供出去,还指望使用者能够正常使用,简直是做梦!违背以上两个原则。

ICollection<T>

那我们看看 IList<T> 的底层集合 ICollection<T>,实际上并没有解决问题,所以依然排除不能用!

    public class WeakCollection<T> : ICollection<T> where T : class
    {
--      public T this[int index] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
        public int Count => throw new NotImplementedException();
        public bool IsReadOnly => throw new NotImplementedException();
        public void Add(T item) => throw new NotImplementedException();
        public void Clear() => throw new NotImplementedException();
        public bool Contains(T item) => throw new NotImplementedException();
        public void CopyTo(T[] array, int arrayIndex) => throw new NotImplementedException();
        public IEnumerator<T> GetEnumerator() => throw new NotImplementedException();
--      public int IndexOf(T item) => throw new NotImplementedException();
--      public void Insert(int index, T item) => throw new NotImplementedException();
        public bool Remove(T item) => throw new NotImplementedException();
--      public void RemoveAt(int index) => throw new NotImplementedException();
        IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();
    }

不过,AddRemove 方法可能我们会考虑留下来,但这就不能是继承自 ICollection<T> 了。

IEnumerable<T>

IEnumerable<T> 里面只有两个方法,看起来少多了,那么我们能用吗?

public IEnumerator<T> GetEnumerator() => throw new NotImplementedException();
IEnumerator IEnumerable.GetEnumerator() => throw new NotImplementedException();

这个方法仅供 foreach 使用,本来如果只是如此的话,问题还不是很大,但针对 IEnumerator<T> 有一大堆的 Linq 扩展方法,于是这相当于给此弱引用集合提供了大量可以用来读取状态的方法。

这依然非常危险!

使用者随时可能使用其中一个扩展方法得到了其中一个状态,随后使用另一个扩展方法得知其第二个状态,例如:

// 判断集合中是否存在 IFoo 类型以及是否存在 IBar 类型。
var hasFoo = weakList.OfType<IFoo>().Any();
var hasBar = weakList.OfType<IBar>().Any();

对具有并发开发经验的你来说,以上方法第一眼就能识别出这是不正确的写法。然而类型既然已经开放出去给大家使用了,那么这就非常危险。关键是这不是一个并发场景,于是开发者可能更难感受到在同一个上下文中调用两个方法将得到不确定的结果。对于并发可以使用锁,但对于弱引用,没有可以使用的相关方法来快速解决问题。

因此,IEnumerable<T> 也是不能继承的。

object

看来,我们只能继承自单纯的 object 基类了。此类型没有对托管来说可见的状态,于是谁也不会多次读取状态造成状态不确定了。

因此,我们需要自行实现所有场景下的 API。

动手

弱引用集合我们需要这些使用场景:

  • 向弱引用集合中添加一个元素 此场景下仅仅修改集合而不需要读取任何状态。
  • 向弱引用集合中移除一个元素 既然可以在参数中传入元素,说明此元素一定没有会垃圾回收;因此只要集合中还存在此元素,一定可以确定地移除,不会出现不确定的状态。
  • 在弱引用集合中找到符合要求的一个或多个元素 一旦满足要求,必须得到完全确定的结果,且在此结果保存的过程中一直生效。

可选考虑下面这些场景:

  • 清除所有元素 通常是为了复用某个缓存池的实例。

一定不能实现下面这些方法:

  • 判断是否存在某个元素 因为判断是否存在通常不是单独的操作,通常会使用此集合继续下一个操作,因此一定不能直接提供。
  • 其他在本文前面已经喷过不能添加进来的方法

于是,我们的 API 设计将是这样的:

public class WeakCollection<T> where T : class
{
    public void Add(T item) => throw new NotImplementedException();
    public bool Remove(T item) => throw new NotImplementedException();
    public void Clear() => throw new NotImplementedException();
    public T[] TryGetItems(Func<T, bool> filter) => throw new NotImplementedException();
}

完整代码

此类型已经以源代码包的形式发布到了 NuGet 上,你可以安装以下 NuGet 包阅读和使用其源代码:

安装后,你可以在你的项目中使用其源代码,并且可以直接使用 Ctrl + 鼠标点击的方式打开类型的源代码,而不需要进行反编译。

08-03 2019

如何为非常不确定的行为(如并发)设计安全的 API,使用这些 API 时如何确保安全

.NET 中提供了一些线程安全的类型,如 ConcurrentDictionary<TKey, TValue>,它们的 API 设计与常规设计差异很大。如果你对此觉得奇怪,那么正好阅读本文。本文介绍为这些非常不确定的行为设计 API 时应该考虑的原则,了解这些原则之后你会体会到为什么会有这些 API 设计上的差异,然后指导你设计新的类型。


不确定性

像并发集合一样,如 ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>,其设计为线程安全,于是它的每一个对外公开的方法调用都不会导致其内部状态错误。但是,你在调用其任何一个方法的时候,虽然调用的方法本身能够保证其线程安全,能够保证此方法涉及到的状态是确定的,但是一旦完成此方法的调用,其状态都将再次不确定。你只能依靠其方法的返回值来使用刚刚调用那一刻确定的状态。

我们来看几段代码:

var isRunning = Interlocked.CompareExchange(ref _isRunning, 1, 0);
if (isRunning is 1)
{
    // 当前已经在执行队列,因此无需继续执行。
}
private ConcurrentDictionary<string, object> KeyValues { get; }
    = new ConcurrentDictionary<string, object>();

object Get(string key)
{
    var value = KeyValues.TryGetValue(key, out var v) ? v : null;
    return value;
}

这两段代码都使用到了可能涉及线程安全的一些代码。前者使用 Interlocked 做原则操作,而后者使用并发字典。

无论写上面哪一段代码,都面临着问题:

  • 此刻调用的那一句话得到的任何结果都仅仅只表示这一刻,而不代表其他任何代码时的结果。

比如前者的 Interlocked.CompareExchange(ref _isRunning, 1, 0) 我们得到一个返回值 isRunning,然后判断这个返回值。但是我们绝对不能够判断 _isRunning 这个字段,因为这个字段非常易变,在你的任何一个代码上下文中都可能变成你不希望看到的值。Interlocked 是原子操作,所以才确保安全。

而后者,此时访问得到的字典数据,和下一时刻访问得到的字典数据将可能完全不匹配,两次的数据不能通用。

API 用法指导

如果你正在为一个易变的状态设计 API,或者说你需要编写的类型带有很强的不确定性(类型状态的变化可能发生在任何一行代码上),那么你需要遵循一些设计原则才能确保安全。

同一个上下文仅能查看或修改一次状态

比如要为缓存设计一个获取可用实例的方法,可以使用:

private ConcurrentDictionary<string, object> KeyValues { get; }
    = new ConcurrentDictionary<string, object>();

void Get(string key)
{
    // CreateCachedInstance 是一个工厂方法,所有 GetOrAdd 的地方都是用此工厂方法创建。
    var value = KeyValues.GetOrAdd(key, CreateCachedInstance);
    return value;
}

但是绝对不能使用:

if(!KeyValues.TryGetValue(key, out var v))
{
    KeyValues.TryAdd(key, CreateCachedInstance(key));
}

这一段代码就是对并发的状态 KeyValues 做了两次访问。

ConcurrentDictionary 也正是考虑到了这种设计场景,于是才提供了 API GetOrAdd 方法。让你在获取对象实例的时候可以通过工厂方法去创建实例。

如果你需要设计这种状态极易变的 API,那么需要针对一些典型的设计场景提供一次调用就能获取此时此刻所有状态的方法。就像上文的 GetOrAdd 一样。

另一个例子,WeakReference<T> 弱引用对象的管理也是在一个方法里面可以获取到一个绝对确定的状态,而避免使用方进行两次判断:

if (weak.TryGetTarget(out var value))
{
    // 一旦这里拿到了对象,这个对象一定会存在且可用。
}

一定不能提供两个方法调用来完成这样的事情(比如先判断是否存在再获取对象的实例,就像 .NET Framework 4.0 和早期版本弱引用的 API 设计一样)。

对于并发,如果有多次查看或者修改状态,必须加锁

比如以下方法,是试图一个接一个地依次执行 _queue 中的所有任务。

虽然我们使用 Interlocked.CompareExchange 原子操作,但因为后面依然涉及到了多次状态的获取,导致不得不加锁才能确保安全。我们依然使用原则操作是为了避免单纯 lock 带来的性能损耗。

private volatile int _isRunning;
private readonly object _locker = new object();
private readonly ConcurrentQueue<TaskWrapper> _queue = new ConcurrentQueue<TaskWrapper>();

private async void Run()
{
    var isRunning = Interlocked.CompareExchange(ref _isRunning, 1, 0);
    if (isRunning is 1)
    {
        lock (_locker)
        {
            if (_isRunning is 1)
            {
                // 当前已经在执行队列,因此无需继续执行。
                return;
            }
        }
    }

    var hasTask = true;
    while (hasTask)
    {
        // 当前还没有任何队列开始执行,因此需要开始执行队列。
        while (_queue.TryDequeue(out var wrapper))
        {
            // 内部已包含异常处理,因此外面可以无需捕获或者清理。
            await wrapper.RunAsync().ConfigureAwait(false);
        }

        lock (_locker)
        {
            hasTask = _queue.TryPeek(out _);
            if (!hasTask)
            {
                _isRunning = 0;
            }
        }
    }
}

这段代码的完全解读:

  1. 当执行 Run 方法的时候,先判断当前是否已经在跑其他的任务:
    • isRunning0 表示当前一定没有在跑其他任务,我们使用原则操作立刻将其修改为 1
    • isRunning1 表示当前不确定是否在跑其他任务;
  2. 既然 isRunning1 的时候状态不确定,于是我们加锁来判断其是否真的有任务在跑:
    • lock 环境中确认 _isRunning 字段而非变量为 1 则说明真的有任务在跑,此时等待任务完成即可,这里就可以退出了;
    • lock 环境中发现 _isRunning 字段而非变量为 0 则说明实际上是没有任务在跑的(刚刚判断为 1 只是因为这两次判断之间,并发的任务刚刚在结束的过程中),于是需要跟一开始判断为 0 一样,进入到后面的循环中;
  3. 外层的 while 循环第一次是一定能进去的,于是我们暂且不谈;
  4. while 内循环中,我们依次检查并发队列 _queue 中是否还有任务要执行,如果有要执行的,就执行:
    • 这个过程我们完全没有做加锁,因为这可能是非常耗时的任务,如果我们加锁,将导致其他线程出现非常严重的资源浪费;
  5. 如果 queue 中的所有任务执行完毕,我们将进入一个 lock 区间:
    • 在这个 lock 区间里面我们再次确认任务是否已经完成,如果没有完成,我们靠最外层的 while 循环重新回到内层 while 循环中继续任务;
    • 如果在这个 lock 区间里面我们发现任务已经完成了,就设置 _isRunning0,表示任务真的已经完成,随后退出 while 循环;

你可以注意到我们的 lock 是用来确认一开始 isRunning1 时的那个不确定的状态的。因为我们需要多次访问这个状态,所以必须加锁来确认状态是同步的。

API 设计指导

在了解了上面的用法指导后,API 设计指导也呼之欲出了:

  1. 针对典型的应用场景,必须设计一个专门的方法,一次调用即可完全获取当时需要的状态,或者一次调用即可完全修改需要修改的状态;
  2. 不要提供大于 1 个方法组合在一起才能使用的 API,这会让调用方获取不一致的状态。

对于多线程并发导致的不确定性,使用方虽然可以通过 lock 来规避以上第二条问题,但设计方最好在设计之初就避免问题,以便让 API 更好使用。

关于通用 API 设计指导,你可以阅读我的另一篇双语博客:

08-01 2019

通过 AppSwitch 禁用 WPF 内置的触摸让 WPF 程序可以处理 Windows 触摸消息

WPF 框架自己实现了一套触摸机制,但同一窗口只能支持一套触摸机制,于是这会禁用系统的触摸消息(WM_TOUCH)。这能够很大程度提升 WPF 程序的触摸响应速度,但是很多时候又会产生一些 Bug。

如果你有需要,可以考虑禁用 WPF 的内置的实时触摸(RealTimeStylus)。本文介绍禁用方法,使用 AppSwitch,而不是网上广为流传的反射方法。


如何设置 AppSwitch

在你的应用程序的 app.config 文件中加入 Switch.System.Windows.Input.Stylus.DisableStylusAndTouchSupport=true 开关,即可关闭 WPF 内置的实时触摸,而改用 Windows 触摸消息(WM_TOUCH)。

<configuration>
  <runtime>
    <AppContextSwitchOverrides value="Switch.System.Windows.Input.Stylus.DisableStylusAndTouchSupport=true" />
  </runtime>
</configuration>

如果你的解决方案中没有找到 app.config 文件,可以创建一个:

新建文件

应用程序配置文件

然后,把上面的代码拷贝进去即可。

反射禁用的方法

微软的官方文档也有提到使用放射禁用的方法,但一般不推荐这种调用内部 API 的方式,比较容易在 .NET 的版本更新中出现问题:

此方法可以解决的问题一览

拖拽窗口或者调整窗口大小时不能实时跟随的问题

在部分设备上启动即崩溃

在透明窗口上触摸会挡住 UWP 程序


参考资料

07-27 2019

The partial same C# namespace may cause source code compatibility issue

You might just add some simple APIs in your library and you’ll not think that will break down your compatibility. But actually, it might, that is – the source-code compatibility.


This post is written in multiple languages. Please select yours:

Assume that we’ve written a project P which references another two libraries A and B. And we have a Walterlv.A.Diagnostics.Foo class in library A.

using Walterlv.A;
using Walterlv.B;

namespace Walterlv.Demo
{
    class Hello
    {
        Run(Diagnostics.Foo foo)
        {
        }
    }
}

And now we add a new class Walterlv.B.Diagnostics.Bar class into the B library. That is adding a new API only.

Unfortunately, the code above would fail to compile because of the ambiguity of Diagnostics namespace. The Foo class cannot be found in an ambiguity namespace.

I write this post down to tell you that there may be source-code compatibility issue even if you only upgrade your library by simply adding APIs.

07-27 2019

使用基于 Roslyn 的 Microsoft.CodeAnalysis.PublicApiAnalyzers 来追踪项目的 API 改动,帮助保持库的 API 兼容性

做库的时候,需要一定程度上保持 API 的兼容性


第一步:安装 NuGet 包

首先打开你的库项目,或者如果你希望从零开始也可以直接新建一个项目。这里为了博客阅读的简单,我创建一个全新的项目来演示。

打开一个项目

然后,为主要的库项目安装 NuGet 包:

安装 NuGet 包

安装完成之后,你的项目文件(.csproj)可能类似于下面这样:

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
  </ItemGroup>

</Project>

第二步:创建 API 记录文件

在你的项目内创建两个文件:

  • PublicAPI.Shipped.txt
  • PublicAPI.Unshipped.txt

创建 API 记录文件

这就是两个普通的文本文件。创建纯文本文件的方法是在项目上右键 -> 添加 -> 新建项...,然后在打开的模板中选择 文本文件,使用上面指定的名称即可(要创建两个)。

然后,编辑项目文件,我们需要将这两个文件加入到项目中来。

编辑项目文件

如果你看不到上图中的“编辑项目文件”选项,则需要升级项目文件到 SDK 风格,详见:

然后,将这两个文件添加为 AdditionalFiles

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

    <PropertyGroup>
      <TargetFramework>netstandard2.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
      <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
    </ItemGroup>

+   <ItemGroup>
+     <AdditionalFiles Include="PublicAPI.Shipped.txt" />
+     <AdditionalFiles Include="PublicAPI.Unshipped.txt" />
+   </ItemGroup>

  </Project>

如果你把这两个文件放到了其他的路径,那么上面也需要改成对应的路径。

这时,这两个文件内容还是空的。

第三步:添加 API 记录

这个时候,你会看到库中的 public 类、方法、属性等都会发出修改建议,说此符号并不是已声明 API 的一部分。

类型

属性

点击小灯泡,即可将点击所在的 API 加入到 PublicAPI.Unshipped.txt 中。

我将两个 API 都添加之后,PublicAPI.Unshipped.txt 文件中现在是这样的(注意有一个隐式构造函数哦):

Walterlv.PackageDemo.ApiTracking.Class1
Walterlv.PackageDemo.ApiTracking.Class1.Class1() -> void
Walterlv.PackageDemo.ApiTracking.Class1.Foo.get -> string

体验 API 的追踪

现在,我们将 Foo 属性改名成 Foo2 属性,于是就会出现编译警告:

编译警告

RS0016 Symbol ‘Foo2.get’ is not part of the declared API.
RS0017 Symbol ‘Walterlv.PackageDemo.ApiTracking.Class1.Foo.get -> string’ is part of the declared API, but is either not public or could not be found

提示 Foo2 属性不是已声明 API 的一部分,而 Foo 属性虽然是已声明 API 的一部分,但已经找不到了。

这种提示对于保持库的兼容性是非常有帮助的。

将警告变成错误

在分析器的规则上面右键,可以为某项规则设置严重性。

将警告设置为错误

这时,再编译即会报告编译错误。

编译错误

项目中也会多一个规则集文件:

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

    <PropertyGroup>
      <TargetFramework>netstandard2.0</TargetFramework>
+     <CodeAnalysisRuleSet>Walterlv.PackageDemo.ApiTracking.ruleset</CodeAnalysisRuleSet>
    </PropertyGroup>

    <ItemGroup>
      <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="2.9.3" />
    </ItemGroup>

    <ItemGroup>
      <AdditionalFiles Include="PublicAPI.Shipped.txt" />
      <AdditionalFiles Include="PublicAPI.Unshipped.txt" />
    </ItemGroup>

  </Project>

第四步:将 API 打包

前面我们都是在 PublicAPI.Unshipped.txt 文件中追踪 API。但是如果我们的库需要发布一个版本的时候,我们就需要跟上一个版本比较 API 的差异。

上一个发布版本的 API 就记录在 PublicAPI.Shipped.txt 文件中,这两个文件的差异即是这两个版本的 API 差异。在一个新的版本发布后,就需要将 API 归档到 PublicAPI.Shipped.txt 文件中。


参考资料

07-22 2019

使用 Roslyn 分析代码注释,给 TODO 类型的注释添加负责人、截止日期和 issue 链接跟踪

如果某天改了一点代码但是没有完成,我们可能会在注释里面加上 // TODO。如果某个版本为了控制影响范围临时使用不太合适的方法解了 Bug,我们可能也会在注释里面加上 // TODO。但是,对于团队项目来说,一个人写的 TODO 可能过了一段时间就淹没在大量的 TODO 堆里面了。如果能够强制要求所有的 TODO 被跟踪,那么代码里面就比较容易能够控制住 TODO 的影响了。

本文将基于 Roslyn 开发代码分析器,要求所有的 TODO 注释具有可被跟踪的负责人等信息。


预备知识

如果你对基于 Roslyn 编写分析器和代码修改器不了解,建议先阅读我的一篇入门教程:

分析器

我们先准备一些公共的信息:

namespace Walterlv.Demo
{
    internal static class DiagnosticIds
    {
        /// <summary>
        /// 标记了待办事项的代码必须被追踪。WAL 是我名字(walterlv)的前三个字母。
        /// </summary>
        public const string TodoMustBeTracked = "WAL302";
    }
}

在后面的代码分析器和修改器中,我们将都使用此公共的字符串常量来作为诊断 Id。

我们先添加分析器(TodoMustBeTrackedAnalyzer)最基础的代码:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class TodoMustBeTrackedAnalyzer : DiagnosticAnalyzer
{
    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        DiagnosticIds.TodoMustBeTracked,
        "任务必须被追踪",
         "未完成的任务缺少负责人和完成截止日期:{0}",
        "Maintainability",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true,
        description: "未完成的任务必须有对应的负责人和截止日期(// TODO @lvyi 2019-08-01),最好有任务追踪系统(如 JIRA)跟踪。");

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

    public override void Initialize(AnalysisContext context)
        => context.RegisterSyntaxTreeAction(AnalyzeSingleLineComment);

    private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
    {
        // 这里将是我们分析器的主要代码。
    }
}

接下来我们则是要完善语法分析的部分,我们需要找到单行注释和多行注释。

注释在语法节点中不影响代码含义,这些不影响代码含义的语法部件被称作 Trivia(闲杂部件)。这跟我前面入门教程部分说的语法节点不同,其 API 会少一些,但也更加简单。

我们从语法树的 DescendantTrivia 方法中可以拿到文档中的所有的 Trivia 然后过滤掉获得其中的注释部分。

比如,我们要分析下面的这个注释:

// TODO 林德熙在这个版本写的逗比代码,下个版本要改掉。

在语法节点中判断注释的袋子性,然后使用正则表达式匹配 TODO、负责人以及截止日期即可。

private static readonly Regex TodoRegex = new Regex(@"//\s*todo", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);

private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
{
    var root = context.Tree.GetRoot();

    foreach (var comment in root.DescendantTrivia()
        .Where(x =>
            x.IsKind(SyntaxKind.SingleLineCommentTrivia)
            || x.IsKind(SyntaxKind.MultiLineCommentTrivia)))
    {
        var value = comment.ToString();
        var todoMatch = TodoRegex.Match(value);
        if (todoMatch.Success)
        {
            var assigneeMatch = AssigneeRegex.Match(value);
            var dateMatch = DateRegex.Match(value);

            if (!assigneeMatch.Success || !dateMatch.Success)
            {
                var diagnostic = Diagnostic.Create(Rule, comment.GetLocation(), value);
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}

将上面的类组装起来运行 Visual Studio 调试即可看到效果。没有负责人和截止日期的 TODO 注释将报告编译错误。

注释上的编译错误

TodoMustBeTrackedAnalyzer 类型的完整代码如下:

using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using Walterlv.Demo;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Walterlv.Analyzers.Maintainability
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class TodoMustBeTrackedAnalyzer : DiagnosticAnalyzer
    {
        private static readonly LocalizableString Title = "任务必须被追踪";
        private static readonly LocalizableString MessageFormat = "未完成的任务缺少负责人和完成截止日期:{0}";
        private static readonly LocalizableString Description = "未完成的任务必须有对应的负责人和截止日期(// TODO @lvyi 2019-08-01),最好有任务追踪系统(如 JIRA)跟踪。";
        private static readonly Regex TodoRegex = new Regex(@"//\s*todo", RegexOptions.Compiled | RegexOptions.IgnoreCase);
        private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
        private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);

        private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
            DiagnosticIds.TodoMustBeTracked,
            Title, MessageFormat,
            Categories.Maintainability,
            DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);

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

        public override void Initialize(AnalysisContext context)
        {
            context.EnableConcurrentExecution();
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
            context.RegisterSyntaxTreeAction(AnalyzeSingleLineComment);
        }

        private void AnalyzeSingleLineComment(SyntaxTreeAnalysisContext context)
        {
            var root = context.Tree.GetRoot();

            foreach (var comment in root.DescendantTrivia()
                .Where(x =>
                    x.IsKind(SyntaxKind.SingleLineCommentTrivia)
                    || x.IsKind(SyntaxKind.MultiLineCommentTrivia)))
            {
                var value = comment.ToString();
                var todoMatch = TodoRegex.Match(value);
                if (todoMatch.Success)
                {
                    var assigneeMatch = AssigneeRegex.Match(value);
                    var dateMatch = DateRegex.Match(value);

                    if (!assigneeMatch.Success || !dateMatch.Success)
                    {
                        var diagnostic = Diagnostic.Create(Rule, comment.GetLocation(), value);
                        context.ReportDiagnostic(diagnostic);
                    }
                }
            }
        }
    }
}

代码修改器

只是报错的话,开发者看到错误可能会一脸懵逼,因为从未见过注释还会报告编译错误的,不知道怎么改。

于是我们需要编写一个代码修改器以便自动完成注释的修改,添加负责人和截止日期。我这里代码修改器修改后的结果就像下面这样:

TODO 注释的代码修改器

生成一个新的注释字符串然后替换即可:

using System;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Walterlv.Demo;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;

namespace Walterlv.Analyzers.Maintainability
{
    [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(TodoMustBeTrackedCodeFixProvider)), Shared]
    public class TodoMustBeTrackedCodeFixProvider : CodeFixProvider
    {
        private const string Title = "添加任务负责人 / 完成日期 / JIRA Id 追踪";
        private static readonly Regex AssigneeRegex = new Regex(@"@\w+", RegexOptions.Compiled);
        private static readonly Regex DateRegex = new Regex(@"[\d]{4}\s?[年\-\.]\s?[01]?[\d]\s?[月\-\.]\s?[0123]?[\d]\s?日?", RegexOptions.Compiled);

        public sealed override ImmutableArray<string> FixableDiagnosticIds =>
            ImmutableArray.Create(DiagnosticIds.TodoMustBeTracked);

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

        public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            var diagnostic = context.Diagnostics.First();
            context.RegisterCodeFix(CodeAction.Create(
                Title,
                c => FormatTrackableTodoAsync(context.Document, diagnostic, c),
                nameof(TodoMustBeTrackedCodeFixProvider)),
                diagnostic);
            return Task.CompletedTask;
        }

        private async Task<Document> FormatTrackableTodoAsync(
            Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
        {
            var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);

            var oldTrivia = root.FindTrivia(diagnostic.Location.SourceSpan.Start);
            var oldComment = oldTrivia.ToString();
            if (oldComment.Length > 3)
            {
                oldComment = oldComment.Substring(2).Trim();
                if (oldComment.StartsWith("todo", StringComparison.CurrentCultureIgnoreCase))
                {
                    oldComment = oldComment.Substring(4).Trim();
                }
            }

            var comment = $"// TODO @{Environment.UserName} {DateTime.Now:yyyyMd} {oldComment}";
            var newTrivia = SyntaxFactory.ParseTrailingTrivia(comment);

            var newRoot = root.ReplaceTrivia(oldTrivia, newTrivia);
            return document.WithSyntaxRoot(newRoot);
        }
    }
}

07-20 2019

.NET/C# 使用 #if 和 Conditional 特性来按条件编译代码的不同原理和适用场景

有小伙伴看到我有时写了 #if 有时写了 [Conditional] 问我两个不是一样的吗,何必多此一举。然而实际上两者的编译处理是不同的,因此也有不同的应用场景。

于是我写到这篇文章当中。


条件编译符号和预处理符号

我们有时会使用 #if DEBUG 或者 [Conditional("DEBUG")] 来让我们的代码仅在特定的条件下编译。

而这里的 DEBUG 是什么呢?

  • 在我们编写的 C# 代码中,这个叫做 “条件编译符号”(Conditional compilation symbols)
  • 在项目的构建过程中,这个叫做 “定义常量”(Define constants)
  • 而在将 C# 代码编译到 dll 的编译环节,这个叫做 “预处理符号”(Preprocessor symbols)

本文要讨论的是 #ifConditional 的使用,这是在 C# 代码中的使用场景,因此,本文后面都将其称之为 “条件编译符号”。

区别

#if

#if DEBUG

Console.WriteLine("欢迎来 blog.walterlv.com 来做客呀!");

#endif

在这段代码中,#if DEBUG#endif 之间的代码仅在 DEBUG 下会编译,在其他配置下是不会编译的。

Conditional

[Conditional("DEBUG")]
public void Foo()
{
    Console.WriteLine("欢迎来 blog.walterlv.com 来做客呀!");
}

而这段代码,是会被编译到目标程序集中的。它影响的,是调用这个方法的代码。调用这个方法的代码,仅在 DEBUG 下会编译,在其他配置下是不会编译的。

场景

因为 #if DEBUG#endif 仅仅影响包含在其内的代码块,因此其仅仅影响写的这点代码所在的项目(或者说程序集)。于是使用 #if 只会影响实现代码。

[Conditional("DEBUG")] 影响的是调用它的代码,因此可以设计作为 API 使用——让目标项目(或者程序集)仅在目标项目特定的配置下才会编译。

07-12 2019

The VisualBrush of WPF only refresh the visual but not the layout

Now we’ll talk about a behavior of WPF VisualBrush. Maybe it is a bug, but let’s view some details and determine whether it is or not.


The reproduction code

Let’s make a XAML layout to reproduce such an issue.

We have a large Grid which contains an inner Grid and an inner Border. The Grid contains a Rectangle which is as large as the Grid itself and a TextBlock which presents some contents. The Border only shows its background which a VisualBrush of the Grid.

The layout that can reproduce this issue

This is the whole XAML file:

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Walterlv 的 WindowChrome 示例窗口" Height="450" Width="800"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="VisualSource">
            <Rectangle x:Name="VisibleOr" Fill="LightCoral" Visibility="Visible" />
            <TextBlock FontSize="24" TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="I'm walterlv, " />
                <LineBreak />
                <Run Text="I'm reproducing this Visual bug." />
            </TextBlock>
        </Grid>
        <Border>
            <Border.Background>
                <VisualBrush Visual="{Binding Source={x:Reference VisualSource}}" />
            </Border.Background>
        </Border>
    </Grid>
</Window>

This is the code-behind. Notice that it changes the visibility of the Rectangle every 1 second.

using System.Threading.Tasks;
using System.Windows;

namespace Walterlv.Demo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            while (true)
            {
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Collapsed;
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Visible;
            }
        }
    }
}

To verify the issue

We know that the VisualBrush shows and stretch the whole Visual so we can predicate only two results:

  • If the Rectangle is visible with Visibility property Visible, the Border background which contains the VisualBrush will be exactly the same with the Grid.
  • If the Rectangle is invisible with Visibility property Collapsed, the Border background which contains the VisualBrush will stretch the whole content to the Border without any area of the Rectangle.

But it is the real result?

The animation picture below shows the result when the Rectangle is visible as the startup:

Visible at the beginning

The animation picture below shows the result when the Rectangle is invisible as the startup:

Invisible at the beginning

Did you notice that?

Only at the very beginning when the program runs it behaves the same as we predicted. But when we change the visibility of the Rectangle the layout never changes.

The issue?

I’ve fired this issue into GitHub and this is the link:

07-12 2019

WPF 的 VisualBrush 只刷新显示的视觉效果,不刷新布局范围

WPF 的 VisualBrush 可以帮助我们在一个控件中显示另一个控件的外观。这是非常妙的功能。

但是本文需要说其中的一个 Bug —— 如果使用 VisualBrush 显示另一个控件的外观,那么只会在其显示效果有改变的时候刷新,而不会在目标布局改变的时候刷新布局。


用于复现问题的代码

我们现在做一个可以用于验证此问题的布局。

在一个大的 Grid 容器中有一个 Grid 和一个 Border,这个 Grid 将放一个大面积的 Rectangle 和一个表示内容的 TextBlock;而那个 Border 将完全以 VisualBrush 的形式呈现,呈现的内容是此 Grid 中的全部内容。

可以用于验证此问题的布局

它的完整 XAML 代码如下:

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Walterlv 的 WindowChrome 示例窗口" Height="450" Width="800"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="VisualSource">
            <Rectangle x:Name="VisibleOr" Fill="LightCoral" Visibility="Visible" />
            <TextBlock FontSize="24" TextAlignment="Center" VerticalAlignment="Center">
                <Run Text="I'm walterlv, " />
                <LineBreak />
                <Run Text="I'm reproducing this Visual bug." />
            </TextBlock>
        </Grid>
        <Border>
            <Border.Background>
                <VisualBrush Visual="{Binding Source={x:Reference VisualSource}}" />
            </Border.Background>
        </Border>
    </Grid>
</Window>

其后台 C# 代码如下,包含每隔 1 秒钟切换 Rectangle 可见性的代码。

using System.Threading.Tasks;
using System.Windows;

namespace Walterlv.Demo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            while (true)
            {
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Collapsed;
                await Task.Delay(1000);
                VisibleOr.Visibility = Visibility.Visible;
            }
        }
    }
}

验证问题

我们知道,VisualBrush 在默认情况下会将 Visual 中的全部内容拉伸到控件中显示,于是可以预估出两个可能的结果:

  • 如果 Rectangle 可见(VisibilityVisible),那么 Border 中以 VisualBrush 显示的内容将完全和下面重叠(因为大小相同,拉伸后正好重叠)。
  • 如果 Rectangle 不可见(VisibilityCollapsed),那么 Border 中以 VisualBrush 显示的内容将仅有文字且拉伸到整个 Border 范围。

然而实际运行真的是这样子吗?

下面的动图是 Rectangle 初始状态可见时,窗口运行后的结果:

初始状态可见

下面的动图是 Rectangle 初始状态不可见时,窗口运行后的结果:

初始状态不可见

注意到了吗?

只有初始状态才能正确反应我们之前预估出的结果,而无论后面怎么再改变可见性,布局都不会再刷新了。只是——后面 VisualBrush 的内容始终重叠。这意味着 VisualBrush 中目标 Visual 的范围增大之后不会再缩小了。

问题?

这是问题吗?

于是在以下 issue 中跟进此问题:

VisualBrush 的其他 Bug

参见:

07-12 2019

一文看懂 .NET 的异常处理机制、原则以及最佳实践

什么时候该抛出异常,抛出什么异常?什么时候该捕获异常,捕获之后怎么处理异常?你可能已经使用异常一段时间了,但对 .NET/C# 的异常机制依然有一些疑惑。那么,可以阅读本文。

本文适用于已经入门 .NET/C# 开发,已经开始在实践中抛出和捕获异常,但是对 .NET 异常机制的用法以及原则比较模糊的小伙伴。通过阅读本文,小伙伴们可以迅速在项目中使用比较推荐的异常处理原则来处理异常。


快速了解 .NET 的异常机制

Exception 类

我们大多数小伙伴可能更多的使用 Exception 的类型、Message 属性、StackTrace 以及内部异常来定位问题,但其实 Exception 类型还有更多的信息可以用于辅助定位问题。

  • Message 用来描述异常原因的详细信息
    • 如果你捕捉到了异常,一般使用这段描述能知道发生的大致原因。
    • 如果你准备抛出异常,在这个信息里面记录能帮助调试问题的详细文字信息。
  • StackTrace 包含用来确定错误位置的堆栈跟踪(当有调试信息如 PDB 时,这里就会包含源代码文件名和源代码行号)
  • InnerException 包含内部异常信息
  • Source 这个属性包含导致错误的应用程序或对象的名称
  • Data 这是一个字典,可以存放基于键值的任意数据,帮助在异常信息中获得更多可以用于调试的数据
  • HelpLink 这是一个 url,这个 url 里可以提供大量用于说明此异常原因的信息

如果你自己写一个自定义异常类,那么你可以在自定义的异常类中记录更多的信息。然而大多数情况下我们都考虑使用 .NET 中自带的异常类,因此可以充分利用 Exception 类中的已有属性在特殊情况下报告更详细的利于调试的异常信息。

捕捉异常

捕捉异常的基本语法是:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}
catch (IOException ex)
{
    // 处理另一种类的异常。
}

除此之外,还有 when 关键字用于筛选异常:

try
{
    // 可能引发异常的代码。
}
catch (FileNotFoundException ex) when (Path.GetExtension(ex.FileName) is ".png")
{
    // 处理一种类型的异常,并且此文件扩展名为 .png。
}
catch (FileNotFoundException ex)
{
    // 处理一种类型的异常。
}

无论是否有带 when 关键字,都是前面的 catch 块匹配的时候执行匹配的 catch 块而无视后面可能也匹配的 catch 块。

如果 when 块中抛出异常,那么此异常将被忽略,when 中的表达式值视为 false。有个但是,请看:.NET Framework 的 bug?try-catch-when 中如果 when 语句抛出异常,程序将彻底崩溃 - walterlv

引发异常

引发异常使用 throw 关键字。只是注意如果要重新抛出异常,请使用 throw; 语句或者将原有异常作为内部异常。

创建自定义异常

如果你只是随便在业务上创建一个异常,那么写一个类继承自 Exception 即可:

public class MyCustomException : Exception
{
    public string MyCustomProperty { get; }

    public MyCustomException(string customProperty) => MyCustomProperty = customProperty;
}

不过,如果你需要写一些比较通用抽象的异常(用于被继承),或者在底层组件代码中写自定义异常,那么就建议考虑写全异常的所有构造函数,并且加上可序列化:

[Serializable]
public class InvalidDepartmentException : Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message, Exception innerException) : base(message, innerException) { }

    // 如果异常需要跨应用程序域、跨进程或者跨计算机抛出,就需要能被序列化。
    protected InvalidDepartmentException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}

在创建自定义异常的时候,建议:

  • 名称以 Exception 结尾
  • Message 属性的值是一个句子,用于描述异常发生的原因。
  • 提供帮助诊断错误的属性。
  • 尽量写全四个构造函数,前三个方便使用,最后一个用于序列化异常(新的异常类应可序列化)。

finally

异常堆栈跟踪

堆栈跟踪从引发异常的语句开始,到捕获异常的 catch 语句结束。

利用这一点,你可以迅速找到引发异常的那个方法,也能找到是哪个方法中的 catch 捕捉到的这个异常。

异常处理原则

try-catch-finally

我们第一个要了解的异常处理原则是——明确 try catch finally 的用途!

try 块中,编写可能会发生异常的代码。

最好的情况是,你只将可能会发生异常的代码放到 try 块中,当然实际应用的时候可能会需要额外放入一些相关代码。但是如果你将多个可能发生异常的代码放到一个 try 块中,那么将来定位问题的时候你就会很抓狂(尤其是多个异常还是一个类别的时候)。

catch 块的作用是用来 “恢复错误” 的,是用来 “恢复错误” 的,是用来 “恢复错误” 的。

如果你在 try 块中先更改了类的状态,随后出了异常,那么最好能将状态改回来——这可以避免这个类型或者应用程序的其他状态出现不一致——这很容易造成应用程序“雪崩”。举一个例子:我们写一个程序有简洁模式和专业模式,在从简洁模式切换到专业模式的时候,我们设置 IsProfessionalModetrue,但随后出现了异常导致没有成功切换为专业模式;然而接下来所有的代码在执行时都判断 IsProfessionalModetrue 状态不正确,于是执行了一些非预期的操作,甚至可能用到了很多专业模式中才会初始化的类型实例(然而没有完成初始化),产生大量的额外异常;我们说程序雪崩了,多数功能再也无法正常使用了。

当然如果任务已全部完成,仅仅在对外通知的时候出现了异常,那么这个时候不需要恢复状态,因为实际上已经完成了任务。

你可能会有些担心如果我没有任何手段可以恢复错误怎么办?那这个时候就不要处理异常!——如果不知道如何恢复错误,请不要处理异常!让异常交给更上一层的模块处理,或者交给整个应用程序全局异常处理模块进行统一处理(这个后面会讲到)。

另外,异常不能用于在正常执行过程中更改程序的流程。异常只能用于报告和处理错误条件。

finally 块的作用是清理资源。

虽然 .NET 的垃圾回收机制可以在回收类型实例的时候帮助我们回收托管资源(例如 FileStream 类打开的文件),但那个时机不可控。因此我们需要在 finally 块中确保资源可被回收,这样当重新使用这个文件的时候能够立刻使用而不会被占用。

一段异常处理代码中可能没有 catch 块而有 finally 块,这个时候的重点是清理资源,通常也不知道如何正确处理这个错误。

一段异常处理代码中也可能 try 块留空,而只在 finally 里面写代码,这是为了“线程终止”安全考虑。在 .NET Core 中由于不支持线程终止因此可以不用这么写。详情可以参考:.NET/C# 异常处理:写一个空的 try 块代码,而把重要代码写到 finally 中(Constrained Execution Regions) - walterlv

该不该引发异常?

什么情况下该引发异常?答案是——这真的是一个异常情况!

于是,我们可能需要知道什么是“异常情况”。

一个可以参考的判断方法是——判断这件事发生的频率:

  • 如果这件事并不常见,当它发生时确实代表发生了一个错误,那么这件事情就可以认为是异常。
  • 如果这件事经常发生,代码中正常情况就应该处理这件事情,那么这件事情就不应该被认为是异常(而是正常流程的一部分)。

例如这些情况都应该认为是异常:

  • 方法中某个参数不应该传入 null 时但传入了 null
    • 这是开发者使用这个方法时没有遵循此方法的契约导致的,让开发者改变调用此方法的代码就可以完全避免这件事情发生

而下面这些情况则不应该认为是异常:

  • 用户输入了一串字符,你需要将这串字符转换为数字
    • 用户输入的内容本身就千奇百怪,出现非数字的输入再正常不过了,对非数字的处理本就应该成为正常流程的一部分

对于这些不应该认为是异常的情况,编写的代码就应该尽可能避免异常。

有两种方法来避免异常:

  1. 先判断再使用。
    • 例如读取文件之前,先判断文件是否存在;例如读取文件流时先判断是否已到达文件末尾。
    • 如果提前判断的成本过高,可采用 TryDo 模式来完成,例如字符串转数字中的 TryParse 方法,字典中的 TryGetValue 方法。
  2. 对极为常见的错误案例返回 null(或默认值),而不是引发异常。极其常见的错误案例可被视为常规控制流。通过在这些情况下返回 NULL(或默认值),可最大程度地减小对应用的性能产生的影响。(后面会专门说 null)

而当存在下列一种或多种情况时,应引发异常:

  1. 方法无法完成其定义的功能。
  2. 根据对象的状态,对某个对象进行不适当的调用。

请勿有意从自己的源代码中引发 System.ExceptionSystem.SystemExceptionSystem.NullReferenceExceptionSystem.IndexOutOfRangeException

该不该捕获异常?

在前面 try-catch-finally 小节中,我们提到了 catch 块中应该写哪些代码,那里其实已经说明了哪些情况下应该处理异常,哪些情况下不应该处理异常。一句总结性的话是——如果知道如何从错误中恢复,那么就捕获并处理异常,否则交给更上层的业务去捕获异常;如果所有层都不知道如何处理异常,就交给全局异常处理模块进行处理。

应用程序全局处理异常

对于 .NET 程序,无论是 .NET Framework 还是 .NET Core,都有下面这三个可以全局处理的异常。这三个都是事件,可以自行监听。

  • AppDomain.UnhandledException
    • 应用程序域未处理的异常,任何线程中未处理掉的异常都会进入此事件中
    • 当这里能够收到事件,意味着应用程序现在频临崩溃的边缘(从设计上讲,都到这里了,也再没有任何代码能够使得程序从错误中恢复了)
    • 不过也可以配置 legacyUnhandledExceptionPolicy 防止后台线程抛出的异常让程序崩溃退出
    • 建议在这个事件中记录崩溃日志,然后对应用程序进行最后的拯救恢复操作(例如保存用户的文档数据)
  • AppDomain.FirstChanceException
    • 应用程序域中的第一次机会异常
    • 我们前面说过,一个异常被捕获时,其堆栈信息将包含从 throw 块到 catch 块之间的所有帧,而在第一次机会异常事件中,只是刚刚 throw 出来,还没有被任何 catch 块捕捉,因此在这个事件中堆栈信息永远只会包含一帧(不过可以稍微变通一下在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈
    • 注意第一次机会异常事件即便异常会被 catch 也会引发,因为它引发在 catch 之前
    • 不要认为异常已经被 catch 就万事大吉可以无视这个事件了。前面我们说过异常仅在真的是异常的情况才应该引发,因此如果这个事件中引发了异常,通常也真的意味着发生了错误(差别只是我们能否从错误中恢复而已)。如果你经常在正常的操作中发现可以通过此事件监听到第一次机会异常,那么一定是应用程序或框架中的异常设计出了问题(可能把正常应该处理的流程当作了异常,可能内部实现代码错误,可能出现了使用错误),这种情况一定是要改代码修 Bug 的。而一些被认为是异常的情况下收到此事件则是正常的。
  • TaskScheduler.UnobservedTaskException
    • 在使用 async / await 关键字编写异步代码的时候,如果一直有 await 传递,那么异常始终可以被处理到;但中间有异步任务没有 await 导致异常没有被传递的时候,就会引发此事件。
    • 如果在此事件中监听到异常,通常意味着代码中出现了不正确的 async / await 的使用(要么应该修改实现避免异常,要么应该正确处理异常并从中恢复错误)

对于 GUI 应用程序,还可以监听 UI 线程上专属的全局异常:

  • WPF:Application.DispatcherUnhandledException 或者 Dispatcher.UnhandledException
  • Windows Forms:Application.ThreadException

关于这些全局异常的处理方式和示例代码,可以参阅博客:

抛出哪些异常?

任何情况下都不应该抛出这些异常:

  • 过于抽象,以至于无法表明其含义
    • Exception 这可是顶级基类,这都抛出来了,使用者再也无法正确地处理此异常了
    • SystemException 这是各种异常的基类,本身并没有明确的意义
    • ApplicationException 这是各种异常的基类,本身并没有明确的意义
  • 由 CLR 引发的异常
    • NullReferenceException 试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了
    • IndexOutOfRangeException 使用索引的时候超出了边界
    • InvalidCastException 表示试图对某个类型进行强转但类型不匹配
    • StackOverflow 表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归
    • OutOfMemoryException 表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了
    • AccessViolationException 这说明使用非托管内存时发生了错误
    • BadImageFormatException 这说明了加载的 dll 并不是期望中的托管 dll
    • TypeLoadException 表示类型初始化的时候发生了错误
  • .NET 设计失误
    • FormatException 因为当它抛出来时无法准确描述到底什么错了

首先是你自己不应该抛出这样的异常。其次,你如果在运行中捕获到了上面这些异常,那么代码一定是写得有问题。

如果是捕获到了上面 CLR 的异常,那么有两种可能:

  1. 你的代码编写错误(例如本该判空的代码没有判空,又如索引数组超出界限)
  2. 你使用到的别人写的代码编写错误(那你就需要找到它改正,或者如果开源就去开源社区中修复吧)

而一旦捕获到了上面其他种类的异常,那就找到抛这个异常的人,然后对它一帧狂扁即可。

其他的异常则是可以抛出的,只要你可以准确地表明错误原因。

另外,尽量不要考虑抛出聚合异常 AggregateException,而是优先使用 ExceptionDispatchInfo 抛出其内部异常。详见:使用 ExceptionDispatchInfo 捕捉并重新抛出异常 - walterlv

异常的分类

该不该引发异常 小节中我们说到一个异常会被引发,是因为某个方法声称的任务没有成功完成(失败),而失败的原因有四种:

  1. 方法的使用者用错了(没有按照方法的契约使用)
  2. 方法的执行代码写错了
  3. 方法执行时所在的环境不符合预期

简单说来,就是:使用错误,实现错误、环境错误。

使用错误:

  • ArgumentException 表示参数使用错了
  • ArgumentNullException 表示参数不应该传入 null
  • ArgumentOutOfRangeException 表示参数中的序号超出了范围
  • InvalidEnumArgumentException 表示参数中的枚举值不正确
  • InvalidOperationException 表示当前状态下不允许进行此操作(也就是说存在着允许进行此操作的另一种状态)
  • ObjectDisposedException 表示对象已经 Dispose 过了,不能再使用了
  • NotSupportedException 表示不支持进行此操作(这是在说不要再试图对这种类型的对象调用此方法了,不支持)
  • PlatformNotSupportedException 表示在此平台下不支持(如果程序跨平台的话)
  • NotImplementedException 表示此功能尚在开发中,暂时请勿使用

实现错误:

前面由 CLR 抛出的异常代码主要都是实现错误

  • NullReferenceException 试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了
  • IndexOutOfRangeException 使用索引的时候超出了边界
  • InvalidCastException 表示试图对某个类型进行强转但类型不匹配
  • StackOverflowException 表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归
  • OutOfMemoryException 表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了
  • AccessViolationException 这说明使用非托管内存时发生了错误
  • BadImageFormatException 这说明了加载的 dll 并不是期望中的托管 dll
  • TypeLoadException 表示类型初始化的时候发生了错误

环境错误:

  • IOException 下的各种子类
  • Win32Exception 下的各种子类
  • ……

另外,还剩下一些不应该抛出的异常,例如过于抽象的异常和已经过时的异常,这在前面一小结中有说明。

其他

一些常见异常的原因和解决方法

在平时的开发当中,你可能会遇到这样一些异常,它不像是自己代码中抛出的那些常见的异常,但也不包含我们自己的异常堆栈。

这里介绍一些常见这些异常的原因和解决办法。

AccessViolationException

当出现此异常时,说明非托管内存中发生了错误。如果要解决问题,需要从非托管代码中着手调查。

这个异常是访问了不允许的内存时引发的。在原因上会类似于托管中的 NullReferenceException

FileNotFoundException

捕捉非 CLS 异常


参考资料

07-11 2019

WPF 很少人知道的科技

本文介绍不那么常见的 WPF 相关的知识。


在 C# 代码中创建 DataTemplate

大多数时候我们只需要在 XAML 中就可以实现我们想要的各种界面效果。这使得你可能已经不知道如何在 C# 代码中创建同样的内容。

比如在代码中创建 DataTemplate,主要会使用到 FrameworkElementFactory 类型。

可以参考:

多个数据源合并为一个列表显示

WPF 提供 CompositionCollection 用于将多个列表合并为一个,以便在 WPF 界面的同一个列表中显示多个数据源的数据。

<ListBox Name="WalterlvDemoListBox">
    <ListBox.Resources>
        <CollectionViewSource x:Key="Items1Source" Source="{Binding Items1}"/>
        <CollectionViewSource x:Key="Items2Source" Source="{Binding Items2}"/>
    </ListBox.Resources>
    <ListBox.ItemsSource>
        <CompositeCollection>
            <CollectionContainer Collection="{Binding Source={StaticResource Items1Source}}" />
            <CollectionContainer Collection="{Binding Source={StaticResource Items2Source}}" />
            <ListBoxItem>Walterlv End Item 1</ListBoxItem>
            <ListBoxItem>Walterlv End Item 2</ListBoxItem>
        </CompositeCollection>
    </ListBox.ItemsSource>
</ListBox>

关于 CompositeCollection 的使用示例可以参考:

神樹桜乃写了一份非 WPF 框架的版本,如果希望在非 WPF 程序中使用,可以参考:

使用附加属性做缓存,避免内存泄漏

在没有使用 WPF 的时候,如果我们要为一个对象添加属性或者行为,我们可能会使用字典来实现。但字典带来了内存泄漏的问题,要自己处理内存泄漏问题可能会写比较复杂的代码。

然而,WPF 的附加属性可以非常容易地为对象添加属性或者行为,而且也不用担心内存泄漏问题。

例如,我曾经用 WPF 来模拟 UWP 流畅设计(Fluent Design)中的光照效果,使用附加属性来管理此行为则完全不用担心内存泄漏问题:

使用 ConditionalWeakTable 做非 WPF 版本的缓存

如果你有一些非 WPF 的对象需要做类似 WPF 那种附加属性,那么可以考虑使用 ConditionalWeakTable 来实现,Key 是那个对象,而 Value 是你需要附加的属性或者行为。

这里的引用关系是 Key 引用着 Value,如果 Key 被回收,那么 Value 也可以被回收。

使用代码模拟触摸

WPF 默认情况下的触摸是通过 COM 组件 PimcManager 获取到的,在禁用实时触摸后会启用系统的 TOUCH 消息获取到,如果开启了 Pointer 消息那么会使用 POINTER 消息。

我们可以继承自 TouchDevice 来模拟触摸,详见:

模拟 UWP 界面

在现有的 Windowing API 下,系统中看起来非常接近系统级的窗口样式可能都是用不同技术模拟实现的,只是模拟得很像而已。

如果要将 WPF 模拟得很像 UWP,可以参考我的这两篇博客:

模拟 Fluent Design 特效

目前 WPF 还不能直接使用 Windows 10 Fluent Design 特效。当然如果你的程序非常小,那么模拟一下也不会伤害太多性能:

然而充分利用 Fluent Design 的高性能,需要上 XAML Islands,详见:

  • [Using the UWP XAML hosting API in a desktop application - Windows apps Microsoft Docs](https://docs.microsoft.com/en-us/windows/apps/desktop/modernize/using-the-xaml-hosting-api)

07-10 2019

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

本文只谈论 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 自带的代码片段就弱很多,只能支持最基本的宏。

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

07-07 2019

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

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 哟!

别忘了单元测试

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


参考资料

07-05 2019

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

使用 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 原生代码片段存在感低的问题。


参考资料

07-04 2019

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

使用 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,我们可以在一般测试代码的时候也能获得出现特定异常时立刻开始断点调查异常的特性。

07-04 2019

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

当使用 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() 所在的代码处,可能异常还没发生或者已经发生过了。

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

07-04 2019

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

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


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

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

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

PublishDir 全文查找

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

比如默认是:

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

还有:

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

和其他。

07-02 2019

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

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

06-30 2019

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

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

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


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

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

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

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

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

现在,推荐使用插件 VSColorOutput

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

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

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

VSColorOutput 的设置

06-29 2019

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

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

06-29 2019

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

我们有很多的调试工具可以帮助我们查看 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 应用程序中所有窗口中的键盘焦点。

06-28 2019

如何监视 WPF 中的所有窗口,在所有窗口中订阅事件或者附加 UI

由于 WPF 路由事件(主要是隧道和冒泡)的存在,我们很容易能够通过只监听窗口中的某些事件使得整个窗口中所有控件发生的事件都被监听到。然而,如果我们希望监听的是整个应用程序中所有的事件呢?路由事件的路由可并不会跨越窗口边界呀?

本文将介绍我编写的应用程序窗口监视器,来监听整个应用程序中所有窗口中的路由事件。这样的方法可以用来无时无刻监视 WPF 程序的各种状态。


其实问题依旧摆在那里,因为我们依然无法让路由事件跨越窗口边界。更麻烦的是,我们甚至不知道应用程序有哪些窗口,这些窗口都是什么时机显示出来的。

Application 类中有一个属性 Windows,这是一个 WindowCollection 类型的属性,可以用来获取当前已经被 Application 类管理的所有的窗口的集合。当然 Application 类内部还有一个属性 NonAppWindowsInternal 用来管理与此 Application 没有逻辑关系的窗口集合。

于是,我们只需要遍历 Windows 集合便可以获得应用程序中的所有窗口,然后对每一个窗口监听需要的路由事件。

var app = Application.Current;
foreach (Window window in app.Windows)
{
    // 在这里监听窗口中的事件。
}

等等!这种操作意味着将来新打开的窗口是不会被监听到事件的。

我们有没有方法拿到新窗口的显示事件呢?遗憾的是——并不行。

但是,我们有一些变相的处理思路。比如,由于 Windows 系统的特性,整个用户空间内,统一时刻只能有一个窗口能处于激活状态。我们可以利用当前窗口的激活与非激活的切换时机再去寻找新的窗口。

于是,一开始的时候,我们可以监听一些窗口的激活事件。如果执行这段初始化代码的时候没有任何窗口是激活的状态,那么就监听所有窗口的激活事件;如果有一个窗口是激活的,那么就监听这个窗口的取消激活事件。

private void InitializeActivation()
{
    var app = Application.Current;
    var availableWindows = app.Windows.ToList();
    var activeWindow = availableWindows.FirstOrDefault(x => x.IsActive);
    if (activeWindow == null)
    {
        foreach (var window in availableWindows)
        {
            window.Activated -= Window_Activated;
            window.Activated += Window_Activated;
        }
    }
    else
    {
        activeWindow.Deactivated -= Window_Deactivated;
        activeWindow.Deactivated += Window_Deactivated;
        UpdateActiveWindow(activeWindow);
    }
}

private void UpdateActiveWindow(Window window)
{
    // 当前激活的窗口已经发生了改变,可以在这里为新的窗口做一些事情了。
}

Window_ActivatedWindow_Deactivated 事件中,我们主要也是在做初始化。

现在思路基本上全部清晰了,于是我将我写的 ApplicationWindowMonitor 类的全部源码贴出来。

using System;
using System.Linq;
using System.Windows;
using System.Windows.Threading;

namespace Walterlv.Windows
{
    public sealed class ApplicationWindowMonitor
    {
        private readonly Application _app;
        private readonly Predicate<Window> _windowFilter;
        private Window _lastActiveWindow;

        public ApplicationWindowMonitor(Application app, Predicate<Window> windowFilter = null)
        {
            _app = app ?? throw new ArgumentNullException(nameof(app));
            _windowFilter = windowFilter;
            _app.Dispatcher.InvokeAsync(InitializeActivation, DispatcherPriority.Send);
        }

        private void InitializeActivation()
        {
            var availableWindows = _app.Windows.OfType<Window>().Where(FilterWindow).ToList();
            var activeWindow = availableWindows.FirstOrDefault(x => x.IsActive);
            if (activeWindow == null)
            {
                foreach (var window in availableWindows)
                {
                    window.Activated -= Window_Activated;
                    window.Activated += Window_Activated;
                }
            }
            else
            {
                activeWindow.Deactivated -= Window_Deactivated;
                activeWindow.Deactivated += Window_Deactivated;
                UpdateActiveWindow(activeWindow);
            }
        }

        private void Window_Activated(object sender, EventArgs e)
        {
            var window = (Window) sender;
            window.Activated -= Window_Activated;
            window.Deactivated -= Window_Deactivated;
            window.Deactivated += Window_Deactivated;
            UpdateActiveWindow(window);
        }

        private void Window_Deactivated(object sender, EventArgs e)
        {
            var availableWindows = _app.Windows.OfType<Window>().Where(FilterWindow).ToList();
            foreach (var window in availableWindows)
            {
                window.Deactivated -= Window_Deactivated;
                window.Activated -= Window_Activated;
                window.Activated += Window_Activated;
            }
        }

        private void UpdateActiveWindow(Window window)
        {
            if (!Equals(window, _lastActiveWindow))
            {
                try
                {
                    OnActiveWindowChanged(_lastActiveWindow, window);
                }
                finally
                {
                    _lastActiveWindow = window;
                }
            }
        }

        private bool FilterWindow(Window window) => _windowFilter == null || _windowFilter(window);

        public event EventHandler<ActiveWindowEventArgs> ActiveWindowChanged;

        private void OnActiveWindowChanged(Window oldWindow, Window newWindow)
        {
            ActiveWindowChanged?.Invoke(this, new ActiveWindowEventArgs(oldWindow, newWindow));
        }
    }
}

使用方法是:

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

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

另外,我在 ApplicationWindowMonitor 的构造函数中加入了一个过滤窗口的委托。比如你可以让窗口的监听只对主要的几个窗口生效,而对一些信息提示窗口忽略等等。

06-17 2019

.NET 使用 ILMerge 合并多个程序集,避免引入额外的依赖

我们有多种工具可以将程序集合并成为一个。打包成一个程序集可以避免分发程序的时候带上一堆依赖而出问题。

ILMerge 可以用来将多个程序集合并成一个程序集。本文介绍使用 ILMerge 工具和其 NuGet 工具包来合并程序集和其依赖。


以 NuGet 包的形式使用 ILMerge

ILMerge 提供了可供你项目使用的 NuGet 包。如果你在团队项目当中安装了 ILMerge 的 NuGet 包,那么无论团队其他人是否安装了 ILMerge 的工具,都可以使用 ILMerge 工具。这可以避免要求团队所有成员安装工具或者将工具内置到项目的源代码管理中。

要以 NuGet 包的形式来使用 ILMerge,需要首先安装 ILMerge 的 NuGet 包:

  • [NuGet Gallery ilmerge](https://www.nuget.org/packages/ilmerge)

或者直接在你的项目的 csproj 文件中添加 PackageReference

<ItemGroup>
    <PackageReference Include="ILMerge" Version="3.0.29" />
</ItemGroup>

我现在有一个项目 Walterlv.Demo.AssemblyLoading,这是一个控制台程序。这个程序引用了一个 NuGet 包 Ben.Demystifier。为此带来了三个额外的依赖。

- Walterlv.Demo.AssemblyLoading.exe
- Ben.Demystifier.dll
- System.Collections.Immutable.dll
- System.Reflection.Metadata.dll

而我们可以使用 ILMerge 将这些依赖和我们生成的主程序合并成一个程序集,这样分发程序的时候只需要一个程序集即可。

那么,我们现在需要编辑我们的项目文件:

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

        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
        </PropertyGroup>
        
        <ItemGroup>
            <PackageReference Include="Ben.Demystifier" Version="0.1.4" />
            <PackageReference Include="ILMerge" Version="3.0.29" />
        </ItemGroup>

++      <Target Name="ILMerge">
++          <Exec Command="&quot;$(ILMergeConsolePath)&quot; /ndebug /target:exe /out:$(OutputPath)$(AssemblyName).exe /log $(OutputPath)$(AssemblyName).exe /log $(OutputPath)Ben.Demystifier.dll /log $(OutputPath)System.Collections.Immutable.dll /log $(OutputPath)System.Reflection.Metadata.dll /targetplatform:v4" />
++      </Target>
    
    </Project>

我们只增加了三行,添加了一个名称为 ILMerge 的 Target。(注意到项目文件中我有额外引用一个其他的 NuGet 包 Ben.Demystifier,这是为了演示将依赖进行合并而添加的 NuGet 包,具体是什么都没有关系,我们只是在演示依赖的合并。)在这个 Target 里面,我们使用 Exec 的 Task 来执行 ILMerge 命令。具体这个命令代表的含义我们在下一节介绍 ILMerge 工具的时候会详细介绍。如果你希望在你的项目当中进行尝试,可以把所有 /log 参数之后的那些程序集名称改为你自己的名称。

那么在编译的时候使用命令 msbuild /t:ILMerge 就可以完成程序集的合并了。

注意,你普通编译的话是不会进行 IL 合并的。

如果你希望常规编译也可以进行 IL 合并,或者说希望在 Visual Studio 里面点击生成按钮的时候也能完成 IL 合并的话,那么你还需要增加一个跳板的编译目标 Target。

我将这个名为 _ProjectRemoveDependencyFiles 的 Target 增加到了下面。它的目的是在 AfterBuild 这个编译目标完成之后(AfterTargets)执行,然后执行前需要先执行(DependsOnTargets)ILMerge 这个 Target。在这个编译目标执行的时候还会将原本的三个依赖删除掉,这样在生成的目录下我们将只会看到我们最终期望的程序集 Walterlv.Demo.AssemblyLoading.exe 而没有其他依赖程序集。

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

        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
        </PropertyGroup>
        
        <ItemGroup>
            <PackageReference Include="Ben.Demystifier" Version="0.1.4" />
            <PackageReference Include="ILMerge" Version="3.0.29" />
        </ItemGroup>

        <Target Name="ILMerge">
            <Exec Command="&quot;$(ILMergeConsolePath)&quot; /ndebug /target:exe /out:$(OutputPath)$(AssemblyName).exe /log $(OutputPath)$(AssemblyName).exe /log $(OutputPath)Ben.Demystifier.dll /log $(OutputPath)System.Collections.Immutable.dll /log $(OutputPath)System.Reflection.Metadata.dll /targetplatform:v4" />
        </Target>

++      <Target Name="_ProjectRemoveDependencyFiles" AfterTargets="AfterBuild" DependsOnTargets="ILMerge">
++          <ItemGroup>
++              <_ProjectDependencyFile Include="$(OutputPath)Ben.Demystifier.dll" />
++              <_ProjectDependencyFile Include="$(OutputPath)System.Collections.Immutable.dll" />
++              <_ProjectDependencyFile Include="$(OutputPath)System.Reflection.Metadata.dll" />
++          </ItemGroup>
++          <Delete Files="@(_ProjectDependencyFile)" />
++      </Target>
    
    </Project>

最终生成的输出目录下只有我们最终期望生成的程序集:

最终生成的程序集

以命令行工具的形式使用 ILMerge

你可以在这里下载到 ILMerge:

实际上 ILMerge 已经开源,你可以在 GitHub 上找到它:

装完之后,如果将 ILMerge 的可执行目录加入到环境变量,那么你将可以在任意的目录下在命令行中直接使用 ILMerge 命令了。加入环境变量的方法我就不用说了,可以在网上搜索到非常多的资料。

ILMerge 装完的默认目录在 C:\Program Files (x86)\Microsoft\ILMerge,所以如果你保持默认路径安装,那么几乎可以直接把这个路径加入到环境变量中。

那么 ILMerge 的命令行如何使用呢?它的参数列表是怎样的呢?

我们来写一个简单的例子:

ilmerge /ndebug /target:exe /out:Walterlv.Demo.AssemblyLoading.exe /log Walterlv.Demo.AssemblyLoading.exe /log Ben.Demystifier.dll /log System.Collections.Immutable.dll /log System.Reflection.Metadata.dll /targetplatform:v4

其中:

  • /ndebug 表示以非调试版本编译,如果去掉,将会生成 pdb 文件
  • /target 合并之后的程序集类型,如果是控制台程序,则为 exe
  • /out 输出文件的名称(或路径)(此路径可以和需要合并的程序集名称相同,这样在合并完之后会覆盖同名称的那个程序集)
  • /log 所有需要合并的程序集名称(或路径)
  • /targetplatform 目标平台,如果是 .NET Framework 4.0 - .NET Framework 4.8 之间,则都是 v4

在合并完成之后,我们反编译可以发现程序集中已经包含了依赖程序集中的全部类型了。

合并后的程序集

以封装的 NuGet 包来使用 ILRepack

安装 NuGet 包:

之后,你就能直接使用 ILRepack 这个编译任务了,而不是在 MSBuild 中使用 Exec 来间接执行 ILRepack 的任务。

关于此 NuGet 包的使用,GitHub 中有很棒的例子,可以查看:

需要注意

如果使用新的基于 Sdk 的项目文件,那么默认生成的 PDB 是 Portable PDB,但是 ILMerge 暂时不支持 Portable PDB,会在编译时提示错误:

An exception occurred during merging:
ILMerge.Merge:  There were errors reported in dotnetCampus.EasiPlugin.Sample's metadata.
        数组维度超过了支持的范围。
   在 ILMerging.ILMerge.Merge()
   在 ILMerging.ILMerge.Main(String[] args)

或者英文提示:

An exception occurred during merging:
ILMerge.Merge:        There were errors reported in ReferencedProject's metadata.
      Array dimensions exceeded supported range.
   at ILMerging.ILMerge.Merge()
   at ILMerging.ILMerge.Main(String[] args)

目前,GitHub 上有 issue 在追踪此问题:


参考资料

06-17 2019

.NET 使用 ILRepack 合并多个程序集(替代 ILMerge),避免引入额外的依赖

我们有多种工具可以将程序集合并成为一个。比如 ILMerge、Mono.Merge。前者不可定制、运行缓慢、消耗资源(不过好消息是现在开源了);后者已被弃用、不受支持且基于旧版本的 Mono.Cecil。

而本文介绍用来替代它们的 ILRepack,使用 ILRepack 来合并程序集。


以 NuGet 包的形式使用 ILRepack

ILRepack 提供了可供你项目使用的 NuGet 包。如果你在团队项目当中安装了 ILRepack 的 NuGet 包,那么无论团队其他人是否安装了 ILRepack 的工具,都可以使用 ILRepack 工具。这可以避免要求团队所有成员安装工具或者将工具内置到项目的源代码管理中。

要以 NuGet 包的形式来使用 ILRepack,需要首先安装 ILRepack 的 NuGet 包:

  • [NuGet Gallery ILRepack](https://www.nuget.org/packages/ILRepack/)

或者直接在你的项目的 csproj 文件中添加 PackageReference

<ItemGroup>
    <PackageReference Include="ILRepack" Version="2.0.17" />
</ItemGroup>

我现在有一个项目 Walterlv.Demo.AssemblyLoading,这是一个控制台程序。这个程序引用了一个 NuGet 包 Ben.Demystifier。为此带来了三个额外的依赖。

- Walterlv.Demo.AssemblyLoading.exe
- Ben.Demystifier.dll
- System.Collections.Immutable.dll
- System.Reflection.Metadata.dll

而我们可以使用 ILRepack 将这些依赖和我们生成的主程序合并成一个程序集,这样分发程序的时候只需要一个程序集即可。

那么,我们现在需要编辑我们的项目文件:

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

        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
        </PropertyGroup>
        
        <ItemGroup>
            <PackageReference Include="Ben.Demystifier" Version="0.1.4" />
            <PackageReference Include="ILRepack" Version="2.0.17" />
        </ItemGroup>

++      <Target Name="ILRepack">
++          <Exec Command="&quot;$(ILRepack)&quot; /out:$(OutputPath)$(AssemblyName).exe $(OutputPath)$(AssemblyName).exe $(OutputPath)Ben.Demystifier.dll $(OutputPath)System.Collections.Immutable.dll $(OutputPath)System.Reflection.Metadata.dll" />
++      </Target>
    
    </Project>

我们只增加了三行,添加了一个名称为 ILRepack 的 Target。(注意到项目文件中我有额外引用一个其他的 NuGet 包 Ben.Demystifier,这是为了演示将依赖进行合并而添加的 NuGet 包,具体是什么都没有关系,我们只是在演示依赖的合并。)在这个 Target 里面,我们使用 Exec 的 Task 来执行 ILRepack 命令。具体这个命令代表的含义我们在下一节介绍 ILRepack 工具的时候会详细介绍。如果你希望在你的项目当中进行尝试,可以把后面那些代表程序集的名称改为你自己项目中依赖程序集的名称。

现在在编译的时候使用命令 msbuild /t:ILRepack 就可以完成程序集的合并了。

注意,你普通编译的话是不会进行 IL 合并的。

如果你希望常规编译也可以进行 IL 合并,或者说希望在 Visual Studio 里面点击生成按钮的时候也能完成 IL 合并的话,那么你还需要增加一个跳板的编译目标 Target。

我将这个名为 _ProjectRemoveDependencyFiles 的 Target 增加到了下面。它的目的是在 AfterBuild 这个编译目标完成之后(AfterTargets)执行,然后执行前需要先执行(DependsOnTargets)ILRepack 这个 Target。在这个编译目标执行的时候还会将原本的三个依赖删除掉,这样在生成的目录下我们将只会看到我们最终期望的程序集 Walterlv.Demo.AssemblyLoading.exe 而没有其他依赖程序集。

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

        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
        </PropertyGroup>

        <ItemGroup>
            <PackageReference Include="Ben.Demystifier" Version="0.1.4" />
            <PackageReference Include="ILRepack" Version="2.0.17" />
        </ItemGroup>

        <Target Name="ILRepack">
            <Exec Command="&quot;$(ILRepack)&quot; /out:$(OutputPath)$(AssemblyName).exe $(OutputPath)$(AssemblyName).exe $(OutputPath)Ben.Demystifier.dll $(OutputPath)System.Collections.Immutable.dll $(OutputPath)System.Reflection.Metadata.dll" />
        </Target>

++      <Target Name="_ProjectRemoveDependencyFiles" AfterTargets="AfterBuild" DependsOnTargets="ILRepack">
++          <ItemGroup>
++              <_ProjectDependencyFile Include="$(OutputPath)Ben.Demystifier.dll" />
++              <_ProjectDependencyFile Include="$(OutputPath)System.Collections.Immutable.dll" />
++              <_ProjectDependencyFile Include="$(OutputPath)System.Reflection.Metadata.dll" />
++              </ItemGroup>
++          <Delete Files="@(_ProjectDependencyFile)" />
++      </Target>

    </Project>

最终生成的输出目录下只有我们最终期望生成的程序集:

最终生成的程序集

ILRepack 的命令行使用

相比于 ILMerge,ILRepack 的命令行在尽量贴近 ILMerge 的情况下做得更加简化了。

ilrepack /out:Walterlv.Demo.AssemblyLoading.exe Walterlv.Demo.AssemblyLoading.exe Ben.Demystifier.dll System.Collections.Immutable.dll System.Reflection.Metadata.dll

其中,/out 表示最终的输出程序集的名称或路径,后面没有前缀的参数都是需要合并的程序集的名称或路径。这些需要合并的参数中,第一个参数是主程序集,而后续其他的都是待合并的程序集。区别主程序集和其他程序集的原因是输出的程序集需要有名称、版本号等等信息,而这些信息将使用主程序集中的信息。

如果希望使用 ILRepack 的其他命令,可以考虑使用帮助命令:

ilrepack /help

或者直接访问 ILRepack 的 GitHub 仓库来查看用法:

如果解决合并错误?

缺少依赖

如果你在使用 ILRepack 合并程序集的过程中出现了缺少依赖的错误,例如下面这样:

Mono.Cecil.AssemblyResolutionException: Failed to resolve assembly: 'xxxxxxxxx'

缺少依赖错误提示

那么你需要做以下两种事情中的任何一种:

  1. 将所有依赖合并;
  2. 将依赖加入搜索目录。

将所有依赖合并指的是将缺少的依赖也一起作为命令行参数传入要合并的程序集中。

而另一种是增加一个参数 /lib,即添加一个被搜索的依赖程序集的目录。将这个目录指定后,则可以正确解析依赖完成合并。而且这些依赖将成为合并后的程序集的依赖,不会合并到程序集中。

ilrepack /lib:D:\Dependencies /out:Walterlv.Demo.AssemblyLoading.exe Walterlv.Demo.AssemblyLoading.exe Ben.Demystifier.dll System.Collections.Immutable.dll System.Reflection.Metadata.dll

没有生成 PDB 文件

如果使用新的基于 Sdk 的项目文件,那么默认生成的 PDB 是 Portable PDB,但是 ILRepack 暂时不支持 Portable PDB,其在内部捕获了异常以至于可以完成合并但不会生成 PDB 文件。

目前此问题在 ILRepack 中还处于打开状态,且持续两年都没关闭了。同时很早就有支持 Portable PDB 的拉取请求,但至今未合并。

以下是 GitHub 社区中的讨论:


参考资料

06-16 2019

从零开始制作 NuGet 源代码包(全面支持 .NET Core / .NET Framework / WPF 项目)

默认情况下,我们打包 NuGet 包时,目标项目安装我们的 NuGet 包会引用我们生成的库文件(dll)。除此之外,我们也可以专门做 NuGet 工具包,还可以做 NuGet 源代码包。然而做源代码包可能是其中最困难的一种了,目标项目安装完后,这些源码将直接随目标项目一起编译。

本文将从零开始,教你制作一个支持 .NET 各种类型项目的源代码包。


前置知识

在开始制作一个源代码包之间,建议你提前了解项目文件的一些基本概念:

当然就算不了解也没有关系。跟着本教程你也可以制作出来一个源代码包,只不过可能遇到了问题的时候不容易调试和解决。

制作一个源代码包

接下来,我们将从零开始制作一个源代码包。

我们接下来的将创建一个完整的解决方案,这个解决方案包括:

  1. 一个将打包成源代码包的项目
  2. 一个调试专用的项目(可选)
  3. 一个测试源代码包的项目(可选)

第一步:创建一个 .NET 项目

像其他 NuGet 包的引用项目一样,我们需要创建一个空的项目。不过差别是我们需要创建的是控制台程序。

创建项目

当创建好之后,Main 函数中的所有内容都是不需要的,于是我们删除 Main 函数中的所有内容但保留 Main 函数。

这时 Program.cs 中的内容如下:

namespace Walterlv.PackageDemo.SourceCode
{
    class Program
    {
        static void Main(string[] args)
        {
        }
    }
}

双击创建好的项目的项目,或者右键项目 “编辑项目文件”,我们可以编辑此项目的 csproj 文件。

在这里,我将目标框架改成了 net48。实际上如果我们不制作动态源代码生成,那么这里无论填写什么目标框架都不重要。在这篇博客中,我们主要篇幅都会是做静态源代码生成,所以你大可不必关心这里填什么。

提示:如果 net48 让你无法编译这个项目,说明你电脑上没有装 .NET Framework 4.8 框架,请改成 net473, net472, net471, net47, net462, net 461, net46, net45, netcoreapp3.0, netcoreapp2.1, netcoreapp2.0 中的任何一个可以让你编译通过的目标框架即可。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

</Project>

第二步:组织项目的目录结构

接下来,我们会让这个项目像一个 NuGet 包的样子。当然,是 NuGet 源代码包。

请在你的项目当中创建这些文件和文件夹:

- Assets
    - build
        + Package.props
        + Package.targets
    - buildMultiTargeting
        + Package.props
        + Package.targets
    - src
        + Foo.cs
    - tools
+ Program.cs

在这里,- 号表示文件夹,+ 号表示文件。

Program.cs 是我们一开始就已经有的,可以不用管。src 文件夹里的 Foo.cs 是我随意创建的一个类,你就想往常创建正常的类文件一样创建一些类就好了。

比如我的 Foo.cs 里面的内容很简单:

using System;

namespace Walterlv.PackageDemo.SourceCode
{
    internal class Foo
    {
        public static void Print() => Console.WriteLine("Walterlv is a 逗比.");
    }
}

props 和 targets 文件你可能在 Visual Studio 的新建文件的模板中找不到这样的模板文件。这不重要,你随便创建一个文本文件,然后将名称修改成上面列举的那样即可。接下来我们会依次修改这些文件中的所有内容,所以无需担心模板自动为我们生成了哪些内容。

为了更直观,我将我的解决方案截图贴出来,里面包含所有这些文件和文件夹的解释。

目录结构

我特别说明了哪些文件和文件夹是必须存在的,哪些文件和文件夹的名称一定必须与本文说明的一样。如果你是以教程的方式阅读本文,建议所有的文件和文件夹都跟我保持一样的结构和名称;如果你已经对 NuGet 包的结构有一定了解,那么可自作主张修改一些名称。

第三步:编写项目文件 csproj

现在,我们要双击项目名称或者右键“编辑项目文件”来编辑项目的 csproj 文件

编辑项目文件

我们编辑项目文件的目的,是让我们前一步创建的项目文件夹结构真正成为 NuGet 包中的文件夹结构。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>

    <!-- 要求此项目编译时要生成一个 NuGet 包。-->
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>

    <!-- 这里为了方便,我将 NuGet 包的输出路径设置在了解决方案根目录的 bin 文件夹下,而不是项目的 bin 文件夹下。-->
    <PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>

    <!-- 创建 NuGet 包时,项目的输出文件对应到 NuGet 包的 tools 文件夹,这可以避免目标项目引用我们的 NuGet 包的输出文件。
         同时,如果将来我们准备动态生成源代码,而不只是引入静态源代码,还可以有机会运行我们 Program 中的 Main 函数。-->
    <BuildOutputTargetFolder>tools</BuildOutputTargetFolder>

    <!-- 此包将不会传递依赖。意味着如果目标项目安装了此 NuGet 包,那么安装目标项目包的项目不会间接安装此 NuGet 包。-->
    <DevelopmentDependency>true</DevelopmentDependency>
    
    <!-- 包的版本号,我们设成了一个预览版;当然你也可以设置为正式版,即没有后面的 -alpha 后缀。-->
    <Version>0.1.0-alpha</Version>
    
    <!-- 设置包的作者。在上传到 nuget.org 之后,如果作者名与 nuget.org 上的账号名相同,其他人浏览包是可以直接点击链接看作者页面。-->
    <Authors>walterlv</Authors>

    <!-- 设置包的组织名称。我当然写成我所在的组织 dotnet 职业技术学院啦。-->
    <Company>dotnet-campus</Company>
  </PropertyGroup>

  <!-- 在生成 NuGet 包之前,我们需要将我们项目中的文件夹结构一一映射到 NuGet 包中。-->
  <Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
    <ItemGroup>

      <!-- 将 Package.props / Package.targets 文件的名称在 NuGet 包中改为需要的真正名称。
           因为 NuGet 包要自动导入 props 和 targets 文件,要求文件的名称必须是 包名.props 和 包名.targets;
           然而为了避免我们改包名的时候还要同步改四个文件的名称,所以就在项目文件中动态生成。-->
      <None Include="Assets\build\Package.props" Pack="True" PackagePath="build\$(PackageId).props" />
      <None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
      <None Include="Assets\buildMultiTargeting\Package.props" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).props" />
      <None Include="Assets\buildMultiTargeting\Package.targets" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).targets" />

      <!-- 我们将 src 目录中的所有源代码映射到 NuGet 包中的 src 目录中。-->
      <None Include="Assets\src\**" Pack="True" PackagePath="src" />

    </ItemGroup>
  </Target>

</Project>

第四步:编写编译文件 targets

接下来,我们将编写编译文件 props 和 targets。注意,我们需要写的是四个文件的内容,不要弄错了。

如果我们做好的 NuGet 源码包被其他项目使用,那么这四个文件中的其中一对会在目标项目被自动导入(Import)。在你理解 理解 C# 项目 csproj 文件格式的本质和编译流程 一文内容之前,你可能不明白“导入”是什么意思。但作为从零开始的入门博客,你也不需要真的理解导入是什么意思,只要知道这四个文件中的代码将在目标项目编译期间运行就好。

buildMultiTargeting 文件夹中的 Package.props 文件

你只需要将下面的代码拷贝到 buildMultiTargeting 文件夹中的 Package.props 文件即可。注意将包名换成你自己的包名,也就是项目名。

<Project>

  <PropertyGroup>
    <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
  </PropertyGroup>
  
  <!-- 为了简单起见,如果导入了这个文件,那么我们将直接再导入 ..\build\Walterlv.PackageDemo.SourceCode.props 文件。
       注意到了吗?我们并没有写 Package.props,因为我们在第三步编写项目文件时已经将这个文件转换为真实的包名了。-->
  <Import Project="..\build\Walterlv.PackageDemo.SourceCode.props" />

</Project>

buildMultiTargeting 文件夹中的 Package.targets 文件

你只需要将下面的代码拷贝到 buildMultiTargeting 文件夹中的 Package.targets 文件即可。注意将包名换成你自己的包名,也就是项目名。

<Project>

  <PropertyGroup>
    <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
  </PropertyGroup>
  
  <!-- 为了简单起见,如果导入了这个文件,那么我们将直接再导入 ..\build\Walterlv.PackageDemo.SourceCode.targets 文件。
       注意到了吗?我们并没有写 Package.targets,因为我们在第三步编写项目文件时已经将这个文件转换为真实的包名了。-->
  <Import Project="..\build\Walterlv.PackageDemo.SourceCode.targets" />

</Project>

build 文件夹中的 Package.props 文件

下面是 build 文件夹中 Package.props 文件的全部内容。可以注意到我们几乎没有任何实质性的代码在里面。即便我们在此文件中还没有写任何代码,依然需要创建这个文件,因为后面第五步我们将添加更复杂的代码时将再次用到这个文件完成里面的内容。

现在,保持你的文件中的内容与下面一模一样就好。

<Project>

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

</Project>

build 文件夹中的 Package.targets 文件

下面是 build 文件夹中的 Package.targets 文件的全部内容。

我们写了两个编译目标,即 Target。_WalterlvDemoEvaluateProperties 没有指定任何执行时机,但帮我们计算了两个属性:

  • _WalterlvDemoRoot 即 NuGet 包的根目录
  • _WalterlvDemoSourceFolder 即 NuGet 包中的源代码目录

另外,我们添加了一个 Message 任务,用于在编译期间显示一条信息,这对于调试来说非常方便。

_WalterlvDemoIncludeSourceFiles 这个编译目标指定在 CoreCompile 之前执行,并且执行需要依赖于 _WalterlvDemoEvaluateProperties 编译目标。这意味着当编译执行到 CoreCompile 步骤时,将在它执行之前插入 _WalterlvDemoIncludeSourceFiles 编译目标来执行,而 _WalterlvDemoIncludeSourceFiles 依赖于 _WalterlvDemoEvaluateProperties,于是 _WalterlvDemoEvaluateProperties 会插入到更之前执行。那么在微观上来看,这三个编译任务的执行顺序将是:_WalterlvDemoEvaluateProperties -> _WalterlvDemoIncludeSourceFiles -> CoreCompile

_WalterlvDemoIncludeSourceFiles 中,我们定义了一个集合 _WalterlvDemoCompile,集合中包含 NuGet 包源代码文件夹中的所有 .cs 文件。另外,我们又定义了 Compile 集合,将 _WalterlvDemoCompile 集合中的所有内容添加到 Compile 集合中。Compile 是 .NET 项目中的一个已知集合,当 CoreCompile 执行时,所有 Compile 集合中的文件将参与编译。注意到我没有直接将 NuGet 包中的源代码文件引入到 Compile 集合中,而是经过了中转。后面第五步中,你将体会到这样做的作用。

我们也添加一个 Message 任务,用于在编译期间显示信息,便于调试。

<Project>

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

  <Target Name="_WalterlvDemoEvaluateProperties">
    <PropertyGroup>
      <_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
      <_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
    </PropertyGroup>
    <Message Text="1. 初始化源代码包的编译属性" />
  </Target>

  <!-- 引入 C# 源码。 -->
  <Target Name="_WalterlvDemoIncludeSourceFiles"
          BeforeTargets="CoreCompile"
          DependsOnTargets="_WalterlvDemoEvaluateProperties">
    <ItemGroup>
      <_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
      <Compile Include="@(_WalterlvDemoCompile)" />
    </ItemGroup>
    <Message Text="2 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
  </Target>

</Project>

这四个文件分别的作用

我们刚刚花了很大的篇幅教大家完成 props 和 targets 文件,那么这四个文件是做什么的呢?

如果安装我们源代码包的项目使用 TargetFramework 属性写目标框架,那么 NuGet 会自动帮我们导入 build 文件夹中的两个编译文件。如果安装我们源代码包的项目使用 TargetFrameworks(注意复数形式)属性写目标框架,那么 NuGet 会自动帮我们导入 buildMultiTargeting 文件夹中的两个编译文件。

如果你对这个属性不熟悉,请回到第一步看我们一开始创建的代码,你会看到这个属性的设置的。如果还不清楚,请阅读博客:

体验和查看 NuGet 源代码包

也许你已经从本文拷贝了很多代码过去了,但直到目前我们还没有看到这些代码的任何效果,那么现在我们就可以来看看了。这可算是一个阶段性成果呢!

先编译生成一下我们一直在完善的项目,我们就可以在解决方案目录的 bin\Debug 目录下找到一个 NuGet 包。

生成项目

生成的 NuGet 包

现在,我们要打开这个 NuGet 包看看里面的内容。你需要先去应用商店下载 NuGet Package Explorer,装完之后你就可以开始直接双击 NuGet 包文件,也就是 nupkg 文件。现在我们双击打开看看。

NuGet 包中的内容

我们的体验到此为止。如果你希望在真实的项目当中测试,可以阅读其他博客了解如何在本地测试 NuGet 包。

第五步:加入 WPF 项目支持

截至目前,我们只是在源代码包中引入了 C# 代码。如果我们需要加入到源代码包中的代码包含 WPF 的 XAML 文件,或者安装我们源代码包的目标项目包含 WPF 的 XAML 文件,那么这个 NuGet 源代码包直接会导致无法编译通过。至于原因,你需要阅读我的另一篇博客来了解:

即便你不懂 WPF 程序的编译过程,你也可以继续完成本文的所有内容,但可能就不会明白为什么接下来我们要那样去修改我们之前创建的文件。

接下来我们将修改这些文件:

  • build 文件夹中的 Package.props 文件
  • build 文件夹中的 Package.targets 文件

build 文件夹中的 Package.props 文件

在这个文件中,我们将新增一个属性 ShouldFixNuGetImportingBugForWpfProjects。这是我取的名字,意为“是否应该修复 WPF 项目中 NuGet 包自动导入的问题”。

我做一个开关的原因是怀疑我们需要针对 WPF 项目进行特殊处理是 WPF 项目自身的 Bug,如果将来 WPF 修复了这个 Bug,那么我们将可以直接通过此开关来关闭我们在这一节做的特殊处理。另外,后面我们将采用一些特别的手段来调试我们的 NuGet 源代码包,在调试项目中我们也会将这个属性设置为 False 以关闭 WPF 项目的特殊处理。

    <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>

build 文件夹中的 Package.targets 文件

请按照下面的差异说明来修改你的 Package.targets 文件。实际上我们几乎删除任何代码,所以其实你可以将下面的所有内容作为你的新的 Package.targets 中的内容。

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

++    <PropertyGroup>
++      <!-- 我们增加了一个属性,用于处理 WPF 特殊项目的源代码之前,确保我们已经收集到所有需要引入的源代码。 -->
++      <_WalterlvDemoImportInWpfTempProjectDependsOn>_WalterlvDemoIncludeSourceFiles</_WalterlvDemoImportInWpfTempProjectDependsOn>
++    </PropertyGroup>
    
      <Target Name="_WalterlvDemoEvaluateProperties">
        <PropertyGroup>
          <_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
          <_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
        </PropertyGroup>
        <Message Text="1. 初始化源代码包的编译属性" />
      </Target>
    
      <!-- 引入 C# 源码。 -->
      <Target Name="_WalterlvDemoIncludeSourceFiles"
              BeforeTargets="CoreCompile"
              DependsOnTargets="_WalterlvDemoEvaluateProperties">
        <ItemGroup>
          <_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
++        <_WalterlvDemoAllCompile Include="@(_WalterlvDemoCompile)" />
          <Compile Include="@(_WalterlvDemoCompile)" />
        </ItemGroup>
--      <Message Text="2 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
++      <Message Text="2.1 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
      </Target>
    
++    <!-- 引入 WPF 源码。 -->
++    <Target Name="_WalterlvDemoIncludeWpfFiles"
++            BeforeTargets="MarkupCompilePass1"
++            DependsOnTargets="_WalterlvDemoEvaluateProperties">
++      <ItemGroup>
++        <_WalterlvDemoPage Include="$(_WalterlvDemoSourceFolder)**\*.xaml" />
++        <Page Include="@(_WalterlvDemoPage)" Link="%(_WalterlvDemoPage.FileName).xaml" />
++      </ItemGroup>
++      <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoPage)" />
++    </Target>

++    <!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
++         然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
++         WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
++         所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
++    <Target Name="_WalterlvDemoImportInWpfTempProject"
++            AfterTargets="MarkupCompilePass1"
++            BeforeTargets="GenerateTemporaryTargetAssembly"
++            DependsOnTargets="$(_WalterlvDemoImportInWpfTempProjectDependsOn)"
++            Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == 'True' ">
++      <ItemGroup>
++        <_GeneratedCodeFiles Include="@(_WalterlvDemoAllCompile)" />
++      </ItemGroup>
++      <Message Text="3. 正在欺骗临时项目,误以为此 NuGet 包中的文件是 XAML 编译后的中间代码:@(_WalterlvDemoAllCompile)" />
++    </Target>

    </Project>

我们增加了 _WalterlvDemoImportInWpfTempProjectDependsOn 属性,这个属性里面将填写一个到多个编译目标(Target)的名称(多个用分号分隔),用于告知 _WalterlvDemoImportInWpfTempProject 这个编译目标在执行之前必须确保执行的依赖编译目标。而我们目前的依赖目标只有一个,就是 _WalterlvDemoIncludeSourceFiles 这个引入 C# 源代码的编译目标。如果你有其他考虑有引入更多 C# 源代码的编译目标,则需要把他们都加上(当然本文是不需要的)。为此,我还新增了一个 _WalterlvDemoAllCompile 集合,如果存在多个依赖的编译目标会引入 C# 源代码,则需要像 _WalterlvDemoIncludeSourceFiles 一样,将他们都加入到 Compile 的同时也加入到 _WalterlvDemoAllCompile 集合中。

为什么可能有多个引入 C# 源代码的编译目标?因为本文我们只考虑了引入我们提前准备好的源代码放入源代码包中,而我们提到过可能涉及到动态生成 C# 源代码的需求。如果你有一两个编译目标会动态生成一些 C# 源代码并将其加入到 Compile 集合中,那么请将这个编译目标的名称加入到 _WalterlvDemoImportInWpfTempProjectDependsOn 属性(注意多个用分号分隔),同时将集合也引入一份到 _WalterlvDemoAllCompile 中。

_WalterlvDemoIncludeWpfFiles 这个编译目标的作用是引入 WPF 的 XAML 文件,这很容易理解,毕竟我们的源代码中包含 WPF 相关的文件。

请特别注意

  1. 我们加了一个 Link 属性,并且将其指定为 %(_WalterlvDemoPage.FileName).xaml。这意味着我们会把所有的 XAML 文件都当作在项目根目录中生成,如果你在其他的项目中用到了相对或绝对的 XAML 文件的路径,这显然会改变路径。但是,我们没有其他的方法来根据 XAML 文件所在的目录层级来自定指定 Link 属性让其在正确的层级上,所以这里才写死在根目录中。
    • 如果要解决这个问题,我们就需要在生成 NuGet 包之前生成此项目中所有 XAML 文件的正确的 Link 属性(例如改为 Views\%(_WalterlvDemoPage.FileName).xaml),这意味着需要在此项目编译期间执行一段代码,把 Package.targets 文件中为所有的 XAML 文件生成正确的 Link 属性。本文暂时不考虑这个问题,但你可以参考 dotnet-campus/SourceYard 项目来了解如何动态生成 Link
  2. 我们使用了 _WalterlvDemoPage 集合中转地存了 XAML 文件,这是必要的。因为这样才能正确通过 % 符号获取到 FileName 属性。

_WalterlvDemoImportInWpfTempProject 这个编译目标就不那么好理解了,而这个也是完美支持 WPF 项目源代码包的关键编译目标!这个编译目标指定在 MarkupCompilePass1 之后,GenerateTemporaryTargetAssembly 之前执行。GenerateTemporaryTargetAssembly 编译目标的作用是生成一个临时的项目,用于让 WPF 的 XAML 文件能够依赖同项目的 .NET 类型而编译。然而此临时项目编译期间是不会导入任何 NuGet 的 props 或 targets 文件的,这意味着我们特别添加的所有 C# 源代码在这个临时项目当中都是不存在的——如果项目使用到了我们源代码包中的源代码,那么必然因为类型不存在而无法编译通过——临时项目没有编译通过,那么整个项目也就无法编译通过。但是,我们通过在 MarkupCompilePass1GenerateTemporaryTargetAssembly 之间将我们源代码包中的所有源代码加入到 _GeneratedCodeFiles 集合中,即可将这些文件加入到临时项目中一起编译。而原本 _GeneratedCodeFiles 集合中是什么呢?就是大家熟悉的 XAML 转换而成的 xxx.g.cs 文件。

测试和发布源代码包

现在我们再次编译这个项目,你将得到一个支持 WPF 项目的 NuGet 源代码包。

完整项目结构和源代码

至此,我们已经完成了编写一个 NuGet 源代码包所需的全部源码。接下来你可以在项目中添加更多的源代码,这样打出来的源代码包也将包含更多源代码。由于我们将将 XAML 文件都通过 Link 属性指定到根目录了,所以如果你需要添加 XAML 文件,你将只能添加到我们项目中的 Assets\src 目录下,除非做 dotnet-campus/SourceYard 中类似的动态 Link 生成的处理,或者在 Package.targets 文件中手工为每一个 XAML 编写一个特别的 Link 属性。

另外,在不改变我们整体项目结构的情况下,你也可以任意添加 WPF 所需的图片资源等。但也需要在 Package.targets 中添加额外的 Resource 引用。如果没有 dotnet-campus/SourceYard 的自动生成代码,你可能也需要手工编写 Resource

接下来我会贴出更复杂的代码,用于处理更复杂的源代码包的场景。

目录结构

更复杂源代码包的项目组织形式会是下面这样图这样:

更复杂的源代码包项目结构

我们在 Assets 文件夹中新增了一个 assets 文件夹。由于资源在此项目中的路径必须和安装后的目标项目中一样才可以正确用 Uri 的方式使用资源,所以我们在项目文件 csproj 和编译文件 Package.targets 中都对这两个文件设置了 Link 到同一个文件夹中,这样才可以确保两边都能正常使用。

我们在 src 文件夹的不同子文件夹中创建了 XAML 文件。按照我们前面的说法,我们也需要像资源文件一样正确在 Package.targets 中设置 Link 才可以确保 Uri 是一致的。注意,我们接下来的源代码中没有在项目文件中设置 Link,原则上也是需要设置的,就像资源一样,这样才可以确保此项目和安装此 NuGet 包中的目标项目具有相同的 XAML Uri。此例子只是因为没有代码使用到了 XAML 文件的路径,所以才能得以幸免。

我们还利用了 tools 文件夹。我们在项目文件的末尾将输出文件拷贝到了 tools 目录下,这样,我们项目的 Assets 文件夹几乎与最终的 NuGet 包的文件夹结构一模一样,非常利于调试。但为了防止将生成的文件上传到版本管理,我在 tools 中添加了 .gitignore 文件:

/net*/*

项目文件

--  <Project Sdk="Microsoft.NET.Sdk">
++  <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net48</TargetFramework>
++      <UseWpf>True</UseWpf>
    
        <!-- 要求此项目编译时要生成一个 NuGet 包。-->
        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    
        <!-- 这里为了方便,我将 NuGet 包的输出路径设置在了解决方案根目录的 bin 文件夹下,而不是项目的 bin 文件夹下。-->
        <PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>
    
        <!-- 创建 NuGet 包时,项目的输出文件对应到 NuGet 包的 tools 文件夹,这可以避免目标项目引用我们的 NuGet 包的输出文件。
             同时,如果将来我们准备动态生成源代码,而不只是引入静态源代码,还可以有机会运行我们 Program 中的 Main 函数。-->
        <BuildOutputTargetFolder>tools</BuildOutputTargetFolder>
    
        <!-- 此包将不会传递依赖。意味着如果目标项目安装了此 NuGet 包,那么安装目标项目包的项目不会间接安装此 NuGet 包。-->
        <DevelopmentDependency>true</DevelopmentDependency>
    
        <!-- 包的版本号,我们设成了一个预览版;当然你也可以设置为正式版,即没有后面的 -alpha 后缀。-->
        <Version>0.1.0-alpha</Version>
    
        <!-- 设置包的作者。在上传到 nuget.org 之后,如果作者名与 nuget.org 上的账号名相同,其他人浏览包是可以直接点击链接看作者页面。-->
        <Authors>walterlv</Authors>
    
        <!-- 设置包的组织名称。我当然写成我所在的组织 dotnet 职业技术学院啦。-->
        <Company>dotnet-campus</Company>
      </PropertyGroup>
    
++    <!-- 我们添加的其他资源需要在这里 Link 到一个统一的目录下,以便在此项目和安装 NuGet 包的目标项目中可以用同样的 Uri 使用。 -->
++    <ItemGroup>
++      <Resource Include="Assets\assets\Icon.ico" Link="Assets\Icon.ico" Visible="False" />
++      <Resource Include="Assets\assets\Background.png" Link="Assets\Background.png" Visible="False" />
++    </ItemGroup>
      
      <!-- 在生成 NuGet 包之前,我们需要将我们项目中的文件夹结构一一映射到 NuGet 包中。-->
      <Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
        <ItemGroup>
    
          <!-- 将 Package.props / Package.targets 文件的名称在 NuGet 包中改为需要的真正名称。
               因为 NuGet 包要自动导入 props 和 targets 文件,要求文件的名称必须是 包名.props 和 包名.targets;
               然而为了避免我们改包名的时候还要同步改四个文件的名称,所以就在项目文件中动态生成。-->
          <None Include="Assets\build\Package.props" Pack="True" PackagePath="build\$(PackageId).props" />
          <None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
          <None Include="Assets\buildMultiTargeting\Package.props" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).props" />
          <None Include="Assets\buildMultiTargeting\Package.targets" Pack="True" PackagePath="buildMultiTargeting\$(PackageId).targets" />
    
          <!-- 我们将 src 目录中的所有源代码映射到 NuGet 包中的 src 目录中。-->
          <None Include="Assets\src\**" Pack="True" PackagePath="src" />

++        <!-- 我们将 assets 目录中的所有源代码映射到 NuGet 包中的 assets 目录中。-->
++        <None Include="Assets\assets\**" Pack="True" PackagePath="assets" />
    
        </ItemGroup>
      </Target>
    
++    <!-- 在编译结束后将生成的可执行程序放到 Tools 文件夹中,使得 Assets 文件夹的目录结构与 NuGet 包非常相似,便于 Sample 项目进行及时的 NuGet 包调试。 -->
++    <Target Name="_WalterlvDemoCopyOutputToDebuggableFolder" AfterTargets="AfterBuild">
++        <ItemGroup>
++        <_WalterlvDemoToCopiedFiles Include="$(OutputPath)**" />
++        </ItemGroup>
++        <Copy SourceFiles="@(_WalterlvDemoToCopiedFiles)" DestinationFolder="Assets\tools\$(TargetFramework)" />
++    </Target>

    </Project>

编译文件

    <Project>
    
      <PropertyGroup>
        <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
      </PropertyGroup>
    
      <PropertyGroup>
        <!-- 我们增加了一个属性,用于处理 WPF 特殊项目的源代码之前,确保我们已经收集到所有需要引入的源代码。 -->
        <_WalterlvDemoImportInWpfTempProjectDependsOn>_WalterlvDemoIncludeSourceFiles</_WalterlvDemoImportInWpfTempProjectDependsOn>
      </PropertyGroup>
      
      <Target Name="_WalterlvDemoEvaluateProperties">
        <PropertyGroup>
          <_WalterlvDemoRoot>$(MSBuildThisFileDirectory)..\</_WalterlvDemoRoot>
          <_WalterlvDemoSourceFolder>$(MSBuildThisFileDirectory)..\src\</_WalterlvDemoSourceFolder>
        </PropertyGroup>
        <Message Text="1. 初始化源代码包的编译属性" />
      </Target>
    
      <!-- 引入主要的 C# 源码。 -->
      <Target Name="_WalterlvDemoIncludeSourceFiles"
              BeforeTargets="CoreCompile"
              DependsOnTargets="_WalterlvDemoEvaluateProperties">
        <ItemGroup>
          <_WalterlvDemoCompile Include="$(_WalterlvDemoSourceFolder)**\*.cs" />
          <_WalterlvDemoAllCompile Include="@(_WalterlvDemoCompile)" />
          <Compile Include="@(_WalterlvDemoCompile)" />
        </ItemGroup>
        <Message Text="2.1 引入源代码包中的所有源代码:@(_WalterlvDemoCompile)" />
      </Target>
    
      <!-- 引入 WPF 源码。 -->
      <Target Name="_WalterlvDemoIncludeWpfFiles"
              BeforeTargets="MarkupCompilePass1"
              DependsOnTargets="_WalterlvDemoEvaluateProperties">
        <ItemGroup>
--        <_WalterlvDemoPage Include="$(_WalterlvDemoSourceFolder)**\*.xaml" />
--        <Page Include="@(_WalterlvDemoPage)" Link="Views\%(_WalterlvDemoPage.FileName).xaml" />
++        <_WalterlvDemoRootPage Include="$(_WalterlvDemoSourceFolder)FooView.xaml" />
++        <Page Include="@(_WalterlvDemoRootPage)" Link="Views\%(_WalterlvDemoRootPage.FileName).xaml" />
++        <_WalterlvDemoThemesPage Include="$(_WalterlvDemoSourceFolder)Themes\Walterlv.Windows.xaml" />
++        <Page Include="@(_WalterlvDemoThemesPage)" Link="Views\%(_WalterlvDemoThemesPage.FileName).xaml" />
++        <_WalterlvDemoIcoResource Include="$(_WalterlvDemoRoot)assets\*.ico" />
++        <_WalterlvDemoPngResource Include="$(_WalterlvDemoRoot)assets\*.png" />
++        <Resource Include="@(_WalterlvDemoIcoResource)" Link="assets\%(_WalterlvDemoIcoResource.FileName).ico" />
++        <Resource Include="@(_WalterlvDemoPngResource)" Link="assets\%(_WalterlvDemoPngResource.FileName).png" />
        </ItemGroup>
--      <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoPage);@(_WalterlvDemoIcoResource);@(_WalterlvDemoPngResource)" />
++      <Message Text="2.2 引用 WPF 相关源码:@(_WalterlvDemoRootPage);@(_WalterlvDemoThemesPage);@(_WalterlvDemoIcoResource);@(_WalterlvDemoPngResource)" />
      </Target>
    
      <!-- 当生成 WPF 临时项目时,不会自动 Import NuGet 中的 props 和 targets 文件,这使得在临时项目中你现在看到的整个文件都不会参与编译。
           然而,我们可以通过欺骗的方式在主项目中通过 _GeneratedCodeFiles 集合将需要编译的文件传递到临时项目中以间接参与编译。
           WPF 临时项目不会 Import NuGet 中的 props 和 targets 可能是 WPF 的 Bug,也可能是刻意如此。
           所以我们通过一个属性开关 `ShouldFixNuGetImportingBugForWpfProjects` 来决定是否修复这个错误。-->
      <Target Name="_WalterlvDemoImportInWpfTempProject"
              AfterTargets="MarkupCompilePass1"
              BeforeTargets="GenerateTemporaryTargetAssembly"
              DependsOnTargets="$(_WalterlvDemoImportInWpfTempProjectDependsOn)"
              Condition=" '$(ShouldFixNuGetImportingBugForWpfProjects)' == 'True' ">
        <ItemGroup>
          <_GeneratedCodeFiles Include="@(_WalterlvDemoAllCompile)" />
        </ItemGroup>
        <Message Text="3. 正在欺骗临时项目,误以为此 NuGet 包中的文件是 XAML 编译后的中间代码:@(_WalterlvDemoAllCompile)" />
      </Target>
    
    </Project>

开源项目

本文涉及到的所有代码均已开源到:

更多内容

SourceYard 开源项目

本文服务于开源项目 SourceYard,为其提供支持 WPF 项目的解决方案。dotnet-campus/SourceYard: Add a NuGet package only for dll reference? By using dotnetCampus.SourceYard, you can pack a NuGet package with source code. By installing the new source code package, all source codes behaviors just like it is in your project.

相关博客

更多制作源代码包的博客可以阅读。从简单到复杂的顺序:

06-13 2019

.NET 的程序集加载上下文

我们编写的 .NET 应用程序会使用到各种各样的依赖库。我们都知道 CLR 会在一些路径下帮助我们程序找到依赖,但如果我们需要手动控制程序集加载路径的话,需要了解程序集加载上下文。

如果你不了解程序集加载上下文,你可能会发现你加载了程序集却不能使用其中的类型;或者把同一个程序集加载了两次,导致使用到两个明明是一样的类型时却抛出异常提示不是同一个类型的问题。


程序集加载上下文

当你向应用程序域中加载一个程序集时,可能会加载到以下四种不同的上下文中的一种:

  1. 默认加载上下文(the Default Load Context)
  2. 加载位置加载上下文(the Load-From Context)
  3. 仅反射上下文(the Reflection-Only Context)
  4. 无上下文

你需要了解这些加载上下文,因为跨不同加载上下文加载的程序集是不能访问其中的类型的。

默认加载上下文

  • 在全局程序集缓存中发现的类型会加载到默认加载上下文中
  • 位于应用程序探测路径中的程序集会加载到默认加载上下文中,这包括了 ApplicationBasePrivateBinPath 目录中发现的程序集
  • Assembly.Load 方法的大多数重载都将程序集加载到此上下文中

ApplicationBasePrivateBinPath 这两个属性虽然允许被设置,但它们只对新生成的 AppDomain 生效,直接设置当前 AppDomain 中这两个属性的值并不会产生任何效果。

虽然我们不能直接设置这两个属性,但可以在应用程序的 App.config 文件这配置 configuration -> runtime -> assemblyBinding -> probing.privatePath 属性来设置多个应用程序执行时的依赖探测路径。

将程序集加载到默认加载上下文中时,会自动加载其依赖项。

使用默认加载上下文时,加载到其他上下文中的依赖项将不可用,并且不能将位于探测路径外部位置的程序集加载到默认加载上下文中。

加载位置上下文

当使用 Assembly.LoadFrom 方法加载程序集时,程序集会加载到加载位置上下文中。

如果程序集包含依赖,也会自动从加载位置上下文中加载依赖。另外,在加载位置上下文中加载的程序集,可以使用到默认加载上下文中的依赖;注意,反过来却不成立!

加载位置上下文的使用需要谨慎,因为它会产生一些可能让你感觉到意外的行为。以下意外的行为列表照抄自文档 Best Practices for Assembly Loading

  • 如果已加载一个具有相同标识的程序集,则即使指定了不同的路径,LoadFrom 仍返回已加载的程序集。
  • 如果用 LoadFrom 加载一个程序集,随后默认加载上下文中的一个程序集尝试按显示名称加载同一程序集,则加载尝试将失败。 对程序集进行反序列化时,可能发生这种情况。
  • 如果用 LoadFrom 加载一个程序集,并且探测路径包括一个具有相同标识但位置不同的程序集,则将发生 InvalidCastException、MissingMethodException 或其他意外行为。
  • LoadFrom 需要对指定路径的 FileIOPermissionAccess.Read 和 FileIOPermissionAccess.PathDiscovery 或 WebPermission。

无上下文

使用反射发出生成的瞬态程序集只能选择在没有下文的情况下进行加载。在没有上下文的情况下进行加载是将具有同一标识的多个程序集加载到一个应用程序域中的唯一方式。这将省去探测成本。

从字节数组加载的程序集都是在没有上下文的情况下加载的,除非程序集的标识(在应用策略后建立)与全局程序集缓存中的程序集标识匹配;在此情况下,将会从全局程序集缓存加载程序集。

在没有上下文的情况下加载程序集具有以下缺点,以下摘抄自 Best Practices for Assembly Loading

  • 无法将其他程序集绑定到在没有上下文的情况下加载的程序集,除非处理 AppDomain.AssemblyResolve 事件。
  • 依赖项无法自动加载。 可以在没有上下文的情况下预加载依赖项、将依赖项预加载到默认加载上下文中或通过处理 AppDomain.AssemblyResolve 事件来加载依赖项。
  • 在没有上下文的情况下加载具有同一标识的多个程序集会导致出现类型标识问题,这些问题与将具有同一标识的多个程序集加载到多个上下文中所导致的问题类似。 请参阅避免将一个程序集加载到多个上下文中。

带来的问题

.NET 加载程序集的这种机制可能让你的程序陷入一点点坑:你可以让你的程序加载任意路径下的一个程序集(dll/exe),并且可以执行其中的代码,但你不能依赖那些路径中程序集的特定类型或接口等。

具体一点,比如你定义了一个接口 IPlugin,任意路径中的程序集可以实现这个接口,你加载这个程序集之后也可以通过 IPlugin 接口调用到程序集中的方法,因为这个接口的定义所在的程序集依然在你的探测路径中,而不是在插件程序集中。位于任意路径下的插件程序集可以访问到位于探测路径中所有程序集的所有 API,但反过来探测路径下的程序集不能访问到其他目录下插件程序集的特定类型或接口等。但是,如果这个程序集中有一些特定的类型如 WalterlvPlugin,那么你将不能依赖于这个特定的类型。

我创建了一个控制台程序,用以说明这样的加载上下文机制将带来问题。相关代码可以在我的 GitHub 仓库中找到:

其中 Program.cs 文件如下:

using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;

namespace Walterlv.Demo.AssemblyLoading
{
    class Program
    {
        static async Task Main(string[] args)
        {
            await LoadDependencyAssembliesAsync();
            await RunAsync();
            Console.ReadLine();
        }

        private static async Task RunAsync()
        {
            try
            {
                await ThrowAsync();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Demystify());
            }

            async Task ThrowAsync() => throw new InvalidOperationException();
        }

        private static async Task LoadDependencyAssembliesAsync()
        {
            var folder = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Dependencies");
            Assembly.LoadFile(Path.Combine(folder, "Ben.Demystifier.dll"));
            Assembly.LoadFile(Path.Combine(folder, "System.Collections.Immutable.dll"));
            Assembly.LoadFile(Path.Combine(folder, "System.Reflection.Metadata.dll"));
        }
    }
}

项目文件 csproj 文件如下:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Ben.Demystifier" Version="0.1.4" />
  </ItemGroup>
  <Target Name="_ProjectMoveDependencies" AfterTargets="AfterBuild">
    <ItemGroup>
      <_ProjectToMoveFile Include="$(OutputPath)Ben.Demystifier.dll" />
      <_ProjectToMoveFile Include="$(OutputPath)System.Collections.Immutable.dll" />
      <_ProjectToMoveFile Include="$(OutputPath)System.Reflection.Metadata.dll" />
    </ItemGroup>
    <Move SourceFiles="@(_ProjectToMoveFile)" DestinationFolder="$(OutputPath)Dependencies" />
  </Target>
  
</Project>

在这个程序中,我们引用了一个 NuGet 包 Ben.Demystifier。这个包具体是什么其实并不重要,我只是希望引入一个依赖而已。但是,在项目文件 csproj 中,我写了一个 Target,将这些依赖全部都移动到了 Dependencies 文件夹中。这样,我们就可以获得这样目录结构的输出:

- Walterlv.exe
- Dependencies
    - Ben.Demystifier.dll
    - System.Collections.Immutable.dll
    - System.Reflection.Metadata.dll

如果我们不进行其他设置,那么直接运行程序的话,应该是找不到依赖然后崩溃的。但是现在我们有 LoadDependencyAssembliesAsync 方法,里面通过 Assembly.LoadFile 加载了这三个程序集。但时机运行时依然会崩溃:

抛出异常

明明已经加载了这三个程序集,为什么使用其内部的类型的时候还会抛出异常呢?明明在 Visual Studio 中检查已加载的模块可以发现这些模块都已经加载完毕,但依然无法使用到里面的类型呢?

已加载模块

本文将介绍原因和解决办法。

解决方法

实际上 .NET 推荐的唯一解决方法是创建新的应用程序域来解决非探测路径下 dll 的依赖问题,在创建新应用程序域的时候设置此应用程序域的探测路径。

但是,我们其实有其他的方法依然在原来的应用程序域中解决依赖问题。

使用被遗弃的 API(不推荐)

AppDomain 有一个已经被遗弃的 API AppendPrivatePath,可以将一个路径加入到探测路径列表中。这样,我们不需要考虑去任意路径加载程序集的问题了,因为我们可以将任意路径设置成探测路径。

// 注意,这是一个被遗弃的 API。
AppDomain.CurrentDomain.AppendPrivatePath(folder);

关于此 API 为什么会被遗弃,你可以阅读微软的官方博客:Why is AppDomain.AppendPrivatePath Obsolete? - .NET Blog。因为你随时可以指定应用程序的探测路径,所以它可能让你的程序以各种不确定的方式加载程序集,于是你的程序将变得很不稳定;可能完全崩溃到你无法预知的程度。

另外,.NET Core 中已经不能使用此 API 了,这非常好!

使用 ILRepack / ILMerge 合并依赖

前面我们说过,加载位置上下文中的程序集可以依赖默认加载上下文中的程序集,而反过来却不行。通常默认加载上下文中的程序集是我们的主程序程序集和附属程序集,而加载位置上下文中加载的程序是插件程序集。

如果插件程序集依赖了一些主程序没有的依赖,那么插件可以考虑将所有的依赖合并入插件单个程序集中,避免依赖其他程序集,导致不得不去非探测路径加载程序集。

关于使用 ILRepack 合并依赖的内容,可以阅读我的另一篇博客:

首先推荐使用 ILRepack 来进行合并,如果你愿意,也可以使用 ILMerge:

使用 ILMerge 合并依赖


参考资料

06-11 2019

WPF 程序的编译过程

基于 Sdk 的项目进行编译的时候,会使用 Sdk 中附带的 props 文件和 targets 文件对项目进行编译。Microsoft.NET.Sdk.WindowsDesktop 的 Sdk 包含 WPF 项目的编译过程。

而本文介绍 WPF 项目的编译过程,包含 WPF 额外为编译过程添加的那些扩展编译目标,以及这些扩展的编译目标如何一步步完成 WPF 项目的过程。


提前准备

在阅读本文之前,你可能需要提前了解编译过程到底是怎样的。可以阅读:

如果你不明白上面文章中的一些术语(例如 Target / Task),可能不能理解本文后面的内容。

另外,除了本文所涉及的内容之外,你也可以自己探索编译过程:

WPF 的编译代码都在 Microsoft.WinFx.targets 文件中,你可以通过上面这一篇博客找到这个文件。接下来,我们会一一介绍这个文件里面的编译目标(Target),然后统一说明这些 Target 是如何协同工作,将 WPF 程序编译出来的。

Microsoft.WinFx.targets 的源码可以查看:

Target

WPF 在编译期间会执行以下这些 Target,当然 Target 里面实际用于执行任务的是 Task。

知道 Target 名称的话,你可以扩展 WPF 的编译过程;而知道 Task 名称的话,可以帮助理解编译过程实际做的事情。

本文都会列举出来。

FileClassification

  • Target 名称:FileClassification
  • Task 名称:FileClassifier

用于将资源嵌入到程序集。如果资源没有本地化,则嵌入到主程序集;如果有本地化,则嵌入到附属程序集。

在 WPF 项目中,这个 Target 是一定会执行的;但里面的 Task 则是有 Resource 类型的编译项的时候才会执行。

GenerateTemporaryTargetAssembly

Target 名称和 Task 名称相同,都是 GenerateTemporaryTargetAssembly

只要项目当中包含任何一个生成类型为 Page 的 XAML 文件,则会执行此 Target。

关于生成临时程序集的原因比较复杂,可以阅读本文后面的 WPF 程序的编译过程部分来了解。

MarkupCompilePass1

Target 名称和 Task 名称相同,都是 MarkupCompilePass1

将非本地化的 XAML 文件编译成二进制格式。

MarkupCompilePass2

Target 名称和 Task 名称相同,都是 MarkupCompilePass2

对 XAML 文件进行第二轮编译,而这一次会引用同一个程序集中的类型。

DesignTimeMarkupCompilation

这是一个仅在有设计器执行时才会执行的 Target,当这个编译目标执行时,将会直接调用 MarkupCompilePass1

实际上,如果在 Visual Studio 中编译项目,则会调用到这个 Target。而判断是否在 Visual Studio 中编译的方法可以参见:

<Target Name="DesignTimeMarkupCompilation">

    <!-- Only if we are not actually performing a compile i.e. we are in design mode -->
    <CallTarget Condition="'$(BuildingProject)' != 'true'"
                Targets="MarkupCompilePass1" />
</Target>

MergeLocalizationDirectives

Target 名称和 Task 名称相同,都是 MergeLocalizationDirectives

将本地化属性和一个或多个 XAML 二进制格式文件的注释合并到整个程序集的单一文件中。

<Target Name="MergeLocalizationDirectives"
        Condition="'@(GeneratedLocalizationFiles)' !=''"
        Inputs="@(GeneratedLocalizationFiles)"
        Outputs="$(IntermediateOutputPath)$(AssemblyName).loc"
>
    <MergeLocalizationDirectives GeneratedLocalizationFiles="@(GeneratedLocalizationFiles)"
                                OutputFile="$(IntermediateOutputPath)$(AssemblyName).loc"/>

    <!--
        Add the merged loc file into _NoneWithTargetPath so that it will be copied to the
        output directory
    -->
    <CreateItem Condition="Exists('$(IntermediateOutputPath)$(AssemblyName).loc')"
                Include="$(IntermediateOutputPath)$(AssemblyName).loc"
                AdditionalMetadata="CopyToOutputDirectory=PreserveNewest; TargetPath=$(AssemblyName).loc" >
        <Output ItemName="_NoneWithTargetPath" TaskParameter="Include"/>
        <Output ItemName="FileWrites" TaskParameter="Include"/>

    </CreateItem>

</Target>

MainResourcesGeneration、SatelliteResourceGeneration

  • Target 有两个,MainResourcesGenerationSatelliteResourceGeneration,分别负责主资源生成和附属资源生成。
  • Task 名称:ResourcesGenerator

将一个或多个资源(二进制格式的 .jpg、.ico、.bmp、XAML 以及其他扩展名类型)嵌入 .resources 文件中。

CheckUid、UpdateUid、RemoveUid

  • Target 有三个,CheckUidUpdateUidRemoveUid,分别负责主资源生成和附属资源生成。
  • Task 名称:ResourcesGenerator

检查、更新或移除 UID,以便将 XAML 文件中所有的 XAML 元素进行本地化。

<Target Name="CheckUid"
        Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">

    <UidManager MarkupFiles="@(Page);@(ApplicationDefinition)" Task="Check" />

</Target>
<Target Name="UpdateUid"
        Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">

    <UidManager MarkupFiles="@(Page);
                            @(ApplicationDefinition)"
                IntermediateDirectory ="$(IntermediateOutputPath)"
                Task="Update" />

</Target>
<Target Name="RemoveUid"
        Condition="'@(Page)' != '' or '@(ApplicationDefinition)' != ''">
    <UidManager MarkupFiles="@(Page);
                            @(ApplicationDefinition)"

                IntermediateDirectory ="$(IntermediateOutputPath)"
                Task="Remove" />

</Target>

UpdateManifestForBrowserApplication

当编译基于 XAML 的浏览器项目的时候,会给 manifest 文件中添加一个配置 <hostInBrowser />

WPF 程序的编译过程

编译过程图示

上面列举出来的那些 Target 主要是 WPF 几个关键的 Target,在实际编译时会有更多编译 Target 执行。另外有些也不在常规的编译过程中,而是被专门的编译过程执行。

WPF 程序的编译过程

图的阅读方法是这样的:

  1. 箭头代表依赖关系,如 CoreCompile 有一个指向 DesignTimeMarkupCompilation 的箭头,表示 CoreCompile 执行前会确保 DesignTimeMarkupCompilation 执行完毕;
  2. 如果一个 Target 有多个依赖,则这些依赖会按顺序执行还没执行的依赖,如 PrepareResources 指向了多个 Target MarkupCompilePass1GenerateTemporaryTargetAssemblyMarkupCompilePass2AfterMarkupCompilePass2CleanupTemporaryTargetAssembly,那么在 PrepareResources 执行之前,如果还有没有执行的依赖,会按顺序依次执行;
  3. WPF 所有的 Target 扩展都是通过依赖来指定的,也就是说必须基于现有的核心编译过程,图中从绿色或黄色的节点向前倒退的所有依赖都会被执行。

各种颜色代表的含义:

  • 蓝色,表示 WPF 扩展的 Target
  • 浅蓝色,表示 WPF 扩展的 Target,但是没有执行任何实际的任务,只是提供一个扩展点
  • 绿色,表示核心的编译过程,但是被 WPF 编译过程重写了
  • 黄色,表示核心的编译过程(即便不是 WPF 程序也会执行的 Target)
  • 浅黄色,表示在这张图里面不关心的 Target(不然整个画下来就太多了)
  • 紫色,仅在 Visual Studio 编译期间会执行的 WPF 扩展的 Target

编译过程描述

我们都知道 XAML 是可以引用 CLR 类型的;如果 XAML 所引用的 CLR 类型在其他被引用的程序集,那么编译 XAML 的时候就可以直接引用这些程序集,因为他们已经编译好了。

但是我们也知道,XAML 还能引用同一个程序集中的 CLR 类型,而此时这个程序集还没有编译,XAML 编译过程并不知道可以如何使用这些类型。同时我们也知道 CLR 类型可是使用 XAML 生成的类型,如果 XAML 没有编译,那么 CLR 类型也无法正常完成编译。这是矛盾的,这也是 WPF 扩展的编译过程会比较复杂的原因之一。

WPF 编译过程有两个编译传递,MarkupCompilePass1MarkupCompilePass2

MarkupCompilePass1 的作用是将 XAML 编译成二进制格式。如果 XAML 文件包含 x:Class 属性,那么就会根据语言生成一份代码文件;对于 C# 语言,会生成“文件名.g.cs”文件。但是 XAML 文件中也有可能包含对同一个程序集中的 CLR 类型的引用,然而这一编译阶段 CLR 类型还没有开始编译,因此无法提供程序集引用。所以如果这个 XAML 文件包含对同一个程序集中 CLR 类型的引用,则这个编译会被推迟到 MarkupCompilePass2 中继续。而在 MarkupCompilePass1MarkupCompilePass2 之间,则插入了 GenerateTemporaryTargetAssembly 这个编译目标。

GenerateTemporaryTargetAssembly 的作用是生成一个临时的程序集,这个临时的程序集中包含了 MarkupCompilePass1 推迟到 MarkupCompilePass2 中编译时需要的 CLR 类型。这样,在 MarkupCompilePass2 执行的时候,会获得一个包含原本在统一程序集的 CLR 类型的临时程序集引用,这样就可以继续完成 XAML 格式的编译了。在 MarkupCompilePass2 编译完成之后,XAML 文件就完全编译完成了。之后,会执行 CleanupTemporaryTargetAssembly 清除之前临时编译的程序集。

编译临时程序集时,会生成一个新的项目文件,名字如:$(项目名)_$(随机字符)_wpftmp.csproj,在与原项目相同的目录下。

在需要编译一个临时程序集的时候,CoreCompile 这样的用于编译 C# 代码文件的编译目标会执行两次,第一次是编译这个临时生成的项目,而第二次才是编译原本的项目。

现在,我们看一段 WPF 程序的编译输出,可以看到看到这个生成临时程序集的过程。

生成临时项目和程序集

随后,就是正常的其他的编译过程。

关于临时生成程序集

在 WPF 的编译过程中,我想单独将临时生成程序集的部分进行特别说明。因为如果你不了解这一部分的细节,可能在未来的使用中遇到一些临时生成程序集相关的坑。

下面这几篇博客就是在讨论其中的一些坑:

我需要摘抄生成临时程序集的一部分源码:

<PropertyGroup>
    <_CompileTargetNameForLocalType Condition="'$(_CompileTargetNameForLocalType)' == ''">_CompileTemporaryAssembly</_CompileTargetNameForLocalType>
</PropertyGroup>

<Target Name="_CompileTemporaryAssembly"  DependsOnTargets="BuildOnlySettings;ResolveKeySource;CoreCompile" />

<Target Name="GenerateTemporaryTargetAssembly"
        Condition="'$(_RequireMCPass2ForMainAssembly)' == 'true' " >

    <Message Text="MSBuildProjectFile is $(MSBuildProjectFile)" Condition="'$(MSBuildTargetsVerbose)' == 'true'" />

    <GenerateTemporaryTargetAssembly
            CurrentProject="$(MSBuildProjectFullPath)"
            MSBuildBinPath="$(MSBuildBinPath)"
            ReferencePathTypeName="ReferencePath"
            CompileTypeName="Compile"
            GeneratedCodeFiles="@(_GeneratedCodeFiles)"
            ReferencePath="@(ReferencePath)"
            IntermediateOutputPath="$(IntermediateOutputPath)"
            AssemblyName="$(AssemblyName)"
            CompileTargetName="$(_CompileTargetNameForLocalType)"
            GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
            >

    </GenerateTemporaryTargetAssembly>

    <CreateItem Include="$(IntermediateOutputPath)$(TargetFileName)" >
        <Output TaskParameter="Include" ItemName="AssemblyForLocalTypeReference" />
    </CreateItem>

</Target>

我们需要关注这些点:

  1. 生成临时程序集时,会调用一个编译目标(Target),这个编译目标的名称由 _CompileTargetNameForLocalType 这个私有属性来决定;
  2. _CompileTargetNameForLocalType 没有指定时,会设置其默认值为 _CompileTemporaryAssembly 这个编译目标;
  3. _CompileTemporaryAssembly 这个编译目标执行时,仅会执行三个依赖的编译目标,BuildOnlySettingsResolveKeySourceCoreCompile,至于这些依赖目标所依赖的其他编译目标,则会根据新生成的项目文件动态计算。
  4. 生成临时程序集和临时程序集的编译过程并不在同一个编译上下文中,这也是为什么只能通过传递名称 _CompileTargetNameForLocalType 来执行,而不能直接调用这个编译目标或者设置编译目标的依赖。

新生成的临时项目文件相比于原来的项目文件,包含了这些修改:

  1. 添加了第一轮 XAML 编译传递(MarkupCompilePass1)时生成的 .g.cs 文件;
  2. 将所有引用方式收集到的引用全部换成 ReferencePath,这样就可以避免临时项目编译期间再执行一次 ResolveAssemblyReference 编译目标来收集引用,避免降低太多性能。

关于引用换成 ReferencePath 的内容,可以阅读我的另一篇博客了解更多:

在使用 ReferencePath 的情况下,无论是项目引用还是 NuGet 包引用,都会被换成普通的 dll 引用,因为这个时候目标项目都已经编译完成,包含可以被引用的程序集。

以下是我在示例程序中抓取到的临时生成的项目文件的内容,与原始项目文件之间的差异:

    <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
            <UseWPF>true</UseWPF>
            <GenerateTemporaryTargetAssemblyDebuggingInformation>True</GenerateTemporaryTargetAssemblyDebuggingInformation>
        </PropertyGroup>
        <ItemGroup>
            <PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
        </ItemGroup>
++      <ItemGroup>
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\mscorlib.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationCore.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationFramework.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Core.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Data.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compression.FileSystem.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Numerics.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Runtime.Serialization.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Windows.Controls.Ribbon.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xaml.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.Linq.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClient.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClientsideProviders.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\WindowsBase.dll" />
++      </ItemGroup>
++      <ItemGroup>
++          <Compile Include="D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs" />
++      </ItemGroup>
    </Project>

你可能已经注意到了我在项目中设置了 GenerateTemporaryTargetAssemblyDebuggingInformation 属性,这个属性可以让 WPF 临时生成的项目文件保留下来,便于进行研究和调试。在前面 GenerateTemporaryTargetAssembly 的源码部分我们已经贴出了这个属性使用的源码,只是前面我们没有说明其用途。

注意,虽然新生成的项目文件中有 PackageReference 来表示包引用,但由于只有 _CompileTargetNameForLocalType 指定的编译目标和相关依赖可以被执行,而 NuGet 包中自动 Import 的部分没有加入到依赖项中,所以实际上包中的 .props.targets 文件都不会被 Import 进来,这可能造成部分 NuGet 包在 WPF 项目中不能正常工作。比如下面这个:

更典型的,就是 SourceYard 项目,这个 Bug 给 SourceYard 造成了不小的困扰:


参考资料

06-11 2019

制作通过 NuGet 分发的源代码包时,如果目标项目是 WPF 则会出现一些问题(探索篇,含解决方案)

在使用 NuGet 包来分发源代码时,如果目标项目是 WPF 项目,那么会有一大堆的问题。

本文将这些问题列举出来并进行分析。


源代码包

源代码包不是 NuGet 官方的概念,而是林德熙和我在 GitHub 上做的一个项目,目的是将你的项目以源代码的形式发布成 NuGet 包。在安装此 NuGet 包后,目标项目将获得这些源代码。

你可以通过以下博客了解如何制作一个源代码包。

这可以避免因为安装 NuGet 包后带来的大量程序集引用,因为程序集数量太多对程序的启动性能有很大的影响:

然而制作一个 NuGet 的坑很多,详见:

基础代码:最小的例子

为了让 NuGet 源代码包对 WPF 项目问题暴露得更彻底一些,我们需要一个最简单的例子来说明这一问题。我将它放在了我的 Demo 项目中:

但为了让博客理解起来更顺畅,我还是将关键的源代码贴出来。

用于打源代码包的项目 Walterlv.SourceYard.Demo

为了尽可能避免其他因素的影响,我们这个源码包只做这些事情:

  1. 包含一个 targets 文件,用于给目标项目引入源代码;
  2. 包含一个几乎没有什么代码的 C# 代码文件,用于测试是否正常引入了源代码包;
  3. 项目的 csproj 文件,用于控制源代码包的编译过程。

具体来说,我们的目录结构是这样的:

- Walterlv.SourceYard.Demo
    - Assets
        - build
            - Package.targets
        - src
            - Foo.cs

Walterlv.SourceYard.Demo.targets 中的内容如下:

<Project>

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

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

Foo.cs 中的内容如下:

using System;

namespace Walterlv.SourceYard
{
    internal class Foo
    {
        public static void Run() => Console.WriteLine("walterlv is a 逗比.");
    }
}

而项目文件(csproj)如下:

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

  <PropertyGroup>
    <TargetFramework>net48</TargetFramework>
    <PackageOutputPath>..\bin\$(Configuration)</PackageOutputPath>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <BuildOutputTargetFolder>tools</BuildOutputTargetFolder>
    <PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
    <Version>0.1.0-alpha</Version>
    <Authors>walterlv</Authors>
    <Company>dotnet-campus</Company>
  </PropertyGroup>

  <!-- 在编译结束后将需要的源码拷贝到 NuGet 包中 -->
  <Target Name="IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
    <ItemGroup>
      <None Include="Assets\build\Package.targets" Pack="True" PackagePath="build\$(PackageId).targets" />
      <None Include="Assets\src\**" Pack="True" PackagePath="src" />
    </ItemGroup>
  </Target>
  
</Project>

这样,编译完成之后,我们可以在 ..\bin\Debug 目录下找到我们已经生成好的 NuGet 包,其目录结构如下:

- Walterlv.SourceYard.Demo.nupkg
    - build
        - Walterlv.SourceYard.Demo.targets
    - src
        - Foo.cs
    - tools
        - net48
            - Walterlv.SourceYard.Demo.dll

其中,那个 Walterlv.SourceYard.Demo.dll 完全没有作用。我们是通过项目中设置了属性 BuildOutputTargetFolder 让生成的文件跑到这里来的,目的是避免安装此 NuGet 包之后,引用了我们生成的 dll 文件。因为我们要引用的是源代码,而不是 dll。

用于验证源代码包的项目 Walterlv.GettingStarted.SourceYard.Sample

现在,我们新建另一个简单的控制台项目用于验证这个 NuGet 包是否正常工作。

项目文件就是很简单的项目文件,只是我们安装了刚刚生成的 NuGet 包 Walterlv.SourceYard.Demo.nupkg。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
  </ItemGroup>

</Project>

而 Program.cs 文件中的内容很简单,只是简单地调用了我们源码包中的 Foo.Run() 方法。

using System;
using Walterlv.SourceYard;

namespace Walterlv.GettingStarted.SourceYard.Sample
{
    class Program
    {
        static void Main(string[] args)
        {
            Foo.Run();
            Console.WriteLine("Hello World!");
        }
    }
}

编译

现在,编译我们的项目,发现完全可以正常编译,就像我在这篇博客中说到的一样:

但是,事情并不那么简单。接下来全部剩下的都是问题。

不可思议的错误

普通控制台项目

当我们不进行任何改变,就是以上的代码,对 Walterlv.GettingStarted.SourceYard.Sample 项目进行编译(记得提前 nuget restore),我们可以得到正常的控制台输出。

注意,我使用了 msbuild /t:Rebuild 命令,在编译前进行清理。

PS D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample> msbuild /t:Rebuild
用于 .NET Framework  Microsoft (R) 生成引擎版本 16.1.76+g14b0a930a7
版权所有(C) Microsoft Corporation。保留所有权利。

生成启动时间为 2019/6/10 17:32:50
项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj”在节点 1 (Rebuild 个目标)
_CheckForNETCoreSdkIsPreview:
C:\Program Files\dotnet\sdk\3.0.100-preview5-011568\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInfer
ence.targets(157,5): message NETSDK1057: 你正在使用 .NET Core 的预览版。请查看 https://aka.ms/dotnet-core-preview [D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.
SourceYard.Sample.csproj]
CoreClean:
  正在创建目录“obj\Debug\net48\”。
PrepareForBuild:
  正在创建目录“bin\Debug\net48\”。
GenerateBindingRedirects:
  ResolveAssemblyReferences 中没有建议的绑定重定向。
GenerateTargetFrameworkMonikerAttribute:
正在跳过目标“GenerateTargetFrameworkMonikerAttribute”,因为所有输出文件相对于输入文件而言都是最新的。
CoreCompile:
  C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe /noconfig /unsafe
  - /checked- /nowarn:1701,1702,1701,1702 /nostdlib+ /platform:AnyCPU /errorreport:prompt /warn:4 /define:TRACE;DEBUG;N
  ETFRAMEWORK;NET48 /highentropyva+ /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFra
  mework\v4.8\mscorlib.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
  4.8\System.Core.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\S
  ystem.Data.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System
  .dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.d
  ll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compress
  ion.FileSystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Sy
  stem.Numerics.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Sys
  tem.Runtime.Serialization.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramew
  ork\v4.8\System.Xml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4
  .8\System.Xml.Linq.dll" /debug+ /debug:portable /filealign:512 /optimize- /out:obj\Debug\net48\Walterlv.GettingStarte
  d.SourceYard.Sample.exe /ruleset:"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Team Tools\Static
  Analysis Tools\\Rule Sets\MinimumRecommendedRules.ruleset" /subsystemversion:6.00 /target:exe /warnaserror- /utf8outp
  ut /deterministic+ Program.cs "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs" C:\
  Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs obj\Debug\net48\Walterlv.GettingS
  tarted.SourceYard.Sample.AssemblyInfo.cs /warnaserror+:NU1605
  对来自后列目录的编译器使用共享编译: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn
_CopyAppConfigFile:
  正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe.withSupportedRuntime.config”复制到“D:\Developments\Open\
  Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.G
  ettingStarted.SourceYard.Sample.exe.config”。
CopyFilesToOutputDirectory:
  正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe”复制到“D:\Developments\Open\Walterlv.Demo\Walterlv.Getti
  ngStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sam
  ple.exe”。
  Walterlv.GettingStarted.SourceYard.Sample -> D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Wa
  lterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe
  正在将文件从“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.pdb”复制到“D:\Developments\Open\Walterlv.Demo\Walterlv.Getti
  ngStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\bin\Debug\net48\Walterlv.GettingStarted.SourceYard.Sam
  ple.pdb”。
已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample.csproj(Rebuild 个目标)的操作。


已成功生成。
    0 个警告
    0 个错误

已用时间 00:00:00.59

当然,贴一张图片可能更能体现编译通过:

可以编译通过

上面的输出非常多,但我们提取一下关键的点:

  1. 有输出的 Target 有这些:CoreClean -> PrepareForRebuild -> GenerateBindingRedirects -> GenerateTargetFrameworkMonikerAttribute -> CoreCompile -> _CopyAppConfigFile -> CopyFilesToOutputDirectory
  2. 在 CoreCompile 这个编译任务里面,所有需要编译的 C# 代码有这些:Program.cs "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs" C:\ Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.AssemblyInfo.cs

可以注意到,编译期间成功将 Foo.cs 文件加入了编译。

WPF 项目

现在,我们将我们的项目升级成 WPF 项目。编辑项目文件。

--  <Project Sdk="Microsoft.NET.Sdk">
++  <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net48</TargetFramework>
++      <UseWPF>true</UseWPF>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
    </ItemGroup>

    </Project>

现在编译,依然不会出现任何问题,跟控制台程序一模一样。

但一旦在你的项目中放上一个 XAML 文件,问题立刻变得不一样了。

<UserControl x:Class="Walterlv.GettingStarted.SourceYard.Sample.DemoControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:Walterlv.GettingStarted.SourceYard.Sample">
</UserControl>
PS D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample> msbuild /t:Rebuild
用于 .NET Framework  Microsoft (R) 生成引擎版本 16.1.76+g14b0a930a7
版权所有(C) Microsoft Corporation。保留所有权利。

生成启动时间为 2019/6/10 17:43:18
项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj”在节点 1 (Rebuild 个目标)
_CheckForNETCoreSdkIsPreview:
C:\Program Files\dotnet\sdk\3.0.100-preview5-011568\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.RuntimeIdentifierInfer
ence.targets(157,5): message NETSDK1057: 你正在使用 .NET Core 的预览版。请查看 https://aka.ms/dotnet-core-preview [D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.
SourceYard.Sample.csproj]
CoreClean:
  正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.csprojAssemblyReference.cache”。
  正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Demo.g.cs”。
  正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_MarkupCompile.cache”。
  正在删除文件“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sampl
  e\obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_MarkupCompile.lref”。
GenerateBindingRedirects:
  ResolveAssemblyReferences 中没有建议的绑定重定向。
项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walt
erlv.GettingStarted.SourceYard.Sample.csproj(1)正在节点 1 上生成“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.S
ourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj(2
) (_CompileTemporaryAssembly 个目标)
CoreCompile:
  C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe /noconfig /unsafe
  - /checked- /nowarn:1701,1702,1701,1702 /nostdlib+ /platform:AnyCPU /errorreport:prompt /warn:4 /define:TRACE;DEBUG;N
  ETFRAMEWORK;NET48 /highentropyva+ /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFra
  mework\v4.8\mscorlib.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
  4.8\PresentationCore.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
  4.8\PresentationFramework.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramew
  ork\v4.8\System.Core.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v
  4.8\System.Data.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\S
  ystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Draw
  ing.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Com
  pression.FileSystem.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4
  .8\System.Numerics.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.
  8\System.Runtime.Serialization.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETF
  ramework\v4.8\System.Windows.Controls.Ribbon.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\F
  ramework\.NETFramework\v4.8\System.Xaml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framew
  ork\.NETFramework\v4.8\System.Xml.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.N
  ETFramework\v4.8\System.Xml.Linq.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NE
  TFramework\v4.8\UIAutomationClient.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.
  NETFramework\v4.8\UIAutomationClientsideProviders.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Micros
  oft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Mi
  crosoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\M
  icrosoft\Framework\.NETFramework\v4.8\WindowsBase.dll" /debug+ /debug:portable /filealign:512 /optimize- /out:obj\Deb
  ug\net48\Walterlv.GettingStarted.SourceYard.Sample.exe /ruleset:"C:\Program Files (x86)\Microsoft Visual Studio\2019\
  Professional\Team Tools\Static Analysis Tools\\Rule Sets\MinimumRecommendedRules.ruleset" /subsystemversion:6.00 /tar
  get:exe /warnaserror- /utf8output /deterministic+ Program.cs D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStart
  ed.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs obj\Debug\net48\Walterlv.GettingSta
  rted.SourceYard.Sample_vobqk5lg_wpftmp.AssemblyInfo.cs /warnaserror+:NU1605
  对来自后列目录的编译器使用共享编译: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn
Program.cs(2,16): error CS0234: 命名空间“Walterlv”中不存在类型或命名空间名“SourceYard(是否缺少程序集引用?) [D:\Developments\Open\Walterlv.Demo\
Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_
vobqk5lg_wpftmp.csproj]
已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj(_CompileTemporaryAssembly 个目标)的操作 - 失败。

已完成生成项目“D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample
\Walterlv.GettingStarted.SourceYard.Sample.csproj(Rebuild 个目标)的操作 - 失败。


生成失败。

D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walter
lv.GettingStarted.SourceYard.Sample.csproj(Rebuild 目标) (1) ->
D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walter
lv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj(_CompileTemporaryAssembly 目标) (2) ->
(CoreCompile 目标) ->
  Program.cs(2,16): error CS0234: 命名空间“Walterlv”中不存在类型或命名空间名“SourceYard(是否缺少程序集引用?) [D:\Developments\Open\Walterlv.Dem
o\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sampl
e_vobqk5lg_wpftmp.csproj]

    0 个警告
    1 个错误

已用时间 00:00:00.87

因为上面有编译错误但看不出来,所以我们贴一张图,可以很容易看出来有编译错误。

出现编译错误

并且,如果对比两张图,会发现 CoreCompile 中的内容已经不一样了。变化主要是 /reference 参数和要编译的文件列表参数。

/reference 参数增加了 WPF 需要的库。

    mscorelib.dll
++  PresentationCore.dll
++  PresentationFramework.dll
    System.Core.dll
    System.Data.dll
    System.dll
    System.Drawing.dll
    System.IO.Compression.FileSystem.dll
    System.Numerics.dll
    System.Runtime.Serialization.dll
++  System.Windows.Controls.Ribbon.dll
++  System.Xaml.dll
    System.Xml.dll
    System.Xml.Linq.dll
++  UIAutomationClient.dll
++  UIAutomationClientsideProviders.dll
++  UIAutomationProvider.dll
++  UIAutomationTypes.dll
++  WindowsBase.dll

但是要编译的文件却既有新增,又有减少:

    Program.cs
++  D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs
--  "C:\Users\lvyi\AppData\Local\Temp\.NETFramework,Version=v4.8.AssemblyAttributes.cs"
--  C:\Users\lvyi\.nuget\packages\walterlv.sourceyard.demo\0.1.0-alpha\build\..\src\Foo.cs
--  obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample.AssemblyInfo.cs
++  obj\Debug\net48\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.AssemblyInfo.cs

同时,我们还能注意到还临时生成了一个新的项目文件:

项目“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample.csproj”(1)正在节点 1 上生成“D:\Walterlv.Demo\Walterlv.GettingStarted.SourceYard.Sample\Walterlv.GettingStarted.SourceYard.Sample_vobqk5lg_wpftmp.csproj”(2) (_CompileTemporaryAssembly 个目标)。

新的项目文件有一个后缀 _vobqk5lg_wpftmp,同时我们还能注意到编译的 AssemblyInfo.cs 文件前面也有相同的后缀 _vobqk5lg_wpftmp

  • $(项目名)_$(随机字符)_wpftmp.csproj
  • $(项目名)_$(随机字符)_wpftmp.AssemblyInfo.cs

我们几乎可以认为,当项目是编译成 WPF 时,执行了不同的编译流程。

修复错误

找出原因

要了解问题到底出在哪里了,我们需要知道 WPF 究竟在编译过程中做了哪些额外的事情。WPF 额外的编译任务主要在 Microsoft.WinFX.targets 文件中。在了解了 WPF 的编译过程之后,这个临时的程序集将非常容易理解。

我写了一篇讲解 WPF 编译过程的博客,在解决这个问题之前,建议阅读这篇博客了解 WPF 是如何进行编译的:

在了解了 WPF 程序的编译过程之后,我们知道了前面一些疑问的答案:

  1. 那个临时的项目文件是如何生成的;
  2. 那个临时项目文件和原始的项目文件有哪些不同;
  3. 编译临时项目文件时,哪些编译目标会执行,哪些编译目标不会执行。

在那篇博客中,我们解释到新生成的项目文件会使用 ReferencePath 替代其他方式收集到的引用,这就包含项目引用和 NuGet 包的引用。

在使用 ReferencePath 的情况下,无论是项目引用还是 NuGet 包引用,都会被换成普通的 dll 引用,因为这个时候目标项目都已经编译完成,包含可以被引用的程序集。

以下是我在示例程序中抓取到的临时生成的项目文件的内容,与原始项目文件之间的差异:

    <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net48</TargetFramework>
            <UseWPF>true</UseWPF>
            <GenerateTemporaryTargetAssemblyDebuggingInformation>True</GenerateTemporaryTargetAssemblyDebuggingInformation>
        </PropertyGroup>
        <ItemGroup>
            <PackageReference Include="Walterlv.SourceYard.Demo" Version="0.1.0-alpha" />
        </ItemGroup>
++      <ItemGroup>
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\mscorlib.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationCore.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\PresentationFramework.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Core.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Data.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Drawing.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.IO.Compression.FileSystem.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Numerics.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Runtime.Serialization.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Windows.Controls.Ribbon.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xaml.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\System.Xml.Linq.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClient.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationClientsideProviders.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationProvider.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\UIAutomationTypes.dll" />
++          <ReferencePath Include="C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\WindowsBase.dll" />
++      </ItemGroup>
++      <ItemGroup>
++          <Compile Include="D:\Developments\Open\Walterlv.Demo\Walterlv.GettingStarted.SourceYard\Walterlv.GettingStarted.SourceYard.Sample\obj\Debug\net48\Demo.g.cs" />
++      </ItemGroup>
    </Project>

你可能已经注意到了我在项目中设置了 GenerateTemporaryTargetAssemblyDebuggingInformation 属性,这个属性可以让 WPF 临时生成的项目文件保留下来,便于进行研究和调试。在前面 GenerateTemporaryTargetAssembly 的源码部分我们已经贴出了这个属性使用的源码,只是前面我们没有说明其用途。

注意,虽然新生成的项目文件中有 PackageReference 来表示包引用,但由于只有 _CompileTargetNameForLocalType 指定的编译目标和相关依赖可以被执行,而 NuGet 包中自动 Import 的部分没有加入到依赖项中,所以实际上包中的 .props.targets 文件都不会被 Import 进来,这可能造成部分 NuGet 包在 WPF 项目中不能正常工作。比如本文正片文章都在探索的这个 Bug。

更典型的,就是 SourceYard 项目,这个 Bug 给 SourceYard 造成了不小的困扰:

解决问题

这个问题解决起来其实并不如想象当中那么简单,因为:

  1. WPF 项目的编译包含两个编译上下文,一个是正常的编译上下文,另一个是临时生成的项目文件编译的上下文;正常的编译上下文编译到 MarkupCompilePass1MarkupCompilePass2 之间的 GenerateTemporaryTargetAssembly 编译目标时,会插入一段临时项目文件的编译;
  2. 临时项目文件的编译中,会执行 _CompileTargetNameForLocalType 内部属性指定的编译目标,虽然相当于开放了修改,但由于临时项目文件中不会执行 NuGet 相关的编译目标,所以不会自动 Import NuGet 包中的任何编译目标和属性定义;换句话说,我们几乎没有可以自动 Import 源码的方案。

如果我们强行将 _CompileTargetNameForLocalType 替换成我们自己定义的类型会怎么样?

这是通过 NuGet 包中的 .targets 文件中的内容,用来强行替换:

<Project>

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

  <Target Name="_WalterlvCompileTemporaryAssembly" />
  
</Project>

我们在属性中将临时项目的编译目标改成了我们自己的目标,但会直接出现编译错误,找不到我们定义的编译目标。当然这个编译错误出现在临时生成的程序集上。

编译错误

原因就在于这个 .targets 文件没有自动被 Import 进来,于是我们定义的 _WalterlvCompileTemporaryAssembly 在临时生成的项目编译中根本就不存在。

我们失去了通过 NuGet 自动被 Import 的时机!

既然我们失去了通过 NuGet 被自动 Import 的时机,那么我们只能另寻它法:

  1. 帮助微软修复 NuGet 在 WPF 临时生成的项目中依然可以自动 Import 编译文件 .props 和 .targets;
  2. 直接修改项目文件,使其直接或间接 Import 我们希望 Import 进来的编译文件 .props 和 .targets。
  3. 寻找其他可以被自动 Import 的时机进行自动 Import;
  4. 不管时机了,从 GenerateTemporaryTargetAssembly 这个编译任务入手,修改其需要的参数;

方案一:帮助微软修复(等待中)

// TODO:正在组织 issues 和 pull request

无论结果如何,等待微软将这些修改发布也是需要一段时间的,这段时间我们需要使用方案二和方案三来顶替一段时间。

方案二:修改项目文件(可行,但不好)

方案二的其中一种实施方案是下面这篇文章在最后一小节说到的方法:

具体来说,就是修改项目文件,在项目文件的首尾各加上 NuGet 自动生成的那些 Import 来自 NuGet 中的所有编译文件:

<Project Sdk="Microsoft.NET.Sdk">
  <Import Condition="Exists('obj\$(MSBuildProjectName).csproj.nuget.g.props') " Project="obj\$(MSBuildProjectName).csproj.nuget.g.props" />

  <!-- 项目文件中的原有其他代码。 -->

  <Import Condition="Exists('obj\$(MSBuildProjectName).csproj.nuget.g.targets') " Project="obj\$(MSBuildProjectName).csproj.nuget.g.targets" />
</Project>

另外,可以直接在这里 Import 我们 NuGet 包中的编译文件,但这些不如以上方案来得靠谱,因为上面的代码可以使得项目文件的修改完全确定,不用随着开发计算机的不同或者 NuGet 包的数量和版本不同而变化。

如果打算选用方案二,那么上面这种实施方式是最推荐的实施方式。

当然需要注意,此方案的副作用是会多出重复导入的编译警告。在清楚了 WPF 的编译过程之后,是不是能理解了这个警告的原因了呢?是的,对临时项目来说,由于没有自动 Import,所以这里的 Import 不会导致临时项目出现问题;但对于原项目来说,由于默认就会 Import NuGet 中的那两个文件,所以如果再次 Import 就会重复导入。

重复导入的编译警告

方案三:寻找其他自动 Import 的时机(不可行)

Directory.Build.props 和 Directory.Build.targets 也是可以被自动 Import 的文件,这也是在 Microsoft.NET.Sdk 中将其自动导入的。

关于这两个文件的自动导入,可以阅读博客:

但是,如果我们使用这两个文件帮助自动导入,将造成导入循环,这会形成编译错误!

因导入循环造成的编译错误

方案四:设置 GenerateTemporaryTargetAssembly 编译任务

GenerateTemporaryTargetAssembly 的代码如下:

<GenerateTemporaryTargetAssembly
        CurrentProject="$(MSBuildProjectFullPath)"
        MSBuildBinPath="$(MSBuildBinPath)"
        ReferencePathTypeName="ReferencePath"
        CompileTypeName="Compile"
        GeneratedCodeFiles="@(_GeneratedCodeFiles)"
        ReferencePath="@(ReferencePath)"
        IntermediateOutputPath="$(IntermediateOutputPath)"
        AssemblyName="$(AssemblyName)"
        CompileTargetName="$(_CompileTargetNameForLocalType)"
        GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
        >

</GenerateTemporaryTargetAssembly>

可以看到它的的参数有:

  • CurrentProject,传入了 $(MSBuildProjectFullPath),表示项目文件的完全路径,修改无效。
  • MSBuildBinPath,传入了 $(MSBuildBinPath),表示 MSBuild 程序的完全路径,修改无效。
  • ReferencePathTypeName,传入了字符串常量 ReferencePath,这是为了在生成临时项目文件时使用正确的引用路径项的名称。
  • CompileTypeName,传入了字符串常量 Compile,这是为了在生成临时项目文件时使用正确的编译项的名称。
  • GeneratedCodeFiles,传入了 @(_GeneratedCodeFiles),包含生成的代码文件,也就是那些 .g.cs 文件。
  • ReferencePath,传入了 @(ReferencePath),也就是目前已收集到的所有引用文件的路径。
  • IntermediateOutputPath,传入了 $(IntermediateOutputPath),表示临时输出路径,当使用临时项目文件编译时,生成的临时程序集将放在这个目录中。
  • AssemblyName,传入了 $(AssemblyName),表示程序集名称,当生成临时程序集的时候,将参考这个程序集名称。
  • CompileTargetName,传入了 $(_CompileTargetNameForLocalType),表示当生成了新的项目文件后,要使用哪个编译目标来编译这个项目。
  • GenerateTemporaryTargetAssemblyDebuggingInformation,传入了 $(GenerateTemporaryTargetAssemblyDebuggingInformation),表示是否要为了调试保留临时生成的项目文件和程序集。

可能为我们所用的有:

  • @(_GeneratedCodeFiles),我们可以把我们需要 Import 进来的源代码伪装成生成的 .g.cs 文件

好吧,就这一个了。其他的并不会对我们 Import 源代码造成影响。

于是回到我们本文一开始的 Walterlv.SourceYard.Demo.targets 文件,我们将内容修改一下,增加了一个 _ENSdkImportInTempProject 编译目标。它在 MarkupCompilePass1 之后执行,因为这是 XAML 的第一轮编译,会创造 _GeneratedCodeFiles 这个集合,将 XAML 生成 .g.cs 文件;在 GenerateTemporaryTargetAssembly 之前执行,因为这里会生成一个新的临时项目,然后立即对其进行编译。我们选用这个之间的时机刚好可以在产生 _GeneratedCodeFiles 集合之后修改其内容。

    <Project>

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

      <Target Name="_WalterlvIncludeSomeCode" BeforeTargets="CoreCompile">
        <ItemGroup>
          <Compile Include="$(MSBuildThisFileDirectory)..\src\Foo.cs" />
        </ItemGroup>
      </Target>
      
++    <Target Name="_ENSdkImportInTempProject" AfterTargets="MarkupCompilePass1" BeforeTargets="GenerateTemporaryTargetAssembly">
++      <ItemGroup>
++        <_GeneratedCodeFiles Include="$(MSBuildThisFileDirectory)..\src\Foo.cs" />
++      </ItemGroup>
++    </Target>
++    
    </Project>

现在重新再编译,我们本文一开始疑惑的各种问题,现在终于无警告无错误地解决掉了。

解决掉的源代码包问题

解决关键

如果你觉得本文略长,希望立刻获得解决办法,可以:

  1. 直接使用 “方案四” 中新增的那一段代码;
  2. 阅读我的另一篇专门的只说解决方案的博客:如何为 WPF 项目制作源代码包(SourceYard 基础原理篇,解决 WPF 项目编译问题和 NuGet 包中的各种问题)

参考资料

05-31 2019

如何为 Win32 的打开和保存对话框编写文件过滤器(Filter)

在使用 Win32 / WPF / Windows Forms 的打开或保存文件对话框的时候,多数情况下我们都会考虑编写文件过滤器。UWP 中有 FileTypeFilter 集合可以添加不同的文件种类,但 Win32 中却是一个按一定规则组合而成的字符串。

因为其包含一定的格式,所以可能写错。本文介绍如何编写 Filter。


编写 Filter

Filter 使用竖线分隔不同种类的过滤器,比如 图片|*.png;*.jpg|文本|*.txt|walterlv 的自定义格式|*.lvyi

var dialog = new OpenFileDialog();
dialog.Filter = "图片|*.png;*.jpg|文本|*.txt|walterlv 的自定义格式|*.lvyi";
dialog.ShowDialog(this);

过滤器的显示效果

有时我们会看到一些程序的过滤器里面显示了过滤器本身,而不止是名称,实际上是因为名称中包含了过滤器:

图片 (png, jpg)|*.png;*.jpg|文本 (txt)|*.txt|walterlv 的自定义格式 (lvyi)|*.lvyi

名称中包含过滤器

你不可以在过滤器中省略名称或者过滤器任何一个部分,否则会抛出异常。

附:如何显示对话框

对于 .NET Core 版本的 WPF 或者 Windows Forms 程序来说,需要安装 Windows 兼容 NuGet 包:

安装后可以使用 Windows Forms 版本的 OpenFileDialog 或者 WPF 版本的 Microsoft.Win32.OpenFileDialog


参考资料

05-28 2019

.NET/C# 编译期能确定的字符串会在字符串暂存池中不会被 GC 垃圾回收掉

当我们不再使用某个对象的时候,此对象会被 GC 垃圾回收掉。当然前提是你没有写出内存泄漏的代码。我们也知道如果生成了大量的字符串,会对 GC 造成很大的压力。

但是,如果在编译期间能够确定的字符串,就不会被 GC 垃圾回收掉了。


示例代码

下面,我创建了几个字符串,我关心的字符串是 "walterlv""lindexi" 以及一个当前时间。

于是使用下面的代码来验证:

using System;
using System.Linq;
using System.Runtime.CompilerServices;

namespace Walterlv.Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            var table = new ConditionalWeakTable<string, Foo>
            {
                {"walterlv", new Foo("吕毅")},
                {"lindexi", new Foo("林德熙")},
            };
            var time = DateTime.Now.ToString("T");
            table.Add(time, new Foo("时间"));
            time = null;

            Console.WriteLine($"开始个数:{table.Count()}");
            GC.Collect();
            Console.WriteLine($"剩余个数:{table.Count()}");
        }
    }

    public class Foo
    {
        public string Value { get; }
        public Foo(string value) => Value = value;
    }
}

"walterlv""lindexi" 是在编译期间能够完全确定的字符串,而当前时间字符串我们都知道是编译期间不能确定的字符串。

在 GC 收集之前和之后,ConditionalWeakTable 中的对象数量从三个降到了两个。

运行结果

并没有清除成 0 个,说明字符串现在仍然是被引用着的。

那被什么引用着呢?是字符串暂存池。要理解字符串暂存池,可以阅读我的另一篇博客:

另外,即便设置了 CompilationRelaxations.NoStringInterning,编译期间能确定的字符串在上述代码中也是不会被垃圾回收的。


参考资料

05-28 2019

.NET/C# 的字符串暂存池

本文介绍 .NET 中的字符串暂存池。


字符串暂存池

.NET 的 CLR 运行时会在运行期间管理一个字符串暂存池(string intern pool),在字符串暂存池中的字符串只有一个实例。

例如,在下面的代码中,变量 abc 都是同一个实例:

var a = "walterlv";
var b = "walterlv";
var c = "walterlv";

我有另一篇博客说到了此问题,可以参见:

字符串暂存池的出现是为了避免分配大量的字符串对象造成的过多的内存空间浪费。

编译期间确定

默认进入字符串暂存池中的字符串是那些写程序的时候直接声明或者直接写入代码中的字符串。上一节中列举的三个变量中的字符串就是直接写到代码中的字符串。

默认情况下编译期间能确定出来的字符串会写入到程序集中,运行时能直接将其放入字符串暂存池。

从暂存池中获取字符串

现在,我们要制造出编译期间不能确定出来的字符串,以便进行一些试验。

我们当然不能使用简单的 "walter" + "lv" 这样简单的字符串拼接的方式来生成字符串,因为实际上这样的字符串依然可以在编译期间完全确定。

所以这里使用 StringBuilder 来在运行期间生成字符串。

var a = "walterlv";
var b = new StringBuilder("walter").Append("lv").ToString();
var c = string.Intern(b);

Console.WriteLine(ReferenceEquals(a, b));
Console.WriteLine(ReferenceEquals(a, c));

在这段代码中,虽然 abc 三个字符串的值都是相等的,但 ab 两个字符串是不同的实例,而 ac 两个字符串是相同的实例。

我们使用了 string.Intern 方法从字符串池中取出了一个字符串的实例。

另外,string 类型还提供了 string.IsInterned 来判断一个字符串是否在字符串暂存池中。

不要池化

你可以在程序集中标记 CompilationRelaxations.NoStringInterning,这样,此程序集中的字符串就不会被池化。即便是在编译期间写下的字符串也会在运行时生成新的实例。

方法是在一个 C# 代码文件中添加特性标记。

[assembly: CompilationRelaxations(CompilationRelaxations.NoStringInterning)]

垃圾回收

在字符串暂存池中的字符串不会被垃圾回收,你可以阅读另一篇博客:


参考资料

05-28 2019

.NET/C# 避免调试器不小心提前计算本应延迟计算的值

延迟计算属性的值,应该很多小伙伴都经常使用。比如在属性的 get 方法中判断是否已初始化,如果没有初始化则立即开始初始化。

但这样的写法存在一个很大的问题——如果你使用 Visual Studio 调试,当你把鼠标划到对象的实例上的时候,属性就会立刻开始进行初始化。而此时对你的代码来说可能就过早初始化了。我们不应该让调试器非预期地影响到我们程序的执行结果。

本文介绍如何避免调试器不小心提前计算本应延迟计算的值。


方法是在属性上添加一个特性 DebuggerBrowsableAttribute

private Walterlv _foo;

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
public Walterlv Walterlv => _foo ?? (_foo = new Walterlv());

public bool IsInitialized => !(_foo is null);

当指定为不再显示的话,在调试器中查看此实例的属性的时候就看不到这个属性了,也就不会因为鼠标划过导致提前计算了值。

当然,如果你希望为你的类型定制更多的调试器显示方式,可以参考我的另一篇博客:


参考资料

05-23 2019

WPF 使用 AppBar 将窗口停靠在桌面上,让其他程序不占用此窗口的空间(附我封装的附加属性)

本文介绍如何使用 Windows 的 AppBar 相关 API 实现固定停靠在桌面上的特殊窗口。


停靠窗口

你可能并不明白停靠窗口是什么意思。

看下图,你可能使用过 OneNote 的停靠窗口功能。当打开一个新的 OneNote 停靠窗口之后,这个新的 OneNote 窗口将固定显示在桌面的右侧,其他的窗口就算最大化也只会占据剩余的空间。

OneNote 的这种功能可以让你在一边浏览网页或做其他事情的时候,以便能够做笔记。同时又不用担心其他窗口最大化的时候会占据记笔记的一部分空间。

OneNote 的停靠窗口

这其实也是 Windows 任务栏所使用的方法。

OneNote 中给出的名称叫做“停靠窗口”,于是这可以代表微软希望用户对这个概念的理解名词。

只是,这个概念在 Windows API 中的名称叫做 AppBar。

AppBar

要做出停靠窗口的效果,最核心的 API 是 SHAppBarMessage,用于发送 AppBar 消息给操作系统,以便让操作系统开始处理此窗口已形成一个 AppBar 窗口。也就是我们在用户交互上所说的“停靠窗口”。

虽然说要让一个窗口变成 AppBar 只需要一点点代码,但是要让整个停靠窗口工作得真的像一个停靠窗口,依然需要大量的辅助代码。所以我将其封装成了一个 DesktopAppBar 类,方便 WPF 程序来调用。

如何使用

以下使用,你需要先获取我封装的源码才可以编译通过:

你可以在 XAML 中使用:

<Window x:Class="Walterlv.Demo.DesktopDocking.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:dock="clr-namespace:Walterlv.Demo.DesktopDocking"
        mc:Ignorable="d" Title="Walterlv 的停靠窗口" Height="450" Width="500"
        dock:DesktopAppBar.AppBar="Right">
    <StackPanel Background="#ffcd42">
        <TextBlock FontSize="64" Margin="64" TextAlignment="Center" Text="walterlv 的停靠窗口" />
        <Button Content="再停靠一个 - blog.walterlv.com" FontSize="32" Padding="32" Margin="32" Background="#f9d77b" BorderThickness="0"
                Click="Button_Click"/>
    </StackPanel>
</Window>

核心代码是其中的一处属性赋值 dock:DesktopAppBar.AppBar="Right",以及前面的命名空间声明 xmlns:dock="clr-namespace:Walterlv.Demo.DesktopDocking"

你也可以在 C# 代码中使用:

using System;
using System.Windows;

namespace Walterlv.Demo.DesktopDocking
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        protected override void OnSourceInitialized(EventArgs e)
        {
            base.OnSourceInitialized(e);
            DesktopAppBar.SetAppBar(this, AppBarEdge.Right);
        }
    }
}

使用以上代码中的任何一种方式,你就可以让你的窗口在右边停靠了。

停靠的窗口

从图中我们可以发现,我们的示例窗口停靠在了右边,其宽度就是我们在 XAML 中设置的窗口宽度(当然这是我封装的逻辑,而不是 AppBar 的原生逻辑)。

同时我们还能注意到,Visual Studio 的窗口是处于最大化的状态的——这是停靠窗口的最大优势——可以让其他窗口的工作区缩小,在最大化的时候不会覆盖到停靠窗口的内容。

另外,如果设置了第二个停靠窗口,那么第二个停靠窗口会挤下第一个窗口的位置。

两个停靠窗口

如何还原

Windows AppBar 的 API 有一个很不好的设定,如果进程退出了,那么 AppBar 所占用的空间 并不会还原!!!

不过不用担心,我在封装的代码里面加入了窗口关闭时还原空间的代码,如果你正常关闭窗口,那么停靠窗口占用的空间就会及时还原回来。

当然,你也可以适时调用下面的代码:

DesktopAppBar.SetAppBar(this, AppBarEdge.None);

附源码

由于源码一直在持续改进,所以本文中贴的源代码可能不是最新的。你可以在以下仓库找到这段源码的最新版本:

using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Interop;

// ReSharper disable IdentifierTypo
// ReSharper disable InconsistentNaming
// ReSharper disable EnumUnderlyingTypeIsInt
// ReSharper disable MemberCanBePrivate.Local
// ReSharper disable UnusedMember.Local
// ReSharper disable UnusedMember.Global

namespace Walterlv.Demo.DesktopDocking
{
    /// <summary>
    /// 表示窗口停靠到桌面上时的边缘方向。
    /// </summary>
    public enum AppBarEdge
    {
        /// <summary>
        /// 窗口停靠到桌面的左边。
        /// </summary>
        Left = 0,

        /// <summary>
        /// 窗口停靠到桌面的顶部。
        /// </summary>
        Top,

        /// <summary>
        /// 窗口停靠到桌面的右边。
        /// </summary>
        Right,

        /// <summary>
        /// 窗口停靠到桌面的底部。
        /// </summary>
        Bottom,

        /// <summary>
        /// 窗口不停靠到任何方向,而是成为一个普通窗口占用剩余的可用空间(工作区)。
        /// </summary>
        None
    }

    /// <summary>
    /// 提供将窗口停靠到桌面某个方向的能力。
    /// </summary>
    public class DesktopAppBar
    {
        /// <summary>
        /// 标识 Window.AppBar 的附加属性。
        /// </summary>
        public static readonly DependencyProperty AppBarProperty = DependencyProperty.RegisterAttached(
            "AppBar", typeof(AppBarEdge), typeof(DesktopAppBar),
            new PropertyMetadata(AppBarEdge.None, OnAppBarEdgeChanged));

        /// <summary>
        /// 获取 <paramref name="window"/> 当前的停靠边缘。
        /// </summary>
        /// <param name="window">要获取停靠边缘的窗口。</param>
        /// <returns>停靠边缘。</returns>
        public static AppBarEdge GetAppBar(Window window) => (AppBarEdge)window.GetValue(AppBarProperty);

        /// <summary>
        /// 设置 <paramref name="window"/> 的停靠边缘方向。
        /// </summary>
        /// <param name="window">要设置停靠的窗口。</param>
        /// <param name="value">要设置的停靠边缘方向。</param>
        public static void SetAppBar(Window window, AppBarEdge value) => window.SetValue(AppBarProperty, value);

        private static readonly DependencyProperty AppBarProcessorProperty = DependencyProperty.RegisterAttached(
            "AppBarProcessor", typeof(AppBarWindowProcessor), typeof(DesktopAppBar), new PropertyMetadata(null));

        [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse")]
        private static void OnAppBarEdgeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (DesignerProperties.GetIsInDesignMode(d))
            {
                return;
            }

            var oldValue = (AppBarEdge) e.OldValue;
            var newValue = (AppBarEdge) e.NewValue;
            var oldEnabled = oldValue is AppBarEdge.Left
                             || oldValue is AppBarEdge.Top
                             || oldValue is AppBarEdge.Right
                             || oldValue is AppBarEdge.Bottom;
            var newEnabled = newValue is AppBarEdge.Left
                             || newValue is AppBarEdge.Top
                             || newValue is AppBarEdge.Right
                             || newValue is AppBarEdge.Bottom;
            if (oldEnabled && !newEnabled)
            {
                var processor = (AppBarWindowProcessor) d.GetValue(AppBarProcessorProperty);
                processor.Detach();
            }
            else if (!oldEnabled && newEnabled)
            {
                var processor = new AppBarWindowProcessor((Window) d);
                d.SetValue(AppBarProcessorProperty, processor);
                processor.Attach(newValue);
            }
            else if (oldEnabled && newEnabled)
            {
                var processor = (AppBarWindowProcessor) d.GetValue(AppBarProcessorProperty);
                processor.Update(newValue);
            }
        }

        /// <summary>
        /// 包含对 <see cref="Window"/> 进行操作以便使其成为一个桌面停靠窗口的能力。
        /// </summary>
        private class AppBarWindowProcessor
        {
            /// <summary>
            /// 创建 <see cref="AppBarWindowProcessor"/> 的新实例。
            /// </summary>
            /// <param name="window">需要成为停靠窗口的 <see cref="Window"/> 的实例。</param>
            public AppBarWindowProcessor(Window window)
            {
                _window = window;
                _callbackId = RegisterWindowMessage("AppBarMessage");
                _hwndSourceTask = new TaskCompletionSource<HwndSource>();

                var source = (HwndSource) PresentationSource.FromVisual(window);
                if (source == null)
                {
                    window.SourceInitialized += OnSourceInitialized;
                }
                else
                {
                    _hwndSourceTask.SetResult(source);
                }

                _window.Closed += OnClosed;
            }

            private readonly Window _window;
            private readonly TaskCompletionSource<HwndSource> _hwndSourceTask;
            private readonly int _callbackId;

            private WindowStyle _restoreStyle;
            private Rect _restoreBounds;
            private ResizeMode _restoreResizeMode;
            private bool _restoreTopmost;

            private AppBarEdge Edge { get; set; }

            /// <summary>
            /// 在可以获取到窗口句柄的时候,给窗口句柄设置值。
            /// </summary>
            private void OnSourceInitialized(object sender, EventArgs e)
            {
                _window.SourceInitialized -= OnSourceInitialized;
                var source = (HwndSource) PresentationSource.FromVisual(_window);
                _hwndSourceTask.SetResult(source);
            }

            /// <summary>
            /// 在窗口关闭之后,需要恢复窗口设置过的停靠属性。
            /// </summary>
            private void OnClosed(object sender, EventArgs e)
            {
                _window.Closed -= OnClosed;
                _window.ClearValue(AppBarProperty);
            }

            /// <summary>
            /// 将窗口属性设置为停靠所需的属性。
            /// </summary>
            private void ForceWindowProperties()
            {
                _window.WindowStyle = WindowStyle.None;
                _window.ResizeMode = ResizeMode.NoResize;
                _window.Topmost = true;
            }

            /// <summary>
            /// 备份窗口在成为停靠窗口之前的属性。
            /// </summary>
            private void BackupWindowProperties()
            {
                _restoreStyle = _window.WindowStyle;
                _restoreBounds = _window.RestoreBounds;
                _restoreResizeMode = _window.ResizeMode;
                _restoreTopmost = _window.Topmost;
            }

            /// <summary>
            /// 使一个窗口开始成为桌面停靠窗口,并开始处理窗口停靠消息。
            /// </summary>
            /// <param name="value">停靠方向。</param>
            public async void Attach(AppBarEdge value)
            {
                var hwndSource = await _hwndSourceTask.Task;

                BackupWindowProperties();

                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hwndSource.Handle;

                data.uCallbackMessage = _callbackId;
                SHAppBarMessage((int) ABMsg.ABM_NEW, ref data);
                hwndSource.AddHook(WndProc);

                Update(value);
            }

            /// <summary>
            /// 更新一个窗口的停靠方向。
            /// </summary>
            /// <param name="value">停靠方向。</param>
            public async void Update(AppBarEdge value)
            {
                var hwndSource = await _hwndSourceTask.Task;

                Edge = value;


                var bounds = TransformToAppBar(hwndSource.Handle, _window.RestoreBounds, value);
                ForceWindowProperties();
                Resize(_window, bounds);
            }

            /// <summary>
            /// 使一个窗口从桌面停靠窗口恢复成普通窗口。
            /// </summary>
            public async void Detach()
            {
                var hwndSource = await _hwndSourceTask.Task;

                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hwndSource.Handle;

                SHAppBarMessage((int) ABMsg.ABM_REMOVE, ref data);

                _window.WindowStyle = _restoreStyle;
                _window.ResizeMode = _restoreResizeMode;
                _window.Topmost = _restoreTopmost;

                Resize(_window, _restoreBounds);
            }

            private IntPtr WndProc(IntPtr hwnd, int msg,
                IntPtr wParam, IntPtr lParam, ref bool handled)
            {
                if (msg == _callbackId)
                {
                    if (wParam.ToInt32() == (int) ABNotify.ABN_POSCHANGED)
                    {
                        var hwndSource = _hwndSourceTask.Task.Result;
                        var bounds = TransformToAppBar(hwndSource.Handle, _window.RestoreBounds, Edge);
                        Resize(_window, bounds);
                        handled = true;
                    }
                }

                return IntPtr.Zero;
            }

            private static void Resize(Window window, Rect bounds)
            {
                window.Left = bounds.Left;
                window.Top = bounds.Top;
                window.Width = bounds.Width;
                window.Height = bounds.Height;
            }

            private Rect TransformToAppBar(IntPtr hWnd, Rect area, AppBarEdge edge)
            {
                var data = new APPBARDATA();
                data.cbSize = Marshal.SizeOf(data);
                data.hWnd = hWnd;
                data.uEdge = (int) edge;

                if (data.uEdge == (int) AppBarEdge.Left || data.uEdge == (int) AppBarEdge.Right)
                {
                    data.rc.top = 0;
                    data.rc.bottom = (int) SystemParameters.PrimaryScreenHeight;
                    if (data.uEdge == (int) AppBarEdge.Left)
                    {
                        data.rc.left = 0;
                        data.rc.right = (int) Math.Round(area.Width);
                    }
                    else
                    {
                        data.rc.right = (int) SystemParameters.PrimaryScreenWidth;
                        data.rc.left = data.rc.right - (int) Math.Round(area.Width);
                    }
                }
                else
                {
                    data.rc.left = 0;
                    data.rc.right = (int) SystemParameters.PrimaryScreenWidth;
                    if (data.uEdge == (int) AppBarEdge.Top)
                    {
                        data.rc.top = 0;
                        data.rc.bottom = (int) Math.Round(area.Height);
                    }
                    else
                    {
                        data.rc.bottom = (int) SystemParameters.PrimaryScreenHeight;
                        data.rc.top = data.rc.bottom - (int) Math.Round(area.Height);
                    }
                }

                SHAppBarMessage((int) ABMsg.ABM_QUERYPOS, ref data);
                SHAppBarMessage((int) ABMsg.ABM_SETPOS, ref data);

                return new Rect(data.rc.left, data.rc.top,
                    data.rc.right - data.rc.left, data.rc.bottom - data.rc.top);
            }

            [StructLayout(LayoutKind.Sequential)]
            private struct RECT
            {
                public int left;
                public int top;
                public int right;
                public int bottom;
            }

            [StructLayout(LayoutKind.Sequential)]
            private struct APPBARDATA
            {
                public int cbSize;
                public IntPtr hWnd;
                public int uCallbackMessage;
                public int uEdge;
                public RECT rc;
                public readonly IntPtr lParam;
            }

            private enum ABMsg : int
            {
                ABM_NEW = 0,
                ABM_REMOVE,
                ABM_QUERYPOS,
                ABM_SETPOS,
                ABM_GETSTATE,
                ABM_GETTASKBARPOS,
                ABM_ACTIVATE,
                ABM_GETAUTOHIDEBAR,
                ABM_SETAUTOHIDEBAR,
                ABM_WINDOWPOSCHANGED,
                ABM_SETSTATE
            }

            private enum ABNotify : int
            {
                ABN_STATECHANGE = 0,
                ABN_POSCHANGED,
                ABN_FULLSCREENAPP,
                ABN_WINDOWARRANGE
            }

            [DllImport("SHELL32", CallingConvention = CallingConvention.StdCall)]
            private static extern uint SHAppBarMessage(int dwMessage, ref APPBARDATA pData);

            [DllImport("User32.dll", CharSet = CharSet.Auto)]
            private static extern int RegisterWindowMessage(string msg);
        }
    }
}

参考资料

05-23 2019

.NET/C# 使用 ConditionalWeakTable 附加字段(CLR 版本的附加属性,也可用用来当作弱引用字典 WeakDictionary)

如果你使用过 WPF/UWP 等 XAML UI 框架,那么应该了解到附加属性的概念。那么没有依赖属性支持的时候如何做附加属性的功能呢?你可能会想到弱引用。但这需要做一个弱引用字典,要写的代码还是非常麻烦的。

本文介绍 .NET 的 ConditionalWeakTable<TKey,TValue> 类型,适用于 .NET Framework 4.0 以上和全部 .NET Core 的版本。


这不是字典

现成可用的弱引用字典,即 ConditionalWeakTable<TKey,TValue>。然而实际上这个类的原本作用并不是当作字典使用!

如果你使用过 WPF/UWP 等 XAML UI 框架,那么应该了解到附加属性的概念。这其实是 .NET 为我们提供的一种附加字段的机制。

比如你有一个类:

class Foo
{
    // 请忽略这里公有字段带来的设计问题,只是为了演示。
    public string A;
}

我们希望为它增加一个字段 Bar

class Foo
{
    public string A;
    public Bar Bar;
}

那么我们需要修改类 Foo 本身以实现这个效果;但是这样就使得 Foo 耦合了 Bar,从而破坏了内聚性/依赖倒置原则。典型的情况是 Foo 类表示一个人 Person,它里面不应该包含一个 某行账号 这样的字段,因为很多人是没有那家银行账号的。这个信息让那家银行存起来才是比较符合设计原则的设计。

我们可以通过一个字典 Dictionary<Foo, Bar> 来存储所有 Foo 实例额外增加的 Bar 的值可以避免让 Foo 类中增加 Bar 字段从而获得更好的设计。但这样就引入了一个静态字典从而使得所有的 FooBar 的实例无法得到释放。我们想当然希望拥有一个弱引用字典来解决问题。然而这是一个 X-Y 问题

实际上 .NET 中提供了 ConditionalWeakTable<TKey,TValue> 帮我们解决了最本质的问题——在部分场景下期望为 Foo 类添加一个字段。虽然它不是弱引用字典,但能解决此类问题,同时也能当作一个弱引用字典来使用,仅此而已。

你需要注意的是,ConditionalWeakTable<TKey,TValue> 并不实现 IDictionary<TKey,TValue> 接口,只是里面有一些像 IDictionary<TKey, TValue> 的方法,可以当作字典使用,也可以遍历取出剩下的所有值。

验证

ConditionalWeakTable<TKey,TValue> 中的所有 Key 和所有的 Value 都是弱引用的,并且会在其 Key 被回收或者 Key 和 Value 都被回收之后自动从集合中消失。这意味着当你使用它来为一个类型附加一些字段或者属性的时候完全不用担心内存泄漏的问题。

下面我写了一段代码用于验证其内存泄漏问题:

  1. ConditionalWeakTable<TKey,TValue> 中添加了三个键值对;
  2. 将后两个的 key 设为 null
  3. 进行垃圾回收。
using System;
using System.Linq;
using System.Runtime.CompilerServices;

namespace Walterlv.Demo.Weak
{
    class Program
    {
        public static void Main()
        {
            var key1 = new Key("Key1");
            var key2 = new Key("Key2");
            var key3 = new Key("Key3");

            var table = new ConditionalWeakTable<Key, WalterlvValue>
            {
                {key1, new WalterlvValue()},
                {key2, new WalterlvValue()},
                {key3, new WalterlvValue()}
            };

            var weak2 = new WeakReference(key2);
            key2 = null;
            key3 = null;

            GC.Collect();

            Console.WriteLine($@"key1 = {key1?.ToString() ?? "null"}
key2 = {key2?.ToString() ?? "null"}, weak2 = {weak2.Target ?? "null"}
key3 = {key3?.ToString() ?? "null"}
Table = {{{string.Join(", ", table.Select(x => $"{x.Key} = {x.Value}"))}}}");
        }
    }

    public class Key
    {
        private readonly string _name;
        public Key(string name) => _name = name;
        public override string ToString() => _name;
    }

    public class WalterlvValue
    {
        public DateTime CreationTime = DateTime.Now;
        public override string ToString() => CreationTime.ToShortTimeString();
    }
}

这段代码的运行结果如下图:

运行结果

从中我们可以发现:

  1. 当某个 Key 被回收后,ConditionalWeakTable<TKey,TValue> 中就没有那一项键值对了;
  2. 当 Key 的实例依然在的时候,ConditionalWeakTable<TKey,TValue> 中的 Value 依然还会存在。

另外,我们这里在调查内存泄漏问题,你需要在 Release 配置下执行此代码才能得到最符合预期的结果。


参考资料

05-22 2019

WPF 判断一个对象是否是设计时的窗口类型,而不是运行时的窗口

当我们对 Window 类型写一个附加属性的时候,在属性变更通知中我们需要判断依赖对象是否是一个窗口。但是,如果直接判断是否是 Window 类型,那么在设计器中这个属性的设置就会直接出现异常。

那么有没有什么方法能够得知这是一个设计时的窗口呢?这样就不会抛出异常,而能够完美支持设计器了。


方法一:判断设计时属性

WPF 原生自带一个附加属性可以判断一个依赖对象是否来源于设计器。而这个属性就是 DesignerProperties.IsInDesignMode

在 WPF 的设计器中,这个属性会被设计器重写元数据,指定其值为 true,而其他默认的情况下,它的默认值都是 false

所以通过判断这个值可以得知此时是否是在设计器中使用此附加属性。

if (DesignerProperties.GetIsInDesignMode(d))
{
    // 通常我们考虑在设计器中不做额外的任何事情是最偷懒不会出问题的代码了。
    return;
}

我在这些博客中使用过这样的判断方法,可以参见源码:

方法二:判断设计时窗口

上面的方法是个通用的判断设计器中的方法。不过,如果我们希望得到更多的设计器支持,而不是像上面那样直接 return 导致此属性在设计器中一点效果都没有的话,我们需要进行更精确的判断。

然而设计器中的类型我们不能直接引用到,所以可以考虑进行类型名称判断的方式。类型名称判断的方式会与 Visual Studio 的版本相关,所以实际上代码并不怎么好看。

我将判断方法整理如下:

public static class WalterlvDesignTime
{
    /// <summary>
    /// 判断一个依赖对象是否是设计时的 <see cref="Window"/>。
    /// </summary>
    /// <param name="window">要被判断设计时的 <see cref="Window"/> 对象。</param>
    /// <returns>如果对象是设计时的 <see cref="Window"/>,则返回 true,否则返回 false。</returns>
    private static bool IsDesignTimeWindow(DependencyObject window)
    {
        const string vs201920172015Window =
            "Microsoft.VisualStudio.DesignTools.WpfDesigner.InstanceBuilders.WindowInstance";
        const string vs2013Window = "Microsoft.Expression.WpfPlatform.InstanceBuilders.WindowInstance";

        if (DesignerProperties.GetIsInDesignMode(window))
        {
            var typeName = window.GetType().FullName;
            if (Equals(vs201920172015Window, typeName) || Equals(vs2013Window, typeName))
            {
                return true;
            }
        }

        return false;
    }
}

于是,只需要调用一下这个方法即可得到此窗口实例是否是设计时的窗口:

if (WalterlvDesignTime.IsDesignTimeWindow(d))
{
    // 检测到如果是设计时的窗口,就跳过一些句柄等等一些真的需要一个窗口的代码调用。
}
else if (d is Window)
{
    // 检测到真的是窗口,做一些真实窗口初始化需要做的事情。
}
else
{
    // 这不是一个窗口,需要抛出异常。
}

05-16 2019

通过修改环境变量修改当前进程使用的系统 Temp 文件夹的路径

Windows 系统提供了一个在 Windows 单个用户下全局的 Temp 文件夹,用于给各种不同的应用程序提供一个临时目录。但是,直到 Windows 10 推出存储感知功能之前,这个文件夹都一直只归各个应用程序自己管理,应用自己需要删除里面的文件。另外,进程多了,临时文件也会互相影响(例如个数过多、进程读写竞争等等)。

本文介绍将自己当前进程的 Temp 文件夹临时修改到应用程序自己的一个临时目录下,避免与其他程序之间的各种影响,同时也比较容易自行清理。


如何修改 Temp 文件夹的路径

在程序启动的时候,调用如下方法:

var newTempFolder = @"C:\Walterlv\ApplicationTemp";
Environment.SetEnvironmentVariable("TEMP", newTempFolder);
Environment.SetEnvironmentVariable("TMP", newTempFolder);

这样,可以将当前进程的临时文件夹设置到 C:\Walterlv\ApplicationTemp 文件夹下。

上面设置了两个环境变量,实际上 .NET Framework 中主要使用的临时文件夹环境变量是 TMP 那个。

使用临时文件夹中的临时文件

使用 Path.GetTempPath() 可以获取临时文件夹的路径:

var tempPath = Path.GetTempPath();

使用 Path.GetTempFileName() 可以生成一个唯一的临时文件文件名:

var tempPath = Path.GetTempFileName();

不过,使用此方法需要注意,这要求临时文件夹必须存在。如果你使用了前面的方法修改了临时文件夹的地址,请务必确保文件夹存在。

扩展阅读

如果使用 Path.GetTempFileName() 方法创建的临时文件数量达到了 65535 个,而又不及时删除掉创建的文件的话,那么再调用此方法将抛出异常 IOException

需要注意的是,此 API 调用创建的文件数量是当前用户账户下所有程序共同累计的,其他程序用“满”了你的进程也一样会挂。当然,如果你使用的不是 .NET 的 API,而是使用原生 Win32 API,那么你可以指定临时文件名前缀,相同临时文件名前缀的程序会累计数量。而 .NET 中此 API 使用的是 tmp 前缀,所以所有的 .NET 程序会共享这 65535 个文件累计;其他程序使用其他前缀使则分别累计。

另外,如果此方法无法再生成一个唯一的文件名的时候也会抛出异常。

为了解决这些异常,在用户端的解决方案是删除临时文件夹。而在程序端的解决方案是 —— 本文。

本文是为了和 林德熙 一起解决一个光标问题时提出的解决方案的一种。更多关于光标问题的内容可以阅读以下链接:


参考资料

05-15 2019

C# 8.0 中开启默认接口实现

当你升级到 C# 8.0 和 .NET Core 3.0 之后,你就可以开始使用默认接口实现的功能了。

从现在开始,你可以在接口里面添加一些默认实现的成员,避免在接口中添加成员导致大量对此接口的实现崩溃。


最低要求

要写出并且正常使用接口的默认实现,你需要:

  • C# 8.0
  • .NET Core 3.0
  • Visual Studio 2019 Preview (16.1 以上版本)

下载安装 Visual Studio 2019 Preview 版本

开启 .NET Core 3.0 的支持

对于预览版的 Visual Studio 2019 来说,.NET Core 的预览版是默认打开且无法关闭的,所以不需要关心。

开启 C# 8.0 支持

请设置你项目的属性,修改 C# 语言版本为 8.0(对于预览版的语言来说,这是必要的):

修改语言版本

或者直接修改你的项目文件,加上 LangVersion 属性的设置,设置为 8.0

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
  </PropertyGroup>

</Project>

默认接口实现

以前的做法

比如,我们现在有下面这样一个简单的接口:

public interface IWalterlv
{
    void Print(string text);
}

这个接口被大量实现了。

现在,我们需要在接口中新增一个方法 DouBPrint,其作用是对 Print 方法进行标准化,避免各种不同实现带来的标准差异。于是我们新增一个方法:

    public interface IWalterlv
    {
        void Print(string text);

++      void DouBPrint(string text);
    }

然而我们都知道,这样的修改是破坏性的:

  1. 会使得所有实现这个接口的代码全部失败(无法编译通过,或者运行时抛出异常)
  2. 我们依然很难将接口的实现标准化,靠文档来规约

默认接口实现

那么现在,我们可以这样来新增此方法:

    public interface IWalterlv
    {
        void Print(string text);
        
--      void DouBPrint(string text);
++      public void DouBPrint(string text) => Print($"Walterlv 逗比 {text}");
    }

在使用此方法来定义此接口中的方法后,那些没来得及实现此方法的类型也可以编译通过并获得标准化的实现。

class Program
{
    static void Main(string[] args)
    {
        IWalterlv walterlv = new Foo();
        walterlv.DouBPrint("walterlv");
    }
}

public class Foo : IWalterlv
{
    public void Print(string text)
    {
    }
}

当然,对于 Foo 类型来说,实现也是可以的:


public class Foo : IWalterlv
{
    public void Print(string text)
    {
    }

    public void DouBPrint(string text) => Print($"Walterlv 逗比 {text}");
}

静态字段和方法

除此之外,在接口中还可以编写静态字段和静态方法,这可以用来统一接口中的一些默认实现。

意味着,如果类没有实现接口中带有默认实现的方法,那么具有默认的实现;而如果类中打算实现接口中的带有默认实现的方法,那么也可以调用接口中的静态方法来进行实现。

    public interface IWalterlv
    {
        void Print(string text);

--      public void DouBPrint(string text) => Print($"Walterlv 逗比 {text}");
++      public void DouBPrint(string text) => DefaultDouBPrint(this, text);
++
++      private static readonly string _name = "walterlv";
++
++      protected static void DefaultDouBPrint(IWalterlv walterlv, string text)
++          => walterlv.Print($"{_name} 逗比 {text}");
    }

然后,对于实现方,则需要使用接口名来调用接口中的静态成员:

    public class Foo : IWalterlv
    {
        public void Print(string text)
        {
        }

--      public void DouBPrint(string text) => Print($"Walterlv 逗比 {text}");
++      public void DouBPrint(string text)
++      {
++          // Do Other things.
++          IWalterlv.DefaultDouBPrint(this, text);
++      }
++  }

参考资料

05-14 2019

让 Directory Opus 支持 Windows 10 的暗色主题

使用 Directory Opus 替代 Windows 自带的文件资源管理器来管理你计算机上的文件可以极大地提高你的文件处理效率。

由于我自己的 Windows 10 系统使用的是暗色主题,所以我希望 Directory Opus 也能搭配我系统的纯暗色主题。

本文介绍如何将 Directory Opus 打造成搭配 Windows 10 的暗色主题。


Directory Opus 主题支持

Directory Opus 在安装完之后的默认主题样式是下面这样的:

Directory Opus 默认主题

然而,我的 Windows 10 的主要界面都是暗黑色的:

Windows 10 中的主题

那么,请在 Directory Opus 顶部菜单中选择 设置 -> 主题

设置 -> 主题

然后点击左下角的下载主题去网上下载一款主题。

主题选择页面

Windows 10 暗色风格的主题

你可以直接使用下面的链接下载 Windows 10 暗色风格的主题:

然后,依然进入我们一开始说的 设置 -> 主题 对话框中,导入刚刚我们下载好的主题:

导入主题

点击“应用”,随后 Directory Opus 会重新启动,你将看到全新的 Windows 10 暗色风格主题。

微调主题样式

等等!为什么重启之后看起来样式怪怪的?有一些文件的文字其实在暗色主题下看不太清。

这当然是主题设计者没有考虑到所有的情况导致的,实际上你下载的任何一款主题可能都有各种考虑不周的情况,那么如何修复这些考虑不周的细节呢?

我们需要前往 设置 -> 选项 中微调这些细节。在“选项”对话框中,选择“颜色和字体”标签。

设置 -> 选项

颜色和字体

微调文件组标题

在我一开始的暗色主题应用后,我们注意到我的文件是分组的,组标题是深蓝色,看不清。于是修改“文件组标题”中的颜色:

文件组标题

微调压缩的文件和文件夹

另外,我的多数文件是加入了 NTFS 压缩的,这部分文件被主题设置了很难看清的深紫色,我将它改为其他的颜色:

文件和文件夹

微调其他部件

里面还有大量可以微调的部件,如果你遇到了不符合你要求的颜色设置,则将其修改即可。

以下是我进行了微调之后的主题效果预览:

修改后的主题效果

还原成默认的主题

你可能会注意到在主题选择窗格中只有我们刚刚下载的那一个主题,我们不能选择回默认的主题样式。那如果一个主题被我们改残了,或者就是想重新体验原生效果的时候该如何做呢?

我们依然需要进入到 设置 -> 选项 中,然后选择“颜色和字体”标签。

这时,选择顶部的 文件 -> 重置该页到默认值。于是,我们的主题就会还原到最初没有修改任何字体和颜色的版本。

重置主题的设置

如果主题涉及到图标等其他资源,也需要进入对应的标签页然后还原对应标签页的设置。


参考资料

05-13 2019

通过分析 WPF 的渲染脏区优化渲染性能

本文介绍通过发现渲染脏区来提高渲染性能。


脏区 Dirty Region

在计算机图形渲染中,可以每一帧绘制全部的画面,但这样对计算机的性能要求非常高。

脏区(Dirty Region)的引入便是为了降低渲染对计算机性能的要求。每一帧绘制的时候,仅仅绘制改变的部分,在软件中可以节省大量的渲染资源。而每一帧渲染时,改变了需要重绘的部分就是脏区。

以下是我的一款 WPF 程序 Walterlv.CloudKeyboard 随着交互的进行不断需要重绘的脏区。

较多的脏区

可以看到,脏区几乎涉及到整个界面,而且刷新非常频繁。这显然对渲染性能而言是不利的。

当然这个程序很小,就算一直全部重新渲染性能也是可以接受的。不过当程序中存在比较复杂的部分,如大量的 Geometry 以及 3D 图形的时候,重新渲染这一部分将带来严重的性能问题。

WPF 性能套件

先下载 WPF 性能套件:

脏区监视

启动 WPF Performance Suite,选择工具 Perforator,然后在 Action 菜单中启动一个待分析的 WPF 进程。虽然工具很久没有更新,但依然可以支持基于 .NET Core 3 版本的 WPF 程序。

启动一个进程

当程序运行起来后,可以看到 WPF 程序的各种性能数据图表。

WPF 性能收集工具

现在将 Show dirty-region update overlay 选项打勾即可看到本文一开始的脏区叠加层的显示。

与脏区有关的选项有三个:

  • Show dirty-region update overlay
    • 显示脏区叠加层,每一次脏区出现需要重新渲染时会叠加一层新的半透明颜色。
  • Disable dirty region support
    • 禁用脏区支持。这时,每次渲染都将重绘整个窗口。
  • Clear back-buffer before rendering
    • 每次重绘之前都将清除之前所有的绘制,使用此选项,你可以迅速找到界面中频繁刷新的部分,而重绘频率不高的部分多数时候都是纯黑。

优化脏区重绘

一开始的程序中,因为我使用了模拟 UWP 的高光效果,导致大量的控件在重绘高光部分,这是导致每一帧都在重新渲染的罪魁祸首。

于是我将高光渲染关闭,脏区的重新渲染将仅仅几种在控件样式改变的时候(例如焦点改变):

稍微正常一点的脏区

光照效果可以参见我的另一篇博客:


参考资料

04-30 2019

使用 EnumWindows 找到满足你要求的窗口

在 Windows 应用开发中,如果需要操作其他的窗口,那么可以使用 EnumWindows 这个 API 来枚举这些窗口。

本文介绍使用 EnumWindows 来枚举并找到自己关心的窗口(如 QQ/TIM 窗口)。


EnumWindows

你可以在微软官网了解到 EnumWindows

要在 C# 代码中使用 EnumWindows,你需要编写平台调用 P/Invoke 代码。使用我在另一篇博客中的方法可以自动生成这样的平台调用代码:

我这里直接贴出来:

[DllImport("user32.dll")]
public static extern int EnumWindows(WndEnumProc lpEnumFunc, int lParam);

遍历所有的顶层窗口

官方文档对此 API 的描述是:

Enumerates all top-level windows on the screen by passing the handle to each window, in turn, to an application-defined callback function.

遍历屏幕上所有的顶层窗口,然后给回调函数传入每个遍历窗口的句柄。

不过,并不是所有遍历的窗口都是顶层窗口,有一些非顶级系统窗口也会遍历到,详见:EnumWindows 中的备注节

所以,如果需要遍历得到所有窗口的集合,那么可以使用如下代码:

public static IReadOnlyList<int> EnumWindows()
{
    var windowList = new List<int>();
    EnumWindows(OnWindowEnum, 0);
    return windowList;

    bool OnWindowEnum(int hwnd, int lparam)
    {
        // 可自行加入一些过滤条件。
        windowList.Add(hwnd);
        return true;
    }
}

遍历具有指定类名或者标题的窗口

我们需要添加一些可以用于过滤窗口的 Win32 API。以下是我们即将用到的两个:

// 获取窗口的类名。
[DllImport("user32.dll")]
private static extern int GetClassName(int hWnd, StringBuilder lpString, int nMaxCount);

// 获取窗口的标题。
[DllImport("user32")]
public static extern int GetWindowText(int hwnd, StringBuilder lptrString, int nMaxCount);

于是根据类名找到窗口的方法:

public static IReadOnlyList<int> FindWindowByClassName(string className)
{
    var windowList = new List<int>();
    EnumWindows(OnWindowEnum, 0);
    return windowList;

    bool OnWindowEnum(int hwnd, int lparam)
    {
        var lpString = new StringBuilder(512);
        GetClassName(hwnd, lpString, lpString.Capacity);
        if (lpString.ToString().Equals(className, StringComparison.InvariantCultureIgnoreCase))
        {
            windowList.Add(hwnd);
        }

        return true;
    }
}

使用此方法,我们可以传入 "txguifoundation" 找到 QQ/TIM 的窗口:

var qqHwnd = FindWindowByClassName("txguifoundation");

要获取窗口的标题,或者把标题作为过滤条件,则使用 GetWindowText

在 QQ/TIM 中,窗口的标题是聊天对方的名字或者群聊名称。

var lptrString = new StringBuilder(512);
GetWindowText(hwnd, lptrString, lptrString.Capacity);

参考资料

04-30 2019

XAML 很少人知道的科技

本文介绍不那么常见的 XAML 相关的知识。


Thickness 可以用空格分隔

当你用设计器修改元素的 Margin 时,你会看到用逗号分隔的 Thickness 属性。使用设计器或者属性面板时,使用逗号是默认的行为。

不过你有试过,使用空格分隔吗?

<Button Margin="10 12 0 0" />

使用逗号(,)设置多值枚举

有一些枚举标记了 [Flags] 特性,这样的枚举可以通过位运算设置多个值。

[Flags]
enum NonClientFrameEdges
{
    // 省略枚举内的值。
}

那么在 XAML 里面如何设置多个枚举值呢?使用逗号(,)即可,如下面的例子:

<WindowChrome NonClientFrameEdges="Left,Bottom,Right" GlassFrameThickness="0 64 0 0" UseAeroCaptionButtons="False" />

使用加号(+)设置多值枚举

使用逗号(,) 设置多值枚举是通用的写法,但是在 WPF/UWP 中设置按键/键盘快捷键的时候又有加号(+)的写法。如下面的例子:

<KeyBinding Command="{x:Static WalterlvCommands.Foo}" Modifiers="Control+Shift" Key="W" />

这里的 Modifiers 属性的类型是 ModifierKeys,实际上是因为这个类型特殊地编写了一个 TypeConverter 来转换字符串,所以键盘快捷键多值枚举使用的位或运算用的是加号(+)。

设置 Url 型的 XAML 命名空间(xmlns)

WPF/UWP 中原生控件的 XAML 命名空间是 http://schemas.microsoft.com/winfx/2006/xaml/presentation,与 XAML 编译器相关的 XAML 命名空间是 http://schemas.microsoft.com/winfx/2006/xaml,还有其他 Url 形式的 XAML 命名空间。

只需要在库中写如下特性(Attribute)即可将命名空间指定为一个 url:

using System.Windows.Markup;
[assembly: XmlnsDefinition("http://walterlv.github.io/demo", "Walterlv.NewCsprojDemo")]

详情请阅读博客:

此写法要生效,定义的组件与使用的组件不能在同一程序集。

设置默认的 XAML 命名空间前缀

WPF/UWP XAML 编译器的命名空间前缀是 x。如果你写了自己的控件,希望给控件指定一个默认的命名空间前缀,那么可以通过在库中写如下特性(Attribute)实现:

using System.Windows.Markup;
[assembly: XmlnsPrefix("http://walterlv.github.io/demo", "w")]

这样,当 XAML 设计器帮助你自动添加命名空间时,将会使用 w 前缀。虽然实际上你也能随便改。

详情请阅读博客:

此写法要生效,定义的组件与使用的组件不能在同一程序集。

让你做的控件库不需要 XAML 命名空间前缀

自己写了一个 DemoPage,要在 XAML 中使用,一般需要添加命名空间前缀才可以。但是也可以不写:

<UserControl
    x:Class="HuyaHearhira.UserControl1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Grid>
        <DemoPage />
    </Grid>
</UserControl>

方法是在库中定义命名空间前缀为 http://schemas.microsoft.com/winfx/2006/xaml/presentation

using System.Windows.Markup;
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "Walterlv.NewCsprojDemo")]

此写法要生效,定义的组件与使用的组件不能在同一程序集。

04-30 2019

WPF 程序鼠标在窗口之外的时候,控件拿到的鼠标位置在哪里?

在 WPF 程序中,我们有 Mouse.GetPosition(IInputElement relativeTo) 方法可以拿到鼠标当前相对于某个 WPF 控件的位置,也可以通过在 MouseMove 事件中通过 e.GetPosition(IInputElement relativeTo) 方法拿到同样的信息。不过,在任意时刻去获取鼠标位置的时候,如果鼠标在窗口之外,将获取到什么点呢?

本文将介绍鼠标在窗口之外时获取到的鼠标位置。


可用于演示的 DEMO

直接使用 Visual Studio 2019 创建一个空的 WPF 应用程序。默认 .NET Core 版本的 WPF 会带一个文本框和一个按钮。我们现在就用这两个按钮来显示 Mouse.GetPosition 获取到的值。

using System;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;

namespace Walterlv.Demo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            CompositionTarget.Rendering += OnRendering; 
        }

        private void OnRendering(object sender, EventArgs e)
        {
            DebugTextBlock.Text = Mouse.GetPosition(DebugTextBlock).ToString();
            DebugButton.Content = Mouse.GetPosition(DebugButton).ToString();
        }
    }
}

观察现象

我们运行这个最简单的 Demo,然后不断移动鼠标,可以观察到一旦鼠标脱离窗口客户区,获取到的坐标点将完全固定。

鼠标在各处时获取到的点坐标

如果不知道客户区是什么,可以阅读下面我的另一篇博客:

在以上图中,我拖动改变了窗口的位置,这时将鼠标移动至离开客户区后,获取到的坐标点又被固定为另一个数值。

推断结论

从上面的动图中以及我实际的测量发现,当鼠标移出窗口的客户区之后,获取鼠标的坐标的时候始终拿到的是屏幕的 (0, 0) 点。如果有多个屏幕,是所有屏幕组合起来的虚拟屏幕的 (0, 0) 点。

验证这一点,我们把窗口移动到屏幕的左上角后,将鼠标移出客户区,左上角的控件其获取到的鼠标位置已经变成了 (0, 31),而这个是窗口标题栏非客户区的高度。

将窗口移至屏幕的左上角

原理

Mouse.GetPosition 获取鼠标相对于控件的坐标点的方法在内部的最终实现是 user32.dll 中的 ClientToScreen

[DllImport("user32.dll")]
static extern bool ClientToScreen(IntPtr hWnd, ref Point lpPoint);

此方法需要使用到一个窗口句柄参数,此参数的含义:

A handle to the window whose client area is used for the conversion.

用于转换坐标点的窗口句柄,坐标会被转换到窗口的客户区部分。

If the function succeeds, the return value is nonzero.
If the function fails, the return value is zero.

如果此方法成功,将返回非零的坐标值;如果失败,将返回 0。

而鼠标在窗口客户区之外的时候,此方法将返回 0,并且经过后面的 ToPoint() 方法转换到控件的坐标下。于是这才得到了我们刚刚观察到的坐标值。

[SecurityCritical, SecurityTreatAsSafe]
public static Point ClientToScreen(Point pointClient, PresentationSource presentationSource)
{
    // For now we only know how to use HwndSource.
    HwndSource inputSource = presentationSource as HwndSource;
    if(inputSource == null)
    {
        return pointClient;
    }
    HandleRef handleRef = new HandleRef(inputSource, inputSource.CriticalHandle);

    NativeMethods.POINT ptClient            = FromPoint(pointClient);
    NativeMethods.POINT ptClientRTLAdjusted = AdjustForRightToLeft(ptClient, handleRef);

    UnsafeNativeMethods.ClientToScreen(handleRef, ptClientRTLAdjusted);

    return ToPoint(ptClientRTLAdjusted);
}

参考资料

04-29 2019

使用 dotnet 命令行配合 vscode 完成一个完整 .NET 解决方案的编写和调试

如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。

如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!


安装必要的软件和插件

  1. 点击这里下载正式或者预览版的 .NET Core 然后安装
  2. 点击这里下载 Visual Studio Code 然后安装
  3. 在 Visual Studio Code 里安装 C# for Visual Studio Code 插件(步骤如下图所示)

安装 C# for Visual Studio Code 插件

搜索的时候,推荐使用 OmniSharp 关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。

对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。

创建一个 .NET Core 控制台项目

准备一个空的文件夹,这个文件夹将会成为我们解决方案所在的文件夹,也就是 sln 文件所在的文件夹。在这个空的文件夹中打开 VSCode,然后打开 VSCode 的终端。

在 VSCode 中的终端中输入:

> dotnet new console -o Walterlv.Demo

这样会在当前的文件夹中创建一个 Walterlv.Demo 的子文件夹,并且在此文件夹中新建一个名为 Walterlv.Demo 的控制台项目。

创建一个控制台项目

如果你观察我们刚刚创建的项目,你会发现里面有一个 csproj 文件和一个 Program.cs 文件。csproj 文件是 Sdk 风格的项目文件,而 Program.cs 里面包含最简单的 Hello World 代码:

using System;

namespace Walterlv.Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

我们会考虑在一个子文件夹中创建项目,是因为我们会一步步创建一个比较复杂的解决方案,用以演示比较完整的使用 VSCode 开发 .NET 程序的过程。

添加一个解决方案

我们现在创建一个在 Visual Studio 会特别熟悉的解决方案,sln 文件。

使用以下命令创建一个解决方案文件:

> dotnet new sln

现在,这个解决方案文件还是空的,不包含任何项目,于是我们把我们一开始创建的 Walterlv.Demo 项目加入到此 sln 文件中。

使用以下命令添加:

> dotnet sln add .\Walterlv.Demo\Walterlv.Demo.csproj

于是,我们的解决方案中,就存在一个可以运行的控制台项目了。

开始调试最简单的程序

理论上,你按下 F5,选择 .NET Core 后就能自动生成调试所需的 launch.json 和 tasks.json 文件:

如果不能生成所需的文件,你可以使用以下博客中的方法,手动添加这两个文件:

在经过以上两篇博客中的方法之后,你将可以跑起来你的程序。

如果遇到了编译错误……呃这么简单的程序怎么可能遇到编译错误呢?一定是因为之前的操作有问题。可以考虑删除 binobj 文件夹,然后输入以下命令自行编译:

> dotnet build

这个命令会还原 NuGet 包,然后使用 .NET Core 版本的 MSBuild 编译你的解决方案。在此之后,你并不需要总是输入此命令,只需要像 Visual Studio 一样按下 F5 即可调试。

引用项目

现在我们演示如何引用项目。

首先使用以下命令创建一个类库项目:

> dotnet new classlib -o Walterlv.Library

将其添加到 sln 中。

> dotnet sln add .\Walterlv.Library\Walterlv.Library.csproj

于是我们的目录结构现在是这样的(稍微改了一点代码)。

目录结构

然后让我们的 Walterlv.Demo 项目引用这个刚刚创建的项目:

> dotnet add Walterlv.Demo reference .\Walterlv.Library\

现在,我们即可在 Program.cs 中使用到刚刚 Class1.cs 中编写的方法(见上面截图中写的方法)。

不过,当你写下 Class1 后,会没有此名称,但有快速操作提示可以自动添加命名空间(就像没有装 ReSharper 的 Visual Studio 的效果一样)。

有快速操作提示

可添加命名空间

有智能感知提示

这时再按下 F5 运行,可以看到多输出了一个 walterlv is a 逗比 这样的提示,我们成功使用到了刚刚引用的类。

运行的结果

引用 NuGet 包

接下来介绍如何引用 NuGet 包。

> dotnet add Walterlv.Demo package Newtonsoft.Json

这样可以给 Walterlv.Demo 项目引用 Newtonsoft.Json 包。

接下来就像前面一节我们所描述的那样使用这个包里面的类就好了。

04-26 2019

Visual Studio 通过修改项目的调试配置文件做到临时调试的时候不要编译(解决大项目编译缓慢问题)

.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。

在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。


我找到了两种临时调试而不用编译的方法:

新建一个普通的类库项目,右击项目,属性,打开属性设置页面。进入“调试”标签:

调试标签

现在,将默认的启动从“项目”改为“可执行文件”,然后将我们本来调试时输出的程序路径贴上去。

现在,如果你不希望编译大项目而直接进行调试,那么将启动项目改为这个小项目即可。

04-26 2019

Visual Studio 如何能够不进行编译就调试 .NET/C# 项目(用于解决大项目编译缓慢的问题)

.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。

在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。


我找到了两种临时调试而不用编译的方法:

不编译直接调试

有时候只是为了定位 Bug 不断重复运行以调试程序,并没有修改代码。然而如果 Visual Studio 的差量编译因为逗比项目失效的话,就需要手动告诉 Visual Studio 不需要进行编译,直接进行调试。

在 Visual Studio 中设置编译选项

进入 工具 -> 选项 -> 项目和解决方案 -> 生成并运行

打开选项

生成并运行

“当项目过期时”,选择“从不生成”。

顺便附中文版截图:

中文版生成并运行

这时,你再点击运行你的项目的时候,就不会再编译了,而是直接进入调试状态。

这特别适合用来定位 Bug,因为这时基本不改什么代码,都是在尝试复现问题以及查看各种程序的中间状态。

04-25 2019

WPF 获取元素(Visual)相对于屏幕设备的缩放比例,可用于清晰显示图片

我们知道,在 WPF 中的坐标单位不是屏幕像素单位,所以如果需要知道某个控件的像素尺寸,以便做一些与屏幕像素尺寸相关的操作,就需要经过一些计算(例如得到屏幕的 DPI)。

更繁琐的是,我们的控件可能外面有一些其他的控件做了 RenderTransform 进行了一些缩放,于是了解到屏幕像素单位就更不容易了。

本文将提供一套计算方法,帮助计算某个 WPF 控件相比于屏幕像素尺寸的缩放比例,用于进行屏幕像素级别的渲染控制。


一个 WPF 控件会经历哪些缩放?

如下图,我画了一个屏幕,屏幕里面有一个 WPF 窗口,WPF 窗口里面有一个或者多个 ViewBox 或者设置了 RenderTransform 这样的缩放的控件,一层层嵌套下有我们的最终控件。

这些缩放

于是,我们的控件如何得知此时相比于屏幕像素的缩放比呢?换句话说,如何得知此时此控件的显示占了多少个屏幕像素的宽高呢?

分别计算所有的缩放

从上面的图中,我们可以得知,有两种不同种类的缩放:

  1. 屏幕到 WPF 窗口的缩放
  2. WPF 窗口内部的缩放

屏幕到 WPF 窗口的缩放

我们知道 WPF 的单位叫做 DIP 设备无关单位。不过,我更希望引入 UWP 中的有效像素单位。实际上 WPF 和 UWP 的像素单位含义是一样的,只是 WPF 使用了一个画饼式的叫法,而 UWP 中的叫法就显得现实得多。

你可以阅读我的另一篇博客了解到有效像素单位:

有效像素主要就是考虑了 DPI 缩放。于是实际上我们就是在计算 DPI 缩放。

// visual 是我们准备找到缩放量的控件。
var ct = PresentationSource.FromVisual(visual)?.CompositionTarget;
var matrix = ct == null ? Matrix.Identity : ct.TransformToDevice;

这里,我们使用的是 PresentationSource.FromVisual(visual)?.CompositionTarget 因为不同屏幕可能存在不同的 DPI。

WPF 窗口内部的缩放

WPF 窗口内部的缩放,肯定不会是一层层自己去叠加。

实际上 WPF 提供了方法 TransformToAncestor 可以计算一个两个具有父子关系的控件的相对变换量。

于是我们需要找到 WPF 窗口中的根元素,可以通过不断查找可视化树的父级来找到根。

// VisualRoot 方法用于查找 visual 当前的可视化树的根,如果 visual 已经显示,则根会是窗口中的根元素。
var root = VisualRoot(visual);
var transform = ((MatrixTransform)visual.TransformToAncestor(root)).Value;

我封装的源码

为了方便使用,我进行了一些封装。

要获取某个 Visual 相比于屏幕的缩放量,则调用 GetScalingRatioToDevice 方法即可。

代码已经上传至 gits:https://gist.github.com/walterlv/6015ea19c9338b9e45ca053b102cf456

using System;
using System.Windows;
using System.Windows.Media;

namespace Walterlv
{
    public static class VisualScalingExtensions
    {
        /// <summary>
        /// 获取一个 <paramref name="visual"/> 在显示设备上的尺寸相对于自身尺寸的缩放比。
        /// </summary>
        public static Size GetScalingRatioToDevice(this Visual visual)
        {
            return visual.GetTransformInfoToDevice().size;
        }

        /// <summary>
        /// 获取一个 <paramref name="visual"/> 在显示设备上的尺寸相对于自身尺寸的缩放比和旋转角度(顺时针为正角度)。
        /// </summary>
        public static (Size size, double angle) GetTransformInfoToDevice(this Visual visual)
        {
            if (visual == null) throw new ArgumentNullException(nameof(visual));

            // 计算此 Visual 在 WPF 窗口内部的缩放(含 ScaleTransform 等)。
            var root = VisualRoot(visual);
            var transform = ((MatrixTransform)visual.TransformToAncestor(root)).Value;
            // 计算此 WPF 窗口相比于设备的外部缩放(含 DPI 缩放等)。
            var ct = PresentationSource.FromVisual(visual)?.CompositionTarget;
            if (ct != null)
            {
                transform.Append(ct.TransformToDevice);
            }
            // 如果元素有旋转,则计算旋转分量。
            var unitVector = new Vector(1, 0);
            var vector = transform.Transform(unitVector);
            var angle = Vector.AngleBetween(unitVector, vector);
            transform.Rotate(-angle);
            // 计算考虑了旋转的综合缩放比。
            var rect = new Rect(new Size(1, 1));
            rect.Transform(transform);

            return (rect.Size, angle);
        }

        /// <summary>
        /// 寻找一个 <see cref="Visual"/> 连接着的视觉树的根。
        /// 通常,如果这个 <see cref="Visual"/> 显示在窗口中,则根为 <see cref="Window"/>;
        /// 不过,如果此 <see cref="Visual"/> 没有显示出来,则根为某一个包含它的 <see cref="Visual"/>。
        /// 如果此 <see cref="Visual"/> 未连接到任何其它 <see cref="Visual"/>,则根为它自身。
        /// </summary>
        private static Visual VisualRoot(Visual visual)
        {
            if (visual == null) throw new ArgumentNullException(nameof(visual));

            var root = visual;
            var parent = VisualTreeHelper.GetParent(visual);
            while (parent != null)
            {
                if (parent is Visual r)
                {
                    root = r;
                }
                parent = VisualTreeHelper.GetParent(parent);
            }
            return root;
        }
    }
}

04-21 2019

C#/.NET 使用 git 命令行来操作 git 仓库

我们可以在命令行中操作 git,但是作为一名程序员,如果在大量重复的时候还手动敲命令行,那就太笨了。

本文介绍使用 C# 编写一个 .NET 程序来自动化地使用 git 命令行来操作 git 仓库。

这是一篇很基础的入门文章。


最简单的运行 git 命令的代码

在 .NET 中,运行一个命令只需要使用 Process.Start 开启一个子进程就好了。于是要运行一个 git 命令,我们其实只需要这句足以:

Process.Start("git", "status");

当然,直接能简写成 git 是因为 git.exe 在我的环境变量里面,一般开发者在安装 Git 客户端的时候,都会自动将此命令加入到环境变量。如果没有,你需要使用完整路径 C:\Program Files\Git\mingw64\bin\git.exe 只是每个人的路径可能不同,所以这是不靠谱的。

允许获得命令的输出

对于上节中写的 Process.Start,你一眼就能看出来这是完全没有用的代码。因为 git status 命令只是获得仓库当前的状态,这个命令完全不影响仓库,只是为了看状态的。

所以,命令最好要能够获得输出。

而要获得输出,你需要使用 ProcessStartInfo 来指定如何启动一个进程。

var info = new ProcessStartInfo(ExecutablePath, arguments)
{
    CreateNoWindow = true,
    RedirectStandardOutput = true,
    UseShellExecute = false,
    WorkingDirectory = WorkingDirectory,
};

需要设置至少这四个属性:

  • CreateNoWindow 表示不要为这个命令单独创建一个控制台窗口
    • 实际上如果使用此代码的程序也是一个控制台程序,这句是没有必要的,因为子进程会共用父进程的控制台窗口;但是对于 GUI 程序来说,这句还是很重要的,这可以避免在执行命令的过程中意外弹出一个黑色的控制台窗口出来。
  • RedirectStandardOutput 进行输出的重定向
    • 这是一定要设置为 true 的属性,因为我们希望拿到命令的输出结果。
  • WorkingDirectory 设置工作路径
    • 本来这是一个可选设置,不过对于 git 命令来说,一般都是对一个已有的 git 仓库进行操作,所以当然要指定一个合理的 git 仓库了。
  • UseShellExecute 设置为 false 表示不要使用 ShellExecute 函数创建进程
    • 此属性的详细说明,请阅读我的另一篇博客:ProcessStartInfo 中的 UseShellExecute - 吕毅
    • 这里我们必须指定为 false,因为要重定向输出的话,这是唯一有效值。顺便一提,此属性如果不设置,默认值是 true

CommandRunner

为了方便起见,我将全部运行一个命令的代码封装到了一个 CommandRunner 的类当中。

using System;
using System.Diagnostics;
using System.IO;

namespace Walterlv.GitDemo
{
    public class CommandRunner
    {
        public string ExecutablePath { get; }
        public string WorkingDirectory { get; }

        public CommandRunner(string executablePath, string? workingDirectory = null)
        {
            ExecutablePath = executablePath ?? throw new ArgumentNullException(nameof(executablePath));
            WorkingDirectory = workingDirectory ?? Path.GetDirectoryName(executablePath);
        }

        public string Run(string arguments)
        {
            var info = new ProcessStartInfo(ExecutablePath, arguments)
            {
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                UseShellExecute = false,
                WorkingDirectory = WorkingDirectory,
            };
            var process = new Process
            {
                StartInfo = info,
            };
            process.Start();
            return process.StandardOutput.ReadToEnd();
        }
    }
}

测试与结果

以上 CommandRunner 命令的使用非常简单,new 出来之后,得到一个可以用来执行命令的实例,然后每次执行调用 Run 方法传入参数即可。

var git = new CommandRunner("git", @"D:\Developments\Blogs\walterlv.github.io");
git.Run("add .");
git.Run(@"commit -m ""这是自动提交的""");

如果需要获得命令的执行结果,直接使用 Run 方法的返回值即可。

比如下面我贴了 Main 函数的完整代码,可以输出我仓库的当前状态:

using System;

namespace Walterlv.GitDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("walterlv 的自动 git 命令");

            var git = new CommandRunner("git", @"D:\Developments\Blogs\walterlv.github.io");
            var status = git.Run("status");

            Console.WriteLine(status);
            Console.WriteLine("按 Enter 退出程序……");
            Console.ReadLine();
        }
    }
}

运行结果

04-17 2019

WPF 像素着色器入门:使用 Shazzam Shader Editor 编写 HLSL 像素着色器代码

HLSL,High Level Shader Language,高级着色器语言,是 Direct3D 着色器模型所必须的语言。WPF 支持 Direct3D 9,也支持使用 HLSL 来编写着色器。你可以使用任何一款编辑器来编写 HLSL,但 Shazzam Shader Editor 则是专门为 WPF 实现像素着色器而设计的一款编辑器,使用它来编写像素着色器,可以省去像素着色器接入到 WPF 所需的各种手工操作。

本文是 WPF 编写 HLSL 的入门文章,带大家使用 Shazzam Shader Editor 来编写最简单的像素着色器代码。


下载安装

实际上 Shazzam Shader Editor 有一段时间没有维护了,不过在 WPF 下依然是一个不错的编写 HLSL 的工具。

下载完成之后安装到你的电脑上即可。

Shazzam 是开源的,但是官方开源在 CodePlex 上,https://archive.codeplex.com/?p=shazzam,而 CodePlex 已经关闭。JohanLarsson 将其 Fork 到了 GitHub 上,https://github.com/JohanLarsson/Shazzam,不过几乎只有代码查看功能而不提供维护。

Shazzam Shader Editor

主界面

Shazzam 的主界面

打开 Shazzam,左侧会默认选中 Sample Shaders 即着色器示例,对于不了解像素着色器能够做到什么效果的小伙伴来说,仅浏览这里面的特效就能够学到很多好玩的东西。

旁边是 Tutorial 教程,这里的教程是配合 HLSL and Pixel Shaders for XAML Developers 这本书来食用的,所以如果希望能够系统地学习 HLSL,那么读一读这本书跟着学习里面的代码吧!

左边的另一个标签是 Your Folder,可以放平时学习 HLSL 时的各种代码,也可以是你的项目代码,这里会过滤出 .fx 文件用于编写 HLSL 代码。

如果你打开关于界面,你可以看到这款软件很用心地在关于窗口背后使用了 TelescopicBlur 特效,这是一个 PS_3 特效,后面会解释其含义。

加了特效的关于界面

公共设置

依然在左侧,可以选择 Settings 设置。

Shazzam 设置

目标框架 Target Framework

WPF 自 .NET Framework 4.0 开始支持 PS_3,当然也包括现在的 .NET Core 3.0。如果你不是为了兼容古老的 .NET Framework 3.5 或者更早版本,则建议将默认的 PS_2 修改为 PS_3。因为 PS_2 的限制还是太多了。

关于 PS_3 相比于此前带来的更新可以查看微软的官方文档了解:ps_3_0 - Windows applications - Microsoft Docs

生成的命名空间 Generated Namespace

默认是 Shazzam,实际上在接入到你的项目的时候,这个命名空间肯定是要改的,所以建议改成你项目中需要使用到的命名空间。比如我的是 Walterlv.Effects

改好之后,如果你编译你的 .fx 文件,也就是编写了 HLSL 代码的文件,那么顺便也会生成一份使用 Walterlv.Effects 命名空间的 C# 代码便于你将此特效接入到你的 WPF 应用程序中。

缩进 Indentation

默认的缩进是 Tab,非常不清真,建议改成四个空格。

默认动画时长 Default Animation Length

如果你的特效是为了制作动画(实际上在 Shazzam 中编写的 HLSL,任何一个寄存器(变量)都可以拿来做动画),那么此值将给动画设置一个默认的时长。

相比于前面的所有设置,这个设置不会影响到你的任何代码,只是决定你预览动画效果时的时长,所以设置多少都没有影响。

编写 HLSL 代码

HLSL 代码窗格

实际上本文不会教你编写任何 HLSL 代码,也不会进行任何语法入门之类的,我们只需要了解 Shazzam 是如何帮助我们为 WPF 程序编写像素着色器代码的。

将你的视线移至下方富含代码的窗格,这里标记着 XXX.fx 的标签就是 HLSL 代码了。大致浏览一下,你会觉得这风格就是 C 系列的语言风格,所以从学校里出来的各位应该很有亲切感,上手难度不高。

按下 F5,即可立即编译你的 HLSL 代码,并在界面上方看到预览效果。别说你没有 HLSL 代码,前面我们可是打开了那么多个示例教程呀。

预览调节窗格

确保你刚刚使用 F5 编译了你的 HLSL 代码。这样,你就能在这个窗格看到各种预览调节选项。

预览调节

你可以直接拉动拉杆调节参数范围,也可以直接开启一个动画预览各种值的连续变化效果。

生成的 C# 代码

继续切换一个标签,你可以看到 Shazzam 为你生成的 C# 代码。实际上稍后你就可以直接使用这份代码驱动起你刚刚编写的特效。

代码风格使用了我们刚刚设置的一些全局参数。

生成的 C# 代码

将像素着色器放到 WPF 项目中

将像素着色器放到 WPF 项目中需要经过两个步骤:

  1. 找到生成的像素着色器文件,并放入 WPF 工程中;
  2. 修改像素着色器的生成方式。

将特效放入到你的 WPF 项目中

我们需要将两个文件加入到你的 WPF 程序中:

  1. 一个 .ps 文件,即刚刚的 .fx 文件编译后的像素着色器文件;
  2. 一份用于驱动此像素着色器的 C# 代码。

这些文件都可以使用以下方法找到:

  1. 请前往 %LocalAppData%\Shazzam\GeneratedShaders 文件夹;
  2. 根据名称变化规则找到对应的文件夹:
    • 注意命名,如果你的 .fx 文件命名为 walterlv.fx,那么生成的文件就会在 WalterlvEffect 文件夹下
  3. 进入刚刚找到的 XxxEffect 文件夹,里面有你需要的所有文件:
    • 一个 .ps 文件
    • 一个 C# 文件(以及 VB 文件)

随后,将这两份文件一并加入到你的 WPF 项目工程文件中。

但是,请特别注意路径!留意你的 C# 代码,里面是编写了像素着色器的路径的:

  1. 如果你的程序集名称是其他名称,需要修改下面 Walterlv.Effects 的部分改成你的程序集名称;
  2. 如果你放到了其他的子文件夹中,你也需要在下面 /WalterlvEffect.ps 的前面加上子文件夹。
// 记得修改程序集名称,以及 .ps 文件所在的文件夹路径!切记!
pixelShader.UriSource = new Uri("/Walterlv.Effects;component/WalterlvEffect.ps", UriKind.Relative);

修改像素着色器的生成方式

需要使用 Resource 方式编译此 .ps 文件到 WPF 项目中。

如果你使用的是旧的项目格式,则右键此 .ps 文件的时候选择属性,你可以在 Visual Studio 的属性窗格的生成操作中将其设置为 Resource

右键属性

使用 Resource 编译

如果你使用的是 Sdk 风格的新项目格式,则在属性窗格中无法将其设置为 Resource,这个时候请直接修改 .csproj 文件,加上下面一行:

<Resource Include="**\*.ps" />

如果不知道怎么放,我可以多贴一些 csproj 的代码,用于指示其位置:

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

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <UseWPF>true</UseWPF>
    <AssemblyName>Walterlv.Demo</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <Resource Include="**\*.ps" />
  </ItemGroup>

</Project>

在 WPF 程序中使用这个特效

要在 WPF 程序中使用这个特效,则设置控件的 Effect 属性,将我们刚刚生成的像素着色器对应 C# 代码的类名写进去即可。当然,需要在前面引入 XAML 命名空间。

<Window x:Class="Walterlv.CloudTyping.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:effects="clr-namespace:Walterlv.Effects"
        Title="walterlv">
    <Grid>
        <Grid.Effect>
            <effects:WalterlvEffect />
        </Grid.Effect>
        <!-- 省略了界面上的各种代码 -->
    </Grid>
</Window>

下面是我将 Underwater 特效加入到我的云键盘窗口中,给整个窗口带来的视觉效果。

云键盘的水下特效

入门总结

本文毕竟是一篇入门文章,没有涉及到任何的技术细节。你可以按照以下问题检查是否入门成功:

  1. 你能否成功安装并打开 Shazzam Shader Editor 软件?
  2. 你能否找到并打开一个示例像素着色器代码,并完成编译预览效果?
  3. 知道如何设置像素着色器使用 PS_3 版本吗?
  4. 尝试将一个示例像素着色器编译完并放入到你的 WPF 项目中。
  5. 尝试将特效应用到你的一个 WPF 控件中查看其效果。

参考资料

04-16 2019

C# 跨设备前后端开发探索

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

带起你的好奇心,本文将使用 C# 开发各种各样好玩的东西。


C# 跨设备前后端开发探索

本文内容已加入 2019 年 4 月 13 日的广州 .NET 俱乐部第 2 届线下沙龙

0x00 序章

好奇心

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

比如这件事:

手机上打字慢

在好奇心的驱使下,我们立刻 尝试 我们的想法。

我们需要用电脑打字,手机端出字;于是我们需要开发的是一款云输入法。而一个最简单的云驱动的软件需要至少一个 Web 后端、一个桌面端和一个移动端。

还没开始呢,就这么复杂。

需要至少三个端

先搞起来

摆在我们面前的,有两条路可以选:

  1. 先掌握所有理论知识再实践
  2. 无论什么技术,先搞起来

如果先搞起来,那么我们能够迅速出效果,出产品,出玩具,那么这种成就感会鼓励我们继续完善我们的代码,继续去做更多好玩的东西。

而如果是先掌握所有理论知识再实践,这是我们从学校带来的学习方式,我们中的多数人在校期间就是这么学习的。虽然对学霸来说可以无视,但对于我们这样大多数的小伙伴来说,简直就是“从入门到放弃”。

从入门到放弃

如果先搞起来呢?如果我们连“入门”都不需要呢?是不是就不需要放弃了!

怎么才能够先搞起来?我们需要调整一下心态——我们不是在学,而是在玩!

我们需要做的是降低学习成本,甚至入门不学习,那么立刻就能玩起来!

搞起来

我们有 C#,还有什么不能马上搞起来!

0x01 C# 跨设备前后端开发

打开 Visual Studio 2019,我们先搞起来!

Visual Studio 2019

Web 后端

创建一个 Asp.NET Core Web 应用程序

输入项目的名称

选择 API 开发

对于简单的云服务来说,使用 Asp.NET Core 开发是非常简单快速的。你可以阅读林德熙的博客入门 Asp.NET Core 开发:

Windows 桌面端

开发 Windows 桌面端

我们是要玩的呀,什么东西好玩。我们自己就是用户,用户看得到的部分才是最具有可玩性的。这就是指客户端或者 Web 前端。

我们现在要拿 C# 写客户端,一般 C# 或者 .NET 的开发者拿什么来写桌面客户端呢?

  • WPF 或者 Windows Forms 应用程序

WPF 程序

Windows Forms 程序

公共代码

我们现在已经有至少两个端了。由于我们是同一个软件系统,所以实际上非常容易出现公共代码。典型的就是一些数据模型的定义,以及 Web API 的访问代码,还有一些业务需要的其他公共代码等等。

所以,我们最好使用一个新的项目将这些代码整合起来。

我们选用 .NET Standard 项目来存放这些代码,这样可以在各种 .NET 中使用这些库。

.NET Standard 类库

控制台

由于我们多数的代码都可以放到 .NET Standard 类库中,以确保绝大多数的代码都是平台和框架无关的,所以实际上我们在其他各个端项目中的代码会是很少的。

这个时候,写一个控制台程序来测试我们的项目,控制台程序的部分其实只需要很少的用于控制控制台输入输出的代码,其他多数的代码例如用来访问 Web API 的代码都是不需要放在控制台项目中的,放到 .NET Standard 的类库中编写就可以做到最大程度的共用了。

控制台程序

iOS 端

接下来要完成这个云键盘程序,我们还需要开发一个移动端。使用 Xamarin 可以帮助我们完成这样的任务。

Xamarin.Forms

Xamarin 自定义键盘扩展

关于使用 Xamarin.Forms 开发一个键盘扩展,可以阅读我的另一篇博客:

Web 前端

于是,我们仅仅使用 C# 还有客户端开发者熟悉的 XAML 就开发出了三个端了。

三个端

这三个端中,有两个都是客户端,于是就会存在向用户分发客户端的问题。虽然可以让用户去商店下载,但是提供一个官方下载页面可以让用户在一处地方找到所有端的下载和部署方法。

这需要使用到前端。然而如何使用 C# 代码来编写去前端呢?

如何使用 C# 来编写前端?

使用 CSHTML5!

你可以前往 CSHTML5 的官网 下载 Visual Studio 的插件,这样你就可以在 Visual Studio 中编写 CSHTML5 的代码了,还有设计器的支持。

CSHTML5 如何编译 C# 和 XAML 代码

0x02 C# 还能做什么?

于是我们使用 XAML + C# 就编写出了各个端了。

各个端

如果没有 GUI,那么跨平台将是非常容易的一件事情。例如我们想要在 Mac 电脑上也做一个打字发送的一方,那么一个控制台应用也是能够直接完成的。

没有 GUI,更容易跨平台

不过,这并不是说,我们只能通过控制台来开发桌面端应用。

我们还有:

利用这些平台,我们能开发其他桌面平台的 GUI 客户端。

另外,利用 ML.NET,我们还能用 C# 进行机器学习。可参见:Bean.Hsiang - 博客园

利用 Roslyn,我们还能用直接做编译器,然后你还有什么不能做的?关于 Roslyn 的入门,可以阅读:从零开始学习 dotnet 编译过程和 Roslyn 源码分析 - walterlv

还有 IoT。

还有其他……

0x03 终章

每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。

使用你熟悉的语言 C#,不需要太多额外的入门,即可玩转你身边各种你需要的技术栈,玩出各种各样你自己期望尝试开发的小东西。

04-12 2019

如何更精准地设置 C# / .NET Core 项目的输出路径?(包括添加和删除各种前后缀)

我们都知道可以通过在 Visual Studio 中设置输出路径(OutputPath)来更改项目输出文件所在的位置。对于 .NET Core 所使用的 Sdk 风格的 csproj 格式来说,你可能会发现实际生成路径中带了 netcoreapp3.0 或者 net472 这样的子文件夹。

然而有时我们并不允许生成这样的子文件夹。本文将介绍可能影响实际输出路径的各种设置。


项目和输出路径

对于这样的一个简单的项目文件,这个项目的实际输出路径可能是像下图那样的。

<Project>
  <ItemGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <OutputPath>bin\$(Configuration)</OutputPath>
  </ItemGroup>
</Project>

输出路径带有框架子文件夹

有没有办法可以不要生成这样的子文件夹呢?答案是可以的。

我在 解读 Microsoft.NET.Sdk 的源码,你能定制各种奇怪而富有创意的编译过程 一文中有说到如何解读 Microsoft.NET.Sdk,而我们的答案就是从解读这个 Sdk 而来。

影响输出路径的属性

OutputPath 属性由这些部分组成:

$(BaseOutputPath)\$(PlatformName)\$(Configuration)\$(RuntimeIdentifier)\$(TargetFramework.ToLowerInvariant())\

如果以上所有属性都有值,那么生成的路径可能就像下面这样:

bin\x64\Debug\win7-x64\netcoreapp3.0

具体的,这些属性以及其相关的设置有:

  • $(BaseOutputPath) 默认值 bin\,你也可以修改。

  • $(PlatformName) 默认值是 $(Platform),而 $(Platform) 的默认值是 AnyCPU;当这个值等于 AnyCPU 的时候,这个值就不会出现在路径中。

  • $(Configuration) 默认值是 Debug

  • $(RuntimeIdentifier) 这个值和 $(PlatformTarget) 互为默认值,任何一个先设置都会影响另一个;此值即 x86x64 等标识符。可以通过 $(AppendRuntimeIdentifierToOutputPath) 属性指定是否将此加入到输出路径中。

  • $(TargetFramework) 这是在 csproj 文件中强制要求指定的,如果不设置的话项目是无法编译的;可以通过 $(AppendTargetFrameworkToOutputPath) 属性指定是否将此加入到输出路径中。

现在,你应该可以更轻松地设置你的输出路径,而不用担心总会出现各种意料之外的子文件夹了吧!

04-12 2019

在 Visual Studio 新旧不同的 csproj 项目格式中启用混合模式调试程序(开启本机代码调试)

因为我使用 Visual Studio 主要用来编写 .NET 托管程序,所以平时调试的时候是仅限托管代码的。不过有时需要在托管代码中混合调试本机代码,那么就需要额外在项目中开启本机代码调试。

本文介绍如何开启本机代码调试。


本文涉及到新旧 csproj 项目格式,不懂这个也不影响你完成开启本机代码调试。不过如果你希望了解,可以阅读:将 WPF、UWP 以及其他各种类型的旧 csproj 迁移成 Sdk 风格的 csproj - walterlv

在旧格式的项目中开启

旧格式指的是 Visual Studio 2015 及以前版本的 Visual Studio 使用的项目格式。目前 Visual Studio 2017 和 2019 对这种格式的支持还是很完善的。

在项目上右键 -> 属性 -> Debug,这时你可以在底部的调试引擎中发现 Enable native code debugging 选项,开启它你就开启了本机代码调试,于是也就可以使用混合模式调试程序。

在旧格式中开启本机代码调试

在新格式的项目中开启

如果你在你项目属性的 Debug 标签下没有找到上面那个选项,那么有可能你的项目格式是新格式的。

新格式中没有开启本机代码调试的选项

这个时候,你需要在 lauchsettings.json 文件中设置。这个文件在你项目的 Properties 文件夹下。

如果你没有找到这个文件,那么随便在上图那个框框中写点什么(比如在启动参数一栏中写 吕毅是逗比),然后保存。我们就能得到一个 lauchsettings.json 文件。

launchsettings.json 文件

打开它,然后删掉刚刚的逗比行为,添加 "nativeDebugging": true。这时,你的 lauchsettings.json 文件影响像下面这样:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

这时你就可以开启本机代码调试了。当然,新的项目格式支持设置多个这样的启动项,于是你可以分别配置本机和非本机的多种配置:

{
  "profiles": {
    "Walterlv.Debugging": {
      "commandName": "Project"
    },
    "本机调试": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

现在,你可以选择你项目的启动方式了,其中一个是开启了本机代码调试的方式。

选择项目的启动方式

关于这些配置的更多博客,你可以阅读:VisualStudio 使用多个环境进行调试 - 林德熙


参考资料

03-30 2019

将 C++/WinRT 中的线程切换体验带到 C# 中来(WPF 版本)

如果你要在 WPF 程序中使用线程池完成一个特殊的任务,那么使用 .NET 的 API Task.Run 并传入一个 Lambda 表达式可以完成。不过,使用 Lambda 表达式会带来变量捕获的一些问题,比如说你需要区分一个变量作用于是在 Lambda 表达式中,还是当前上下文全局(被 Lambda 表达式捕获到的变量)。然后,在静态分析的时候,也难以知道此 Lambda 表达式在整个方法中的执行先后顺序,不利于分析潜在的 Bug。

在使用 async/await 关键字编写异步代码的时候,虽然说实质上也是捕获变量,但这时没有显式写一个 Lambda 表达式,所有的变量都是被隐式捕获的变量,写起来就像在一个同步方法一样,便于理解。


C++/WinRT

以下 C++/WinRT 的代码来自 Raymond Chen 的示例代码。Raymond Chen 写了一个 UWP 的版本用于模仿 C++/WinRT 的线程切换效果。在看他编写的 UWP 版本之前我也思考了可以如何实现一个 .NET / WPF 的版本,然后成功做出了这样的效果。

Raymond Chen 的版本可以参见:C++/WinRT envy: Bringing thread switching tasks to C# (UWP edition) - The Old New Thing

winrt::fire_and_forget MyPage::Button_Click()
{
  // We start on a UI thread.
  auto lifetime = get_strong();

  // Get the control's value from the UI thread.
  auto v = SomeControl().Value();

  // Move to a background thread.
  co_await winrt::resume_background();

  // Do the computation on a background thread.
  auto result1 = Compute1(v);
  auto other = co_await ContactWebServiceAsync();
  auto result2 = Compute2(result1, other);

  // Return to the UI thread to provide an interim update.
  co_await winrt::resume_foreground(Dispatcher());

  // Back on the UI thread: We can update UI elements.
  TextBlock1().Text(result1);
  TextBlock2().Text(result2);

  // Back to the background thread to do more computations.
  co_await winrt::resume_background();

  auto extra = co_await GetExtraDataAsync();
  auto result3 = Compute3(result1, result2, extra);

  // Return to the UI thread to provide a final update.
  co_await winrt::resume_foreground(Dispatcher());

  // Update the UI one last time.
  TextBlock3().Text(result3);
}

可以看到,使用 co_await winrt::resume_background(); 可以将线程切换至线程池,使用 co_await winrt::resume_foreground(Dispatcher()); 可以将线程切换至 UI。

也许你会觉得这样没什么好处,因为 C#/.NET 的版本里面 Lambda 表达式一样可以这么做:

await Task.Run(() =>
{
    // 这里的代码会在线程池执行。
});
// 这里的代码会回到 UI 线程执行。

但是,现在我们给出这样的写法:

// 仅在某些特定的情况下才使用线程池执行,而其他情况依然在主线程执行 DoSomething()。
if (condition) {
  co_await winrt::resume_background();
}

DoSomething();

你就会发现 Lambda 的版本变得很不好理解了。

C# / .NET / WPF 版本

我们现在编写一个自己的 Awaiter 来实现这样的线程上下文切换。

关于如何编写一个 Awaiter,可以阅读我的其他博客:

这里,我直接贴出我编写的 DispatcherSwitcher 类的全部源码。

using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows.Threading;

namespace Walterlv.ThreadSwitchingTasks
{
    public static class DispatcherSwitcher
    {
        public static ThreadPoolAwaiter ResumeBackground() => new ThreadPoolAwaiter();

        public static ThreadPoolAwaiter ResumeBackground(this Dispatcher dispatcher)
            => new ThreadPoolAwaiter();

        public static DispatcherAwaiter ResumeForeground(this Dispatcher dispatcher) =>
            new DispatcherAwaiter(dispatcher);

        public class ThreadPoolAwaiter : INotifyCompletion
        {
            public void OnCompleted(Action continuation)
            {
                Task.Run(() =>
                {
                    IsCompleted = true;
                    continuation();
                });
            }

            public bool IsCompleted { get; private set; }

            public void GetResult()
            {
            }

            public ThreadPoolAwaiter GetAwaiter() => this;
        }

        public class DispatcherAwaiter : INotifyCompletion
        {
            private readonly Dispatcher _dispatcher;

            public DispatcherAwaiter(Dispatcher dispatcher) => _dispatcher = dispatcher;

            public void OnCompleted(Action continuation)
            {
                _dispatcher.InvokeAsync(() =>
                {
                    IsCompleted = true;
                    continuation();
                });
            }

            public bool IsCompleted { get; private set; }

            public void GetResult()
            {
            }

            public DispatcherAwaiter GetAwaiter() => this;
        }
    }
}

Raymond Chen 取的类名是 ThreadSwitcher,不过我认为可能 Dispatcher 在 WPF 中更能体现其线程切换的含义。

于是,我们来做一个试验。以下代码在 MainWindow.xaml.cs 里面,如果你使用 Visual Studio 创建一个 WPF 的空项目的话是可以找到的。随便放一个 Button 添加事件处理函数。

private async void DemoButton_Click(object sender, RoutedEventArgs e)
{
    var id0 = Thread.CurrentThread.ManagedThreadId;

    await Dispatcher.ResumeBackground();

    var id1 = Thread.CurrentThread.ManagedThreadId;

    await Dispatcher.ResumeForeground();

    var id2 = Thread.CurrentThread.ManagedThreadId;
}

id0 和 id2 在主线程上,id1 是线程池中的一个线程。

这样,我们便可以在一个上下文中进行线程切换了,而不需要使用 Task.Run 通过一个 Lambda 表达式来完成这样的任务。

现在,这种按照某些特定条件才切换到后台线程执行的代码就很容易写出来了。

// 仅在某些特定的情况下才使用线程池执行,而其他情况依然在主线程执行 DoSomething()。
if (condition)
{
    await Dispatcher.ResumeBackground();
}

DoSomething();

Raymond Chen 的版本

Raymond Chen 后来在另一篇博客中也编写了一份 WPF / Windows Forms 的线程切换版本。请点击下方的链接跳转至原文阅读:

我在为他的代码添加了所有的注释后,贴在了下面:

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Windows.Forms;
using System.Windows.Threading;

namespace Walterlv.Windows.Threading
{
    /// <summary>
    /// 提供类似于 WinRT 中的线程切换体验。
    /// </summary>
    /// <remarks>
    /// https://devblogs.microsoft.com/oldnewthing/20190329-00/?p=102373
    /// https://blog.walterlv.com/post/bring-thread-switching-tasks-to-csharp-for-wpf.html
    /// </remarks>
    public class ThreadSwitcher
    {
        /// <summary>
        /// 将当前的异步等待上下文切换到 WPF 的 UI 线程中继续执行。
        /// </summary>
        /// <param name="dispatcher">WPF 一个 UI 线程的调度器。</param>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续任务切换到 UI 线程执行。</returns>
        public static DispatcherThreadSwitcher ResumeForegroundAsync(Dispatcher dispatcher) =>
            new DispatcherThreadSwitcher(dispatcher);

        /// <summary>
        /// 将当前的异步等待上下文切换到 Windows Forms 的 UI 线程中继续执行。
        /// </summary>
        /// <param name="control">Windows Forms 的一个控件。</param>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续任务切换到 UI 线程执行。</returns>
        public static ControlThreadSwitcher ResumeForegroundAsync(Control control) =>
            new ControlThreadSwitcher(control);

        /// <summary>
        /// 将当前的异步等待上下文切换到线程池中继续执行。
        /// </summary>
        /// <returns>一个可等待对象,使用 await 等待此对象可以使后续的任务切换到线程池执行。</returns>
        public static ThreadPoolThreadSwitcher ResumeBackgroundAsync() =>
            new ThreadPoolThreadSwitcher();
    }

    /// <summary>
    /// 提供一个可切换到 WPF 的 UI 线程执行上下文的可等待对象。
    /// </summary>
    public struct DispatcherThreadSwitcher : INotifyCompletion
    {
        internal DispatcherThreadSwitcher(Dispatcher dispatcher) =>
            _dispatcher = dispatcher;

        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public DispatcherThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成线程池到 WPF UI 线程的切换。
        /// </summary>
        public bool IsCompleted => _dispatcher.CheckAccess();

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到 WPF 的 UI 线程。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => _dispatcher.BeginInvoke(continuation);

        private readonly Dispatcher _dispatcher;
    }

    /// <summary>
    /// 提供一个可切换到 Windows Forms 的 UI 线程执行上下文的可等待对象。
    /// </summary>
    public struct ControlThreadSwitcher : INotifyCompletion
    {
        internal ControlThreadSwitcher(Control control) =>
            _control = control;

        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public ControlThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成线程池到 Windows Forms UI 线程的切换。
        /// </summary>
        public bool IsCompleted => !_control.InvokeRequired;

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到 Windows Forms 的 UI 线程。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => _control.BeginInvoke(continuation);

        private readonly Control _control;
    }

    /// <summary>
    /// 提供一个可切换到线程池执行上下文的可等待对象。
    /// </summary>
    public struct ThreadPoolThreadSwitcher : INotifyCompletion
    {
        /// <summary>
        /// 当使用 await 关键字异步等待此对象时,将调用此方法返回一个可等待对象。
        /// </summary>
        public ThreadPoolThreadSwitcher GetAwaiter() => this;

        /// <summary>
        /// 获取一个值,该值指示是否已完成 UI 线程到线程池的切换。
        /// </summary>
        public bool IsCompleted => SynchronizationContext.Current == null;

        /// <summary>
        /// 由于进行线程的上下文切换必须使用 await 关键字,所以不支持调用同步的 <see cref="GetResult"/> 方法。
        /// </summary>
        public void GetResult()
        {
        }

        /// <summary>
        /// 当异步状态机中的前一个任务结束后,将调用此方法继续下一个任务。在此可等待对象中,指的是切换到线程池中。
        /// </summary>
        /// <param name="continuation">将异步状态机推进到下一个异步状态。</param>
        public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(_ => continuation());
    }
}

参考资料

03-29 2019

WPF 的命令的自动刷新时机——当你 CanExecute 会返回 true 但命令依旧不可用时可能是这些原因

在 WPF 中,你可以使用 Command="{Binding WalterlvCommand}" 的方式来让 XAML 中的一个按钮或其他控件绑定一个命令。这样,按钮的可用性会自动根据 WalterlvCommand 当前 CanExecute 的状态来改变。这本是一个非常智能的特性,直到你可能发现你按钮的可用性状态不正确……

本文介绍默认情况下,WPF 在 UI 上的这些命令会在什么时机进行刷新;以及没有及时刷新时,可以如何强制让这些命令的可用性状态进行刷新。了解了这些,你可能能够解决你在 WPF 程序中命令绑定的一些坑。


This post is written in multiple languages. Please select yours:

一个最简单的例子

<Button x:Name="TestCommand" Command="{Binding WalterlvCommand}" />
public class Walterlv
{
    // 省略了此命令的初始化。
    public WalterlvCommand WalterlvCommand { get; }
}

public class WalterlvCommand : ICommand
{
    public bool SomeFlag { get; set; }

    bool ICommand.CanExecute(object parameter)
    {
        // 判断命令的可用性。
        return SomeFlag;
    }

    void ICommand.Execute(object parameter)
    {
        // 省略了执行命令的代码。
    }
}

假如 SomeFlag 一开始是 false,5 秒种后变为 true,那么你会注意到这时的按钮状态并不会刷新。

var walterlv = new Walterlv();
TestCommand.DataContext = walterlv;

await Task.Delay(5000);
walterlv.WalterlvCommand.SomeFlag = true;

当然,以上所有代码会更像伪代码,如果你不熟悉 WPF,是一定编译不过的。我只是在表达这个意思。

如何手动刷新命令

调用以下代码,即可让 WPF 中的命令刷新其可用性:

CommandManager.InvalidateRequerySuggested();

WPF 的命令在何时刷新?

默认情况下,WPF 的命令只会在以下时机刷新可用性:

  • KeyUp
  • MouseUp
  • GotKeyboardFocus
  • LostKeyboardFocus

使用通俗的话来说,就是:

  • 键盘按下的按键抬起的时候
  • 在鼠标的左键或者右键松开的时候
  • 在任何一个控件获得键盘焦点或者失去键盘焦点的时候

这部分的代码可以在这里查看:

最关键的代码贴在这里:

// 省略前面。
if (e.StagingItem.Input.RoutedEvent == Keyboard.KeyUpEvent ||
    e.StagingItem.Input.RoutedEvent == Mouse.MouseUpEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.GotKeyboardFocusEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.LostKeyboardFocusEvent)
{
    CommandManager.InvalidateRequerySuggested();
}

然而,并不是只在这些时机进行刷新,还有其他的时机,比如这些:

03-29 2019

When WPF Commands update their CanExecute states?

When writing Command="{Binding WalterlvCommand}" into your XAML code and your button or other controls can automatically execute command and updating the command states, such as enabling or disabling the button.

We’ll talk about when the UI commands will refresh their can-execute states and how to force updating the states.


This post is written in multiple languages. Please select yours:

This post is written for my Stack Overflow answer:

A simple sample

<Button x:Name="TestCommand" Command="{Binding WalterlvCommand}" />
public class Walterlv
{
    // Assume that I've initialized this command.
    public WalterlvCommand WalterlvCommand { get; }
}

public class WalterlvCommand : ICommand
{
    public bool SomeFlag { get; set; }

    bool ICommand.CanExecute(object parameter)
    {
        // Return the real can execution state.
        return SomeFlag;
    }

    void ICommand.Execute(object parameter)
    {
        // The actual executing procedure.
    }
}

See this code below. After 5 seconds, the button will still be disabled even that we set the SomeFlat to true.

var walterlv = new Walterlv();
TestCommand.DataContext = walterlv;

await Task.Delay(5000);
walterlv.WalterlvCommand.SomeFlag = true;

How to update manually?

Call this method after you want to update your command states if it won’t update:

CommandManager.InvalidateRequerySuggested();

When do the commands update their states?

Commands only update when these general events happen:

  • KeyUp
  • MouseUp
  • GotKeyboardFocus
  • LostKeyboardFocus

You can see the code here:

And the key code is here:

if (e.StagingItem.Input.RoutedEvent == Keyboard.KeyUpEvent ||
    e.StagingItem.Input.RoutedEvent == Mouse.MouseUpEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.GotKeyboardFocusEvent ||
    e.StagingItem.Input.RoutedEvent == Keyboard.LostKeyboardFocusEvent)
{
    CommandManager.InvalidateRequerySuggested();
}

Actually, not only those events above but also these methods below refresh the command states:

03-26 2019

C#/.NET 如何获取一个异常(Exception)的关键特征,用来判断两个异常是否表示同一个异常

在 .NET / C# 程序中出现异常是很常见的事情,程序出现异常后记录日志或者收集到统一的地方可以便于分析程序中各种各样此前未知的问题。但是,有些异常表示的是同一个异常,只是因为参数不同、状态不同、用户的语言环境不同就分开成多个异常的话,分析起来会有些麻烦。

本文将提供一个方法,将异常的关键信息提取出来,这样可以比较多次抛出的不同的异常实例是否表示的是同一个异常。


Exception.ToString()

以下是捕获到的一个异常实例,调用 ToString() 方法后拿到的结果:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()
    System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
    Walterlv.Demo.Exceptions.Foo.Take(string fileName)

在英文的系统上,拿到的结果可能是这样的:

System.NotSupportedException: BitmapMetadata is not available on BitmapImage.
   at System.Windows.Media.Imaging.BitmapImage.get_Metadata()
   at System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
   at Walterlv.Demo.Exceptions.Foo.Take(string fileName)

这样,我们就不能使用 ToString() 来判断两个异常是否表示同一个异常了。

另外,在 ToString() 方法中,如果包含 PDB,那么异常堆栈中还会包含源代码文件的路径以及行号信息。

关于 ToString() 中输出的信息,可以阅读 StackTrace.ToString() 方法的源码来了解:

哪些信息是异常的关键信息

从默认的 ToString() 中我们可以得知,它包含三个部分:

  1. 异常类型的全名 Type.FullName
  2. 异常信息 Exception.Message
  3. 异常堆栈 Exception.StackTrace

考虑到 Message 部分受多语言影响非常严重,很难作为关键异常特征,所以我们在提取关键异常特征的时候,需要将这一部分去掉,只能作为此次异常的附加信息,而不能作为关键特征。

所以我们的关键特征就是:

  1. 异常类型的全名 Type.FullName
  2. 异常堆栈中所有帧的方法签名(这能保证语言无关)

比如本文一开始列举出来的异常堆栈,我们应该提取成:

System.NotSupportedException
  System.Windows.Media.Imaging.BitmapImage.get_Metadata()
  System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
  Walterlv.Demo.Exceptions.Foo.Take(string fileName)

提取特征的 C# 代码

为了提取出以上的关键特征,我需要写一段 C# 代码来做这样的事情:

public (string typeName, IReadonlyList<string> frameSignature) GetDescriptor(Exception exception)
{
    var type = exception.GetType().FullName;
    var stackFrames = new StackTrace(exception).GetFrames() ?? new StackFrame[0];
    var frames = stackFrames.Select(x => x.GetMethod()).Select(m =>
        $"{m.DeclaringType?.FullName ?? "null"}.{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
    return (type, frames.ToList());
}

一个是拿到 Exception 实例的类型名称,通过 exception.GetType().FullName

另一个拿到方法签名。

由于 Exception.StackTrace 属性得到的是一个字符串,而且此字符串还真的有可能根本不是异常信息呢,所以我们这里通过创建一个 StackTrace 的实例来从异常中获取真实的堆栈,当然如果拿不到我们这里使用空数组来表示。

随后,遍历异常堆栈中的所有帧,将方法名和方法的所有参数进行拼接,形成 ClassFullName.MethodName(ParameterType parameterName) 这样的形式,于是就拼接成类似 Exception.ToString() 中的格式了。

由于确定一个类型中是否是同一个方法时与返回值无关,所以我们甚至不需要将返回值加上就能唯一确定一个方法了。

一个完整的 ExceptionDescriptor

为了方便,我写了一个完整的 ExceptionDescriptor 类型来完成异常特征提取的事情。这个类同时重写了相等方法,这样可以直接使用相等方法来判断两个异常的关键信息是否表示的是同一个异常。

源码可以在这里找到:https://gist.github.com/walterlv/0ce95369aa78c5f0f38a527bef5779c2

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace Walterlv
{
    /// <summary>
    /// 包含一个 <see cref="Exception"/> 对象的关键特征,可使用此对象的实例判断两个不同的异常实例是否极有可能表示同一个异常。
    /// </summary>
    [DebuggerDisplay("{TypeName,nq}: {FrameSignature[0],nq}")]
    public class ExceptionDescriptor : IEquatable<ExceptionDescriptor>
    {
        /// <summary>
        /// 获取此异常的类型名称。
        /// </summary>
        public string TypeName { get; }

        /// <summary>
        /// 获取此异常堆栈中的所有帧的方法签名,指的是在一个类型中不会冲突的最小部分,所以不含返回值和可访问性。
        /// 比如 private void Foo(Bar b); 方法,在这里会写成 Foo(Bar b)。
        /// </summary>
        public IReadOnlyList<string> FrameSignature { get; }

        /// <summary>
        /// 从一个异常中提取出关键的异常特征,并创建 <see cref="ExceptionDescriptor"/> 的新实例。
        /// </summary>
        /// <param name="exception">要提取特征的异常。</param>
        public ExceptionDescriptor(Exception exception)
        {
            var type = exception.GetType().FullName;
            var stackFrames = new StackTrace(exception).GetFrames() ?? new StackFrame[0];
            var frames = stackFrames.Select(x => x.GetMethod()).Select(m =>
                $"{m.DeclaringType?.FullName ?? "null"}.{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
            TypeName = type;
            FrameSignature = frames.ToList();
        }

        /// <summary>
        /// 根据异常的信息本身创建异常的关键特征。
        /// </summary>
        /// <param name="typeName">异常类型的完整名称。</param>
        /// <param name="frameSignature">
        /// 异常堆栈中的所有帧的方法签名,指的是在一个类型中不会冲突的最小部分,所以不含返回值和可访问性。
        /// 比如 private void Foo(Bar b); 方法,在这里会写成 Foo(Bar b)。
        /// </param>
        public ExceptionDescriptor(string typeName, IReadOnlyList<string> frameSignature)
        {
            TypeName = typeName;
            FrameSignature = frameSignature;
        }

        /// <summary>
        /// 判断此异常特征对象是否与另一个对象实例相等。
        /// 如果参数指定的对象是 <see cref="ExceptionDescriptor"/>,则判断特征是否相等。
        /// </summary>
        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj))
            {
                return false;
            }

            if (ReferenceEquals(this, obj))
            {
                return true;
            }

            if (obj.GetType() != this.GetType())
            {
                return false;
            }

            return Equals((ExceptionDescriptor) obj);
        }

        /// <summary>
        /// 判断此异常特征与另一个异常特征是否是表示同一个异常。
        /// </summary>
        public bool Equals(ExceptionDescriptor other)
        {
            if (ReferenceEquals(null, other))
            {
                return false;
            }

            if (ReferenceEquals(this, other))
            {
                return true;
            }

            return string.Equals(TypeName, other.TypeName) && FrameSignature.SequenceEqual(other.FrameSignature);
        }

        /// <inheritdoc />
        public override int GetHashCode()
        {
            unchecked
            {
                return ((TypeName != null ? StringComparer.InvariantCulture.GetHashCode(TypeName) : 0) * 397) ^
                       (FrameSignature != null ? FrameSignature.GetHashCode() : 0);
            }
        }

        /// <summary>
        /// 判断两个异常特征是否是表示同一个异常。
        /// </summary>
        public static bool operator ==(ExceptionDescriptor left, ExceptionDescriptor right)
        {
            return Equals(left, right);
        }

        /// <summary>
        /// 判断两个异常特征是否表示的不是同一个异常。
        /// </summary>
        public static bool operator !=(ExceptionDescriptor left, ExceptionDescriptor right)
        {
            return !Equals(left, right);
        }
    }
}

参考资料

03-24 2019

C#/.NET 如何在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈

FirstChangeException 事件中,我们通常只能拿到异常堆栈的第一帧,这对于我们捕捉到异常是好的,但对分析第一次机会异常可能并不利。

本文介绍如何在 FirstChangeException 事件中拿到比较完整的异常堆栈,而不只是第一帧。


第一次机会异常

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

这时,这个异常还没有寻找任何一个可以处理它的 catch 块,在此事件中,你几乎是第一时间拿到了这个异常的信息。

监听第一次机会异常的代码是这个样子的:

private void WalterlvDemo()
{
    AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;
}

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

只不过,在这里我们拿到的异常堆栈只有第一帧,因为这个时候,还没有任何 catch 块捕捉到这个异常。比如,我们只能拿到这个:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()

一点知识Exception 实例的异常堆栈,是从第一次抛出异常的地方开始,到第一个 catch 它的地方结束,除非这个 catch 块中继续只用 throw; 抛出才继续向外延伸到下一个 catch

另外,你也可以用 ExceptionDispatchInfo 让内部异常的堆栈也连接起来,详见我的另一篇博客:

获取较完整的第一次机会异常堆栈

我们需要等到 FirstChanceException 事件中的异常被 catch 到,就能获取到第一次抛出的地方到 catch 处之间的所有帧。

所以,我们只需要稍作延迟,即可拿到较完整的异常堆栈:

private void WalterlvDemo()
{
    AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;
}

private async void OnFirstChanceException(object sender, FirstChanceExceptionEventArgs e)
{
    // 刚刚进入第一次机会异常事件的时候,异常堆栈只有一行,因为此时还没有任何地方 catch。
    // 现在等待一点点时间,使得异常的堆栈能够延伸到 catch。等待多长不重要,关键是为了让异常得以找到第一个 catch。
    await Task.Delay(10);

    // 在这里,可以通过 e.Exception 来获取到这个异常。
    Console.WriteLine(e.Exception.ToString());
}

这样,我们可以得到:

System.NotSupportedException: BitmapMetadata  BitmapImage 上可用。
    System.Windows.Media.Imaging.BitmapImage.get_Metadata()
    System.Windows.Media.Imaging.BitmapFrame.Create(BitmapSource source)
    Walterlv.Demo.Exceptions.Foo.Take(string fileName)

这里,等待多长时间是不重要的,只要不是 0 就好。因为我们只需要当前调用堆栈中的异常处理执行完成即可。

关于等待时间,可以阅读我的另一篇博客:

如果需要对此异常进行后续的分析,可以参考我的另一篇博客:

03-21 2019

制作一个极简的 .NET 客户端应用自安装或自更新程序

本文主要说的是 .NET 客户端应用,可以是只能在 Windows 端运行的基于 .NET Framework 或基于 .NET Core 的 WPF / Windows Forms 应用,也可以是其他基于 .NET Core 的跨平台应用。但是不是那些更新权限受到严格控制的 UWP / iOS / Android 应用。

本文将编写一个简单的程序,这个程序初次运行的时候会安装自己,如果已安装旧版本会更新自己,如果已安装最新则直接运行。


自安装或自更新的思路

简单的安装过程实际上是 解压 + 复制 + 配置 + 外部命令。这里,我只做 复制 + 配置 + 外部命令,并且把 配置 + 外部命令 合为一个步骤。

于是:

  1. 启动后,检查安装路径下是否有已经安装的程序;
  2. 如果没有,则直接复制自己过去;
  3. 如果有,则比较版本号,更新则复制过去。

本文用到的知识

使用

于是我写了一个简单的类型用来做自安装。创建完 SelfInstaller 的实例后,根据安装完的结果做不同的行为:

  • 显示安装成功的窗口
  • 显示正常的窗口
  • 关闭自己
using System.IO;
using System.Windows;
using Walterlv.Installing;

namespace Walterlv.ENPlugins.Presentation
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            var installer = new SelfInstaller(@"C:\Users\lvyi\AppData\Local\Walterlv");

            var state = installer.TryInstall();
            switch (state)
            {
                case InstalledState.Installed:
                case InstalledState.Updated:
                case InstalledState.UpdatedInUse:
                    new InstallTipWindow().Show();
                    break;
                case InstalledState.Same:
                case InstalledState.Ran:
                    new MainWindow().Show();
                    break;
                case InstalledState.ShouldRerun:
                    Shutdown();
                    break;
            }
        }
    }
}

附全部源码

本文代码在 https://gist.github.com/walterlv/33bdd62e2411c69c2699038e2bc97488

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;

namespace Walterlv.EasiPlugins.Installing
{
    /// <summary>
    /// 自安装或字更新的安装器。
    /// </summary>
    public class SelfInstaller
    {
        /// <summary>
        /// 初始化 <see cref="SelfInstaller"/> 的新实例。
        /// </summary>
        /// <param name="targetFilePath">要安装的主程序的目标路径。</param>
        /// <param name="installingProcedure">如果需要在安装后执行额外的安装步骤,则指定自定义的安装步骤。</param>
        public SelfInstaller(string targetFilePath, IInstallingProcedure installingProcedure = null)
        {
            var assembly = Assembly.GetCallingAssembly();
            var extensionName = assembly.GetCustomAttribute<AssemblyTitleAttribute>().Title;
            TargetFileInfo = new FileInfo(Path.Combine(
                targetFilePath ?? throw new ArgumentNullException(nameof(targetFilePath)),
                extensionName, extensionName + Path.GetExtension(assembly.Location)));
            InstallingProcedure = installingProcedure;
        }

        /// <summary>
        /// 获取要安装的主程序的目标路径。
        /// </summary>
        private FileInfo TargetFileInfo { get; }

        /// <summary>
        /// 获取或设置当应用重新启动自己的时候应该使用的参数。
        /// </summary>
        public string RunSelfArguments { get; set; } = "--rerun-reason {reason}";

        /// <summary>
        /// 获取此自安装器安装中需要执行的自定义安装步骤。
        /// </summary>
        public IInstallingProcedure InstallingProcedure { get; }

        /// <summary>
        /// 尝试安装,并返回安装结果。调用者可能需要对安装结果进行必要的操作。
        /// </summary>
        public InstalledState TryInstall()
        {
            var state0 = InstallOrUpdate();
            switch (state0)
            {
                // 已安装或更新,由已安装的程序处理安装后操作。
                case InstalledState.Installed:
                case InstalledState.Updated:
                case InstalledState.UpdatedInUse:
                case InstalledState.Same:
                    break;
                case InstalledState.ShouldRerun:
                    Process.Start(TargetFileInfo.FullName, BuildRerunArguments(state0.ToString(), false));
                    return state0;
            }

            var state1 = InstallingProcedure?.AfterInstall(TargetFileInfo.FullName) ?? InstalledState.Ran;

            if (state0 is InstalledState.UpdatedInUse || state1 is InstalledState.UpdatedInUse)
            {
                return InstalledState.UpdatedInUse;
            }

            if (state0 is InstalledState.Updated || state1 is InstalledState.Updated)
            {
                return InstalledState.Updated;
            }

            if (state0 is InstalledState.Installed || state1 is InstalledState.Installed)
            {
                return InstalledState.Installed;
            }

            return state1;
        }

        /// <summary>
        /// 进行安装或更新。执行后将返回安装状态以及安装后的目标程序路径。
        /// </summary>
        private InstalledState InstallOrUpdate()
        {
            var extensionFilePath = TargetFileInfo.FullName;
            var selfFilePath = Assembly.GetExecutingAssembly().Location;

            // 判断当前是否已经运行在插件目录下。如果已经在那里运行,那么不需要安装。
            if (string.Equals(extensionFilePath, selfFilePath, StringComparison.CurrentCultureIgnoreCase))
            {
                // 继续运行自己即可。
                return InstalledState.Ran;
            }

            // 判断插件目录下的软件版本是否比较新,如果插件目录已经比较新,那么不需要安装。
            var isOldOneExists = File.Exists(extensionFilePath);
            if (isOldOneExists)
            {
                var isNewer = CheckIfNewer();
                if (!isNewer)
                {
                    // 运行已安装目录下的自己。
                    return InstalledState.Same;
                }
            }

            // 将自己复制到插件目录进行安装。
            var succeedOnce = CopySelfToInstall();
            if (!succeedOnce)
            {
                // 如果不是一次就成功,说明目标被占用。
                return InstalledState.UpdatedInUse;
            }

            return isOldOneExists ? InstalledState.Updated : InstalledState.Installed;

            bool CheckIfNewer()
            {
                Version installedVersion;
                try
                {
                    var installed = Assembly.ReflectionOnlyLoadFrom(extensionFilePath);

                    var installedVersionString =
                        installed.GetCustomAttributesData()
                            .FirstOrDefault(x =>
                                x.AttributeType.FullName == typeof(AssemblyFileVersionAttribute).FullName)
                            ?.ConstructorArguments[0].Value as string ?? "0.0";
                    installedVersion = new Version(installedVersionString);
                }
                catch (FileLoadException)
                {
                    installedVersion = new Version(0, 0);
                }
                catch (BadImageFormatException)
                {
                    installedVersion = new Version(0, 0);
                }

                var current = Assembly.GetExecutingAssembly();
                var currentVersionString =
                    current.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "0.0";
                var currentVersion = new Version(currentVersionString);
                return currentVersion > installedVersion;
            }
        }

        /// <summary>
        /// 将自己复制到目标安装路径。
        /// </summary>
        private bool CopySelfToInstall()
        {
            var extensionFolder = TargetFileInfo.Directory.FullName;
            var extensionFilePath = TargetFileInfo.FullName;
            var selfFilePath = Assembly.GetExecutingAssembly().Location;

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

            var isInUse = false;
            for (var i = 0; i < int.MaxValue; i++)
            {
                try
                {
                    if (i > 0)
                    {
                        File.Move(extensionFilePath, extensionFilePath + $".{i}.bak");
                    }

                    File.Copy(selfFilePath, extensionFilePath, true);
                    return !isInUse;
                }
                catch (IOException)
                {
                    // 不退出循环,于是会重试。
                    isInUse = true;
                }
            }

            return !isInUse;
        }

        /// <summary>
        /// 生成用于重启自身的启动参数。
        /// </summary>
        /// <param name="rerunReason">表示重启原因的一个单词(不能包含空格)。</param>
        /// <param name="includeExecutablePath"></param>
        /// <param name="executablePath"></param>
        /// <returns></returns>
        private string BuildRerunArguments(string rerunReason, bool includeExecutablePath, string executablePath = null)
        {
            if (rerunReason == null)
            {
                throw new ArgumentNullException(nameof(rerunReason));
            }

            if (rerunReason.Contains(" "))
            {
                throw new ArgumentException("重启原因不能包含空格", nameof(rerunReason));
            }

            var args = new List<string>();

            if (includeExecutablePath)
            {
                args.Add(string.IsNullOrWhiteSpace(executablePath)
                    ? Assembly.GetEntryAssembly().Location
                    : executablePath);
            }

            if (!string.IsNullOrWhiteSpace(RunSelfArguments))
            {
                args.Add(RunSelfArguments.Replace("{reason}", rerunReason));
            }

            return string.Join(" ", args);
        }
    }

    /// <summary>
    /// 表示安装完后的状态。
    /// </summary>
    public enum InstalledState
    {
        /// <summary>
        /// 已安装。
        /// </summary>
        Installed,

        /// <summary>
        /// 已更新。说明运行此程序时,已经存在一个旧版本的应用。
        /// </summary>
        Updated,

        /// <summary>
        /// 已更新。但是原始文件被占用,可能需要重启才可使用。
        /// </summary>
        UpdatedInUse,

        /// <summary>
        /// 已代理启动新的程序,所以此程序需要退出。
        /// </summary>
        ShouldRerun,

        /// <summary>
        /// 两个程序都是一样的,跑谁都一样。
        /// </summary>
        Same,

        /// <summary>
        /// 没有执行安装、更新或代理,表示此程序现在是正常启动。
        /// </summary>
        Ran,
    }
}

03-20 2019

应用程序清单 Manifest 中各种 UAC 权限级别的含义和效果

如果你的程序对 Windows 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项 UAC 权限设置的实际效果。


阅读本文之前,你可能需要了解如何创建应用程序清单文件。阅读我的另一篇博客可以了解:

各种不同的 UAC 清单选项

从默认生成的应用程序清单中,我们可以很容易的知道有四种不同的设置:

  • asInvoker
  • requireAdministrator
  • highestAvailable
  • 删除 requestedExecutionLevel 元素 (不要忘了还可以删除)

当然这里我们是没有考虑 uiAccess 的。你可以阅读我的另一篇博客了解 uiAccess 的一项应用:

asInvoker

父进程是什么权限级别,那么此应用程序作为子进程运行时就是什么权限级别。

默认情况下用户启动应用程序都是使用 Windows 资源管理器(explorer.exe)运行的;在开启了 UAC 的情况下,资源管理器是以标准用户权限运行的。于是对于用户点击打开的应用程序,默认就是以标准用户权限运行的。

如果已经以管理员权限启动了一个程序,那么这个程序启动的子进程也会是管理员权限。典型的情况是一个应用程序安装包安装的时候使用管理员权限运行,于是这个安装程序在安装完成后启动的这个应用程序进程实例就是管理员权限的。有时候这种设定会出现问题,你可以阅读 在 Windows 系统上降低 UAC 权限运行程序(从管理员权限降权到普通用户权限)

requireAdministrator

此程序需要以管理员权限运行。

在资源管理器中可以看到这样的程序图标的右下角会有一个盾牌图标。

管理员权限图标

用户在资源管理器中双击启动此程序,或者在程序中使用 Process.Start 启动此程序,会弹出 UAC 提示框。点击“是”会提权,点击“否”则操作取消。

UAC 弹窗

highestAvailable

此程序将以当前用户能获取的最高权限来运行。

这个概念可能会跟前面说的 requireAdministrator 弄混淆。

要更好的理解这两个概念的区别,你可能需要对 UAC 用户账户控制有一个初步的了解,可以阅读我的另一篇博客:

接下来的内容,都假设你已经了解了上文所述的 UAC 用户账户控制。

如果你指定为 highestAvailable

  • 当你在管理员账户下运行此程序,就会要求权限提升。资源管理器上会出现盾牌图标,双击或使用 Process.Start 启动此程序会弹出 UAC 提示框。在用户同意后,你的程序将获得完全访问令牌(Full Access Token)。
  • 当你在标准账户下运行此程序,此账户的最高权限就是标准账户。受限访问令牌(Limited Access Token)就是当前账户下的最高令牌了,于是 highestAvailable 已经达到了要求。资源管理器上不会出现盾牌图标,双击或使用 Process.Start 启动此程序也不会出现 UAC 提示框,此程序将以受限权限执行。

下图是一个例子。lvyi 是我安装系统时创建的管理员账号,但是我使用的是 walterlv 标准账号。正常是在 walterlv 账号下启动程序,但以管理员权限运行时,会要求输入 lvyi 账号的密码来提权,于是就会以 lvyi 的身份运行这个程序。这种情况下,那个管理员权限运行的程序会以为当前运行在 lvyi 这个账户下,程序员需要小心这里的坑,因为拿到的用户路径以及注册表不是你所期望的 walterlv 这个账号下的。

标准账户下运行管理员权限程序会切换账户

删除 requestedExecutionLevel 元素

删除 requestedExecutionLevel 元素指的是将下面标注的这一行删掉:

    <?xml version="1.0" encoding="utf-8"?>
    <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
      <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
        <security>
          <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
--          <requestedExecutionLevel level="asInvoker" uiAccess="false" />
          </requestedPrivileges>
        </security>
      </trustInfo>

注释中说删除 requestedExecutionLevel 元素将开启 UAC 虚拟化。

开启 UAC 虚拟化

我将这个节点删除后,运行我的 Demo 程序后 UAC 虚拟化将启用。默认这里是“已禁用”的。

不过在以下任意一种情况下,UAC 虚拟化即便删了 requestedExecutionLevel 也是不会开启的:

  1. 64 位进程
  2. 不可交互的进程(例如服务)
  3. 进程模拟用户的操作(如果一个进程像用户一样执行了某项操作,那么这个操作不会被虚拟化)
  4. 驱动等内核模式进程

这部分的列表你可以在这里查询到:Registry Virtualization - Windows applications - Microsoft Docs

这些值都用于什么场景?

  • asInvoker 是默认情况下的首选。如果你的程序没有什么特殊的需求,就使用 asInvoker;就算你的程序需要管理员程序做一些特殊的任务,那最好也写成 asInvoker,仅在必要的时候才进行管理员权限提升。
  • requireAdministrator,只有当你的程序大量进行需要管理员权限的操作的时候才建议使用 requireAdministrator 值,例如你正在做安装程序。
  • highestAvailable,当你的程序需要管理员权限,但又要求仅对当前用户修改时设置为此值。因为标准用户申请 UAC 提权之后会以其他用户的身份运行进程,这就不是对当前用户的操作了;使用 highestAvailable 来确保以当前用户运行。

为什么 UWP 程序不能指定 UAC 清单选项?

在我的另一篇博客 Windows 中的 UAC 用户账户控制 中说到了访问令牌。

UWP 程序只能获得受限访问令牌,没得选,所以也就不需要指定 UAC 清单选项了。这也是为什么当你关闭 UAC 之后,UWP 程序将全部闪退的重要原因。


参考资料

03-19 2019

Windows 下使用 runas 命令以指定的权限启动一个进程(非管理员、管理员)

在默认情况下,Windows 系统中启动一个进程会继承父进程的令牌。如果父进程是管理员权限,那么子进程就是管理员权限;如果父进程是标准用户权限,那么子进程也是标准用户权限。

我们也知道,可以使用一些方法为自己的应用程序提权。但是有没有方法可以任意指定一个权限然后运行呢?本文将介绍 Windows 下指定权限运行的做法。


runas 命令

runas 是 Windows 系统上自带的一个命令,通过此命令可以以指定权限级别间接启动我们的程序,而不止是继承父进程的权限。

打开 cmd 或者 PowerShell,输入 runas 命令可以看到其用法。

> runas
RUNAS 用法:

RUNAS [ [/noprofile | /profile] [/env] [/savecred | /netonly] ]
        /user:<UserName> program

RUNAS [ [/noprofile | /profile] [/env] [/savecred] ]
        /smartcard [/user:<UserName>] program

RUNAS /trustlevel:<TrustLevel> program

   /noprofile        指定不应该加载用户的配置文件。
                     这会加速应用程序加载,但
                     可能会造成一些应用程序运行不正常。
   /profile          指定应该加载用户的配置文件。
                     这是默认值。
   /env              要使用当前环境,而不是用户的环境。
   /netonly          只在指定的凭据限于远程访问的情况下才使用。
   /savecred         用用户以前保存的凭据。
   /smartcard        如果凭据是智能卡提供的,则使用这个选项。
   /user             <UserName> 应使用 USER@DOMAIN  DOMAIN\USER 形式
   /showtrustlevels  显示可以用作 /trustlevel 的参数的
                     信任级别。
   /trustlevel       <Level> 应该是在 /showtrustlevels 中枚举
                     的一个级别。
   program           EXE 的命令行。请参阅下面的例子

示例:
> runas /noprofile /user:mymachine\administrator cmd
> runas /profile /env /user:mydomain\admin "mmc %windir%\system32\dsa.msc"
> runas /env /user:user@domain.microsoft.com "notepad \"my file.txt\""

注意:  只在得到提示时才输入用户的密码。
注意:  /profile  /netonly 不兼容。
注意:  /savecred  /smartcard 不兼容。

提权运行或者降权运行

为了演示提权或者降权,我们需要有一个能够验证当前是否是管理员权限运行的程序。关于如何在程序中判断当前是否以管理员权限运行,可以阅读我和林德熙的博客:

本质上是这段代码:

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 检测到当前进程是以管理员权限运行的。
}

此代码如果在 .NET Core 中编写,以上代码需要额外安装 Windows 兼容包:Microsoft.Windows.Compatibility

提权运行或者降权运行

我以标准用户权限和管理员权限分别启动了一个 PowerShell Core,然后准备在这两个窗口里面分别启动我的检测管理员权限的程序。

在两个 PowerShell 中运行命令

0x20000 是标准用户权限,现在运行命令:

> runas /trustlevel:0x20000 .\Walterlv.Demo.exe

运行结束后,两个进程都是非管理员权限

运行发现,两个进程现在都是标准用户权限。即使是管理员的 PowerShell 中运行的也都是非管理员权限。

0x40000 是管理员权限,现在运行命令:

> runas /trustlevel:0x40000 .\Walterlv.Demo.exe

运行结束后,两个进程都取得不高于当前 PowerShell 的最高权限

运行发现,非管理员的 PowerShell 启动的是非管理员权限的进程;而管理员的 PowerShell 启动的是管理员权限的进程。

使用 C# 代码来降权运行

使用 C# 代码,就是要将下面这一句翻译成 C#。

> runas /trustlevel:0x20000 .\Walterlv.Demo.exe

所以其实非常简单,就是 Process.Start 传入参数即可。

Process.Start("runas.exe", $"/trustlevel:0x20000 Walterlv.Demo.exe");

关于更多降权运行的方法,可以参考我的另一篇博客:


参考资料

03-19 2019

在 Windows 系统上降低 UAC 权限运行程序(从管理员权限降权到普通用户权限)

在 Windows 系统中,管理员权限和非管理员权限运行的程序之间不能使用 Windows 提供的通信机制进行通信。对于部分文件夹(ProgramData),管理员权限创建的文件是不能以非管理员权限修改和删除的。

然而,一个进程运行之后启动的子进程,会继承当前进程的 UAC 权限;于是有时我们会有降权运行的需要。本文将介绍 Windows 系统上降权运行的几种方法。


本文的降权运行指的是:

  1. 有一个 A 程序是以管理员权限运行的(典型的,如安装包);
  2. 有一个 B 程序会被 A 启动(我们期望降权运行的 B 程序)。

如何判断当前进程的 UAC 权限

通过下面的代码,可以获得当前进程的 UAC 权限。

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);

而如果要判断是否是管理员权限,则使用:

if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 当前正在以管理员权限运行。
}

此代码如果在 .NET Core 中编写,需要额外安装 Windows 兼容包:Microsoft.Windows.Compatibility

方法一:使用 runas 命令来运行程序(推荐)

使用 runas 命令来运行,可以指定一个权限级别:

> runas /trustlevel:0x20000 "C:\Users\walterlv\Desktop\walterlv.exe"
var subProcessFileName = "C:\Users\walterlv\Desktop\walterlv.exe";
Process.Start("runas.exe", $"/trustlevel:0x20000 {subProcessFileName}");

关于 runas 的更多细节,可以参考我的另一篇博客:

方法二:使用 explorer.exe 代理运行程序

请特别注意,使用 explorer.exe 代理运行程序的时候,是不能带参数的,否则 explorer.exe 将不会启动你的程序。

因为绝大多数用户启动系统的时候,explorer.exe 进程都是处于运行状态,而如果启动一个新的 explorer.exe,都会自动激活当前正在运行的进程而不会启动新的。

于是我们可以委托默认以普通权限运行的 explorer.exe 来代理启动我们需要启动的子进程,这时启动的子进程便是与 explorer.exe 相同权限的。

var subProcessFileName = "C:\Users\walterlv\Desktop\walterlv.exe";
Process.Start("explorer.exe", subProcessFileName);

如果用户计算机上的 UAC 是打开的,那么 explorer.exe 默认就会以标准用户权限运行。通过以上代码,walterlv.exe 就会以与 explorer.exe 相同权限运行,也就是降权运行了。

不过值得注意的是,Windows 7 上控制面板的 UAC 设置拉倒最低就是关掉 UAC 了;Windows 8 开始拉倒最底 UAC 还是打开的,只是不会提示 UAC 弹窗而已。也就是说,拉倒最底的话,Windows 7 的 UAC 就会关闭,explorer.exe 就会以管理员权限启动。

下面的代码,如果发现自己是以管理员权限运行的,那么就降权重新运行自己,然后自己退出。(当然在关闭 UAC 的电脑上是无效的。)

var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
    // 检测到当前进程是以管理员权限运行的,于是降权启动自己之后,把自己关掉。
    Process.Start("explorer.exe", Assembly.GetEntryAssembly().Location);
    Shutdown();
    return;
}

请再次特别注意,使用 explorer.exe 代理运行程序的时候,是不能带参数的,否则 explorer.exe 将不会启动你的程序。

方法三:在启动进程时传入用户名和密码

ProcessStartInfo 中有 UserNamePassword 属性,设置此属性可以以此计算机上的另一个用户身份启动此进程。如果这个用户是普通用户,那么就会以普通权限运行此进程。

var processInfo = new ProcessStartInfo
{
    Verb = "runas",
    FileName = "walterlv.exe",
    UserName = "walterlv",
    Password = ReadPassword(),
    UseShellExecute = false,
    LoadUserProfile = true
};
Process.Start(processInfo);

上面的 ReadPassword 函数来自我的另一篇博客:如何让 .NET Core 命令行程序接受密码的输入而不显示密码明文 - walterlv

然而,此方法最大的问题在于——产品级的程序,不可能也不应该知道用户的密码!所以实际上这样的方法并不实用。

方法四:使用 Shell 进程的 Access Token 来启动进程

此方法需要较多的 Windows API 调用,我没有尝试过这种方法,但是你可以自行尝试下面的链接:


参考资料

03-17 2019

如何创建应用程序清单文件 App.Manifest,如何创建不带清单的应用程序

如果你的程序对 Windows 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项权限设置的实际效果。


嵌入带默认设置的清单

对于 WPF 和 Windows Forms 程序,如果你什么都不做,那么就已经嵌入了一个带有默认设置的清单。

下图可以在 Visual Studio 中的项目上右键属性插件。

嵌入带默认设置的清单

新建一个自定义的清单文件

在项目上右键,添加,新建项。可以在新建模板中找到“应用程序清单文件”。确认后即添加了一个新的清单文件。这时,项目属性页中的清单也会自动设置为刚刚添加的清单文件。

按照清单模板新建清单

默认的清单中,包含 UAC 清单选项、系统兼容性选项、DPI 感知级别选项和 Windows 公共控件和对话框的主题选项。

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
    <security>
      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
        <!-- UAC 清单选项
             如果想要更改 Windows 用户帐户控制级别,请使用
             以下节点之一替换 requestedExecutionLevel 节点。n
        <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
        <requestedExecutionLevel  level="highestAvailable" uiAccess="false" />

            指定 requestedExecutionLevel 元素将禁用文件和注册表虚拟化。
            如果你的应用程序需要此虚拟化来实现向后兼容性,则删除此
            元素。
        -->
        <requestedExecutionLevel level="asInvoker" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>

  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
    <application>
      <!-- 设计此应用程序与其一起工作且已针对此应用程序进行测试的
           Windows 版本的列表。取消评论适当的元素,
           Windows 将自动选择最兼容的环境。 -->

      <!-- Windows Vista -->
      <!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->

      <!-- Windows 7 -->
      <!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->

      <!-- Windows 8 -->
      <!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->

      <!-- Windows 8.1 -->
      <!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->

      <!-- Windows 10 -->
      <!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->

    </application>
  </compatibility>

  <!-- 指示该应用程序可以感知 DPI 且 Windows 在 DPI 较高时将不会对其进行
       自动缩放。Windows Presentation Foundation (WPF)应用程序自动感知 DPI,无需
       选择加入。选择加入此设置的 Windows 窗体应用程序(目标设定为 .NET Framework 4.6 )还应
       在其 app.config 中将 "EnableWindowsFormsHighDpiAutoResizing" 设置设置为 "true"。-->
  <!--
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
    </windowsSettings>
  </application>
  -->

  <!-- 启用 Windows 公共控件和对话框的主题(Windows XP 和更高版本) -->
  <!--
  <dependency>
    <dependentAssembly>
      <assemblyIdentity
          type="win32"
          name="Microsoft.Windows.Common-Controls"
          version="6.0.0.0"
          processorArchitecture="*"
          publicKeyToken="6595b64144ccf1df"
          language="*"
        />
    </dependentAssembly>
  </dependency>
  -->

</assembly>

创建不带清单的应用程序

你也可以创建一个不带应用程序清单的应用程序。方法是在属性页中将清单设置为“创建不带清单的应用程序”。

创建不带清单的应用程序

03-14 2019

C#/.NET 如何结束掉一个进程

本文介绍如何结束掉一个进程。


结束掉特定名字的进程

ProcessInfo 中有 Kill 实例方法可以调用,也就是说如果我们能够拿到一个进程的信息,并且对这个进程拥有访问权限,那么我们就能够结束掉它。

使用 Process.GetProcessesByName(processName) 可以按照名字拿到进程信息。于是我们可以使用这个方法杀掉具有特定名称的进程。

private void KillProcess(string processName)
{
    foreach (var process in Process.GetProcessesByName(processName))
    {
        try
        {
            // 杀掉这个进程。
            process.Kill();

            // 等待进程被杀掉。你也可以在这里加上一个超时时间(毫秒整数)。
            process.WaitForExit();
        }
        catch (Win32Exception ex)
        {
            // 无法结束进程,可能有很多原因。
            // 建议记录这个异常,如果你的程序能够处理这里的某种特定异常了,那么就需要在这里补充处理。
            // Log.Error(ex);
        }
        catch (InvalidOperationException)
        {
            // 进程已经退出,无法继续退出。既然已经退了,那这里也算是退出成功了。
            // 于是这里其实什么代码也不需要执行。
        }
    }
}

结束掉自己

可以是参见林德熙的博客,使用 Environment.FailFast,在结束掉自己的时候记录自己的错误日志。

03-14 2019

让你的 VSCode 具备调试 C# 语言 .NET Core 程序的能力

如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。

如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!


安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code

  1. 点击这里下载正式或者预览版的 .NET Core 然后安装
  2. 点击这里下载 Visual Studio Code 然后安装
  3. 在 Visual Studio Code 里安装 C# for Visual Studio Code 插件(步骤如下图所示)

安装 C# for Visual Studio Code 插件

搜索的时候,推荐使用 OmniSharp 关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。

对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。

使用 VSCode 创建 .NET Core 项目

本文不会讲解如何使用 VSCode 创建 .NET Core 项目,因为这不是本文的重点。

也许你可以参考我还没有写的另一篇博客。

打开一个现有的 .NET Core 项目

现在假设你已经有一个现成的能用 Visual Studio 跑起来的 .NET Core 控制台项目了(可能是刚克隆下来的,也可能就是用我另一篇博客中的教程创建的),于是我们就在这个项目上进行开发。

本文以我的自动化测试程序 Walterlv.InfinityStartupTest 为例进行说明。如果你找不到合适的例子,可以使用这篇博客创建一个。

在这个文件夹的根目录下右键,然后 使用 Code 打开

使用 Visual Studio Code 打开文件夹

配置编译和调试环境

正常情况下,当你用 Visual Studio Code 打开一个包含 .NET Core 项目的文件夹时,C# 插件会在右下角弹出通知提示,问你要不要为这个项目创建编译和调试文件,当然选择“Yes”。

创建编译和调试文件的提示

这个提示一段时间不点会消失的,但是右下角会有一个小铃铛(上面的图片也可以看得到的),点开可以看到刚刚消失的提示,然后继续操作。

这时,你的项目文件夹中会多出两个文件,都在 .vscode 文件夹中。tasks.json 是编译文件,指导如何进行编译;launch.json 是调试文件,指导如何进行调试。

多出的编译文件和调试文件

开始调试

现在,你只需要按下 F5(就是平时 Visual Studio 调试按烂的那个),你就能使用熟悉的调试方式在 Visual Studio Code 中来调试 .NET Core 程序了。

下图是调试进行中各个界面的功能分区。如果你没看到这个界面,请点击左侧那只被圈在圆圈里面的小虫子。

Visual Studio Code 中的 .NET Core 调试界面

当你按照本文操作,在按下 F5 后有各种报错,那么原因只有一个——你的这个项目本身就是编译不过的,你自己用命令行也会编译不过。你需要解决编译问题,而本文只是入门教程,不会说如何解决编译问题。

手工设置 tasks.json 和 launch.json 文件

如果自动创建的这两个文件有问题,或者你根本就找不到自动创建的入口,可以考虑手工创建这两个文件。

请参见博客:

还补充一句,本文说编译文件和调试文件是不对的,因为在 Visual Studio Code 中没有编译这个概念,编译只是任务中的一种而已。

03-14 2019

手工编辑 tasks.json 和 launch.json,让你的 VSCode 具备调试 .NET Core 程序的能力

如果 C# for Visual Studio Code 没有办法自动为你生成正确的 tasks.json 和 launch.json 文件,那么可以考虑阅读本文手工创建他们。


前期准备

你需要安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code,然后打开一个 .NET Core 的项目。如果你没有准备,请先阅读:

本文主要处理自动生成的配置文件无法满足要求,手工生成。

半自动创建 tasks.json 和 launch.json

这依然是个偷懒的好方案,我喜欢。

  1. 按下 F5;
  2. 在弹出的列表中,选择 .NET Core;

选择 .NET Core

自动生成的 tasks.json 和 launch.json

你不需要再做什么其他的工作了,这时再按下 F5 你已经可以开始调试了。

全手工创建 tasks.json 和 launch.json

tasks.json 定义一组任务。其中我们需要的是编译任务,通常编译一个项目使用的动词是 build。比如 dotnet build 命令就是这样的动词。

于是定义一个名字为 build 的任务,对应 label 标签。commandargs 对应我们在命令行中编译一个项目时使用的命令行和参数。typeprocess 表示此任务是启动一个进程。

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/Walterlv.InfinityStartupTest/Walterlv.InfinityStartupTest.csproj"
            ],
            "problemMatcher": "$msCompile"
        }
    ]
}

在 launch.json 中通常配置两个启动配置,一个是启动调试,一个是附加调试。

type 是在安装了 C# for Visual Studio Code (powered by OmniSharp) 插件之后才会有的调试类型。preLaunchTask 表示在此启动开始之前需要执行的任务,这里指定的 build 跟前面的 build 任务就关联起来了。program 是调试的程序路径,console 指定调试控制台使用内部控制台。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "调试 Walterlv 的自动化测试程序",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/Walterlv.InfinityStartupTest/bin/Debug/netcoreapp3.0/Walterlv.InfinityStartupTest.dll",
            "args": [],
            "cwd": "${workspaceFolder}/Walterlv.InfinityStartupTest",
            "console": "internalConsole",
            "stopAtEntry": false,
            "internalConsoleOptions": "openOnSessionStart"
        },
        {
            "name": "附加进程",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        }
    ]
}

这样自己手写的方式更灵活但是也更难。

03-12 2019

在 csproj 文件中使用系统环境变量的值(示例将 dll 生成到 AppData 目录下)

Windows 系统以及很多应用程序会考虑使用系统的环境变量来传递一些公共的参数或者配置。Windows 资源管理器使用 %var% 来使用环境变量,那么我们能否在 Visual Studio 的项目文件中使用环境变量呢?

本文介绍如何在 csproj 文件中使用环境变量。


遇到的问题

在 Windows 资源管理器中,我们可以使用 %AppData% 进入到用户的漫游路径。我正在为 希沃白板5 为互动教学而生 - 课件制作神器 编写插件,于是需要将插件放到指定目录:

%AppData%\Seewo\EasiNote5\Walterlv.Presentation

在 Windows 资源管理器中可以直接输入以上文字进入对应的目录(当然需要确保存在)。

插件目录

更多关于路径的信息可以参考:UWP 中的各种文件路径(用户、缓存、漫游、安装……) - walterlv

然而,为了调试方便,我最好在 Visual Studio 中编写的时候就能直接输出到插件目录。

于是,我需要将 Visual Studio 的调试目录设置为以上目录,但是以上目录中包含环境变量 %AppData%

在 Visual Studio 中修改输出路径

如果直接在 csproj 中使用 %AppData%,那么 Visual Studio 会原封不动地创建一个这样的文件夹。

一个诡异的文件夹

实际上,Visual Studio 是天然支持环境变量的。直接使用 MSBuild 获取属性的语法即可获取环境变量的值。

也就是说,使用 $(AppData) 即可获取到其值。在我的电脑上是 C:\Users\lvyi\AppData\Roaming

于是,在 csproj 中设置 OutputPath 即可正确输出我的插件到目标路径。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFrameworks>net472</TargetFrameworks>
        <OutputPath>$(AppData)\Seewo\EasiNote5\Extensions\Walterlv.Presentation</OutputPath>
        <AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
    </PropertyGroup>
</Project>

这里,我额外设置了 AppendTargetFrameworkToOutputPath 属性,这是避免 net472 出现在了目标输出路径中。你可以阅读我的另一篇博客了解更多关于输出路径的问题:

03-10 2019

为 WPF 程序添加 Windows 跳转列表的支持

Windows 跳转列表是自 Windows 7 时代就带来的功能,这一功能是跟随 Windows 7 的任务栏而发布的。当时应用程序要想用上这样的功能需要调用 shell 提供的一些 API。

然而在 WPF 程序中使用 Windows 跳转列表功能非常简单,在 XAML 里面就能完成。本文将介绍如何让你的 WPF 应用支持 Windows 跳转列表功能。


一个简单的跳转列表程序

新建一个 WPF 程序,然后直接在 App.xaml 中添加跳转列表的代码。这里为了更快上手,我直接贴出整个 App.xaml 的代码。

<Application x:Class="Walterlv.Demo.WindowsTasks.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:Walterlv.Demo.WindowsTasks"
             StartupUri="MainWindow.xaml">
    <JumpList.JumpList>
        <JumpList ShowRecentCategory="True" ShowFrequentCategory="True">
            <JumpTask Title="启动新窗口" Description="启动一个新的空窗口" />
            <JumpTask Title="修改 walterlv 的个性化设置" Description="打开个性化设置页面并定位到 walterlv 的设置"
                      IconResourcePath="C:\Windows\System32\wmploc.dll" IconResourceIndex="17"
                      Arguments="--account" />
        </JumpList>
    </JumpList.JumpList>
</Application>

顺便的,我加了一个简单的图标,这样不至于显示一个默认的应用图标。

添加的简单的图标

运行此程序后就可以在任务栏上右击的时候看到跳转列表:

运行后看到的跳转列表

在这段程序中,我们添加了两个“任务”,在跳转列表中有一个“任务”分类。因为我的系统是英文,所以显示的是“Task”。

在任务分类中,有两个“任务”,启动新窗口 以及 修改 walterlv 的个性化设置。第一个任务只设了标题和鼠标移上去的提示信息,于是显示的图标就是应用本身的图标,点击之后也是启动任务自己。第二个任务设置了 Arguments 参数,于是点击之后会带里面设置的参数启动自己;同时设置了 IconResourcePathIconResourceIndex 用于指定图标。

这种图标的指定方式是 Windows 系统中非常常用的方式。你可以在我的另一篇博客中找到各种各样系统自带的图标;至于序号,则是自己去数。

定制跳转列表的功能

JumpList 有两个属性 ShowRecentCategoryShowFrequentCategory,如果指定为 true 则表示操作系统会自动为我们保存此程序最近使用的文件的最频繁使用的文件。

Windows 的跳转列表有两种不同的列表项,一种是“任务”,另一种是文件。至于这两种不同的列表项如何在跳转列表中安排,则是操作系统的事情。

这两种不同的列表项对应的类型分别是:

  • JumpTask
  • JumpPath

JumpTask 可以理解为这就是一个应用程序的快捷方式,可以指定应用程序的路径(ApplicationPath)、工作目录(WorkingDirectory)、启动参数(Arguments)和图标(IconResourcePathIconResourceIndex)。如果不指定路径,那么就默认为当前程序。也可以指定显示的名称(Title)和鼠标移上去可以看的描述(Description)。

JumpPath 则是一个路径,可以是文件或者文件夹的路径。通常用来作为最近使用文件的展示。特别说明:你必须关联某种文件类型这种类型的文件才会显示到 JumpPath 中。

另外,JumpTaskJumpPath 都有一个 CustomCategory 属性可以指定类别。对于 JumpTask,如果不指定类别,那么就会在默认的“任务”(Task)类别中。对于 JumpPath,如果不指定类别,就在最近的文件中。

JumpTask 如果不指定 TitleCustomCategory 属性,那么他会成为一个分隔符。


参考资料

03-10 2019

Windows 上的应用程序在运行期间可以给自己改名(可以做 OTA 自我更新)

程序如何自己更新自己呢?你可能会想到启动一个新的程序或者脚本来更新自己。然而 Windows 操作系统允许一个应用程序在运行期间修改自己的名称甚至移动自己到另一个文件夹中。利用这一点,我们可以很简单直接地做程序的 OTA 自动更新。

本文将介绍示例程序运行期间改名并解释其原理。


在程序运行期间手工改名

我们写一个简单的程序。

简单的程序

将它运行起来,然后删除。我们会发现无法删除它。

无法删除程序

但是,我们却可以很轻松地在资源管理器中对它进行改名,甚至将它从一个文件夹中移动到另一个文件夹中。

已经成功改名

值得注意的是,你不能跨驱动器移动此文件。

不止是 exe 文件,dll 文件也是可以改名的

实际上,不止是 exe 文件,在 exe 程序运行期间,即使用到了某些 dll 文件,这些 dll 文件也是可以改名的。

当然,一个 exe 的运行不一定在启动期间就加载好了所有的 dll,所以如果你在 exe 启动之后,某个 dll 加载之前改了那个 dll 的名称,那么会出现找不到 dll 的情况,可能导致程序崩溃。

为什么 Windows 上的可执行程序可以在运行期间改名?

Windows 的文件系统由两个主要的表示结构:一个是目录信息,它保存有关文件的元数据(如文件名、大小、属性和时间戳);第二个是文件的数据链。

当运行程序加载一个程序集的时候,会为此程序集创建一个内存映射文件。为了优化性能,往往只有实际用到的部分才会被加入到内存映射文件中;当需要用到程序集文件中的某块数据时,Windows 操作系统就会将需要的部分加载到内存中。但是,内存映射文件只会锁定文件的数据部分,以保证文件文件的数据不会被其他的进程修改。

这里就是关键,内存映射文件只会锁定文件的数据部分,而不会锁住文件元数据信息。这意味着你可以随意修改这些元数据信息而不会影响程序的正常运行。这就包括你可以修改文件名,或者把程序从一个文件夹下移动到另一个文件夹去。

但是跨驱动器移动文件,就意味着需要在原来的驱动器下删除文件,而这个操作会影响到文件的数据部分,所以此操作不被允许。

编写一个程序在运行期间自动改名

一般来说,需要 OTA 更新的程序是客户端程序,所以实际上真正需要此代码的是客户端应用。以下代码中我使用 .NET Core 3.0 来编写一个给自己改名的 WPF 程序。

using System.Diagnostics;
using System.IO;
using System.Windows;

namespace Walterlv.Windows.Updater
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);
            var fileName = Process.GetCurrentProcess().MainModule.FileName;
            var newFileName = Path.Combine(Path.GetDirectoryName(fileName), "OldUpdater.exe");
            File.Move(fileName, newFileName);
            // 省略的代码:将新下载下载的程序改名成 fileName。
        }
    }
}

于是,程序自己在运行后会改名。

程序已经自己改名

顺便的,以上代码仅适用于 .NET Framework 的桌面应用程序或者 .NET Core 3.0 的桌面应用程序。如果是 .NET Core 2.x,那么以上代码在获取到进程名称的时候可能是 dotnet.exe(已发布的 .NET Core 程序除外)。


参考资料

03-10 2019

.NET 使用 JustAssembly 比较两个不同版本程序集的 API 变化

最近我大幅度重构了我一个库的项目结构,使之使用最新的项目文件格式(基于 Microsoft.NET.Sdk)并使用 SourceYard 源码包来打包其中的一些公共代码。不过,最终生成了一个新的 dll 之后却心有余悸,不知道我是否删除或者修改了某些 API,是否可能导致我原有库的使用者出现意料之外的兼容性问题。

另外,准备为一个产品级项目更新某个依赖库,但不知道更新此库对我们的影响有多大,希望知道目前版本和希望更新的版本之间的 API 差异。

索性发现了 JustAssembly 可以帮助我们分析程序集 API 的变化。本文将介绍如何使用 JustAssembly 来分析不同版本程序集 API 的变化。


下载和安装 JustAssembly

JustAssemblyTelerik 开源的一款程序集分析工具。

你可以去它的官网下载并安装:Assembly Diff Tool for .NET - JustAssembly

开始比较

启动 JustAssembly,在一开始丑陋(逃)的界面中选择旧的和新的 dll 文件,然后点击 Load

选择旧的和新的 dll 文件

然后,你就能看到新版本的 API 相比于旧版本的差异了。

新版本的 API 相比于旧版本的差异

关于比较结果的说明

在差异界面中,差异有以下几种显示:

  1. 没有差异
    • 以白色底显示
  2. 新增
    • 以绿色底辅以 + 符号显示
  3. 删除
    • 以醒目的红色底辅以 - 符号显示
  4. 有部分差异
    • 以蓝紫色底辅以 ~ 符号显示

这里可能需要说明一下“部分差异”:由于差异是以树状结构显示的,所以如果子节点有新增,那么父节点因为既有新增又存在未修改的节点,所以会以“有部分差异”的方式显示。

对于每一个差异,双击可以去看差异的代码详情。

上图我的 SourceFusion 项目在版本更新的时候只有新增的 API,没有修改和删除的 API,所以还是一个比较健康的 API 更新。


参考资料

03-10 2019

详解 .NET 反射中的 BindingFlags 以及常用的 BindingFlags 使用方式

使用 .NET 的反射 API 时,通常会要求我们传入一个 BindingFlags 参数用于指定反射查找的范围。不过如果对反射不熟的话,第一次写反射很容易写错导致找不到需要的类型成员。

本文介绍 BindingFlags 中的各个枚举标记的含义、用途,以及常用的组合使用方式。


所有的 BindingFlags

默认值

// 默认值
Default

查找

这些标记用于反射的时候查找类型成员:

// 表示查找的时候,需要忽略大小写。
IgnoreCase

// 仅查找此特定类型中声明的成员,而不会包括这个类继承得到的成员。
DeclaredOnly

// 仅查找类型中的实例成员。
Instance

// 仅查找类型中的静态成员。
Static

// 仅查找类型中的公共成员。
Public

// 仅查找类型中的非公共成员(internal protected private)
NonPublic

// 会查找此特定类型继承树上得到的静态成员。但仅继承公共(public)静态成员和受保护(protected)静态成员;不包含私有静态成员,也不包含嵌套类型。
FlattenHierarchy

调用

这些标记用于为 InvokeMember 方法提供参数,告知应该如何反射调用一个方法:

// 调用方法。
InvokeMethod

// 创建实例。
CreateInstance

// 获取字段的值。
GetField

// 设置字段的值。
SetField

// 获取属性的值。
GetProperty

// 设置属性的值。
SetProperty

其他

接下来下面的部分就不是那么常用的了。

这些标记用于为 InvokeMember 方法提供参数,但是仅在调用一个 COM 组件的时候才应该使用:

PutDispProperty
PutRefDispProperty
ExactBinding
SuppressChangeType
OptionalParamBinding

下面是一些杂项……

// 忽略返回值(在 COM 组件的互操作中使用)
IgnoreReturn

// 反射调用方法时如果出现了异常,通常反射会用 TargetInvocationException 包装这个异常。
// 此标记用于禁止把异常包装到 TargetInvocationException 中。
DoNotWrapExceptions

你可能会有的疑问

  1. 如果 A 程序集对 B 程序集内部可见(InternalsVisibleTo("B")),那么 B 在反射查找 A 的时候,internal 成员的查找应该使用 Public 还是 NonPublic 标记呢?
    • 依然是 NonPublic 标记。
    • 因为反射的是程序集的元数据,这是静态的数据,跟运行时状态是无关的。

常用的组合

从上面的解释中可以发现,这个类型的设计其实是有问题的,不符合单一职责原则。所以我们会在不同的使用场景下使用不同区域的组合。

查找,也就是获取一个类型中的字段、属性、方法等的时候使用的。

拿到所有成员:

BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance

实际上 RuntimeReflectionExtensions.Everything 属性就是这么写的。

拿到公有的实例成员:

BindingFlags.Public | BindingFlags.Instance

附 BindingFlags 的源码

[Flags]
public enum BindingFlags
{
    // NOTES: We have lookup masks defined in RuntimeType and Activator.  If we
    //    change the lookup values then these masks may need to change also.

    // a place holder for no flag specifed
    Default = 0x00,

    // These flags indicate what to search for when binding
    IgnoreCase = 0x01,          // Ignore the case of Names while searching
    DeclaredOnly = 0x02,        // Only look at the members declared on the Type
    Instance = 0x04,            // Include Instance members in search
    Static = 0x08,              // Include Static members in search
    Public = 0x10,              // Include Public members in search
    NonPublic = 0x20,           // Include Non-Public members in search
    FlattenHierarchy = 0x40,    // Rollup the statics into the class.

    // These flags are used by InvokeMember to determine
    // what type of member we are trying to Invoke.
    // BindingAccess = 0xFF00;
    InvokeMethod = 0x0100,
    CreateInstance = 0x0200,
    GetField = 0x0400,
    SetField = 0x0800,
    GetProperty = 0x1000,
    SetProperty = 0x2000,

    // These flags are also used by InvokeMember but they should only
    // be used when calling InvokeMember on a COM object.
    PutDispProperty = 0x4000,
    PutRefDispProperty = 0x8000,

    ExactBinding = 0x010000,    // Bind with Exact Type matching, No Change type
    SuppressChangeType = 0x020000,

    // DefaultValueBinding will return the set of methods having ArgCount or 
    //    more parameters.  This is used for default values, etc.
    OptionalParamBinding = 0x040000,

    // These are a couple of misc attributes used
    IgnoreReturn = 0x01000000,  // This is used in COM Interop
    DoNotWrapExceptions = 0x02000000, // Disables wrapping exceptions in TargetInvocationException
}

参考资料

03-10 2019

如何使用 MyGet 这个激进的 NuGet 源体验日构建版本的 .NET Standard / .NET Core

很多库都会在 nuget.org 上发布预览版本,不过一般来说这个预览版本也是大多可用的。然而想要体验日构建版本,这个就没有了,毕竟要照顾绝大多数开发者嘛……

本文介绍如何使用 MyGet 这个激进的 NuGet 源,介绍如何使用框架级别的库的预览版本如 .NET Standard 的预览版本。


加入 MyGet 这个 NuGet 源

添加 NuGet 源的方法在我和林德熙的博客中都有说明:

简单点,就是在 Visual Studio 中打开 工具 -> 选项 -> NuGet 包管理器 -> 包源

管理包源

然后把 MyGet 的源添加进去:

如果你想添加其他的 NuGet 源,可以参见我的另一篇博客:我收集的各种公有 NuGet 源 - 吕毅

使用 .NET Standard 的预览版本

因为我们在使用 .NET Standard 库的时候,是直接作为目标框架来选择的,就像下面的项目文件内容一样:

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  
</Project>

然而,如果你直接把 TargetFramework 中的值改为预览版本,是无法使用的。因为 TargetFramework 的匹配是按照字符串来匹配的,并不会解析成库和版本号。关于这一点可以如何得知的,可以参考我的另一篇博客(中英双语):

然而实际上的使用方法很简单,就是直接用正常的方法安装对应的 NuGet 包:

PM> Install-Package NETStandard.Library -Version 2.1.0-preview1-27119-01

或者直接去 csproj 中添加 PackageReference

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="NETStandard.Library" Version="2.1.0-preview1-27119-01" />
  </ItemGroup>
  
</Project>

至于版本号如何确定,请直接前往 MyGet 网站查看:dotnet-core - NETStandard.Library - MyGet

这个时候,.NET Standard 的预览版标准库会使用以替换 .NET Standard 2.0 的正式版本库。

03-09 2019

.NET/C# 获取一个正在运行的进程的命令行参数

在自己的进程内部,我们可以通过 Main 函数传入的参数,也可以通过 Environment.GetCommandLineArgs 来获取命令行参数。

但是,可以通过什么方式来获取另一个运行着的程序的命令行参数呢?


进程内部获取传入参数的方法,可以参见我的另一篇博客:.NET 命令行参数包含应用程序路径吗?

.NET Framework / .NET Core 框架内部是不包含获取其他进程命令行参数的方法的,但是我们可以在任务管理器中看到,说明肯定存在这样的方法。

任务管理器中的命令行参数

实际上方法是有的,不过这个方法是 Windows 上的专属方法。

对于 .NET Framework,需要引用程序集 System.Management;对于 .NET Core 需要引用 Microsoft.Windows.Compatibility 这个针对 Windows 系统准备的兼容包(不过这个兼容包目前还是预览版本)。

<ItemGroup Condition="$(TargetFramework) == 'netcoreapp2.1'">
    <PackageReference Include="Microsoft.Windows.Compatibility" Version="2.1.0-preview.19073.11" />
</ItemGroup>
<ItemGroup Condition="$(TargetFramework) == 'net472'">
    <Reference Include="System.Management" />
</ItemGroup>

然后,我们使用 ManagementObjectSearcherManagementBaseObject 来获取命令行参数。

为了简便,我将其封装成一个扩展方法,其中包括对于一些异常的简单处理。

using System;
using System.Diagnostics;
using System.Linq;
using System.Management;

namespace Walterlv
{
    /// <summary>
    /// 为 <see cref="Process"/> 类型提供扩展方法。
    /// </summary>
    public static class ProcessExtensions
    {
        /// <summary>
        /// 获取一个正在运行的进程的命令行参数。
        /// 与 <see cref="Environment.GetCommandLineArgs"/> 一样,使用此方法获取的参数是包含应用程序路径的。
        /// 关于 <see cref="Environment.GetCommandLineArgs"/> 可参见:
        /// .NET 命令行参数包含应用程序路径吗?https://blog.walterlv.com/post/when-will-the-command-line-args-contain-the-executable-path.html
        /// </summary>
        /// <param name="process">一个正在运行的进程。</param>
        /// <returns>表示应用程序运行命令行参数的字符串。</returns>
        public static string GetCommandLineArgs(this Process process)
        {
            if (process is null) throw new ArgumentNullException(nameof(process));

            try
            {
                return GetCommandLineArgsCore();
            }
            catch (Win32Exception ex) when ((uint) ex.ErrorCode == 0x80004005)
            {
                // 没有对该进程的安全访问权限。
                return string.Empty;
            }
            catch (InvalidOperationException)
            {
                // 进程已退出。
                return string.Empty;
            }

            string GetCommandLineArgsCore()
            {
                using (var searcher = new ManagementObjectSearcher(
                    "SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + process.Id))
                using (var objects = searcher.Get())
                {
                    var @object = objects.Cast<ManagementBaseObject>().SingleOrDefault();
                    return @object?["CommandLine"]?.ToString() ?? "";
                }
            }
        }
    }
}

使用此方法得到的命令行参数是一个字符串,而不是我们通常使用字符串时的字符串数组。如果你需要将其转换为字符串数组,可以使用我在另一篇博客中使用的方法:


参考资料

03-09 2019

WPF 让普通 CLR 属性支持 XAML 绑定(非依赖属性),这样 MarkupExtension 中定义的属性也能使用绑定了

如果你写了一个 MarkupExtension 在 XAML 当中使用,你会发现你在 MarkupExtension 中定时的属性是无法使用 XAML 绑定的,因为 MarkupExtension 不是一个 DependencyObject

本文将给出解决方案,让你能够在任意的类型中写出支持 XAML 绑定的属性;而不一定要依赖对象(DependencyObject)和依赖属性(DependencyProperty)。


问题

下面是一个很简单的 MarkupExtension,用户设置了什么值,就返回什么值。拿这么简单的类型只是为了避免额外引入复杂的理解难度。

public class WalterlvExtension : MarkupExtension
{
    private object _value;

    public object Value
    {
        get => _value;
        set => _value = value;
    }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        return Value;
    }
}

可以在 XAML 中直接赋值:

<Button Content="{local:Walterlv Value=blog.walterlv.com" />

但不能绑定:

<TextBox x:Name="SourceTextBox" Text="blog.walterlv.com" />
<Button Content="{local:Walterlv Value={Binding Text, Source={x:Reference SourceTextBox}}}" />

因为运行时会报错,提示绑定必须被设置到依赖对象的依赖属性中。在设计器中也可以看到提示不能绑定。

运行时报错

设计器的警告

解决

实际上这个问题是能够解决的(不过也花了我一些时间思考解决方案)。

既然绑定需要一个依赖属性,那么我们就定义一个依赖属性。非依赖对象中不能定义依赖属性,于是我们定义附加属性。

// 注意:这一段代码实际上是无效的。

public static readonly DependencyProperty ValueProperty = DependencyProperty.RegisterAttached(
    "Value", typeof(object), typeof(WalterlvExtension), new PropertyMetadata(default(object)));

public object Value
{
    get => ???.GetValue(ValueProperty);
    set => ???.SetValue(ValueProperty, value);
}

这里问题来了,获取和设置附加属性是需要一个依赖对象的,那么我们哪里去找依赖对象呢?直接定义一个新的就好了。

于是我们定义一个新的依赖对象:

// 注意:这一段代码实际上是无效的。

public static readonly DependencyProperty ValueProperty = DependencyProperty.RegisterAttached(
    "Value", typeof(object), typeof(WalterlvExtension), new PropertyMetadata(default(object)));

public object Value
{
    get => _dependencyObject.GetValue(ValueProperty);
    set => _dependencyObject.SetValue(ValueProperty, value);
}

private readonly DependencyObject _dependencyObject = new DependencyObject();

现在虽然可以编译通过,但是我们会遇到两个问题:

  1. ValueProperty 的变更通知的回调函数中,我们只能找到 _dependencyObject 的实例,而无法找到外面的类型 WalterlvExtension 的实例;这几乎使得 Value 的变更通知完全失效。
  2. Valueset 方法中得到的 value 值是一个 Binding 对象,而不是正常依赖属性中得到的绑定的结果;这意味着我们无法直接使用 Value 的值。

为了解决这两个问题,我必须自己写一个代理的依赖对象,用于帮助做属性的变更通知,以及处理绑定产生的 Binding 对象。在正常的依赖对象和依赖属性中,这些本来都不需要我们自己来处理。

方案

于是我写了一个代理的依赖对象,我把它命名为 ClrBindingExchanger,意思是将 CLR 属性和依赖属性的绑定进行交换。

代码如下:

public class ClrBindingExchanger : DependencyObject
{
    private readonly object _owner;
    private readonly DependencyProperty _attachedProperty;
    private readonly Action<object, object> _valueChangeCallback;

    public ClrBindingExchanger(object owner, DependencyProperty attachedProperty,
        Action<object, object> valueChangeCallback = null)
    {
        _owner = owner;
        _attachedProperty = attachedProperty;
        _valueChangeCallback = valueChangeCallback;
    }

    public object GetValue()
    {
        return GetValue(_attachedProperty);
    }

    public void SetValue(object value)
    {
        if (value is Binding binding)
        {
            BindingOperations.SetBinding(this, _attachedProperty, binding);
        }
        else
        {
            SetValue(_attachedProperty, value);
        }
    }

    public static void ValueChangeCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((ClrBindingExchanger) d)._valueChangeCallback?.Invoke(e.OldValue, e.NewValue);
    }
}

这段代码的意思是这样的:

  1. 构造函数中的 owner 参数完全没有用,我只是拿来备用,你可以删掉。
  2. 构造函数中的 attachedProperty 参数是需要定义的附加属性。
    • 因为前面我们说过,有一个附加属性才可以编译通过,所以附加属性是一定要定义的
    • 既然一定要定义附加属性,那么就可以用起来,接下来会用
  3. 构造函数中的 valueChangeCallback 参数是为了指定变更通知的,因为前面我们说变更通知不好做,于是就这样代理做变更通知。
  4. GetValueSetValue 这两个方法是用来代替 DependencyObject 自带的 GetValueSetValue 的,目的是执行我们希望特别执行的方法。
  5. SetValue 中我们需要自己考虑绑定对象,如果发现是绑定,那么就真的进行一次绑定。
  6. ValueChangeCallback 是给附加属性用的,因为用我的这种方法定义附加属性时,只能写出相同的代码,所以干脆就提取出来。

而用法是这样的:

public class WalterlvExtension : MarkupExtension
{
    public WalterlvExtension()
    {
        _valueExchanger = new ClrBindingExchanger(this, ValueProperty, OnValueChanged);
    }

    private readonly ClrBindingExchanger _valueExchanger;

    public static readonly DependencyProperty ValueProperty = DependencyProperty.RegisterAttached(
        "Value", typeof(object), typeof(WalterlvExtension),
        new PropertyMetadata(null, ClrBindingExchanger.ValueChangeCallback));

    public object Value
    {
        get => _valueExchanger.GetValue();
        set => _valueExchanger.SetValue(value);
    }

    private void OnValueChanged(object oldValue, object newValue)
    {
        // 在这里可以处理 Value 属性值改变的变更通知。
    }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        return Value;
    }
}

对于一个属性来说,代码确实多了些,这实在是让人难受。可是,这可以达成目的呀!

解释一下:

  1. 定义一个 _valueExchanger,就是在使用我们刚刚写的那个新类。
  2. 在构造函数中对 _valueExchanger 进行初始化,因为要传入 this 和一个实例方法 OnValueChanged,所以只能在构造函数中初始化。
  3. 定义一个附加属性(前面我们说了,一定要有依赖属性才可以编译通过哦)。
    • 注意属性的变更通知方法,需要固定写成 ClrBindingExchanger.ValueChangeCallback
  4. 定义普通的 CLR 属性 Value
    • GetValue 方法要换成我们自定义的 GetValue
    • SetValue 方法也要换成我们自定义的 SetValue 哦,这样绑定才可以生效
  5. OnValueChanged 就是我们实际的变更通知,这里得到的 oldValuenewValue 就是你期望的值,而不是我面前面奇怪的绑定实例。

于是,绑定就这么在一个普通的类型和一个普通的 CLR 属性中生效了,而且还获得了变更通知。

参考资料

本文没有任何参考资料,所有方法都是我(walterlv)的原创方法,因为真的找不到资料呀!不过在找资料的过程中发现了一些没解决的文档或帖子:

03-09 2019

四种方法获取可执行程序的文件路径(.NET Core / .NET Framework)

本文介绍四种不同的获取可执行程序文件路径的方法。适用于 .NET Core 以及 .NET Framework。


使用程序集信息获取

var executablePath = Assembly.GetEntryAssembly().Location;

这种方式的思路是获取入口程序集所在的路径。不过 Assembly.GetEntryAssembly() 能获取到的程序集是入口托管程序集;使用此方法会返回第一个托管程序集。

只有 .NET Framework 程序的入口才是托管程序(exe)。而对于 .NET Core 程序,如果直接发布成带环境依赖声明的 dll,那么实际运行的进程是 dotnet.exe;而如果发布成自包含的 exe 程序,其主 exe 也是一个非托管的 CLR 启动器而已,并不是托管程序集。

所以此方法适用条件:

  1. 必须是 .NET Framework 程序(.NET Core 程序不适用)

使用应用程序域信息获取

var executablePath = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

这种方式的思路是获取当前 AppDomain 所在的文件夹。不过此方法也只是获取到文件夹而已,不包含文件名。

所以此方法适用条件:

  1. 你不需要知道文件名,只是要一个程序所在的文件夹而已。

当然,此方法因为不涉及到托管和非托管程序集,所以与编译结果无关,适用于 .NET Core 和 .NET Framework 程序。

使用进程信息获取

var executablePath = Process.GetCurrentProcess().MainModule.FileName;

这种方式的思路是获取当前进程可执行程序的完全路径。

对于 .NET Framework 程序,其 exe 就是这个路径。

对于 .NET Core 程序来说:

  1. 如果发布成带环境依赖声明的 dll,那么此方法获取到的可执行程序名将是 dotnet.exe,这显然不会是我们预期的行为;
  2. 如果发布成自包含的 exe,那么此方法获取到的可执行程序名就是程序自己的名称,这是期望的结果。

所以此方法适用条件:

  1. 适用于 .NET Framework 程序;
  2. 适用于发布成自包含的 .NET Core 程序。

使用命令行参数获取

我在另一篇博客中提到命令行参数中包含应用程序路径:

于是我们也可以通过命令行参数来获取到可执行程序的路径。

var executablePath = Environment.GetCommandLineArgs()[0];

这种方法的效果和前面使用进程信息获取的效果是相同的,会获取到相同的可执行程序路径。

总结靠谱的方法

通过以上方法的说明,我们可以知道目前没有 100% 可靠的获取当前可执行程序文件路径的方法,不过可以组合多种方法达到 100% 可靠的目的。

  1. 如果我们只需要获取程序所在的文件夹
    • 那么请直接使用 AppDomain.CurrentDomain.SetupInformation.ApplicationBase
  2. 如果我们需要获取到可执行程序的完整路径
    • 先通过进程或者命令行参数的方式获取
      • Process.GetCurrentProcess().MainModule.FileName
      • Environment.GetCommandLineArgs()[0]
    • 如果得到的进程是 dotnet.exe,那么再通过程序集信息获取
      • Assembly.GetEntryAssembly().Location

另外,关于以上方法的性能对比,你可以参阅林德熙的博客:dotnet 获取路径各种方法的性能对比

03-05 2019

为什么 C# 的 string.Empty 是一个静态只读字段,而不是一个常量呢?

使用 C# 语言编写字符串常量的时候,你可能会发现可以使用 "" 而不能使用 string.Empty。进一步可以发现 string.Empty 实际上是一个静态只读字段,而不是一个常量。

为什么这个看起来最适合是常量的 string.Empty,竟然使用静态只读字段呢?


string.Empty

这个问题,我们需要去看 .NET Core 的源码(当然 .NET Framework 也是一样的)。

[Intrinsic]
public static readonly string Empty;

值得注意的是上面的 Intrinsic 特性。

Intrinsic 特性

Intrinsic 特性的注释是这样的:

Calls to methods or references to fields marked with this attribute may be replaced at some call sites with jit intrinsic expansions.
Types marked with this attribute may be specially treated by the runtime/compiler.

翻译过来是:对具有此 Intrinsic 特性标记的字段的方法或引用的调用可以在某些具有 JIT 内部扩展的调用点处替换,标记有此属性的类型可能被运行时或编译器特殊处理。

也就是说,string.Empty 字段并不是一个普通的字段,对它的调用会被特殊处理。但是是如何特殊处理呢?

JIT 编译器

string.Empty 的注释是这样描述的:

The Empty constant holds the empty string value. It is initialized by the EE during startup. It is treated as intrinsic by the JIT as so the static constructor would never run. Leaving it uninitialized would confuse debuggers.
We need to call the String constructor so that the compiler doesn’t mark this as a literal. Marking this as a literal would mean that it doesn’t show up as a field which we can access from native.

翻译过来是:

Empty 常量保存的是空字符串的值,它在启动期间由执行引擎初始化。它被 JIT 视为内在的,因此静态构造函数永远不会运行。将它保持为未初始化的状态将会使得调试器难以解释此行为。
于是我们需要调用 String 的构造函数,以便编译器不会将其标记为文字。将其标记为文字将意味着它不会显示为我们可以从本机代码访问的字段。

说明一下:

  1. 注释里的 EE 是 Execution Engine 的缩写,其实也就是 CLR 运行时。
  2. 那个 literal 我翻译成了文字。实际上这里说的是 IL 调用字符串时的一些区别:
    • 在调用 "" 时使用的 IL 是 ldstr ""(Load String Literal)
    • 而在调用 string.Empty 时使用的 IL 是 ldsfld string [mscorlib]System.String::Empty(Load Static Field)
  3. 虽然 IL 在调用 ""string.Empty 时生成的 IL 不同,但是在 JIT 编译成本机代码的时候,生成的代码完全一样。

string.Empty 字段在整个 String 类型中你都看不到初始化的代码,String 类的静态构造函数也不会执行。也就是说,String 类中的所有静态成员都不会被托管代码初始化。String 的静态初始化过程都是由 CLR 运行时进行的,而这部分的初始化是本机代码实现的。

那本机代码又是如何初始化 String 类型的呢?在 CLR 运行时的 AppDomain::SetupSharedStatics() 方法中实现,可前往 GitHub 阅读这部分的源码:

// This is a convenient place to initialize String.Empty.
// It is treated as intrinsic by the JIT as so the static constructor would never run.
// Leaving it uninitialized would confuse debuggers.

// String should not have any static constructors.
_ASSERTE(g_pStringClass->IsClassPreInited());

FieldDesc * pEmptyStringFD = MscorlibBinder::GetField(FIELD__STRING__EMPTY);
OBJECTREF* pEmptyStringHandle = (OBJECTREF*)
    ((TADDR)pLocalModule->GetPrecomputedGCStaticsBasePointer()+pEmptyStringFD->GetOffset());
SetObjectReference( pEmptyStringHandle, StringObject::GetEmptyString(), this );

总结:为什么 string.Empty 需要是一个静态只读字段而不是常量?

从上文中 string.Empty 的注释描述中可以知道:

  1. 编译器会将 C# 语言编译成中间语言 MSIL;
  2. 如果这是一个常量,那么编译器在不做特殊处理的情况下,就会生成 ldstr "",而这种方式不会调用到 String 类的构造函数(注意不是静态构造函数,String 类的静态构造函数是特殊处理不会调用的);
  3. 而如果这是一个静态字段,那么编译器可以在不做特殊处理的情况下,生成 ldsfld string [mscorlib]System.String::Empty,这在首次执行时会触发 String 类的构造函数,并在本机代码(非托管代码)中完成初始化。

当然,事实上编译器也可以针对此场景做特殊处理,但为什么不是在编译这一层进行特殊处理,我已经找不到出处了。

本文引申的其他问题

能否反射修改 string.Empty 的值?

不行!

实际上,在 .NET Framework 4.0 及以前是可以反射修改其值的,这会造成相当多的基础组件不能正常工作,在 .NET Framework 4.5 和以后的版本,以及 .NET Core 中,CLR 运行时已经不允许你做出这么出格儿的事了。

不过,如果你使用不安全代码(unsafe)来修改这个字段的值就当我没说。关于使用不安全代码转换字符串的方法可以参见:

""string.Empty 到底有什么区别?

从前文你可以得知,在运行时级别,这两者 没有任何区别

于是,当你需要一个代表 “空字符串” 含义的时候,使用 string.Empty;而当你必须要一个常量时,就使用 ""


参考资料

03-05 2019

C#/.NET 调试的时候显示自定义的调试信息(DebuggerDisplay 和 DebuggerTypeProxy)

使用 Visual Studio 调试 .NET 程序的时候,在局部变量窗格或者用鼠标划到变量上就能查看变量的各个字段和属性的值。默认显示的是对象 ToString() 方法调用之后返回的字符串,不过如果 ToString() 已经被占作它用,或者我们只是希望在调试的时候得到我们最希望关心的信息,则需要使用 .NET 中调试器相关的特性。

本文介绍使用 DebuggerDisplayAttributeDebuggerTypeProxyAttribute 来自定义调试信息的显示。(同时隐藏我们在背后做的这些见不得人的事儿。)


示例代码

比如我们有一个名为 CommandLine 的类型,表示从命令行传入的参数;内有一个字典,包含命令行参数的所有信息。

public class CommandLine
{
    private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
    private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
        => _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
}

现在,我们在 Visual Studio 里面调试得到一个 CommandLine 的实例,然后使用调试器查看这个实例的属性、字段和集合。

然后,这样的一个字典嵌套列表的类型,竟然需要点开 4 层才能知道命令行参数究竟是什么。这样的调试效率显然是太低了!

原生的调试显示

DebuggerDisplay

使用 DebuggerDisplayAttribute 可以帮助我们直接在局部变量窗格或者鼠标划过的时候就看到对象中我们最希望了解的信息。

现在,我们在 CommandLine 上加上 DebuggerDisplayAttribute

// 此段代码非最终版本。
[DebuggerDisplay("CommandLine: {DebuggerDisplay}")]
public class CommandLine
{
    private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
    private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
        => _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));

    private string DebuggerDisplay => string.Join(' ', _optionArgs
        .Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));
}

效果有了:

使用 DebuggerDisplay

不过,展开对象查看的时候可以看到一个 DebuggerDisplay 的属性,而这个属性我们只是调试使用,这是个垃圾属性,并不应该影响我们的查看。

里面有一个 DebuggerDisplay 垃圾属性

我们使用 DebuggerBrowsable 特性可以关闭某个属性或者字段在调试器中的显示。于是代码可以改进为:

--  [DebuggerDisplay("CommandLine: {DebuggerDisplay}")]
++  [DebuggerDisplay("CommandLine: {DebuggerDisplay,nq}")]
    public class CommandLine
    {
        private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
        private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
            => _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));
    
++      [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        private string DebuggerDisplay => string.Join(' ', _optionArgs
            .Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));
    }

添加了从不显示此字段(DebuggerBrowsableState.Never),在调试的时候,展开后的属性列表里面没有垃圾 DebuggerDisplay 属性了。

另外,我们在 DebuggerDisplay 特性的中括号中加了 nq 标记(No Quote)来去掉最终显示的引号。

DebuggerTypeProxy

虽然我们使用了 DebuggerDisplay 使得命令行参数一眼能看出来,但是看不出来我们把命令行解析成什么样了。于是我们需要更精细的视图。

然而,上面展开 _optionArgs 字段的时候,依然需要展开 4 层才能看到我们的所有信息,所以我们使用 DebuggerTypeProxyAttribute 来优化调试器实例内部的视图。

class CommandLineDebugView
{
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly CommandLine _owner;

    public CommandLineDebugView(CommandLine owner)
    {
        _owner = owner;
    }

    [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
    private string[] Options => _owner._optionArgs
        .Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}")
        .ToArray();
}

我面写了一个新的类型 CommandLineDebugView,并在构造函数中允许传入要优化显示的类型的实例。在这里,我们写一个新的 Options 属性把原来字典里面需要四层才能展开的值合并成一个字符串集合。

但是,我们在 Options 上标记 DebuggerBrowsableState.RootHidden

  1. 如果这是一个集合,那么这个集合将直接显示到调试视图的上一级视图中;
  2. 如果这是一个普通对象,那么这个对象的各个属性字段将合并到上一级视图中显示。

别忘了我们还需要禁止 _owner 在调试器中显示,然后把 [DebuggerTypeProxy(typeof(CommandLineDebugView))] 加到 CommandLine 类型上。

这样,最终的显示效果是这样的:

使用 DebuggerTypeProxy

点击 Raw View 可以看到我们没有使用 DebuggerTypeProxyAttribute 视图时的属性和字段。

最终代码

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Walterlv.Framework.StateMachine;

namespace Walterlv.Framework
{
    [DebuggerDisplay("CommandLine: {DebuggerDisplay,nq}")]
    [DebuggerTypeProxy(typeof(CommandLineDebugView))]
    public class CommandLine
    {
        private readonly Dictionary<string, IReadOnlyList<string>> _optionArgs;
        private CommandLine(Dictionary<string, IReadOnlyList<string>> optionArgs)
            => _optionArgs = optionArgs ?? throw new ArgumentNullException(nameof(optionArgs));

        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        private string DebuggerDisplay => string.Join(' ', _optionArgs
            .Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}"));

        private class CommandLineDebugView
        {
            [DebuggerBrowsable(DebuggerBrowsableState.Never)]
            private readonly CommandLine _owner;

            public CommandLineDebugView(CommandLine owner) => _owner = owner;

            [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
            private string[] Options => _owner._optionArgs
                .Select(pair => $"{pair.Key}{(pair.Key == null ? "" : " ")}{string.Join(' ', pair.Value)}")
                .ToArray();
        }
    }
}

参考资料

03-04 2019

使用 Xamarin 开发 iOS 键盘扩展(含网络访问)

作为一位 .NET 技术的死忠,开发 iOS 应用当然要使用 Xamarin 啦!

本文用我的阅读的文档和实践为素材,介绍如何使用 Xamarin 开发一个 iOS 的键盘扩展。


你可以在 Walterlv.CloudKeyboard 仓库中获得本文所述的全部源代码。

搭建环境

本文不会花篇幅来讲如何搭建 Xamarin iOS 开发的环境,不然这篇文章就没有重点。

于是,请阅读这一篇来了解如何搭建 Xamarin iOS 的开发环境:

  1. 安装调试工具:Mac 部分 Xamarin开发(Mac开发)环境搭建 - 简书
  2. 安装调试工具:Windows 部分 vs2017开发IOS(vs2017 xamarin 连接mac) - ManGo.XYZ - CSDN博客
  3. 申请开发者账号:https://developer.apple.com/register/阅读这里了解坑
  4. 准备一根 Type-C 到 Lightning 的数据线,用于 Mac 从 Mac 部署到真机进行调试

你需要了解的 iOS 键盘扩展的背景知识

了解以下背景知识,有助于我们接下来开发的时候少踩一些坑。当然我不会在这里说 iOS 应用开发的所有背景知识,只会说与 iOS 键盘扩展相关的部分。

  1. iOS 键盘扩展是 iOS 扩展的一种,而 iOS 扩展是 iOS 8.0 才开始引入的概念。
  2. iOS 扩展需要有一个 iOS 普通应用作为容器一起打包;所以,你需要创建两个项目来完成 iOS 键盘扩展的开发。
    • 在后文,我们将直接使用 iOS 容器应用来描述这个概念
    • 扩展的包标识符(Bundle Identifier)必须以容器应用的包标识符字符串作为开头
  3. iOS 扩展和 iOS 容器应用会被视为两款完全不同的应用,互相之前不能共享任何数据。
    • 如果真的要共享数据,就需要像其他两款不同应用共享数据一样的处理方式
  4. iOS 键盘扩展默认是不能访问网络的,你需要声明允许访问网络,并获得用户的同意才行。

创建 iOS 键盘扩展项目

第一步:创建 Xamarin.Forms 项目。

这个不用太在意里面的实现,因为它只是我们的“容器项目”(前面有介绍)。实际上在本文我们完全不会碰这个项目里面的代码,只是为了配置我们的 iOS 应用包而已。未来你可以在这个容器应用里面做键盘的个性化设置。

创建 Xamarin.Forms 项目

然后,选择 iOS 平台。

我们只需要 iOS 端。因为对于键盘,不同系统的实现差异很大,之间共享的代码只能是非键盘部分的代码了。

我们只选择 iOS 平台

第二步:创建 iOS 键盘扩展项目

创建新项目

创建 Custom Keyboard Extension 项目

创建完成之后的三个项目

当你创建完之后,你会看到三个不同的项目。

你可能发现 Walterlv.KeyboardExtension.Keyboard 项目有些奇怪,里面有 Main 函数和 AppDelegate,按道理这是一个主程序包。然而实际测试中单独有这个项目是跑不起来的(这可能是一个 Bug,如果修复了,请在下面评论或者邮件告知我,谢谢了)。

于是,Main 和 AppDelegate 这两个文件是可以删除的。如果你强迫症,就删掉吧。当然不删掉也不影响,不过我删掉了。

第三步:引用 iOS 键盘扩展项目

在 iOS 容器应用上面添加键盘扩展项目作为引用。

在 iOS 容器应用上添加引用

引用键盘扩展项目

如果你感兴趣去查看 Walterlv.KeyboardExtension.iOS 项目中对 Walterlv.KeyboardExtension.Keyboard 项目的引用节点的话,你会发现 Xamarin 已经自动为这个项目标记上了 <IsAppExtension />。只有加上了 AppExtension 标记,Xamarin 才会把这个项目作为 iOS 扩展项目进行打包。

<ProjectReference Include="..\..\Walterlv.KeyboardExtension.Keyboard\Walterlv.KeyboardExtension.Keyboard.csproj">
    <Project>{d6f006e7-3c98-4b97-b2d5-4d2e3bc2f945}</Project>
    <Name>Walterlv.KeyboardExtension.Keyboard</Name>
    <IsAppExtension>true</IsAppExtension>
    <IsWatchApp>false</IsWatchApp>
</ProjectReference>

在以上三个步骤完成之后,理论上你是可以正常编译此项目的。

可以编译通过

配置包信息

iOS 应用的包信息存储在 plist 中。所以在这一节,你需要正确配置两个项目的 plist。

没错!是两个项目。还记得前面背景知识里面我们说到容器项目和扩展项目就是两个不同的应用吗?

配置 plist 的方法,就是在 Visual Studio 里面双击这个文件。

按照下图这样配置:

配置两个项目的 plist 文件

说明:

  1. Application Name 对应 plist 中的 CFBundleDisplayName 属性,也就是应用的显示名称。
    • 对于容器应用,就是 iOS 图标下面的名称,对于键盘,就是切换键盘的时候所用的名称。
    • 下图中 iOS 应用图标下面的名称 CloudKeyboard 就是我在 Walterlv.CloudKeyboard 项目中的容器应用的名称。
    • 下图中在 iOS 切换键盘时,Cloud 就是我在 Walterlv.CloudKeyboard 项目中的键盘名称。
  2. 扩展项目的 Bundle Identifier 名称必须以容器项目的 Bundle Identifier 名称作为前缀。
    • 如果不满足要求,部署时扩展将不会生效。

iOS 应用图标

iOS 上切换键盘

至此,你的项目可以直接编译了。如果你有真机部署环境,都可以直接部署到真机上看效果了。

真机部署调试

本文不会花篇幅来讲如何真机部署调试,不然这篇文章就没有重点。

但是你可以阅读:使用 Xamarin 在 iOS 真机上部署应用进行调试

当然这是 Mac 版本的(毕竟我在 Windows 上实际也没有成功真机调试过,我是 git 同步到 Mac 上用 Visual Studio for Mac 来真机调试的)。

只是你需要注意做这些内容:

  1. 你需要同意一份开发者证书(不然打不开应用):
    • 设置 -> 通用 -> 设备管理 -> [自己的开发者账号] -> 信任
  2. 还需要打开这个键盘(不然看不到键盘):
    • 设置 -> 通用 -> 键盘 -> 添加新键盘… -> [选择我们刚刚开发的键盘]

下面是我部署到真机上之后,在亮暗两种不同的界面下的键盘截图(就是上面的项目,没有改任何代码):

键盘真机部署后的运行效果

处理键盘的文字输入、退格和确定

我们把 Walterlv.CloudKeyboard.iOS.Extension 也就是那个键盘扩展项目删除得只剩下 KeyboardViewController.cs 了,我们也只需要在这个类中写代码而已。

要控制文字输入,就是使用 TextDocumentProxy 实例。我们的 KeyboardViewController 继承自 UIInputViewController,于是我们能够在类中直接使用 TextDocumentProxy 实例。

在光标处插入文字:

TextDocumentProxy.InsertText("walterlv");

如果要插入换行或者确认输入,则使用:

TextDocumentProxy.InsertText("\n");

在光标处删除前一个字:

TextDocumentProxy.DeleteBackward();

如果想要清空文本,则可以循环删除:

while (TextDocumentProxy.HasText)
{
    TextDocumentProxy.DeleteBackward();
}

你没有办法删除后一个字,也不能获取到用户输入的任何内容。

关于换行,特别注意:如果文本框被设置为发送或者其他非换行的功能,那么使用 InsertText 单独插入换行时才能正常执行这些功能。如果调用此代码之前还有其他的插入文字,那么最终就只会是换行,而不会执行其他的功能。实际上我在这一点上踩了坑,导致在 QQ 或者其他工具中只能实现换行,而无法发送消息。

iOS 的键盘有不同种类的确认,需要键盘针对 TextDocumentProxy. 我还没有找到办法直接完成文本的输入,例如执行确认按钮的逻辑。而确认按钮有这么些不同的情况:

// 我当然是写 C# 语言版本的枚举,而不是 Object-C 版本的啦。
public enum UIReturnKeyType : long
{
    Default,
    Go,
    Google,
    Join,
    Next,
    Route,
    Search,
    Send,
    Yahoo,
    Done,
    EmergencyCall,
    Continue,
}

添加键盘的网络访问支持

允许完全访问(包括网络)

纯本地的键盘很难在打字速度上获得优势,各种主流的输入法也通常借助网络来提高自身的输入准确度。

用户需要在键盘设置里面开启键盘的“允许完全访问”才能让对应的输入法获得网络访问的权限。如果用户没有给权限,那么网络访问的时候键盘扩展就会出现异常,然后闪退。

允许完全访问

然而如果你去我们刚刚开发的输入法中看,你会发现我们的输入法没有提供这样的选项可以设置。那么如何能够添加这个设置以便进行网络访问呢?

方法是修改键盘扩展项目的 Info.plist 文件。这个时候的修改,我们就不能使用 Visual Studio 中自带的 plist 编辑器了,我们需要使用文本编辑器来编辑 plist 文件。

在你的 Info.plist 文件中找到 RequestsOpenAccess 属性,然后将它分值从 false 改为 true

    <key>RequestsOpenAccess</key>
--  <false/>
++  <true/>

这个属性设为 true 之后,再次部署,你将可以在你的键盘设置里面看到“允许完全访问”的设置项。开启之后,你就能在你的键盘里面访问网络了。

允许访问 http 不安全网络

一般来说你不用阅读这一小节的内容。因为现在基本上各种服务都已经是 https 了,http 基本已经绝迹。但是如果你需要临时部署一个服务,没来得及申请 https 证书的话,那么就需要使用本小结的内容让你的键盘支持 http 的访问。

继续打开你的键盘扩展项目的 Info.plist 文件,在根字典的最后添加一个完整的字典属性 NSAppTransportSecurity

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
    <key>NSExceptionDomains</key>
    <dict>
        <key>walterlv.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <true/>
        </dict>
    </dict>
</dict>

特别注意,里面的 walterlv.com 需要换成你自己的域名。是域名,不用包含端口号。

这样,你就能在键盘中访问 http://walterlv.com 了。

本文总结

  1. 本文介绍了使用 Xamarin 开发 iOS 键盘插件的背景知识。
    • 必须了解这些知识才不会在一些不太重要的坑上耗费太长时间。
  2. 本文教大家如何开发 iOS 键盘插件,主要是项目组织以及写代码。
    • 至少,使用文本编写出来的代码,能够在不作任何修改的情况下部署到真机。(实际上我们只在 KeyboardViewController.cs 中加了寥寥几行代码。)
  3. 本文不涉及到搭建开发环境,不涉及如何连接真机调试。

如果你还遇到了一些其他诡异的问题:


参考资料

03-04 2019

如何为你的 Windows 应用程序关联一种或多种文件类型

对于 Windows 桌面应用来说,让应用关联一种或多种文件类型是通过修改注册表来实现的。

本文介绍如何为你的应用关联自定义的文件类型或者关联被广泛使用的文件类型。


文件关联

Windows 上的文件关联是通过文件的扩展名来实现的。有些文件类型是被广泛使用的公共类型,例如 .txt、.png、.mp4 文件;有些则是你自己的应用程序使用的私有类型,例如我自己定义一个 .lvyi 扩展名的文件类型。

我们会关联这些广泛使用的类型可能是因为我们自己写了一个自己的文本编辑器,于是我们会关联 .txt 或者 .md 类型。而我们关联自定义的文件类型是因为我们需要为我们自己的应用生态产生一些文件数据。

那么问题来了,我怎么知道我现在准备使用的扩展名是不是已经被广泛使用的公共类型呢?请进入此网站查看:Media Types

注册一个文件类型

要在 Windows 系统上注册一个文件类型,你需要做三个步骤:

  1. 取一个应用程序标识符(ProgID
  2. 在注册表中添加文件关联(用于告知 Windows 这个文件已经被关联)
  3. 为关联的程序添加谓词(用于打开这个文件)

取一个应用程序标识符

没错,我说的就是取名字,而且要求在 Windows 系统上全局唯一;所以这里取名字也是有讲究的。关于应用程序标识符的相关内容,可以阅读微软的官方文档:Programmatic Identifiers - Windows applications - Microsoft Docs

微软建议的 ProgID 的取名方式是这样的:

厂商名.应用名.版本号

这里的版本号通常是指的大版本号。例如版本号为 1.6.0.97 的应用,通常只取第一位,即 1。一个典型的建议的取名示例是这样的:

Walterlv.Foo.1

还是看微软自己的命名示例会更权威一点:

来自微软的 ProgID 命名示例

竟然取一个名字也能写这么多篇幅,看来程序员的命名果然是世界上的一大难题呀!赶紧试用一下我的命名神器吧 —— 点击下载,其原理可阅读 冷算法:自动生成代码标识符(类名、方法名、变量名) - 吕毅

在注册表中添加文件关联

你需要在注册表的 HKEY_LOCAL_MACHINE\Software\Classes 或者 HKEY_CURRENT_USER\Software\Classes 添加一些子键:

HKEY_CURRENT_USER\Software\Classes
    .walv
        (Default) = Walterlv.Foo.1
    .lvyi
        (Default) = Walterlv.Foo.1
    Walterlv.Foo.1
        (Default) = 吕毅的示例文件
        Shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" "%1"
                      

前面的 .walvlvyi 是我自己定义的两种文件类型,我将它们的 (Default) 值设置成 Walterlv.Foo.1;而 Walterlv.Foo.1 就是前面说的应用程序标识符(ProgID)。后面的又新建了一个 Walterlv.Foo.1 的键,其 (Default) 值设置成了我们这个应用关联时使用的名称,也就是资源管理器中显示这个文件的时候使用的名称。

在注册表中的 Walterlv.Foo.1

只要我们完成了以上的步骤,我们就能在资源管理器中看到我们的文件关联(虽然双击打不开):

在资源管理器中看到的文件关联

关于注册表路径的说明

HKEY_LOCAL_MACHINE 主键是此计算机上的所有用户共享的注册表键值,而 HKEY_CURRENT_USER 是当前用户使用的注册表键值。而我们在注册表的 HKEY_CLASSES_ROOT 中也可以看到跟 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中一样的文件关联项,是因为 HKEY_CLASSES_ROOTHKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 合并之后的一个视图,其中用户键值会覆盖此计算机上的相同键值。

也就是说,如果你试图修改文件关联,那么需要去 HKEY_LOCAL_MACHINE\Software\ClassesHKEY_CURRENT_USER\Software\Classes 中,但如果只是去查看文件关联的情况,则只需要去 HKEY_CLASSES_ROOT 中。

写入计算机范围内的注册表项需要管理员权限,而写入用户范围内的注册表项不需要管理员权限;你可以酌情选用。

为关联的程序添加谓词

我们需要为关联的程序添加谓词才能够使用我们的程序打开这个文件。通常进行文件关联时最常用的谓词是 open,添加路径为 HKEY_CURRENT_USER\Software\Classes\Walterlv.Foo.1\shell\Open\Command。添加后,我们可以在文件资源管理器中通过双击打开这个文件。

Walterlv.Foo.1
    (Default) = 吕毅的示例文件
    shell
        Open
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" -f "%1"

其中路径后面的 "%1" 是文件资源管理器传入的参数,其实就是文件的完整路径。我们加上了引号是避免解析命令行的时候把包含空格的路径拆成了多个参数。

还可以添加其他谓词,有一些是预定义的谓词,你也可以随便写其他的谓词。另外,还可以定义文件的图标。

Walterlv.Foo.1
    (Default) = 吕毅的示例文件
    DefaultIcon = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\lvyi-icon.ico"
    shell
        Open
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1"
        用逗比的方式打开
            Command
                (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1" --doubi

反注册文件类型

当你卸载你的程序的时候,需要反注册之前注册过的文件类型;而反注册的过程并不是把以上的过程完全反过来。

微软推荐我们只删除 ProgID 的键,而不删除文件扩展名的键;因为其他的程序可能已经关联了我们的文件扩展名。就算我们使用的是私有的格式,也有可能是我们程序的未来版本会关联这个扩展名。

总之,你需要做的,只是删除 ProgID 的键,文件扩展名的键不要去动它,Windows 自己会处理好 ProgID 删除之后文件关联的问题的。

一个完整的文件关联示例

HKEY_CLASSES_ROOT
    .walv
        (Default) = Walterlv.Foo.1
    .lvyi
        (Default) = Walterlv.Foo.1
        Content Type = text/xml
    Walterlv.Foo.1
        (Default) = Walterlv Foo
        AlwaysShowExt = 1
        DefaultIcon = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\lvyi-icon.ico"
        FriendlyTypeName = 吕毅的示例文件
        shell
            Open
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1"
            用逗比的方式打开
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" open -f "%1" --doubi
            Edit
                Command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" edit -f "%1"
            print
                command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" print -f "%1"
            printto
                command
                    (Default) = "C:\Users\lvyi\AppData\Local\Walterlv.Foo\walterlv.exe" print -f "%1" -t "%2"

参考资料

03-04 2019

如何在命令行中监听用户输入文本的改变?

这真是一个诡异的需求。为什么我需要在命令行中得知用户输入文字的改变啊!实际上我希望实现的是:在命令行中输入一段文字,然后不断地将这段文字发往其他地方。

本文将介绍如何监听用户在命令行中输入文本的改变。


在命令行中输入有三种不同的方法:

  • Console.Read()
    • 用户可以一直输入,在用户输入回车之前,此方法都会一直阻塞。而一旦用户输入了回车,你后面的 Console.Read 就不会一直阻塞了,直到把用户在这一行输入的文字全部读完。
  • Console.ReadKey()
    • 用户输入之前此方法会一直阻塞,用户只要按下任何一个键这个方法都会返回并得到用户按下的按键信息。
  • Console.ReadLine()
    • 用户可以一直输入,在用户输入回车之前,此方法都会一直阻塞。当用户输入了回车之后,此方法会返回用户在这一行输入的字符串。

从表面上来说,以上这三个方法都不能满足我们的需求,每一个方法都不能直接监听用户的输入文本改变。尤其是 Console.Read()Console.ReadLine() 方法,在用户输入回车之前,我们都得不到任何信息。看起来我们似乎只能通过 Console.ReadKey() 来完成我们的需求了。

但是,一旦我们使用了 Console.ReadKey(),我们将不能获得另外两个方法中的输入体验。例如,我们按下退格键(BackSpace)可以删除光标的前一个字符,按下删除键(Delete)可以删除光标的后一个字符,按下左右键可以移动光标到合适的文本上。

然而,不幸的是,除了这三个方法,我们还真的没有原生的方法来实现命令行的输入监听了。所以看样子我们需要自己来使用 Console.ReadKey() 实现用户输入文字的监听了。

我在 如何让 .NET Core 命令行程序接受密码的输入而不显示密码明文 - walterlv 一问中有说到如何在命令行中输入密码而不会显示明文。我们用到的就是此博客中所述的方法。

var builder = new StringBuilder();
while (true)
{
    var i = Console.ReadKey(true);

    if (i.Key == ConsoleKey.Enter)
    {
        Console.WriteLine();
        // 用户在这里输入了回车,于是我们需要结束输入了。
    }

    if (i.Key == ConsoleKey.Backspace)
    {
        if (builder.Length > 0)
        {
            Console.Write("\b \b");
            builder.Remove(builder.Length - 1, 1);
        }
    }
    else
    {
        builder.Append(i.KeyChar);
        Console.Write(i.KeyChar);
    }
}

然而实际上在使用此方法的时候并不符合预期,因为退格的时候我们得到了半个字:

我们得到了半个字

额外的,我们还不支持左右键移动光标,而且按住控制键的时候也会输入一个字符;这些都是我还没有处理的。

这就意味着我们使用 "\b \b" 来删除我们输入的字符的时候,有可能在一些字符的情况下我们需要删除两个字符宽度。

然而如何获取一个字的字符宽度呢?还是很复杂的。于是我很暴力地使用 OnChar函数的中文处理问题,退格键时,怎么处理-CSDN论坛 论坛中使用的方法直接通过编码范围判断中文的方式来推测字符宽度。如果你有更正统的方法,非常欢迎指导我。

简单起见,我写了一个类来封装输入文本改变。阅读以下代码,或者访问 Walterlv.CloudKeyboard/ConsoleLineReader.cs 阅读此类型的最新版本的代码。

using System;
using System.Text;

namespace Walterlv.Demo
{
    public sealed class ConsoleLineReader
    {
        public event EventHandler<ConsoleTextChangedEventArgs> TextChanged;

        public string ReadLine()
        {
            var builder = new StringBuilder();
            while (true)
            {
                var i = Console.ReadKey(true);

                if (i.Key == ConsoleKey.Enter)
                {
                    var line = builder.ToString();
                    OnTextChanged(line, i.Key);
                    Console.WriteLine();
                    return line;
                }

                if (i.Key == ConsoleKey.Backspace)
                {
                    if (builder.Length > 0)
                    {
                        var lastChar = builder[builder.Length - 1];
                        Console.Write(lastChar > 0xA0 ? "\b\b  \b\b" : "\b \b");
                        builder.Remove(builder.Length - 1, 1);
                    }
                }
                else
                {
                    builder.Append(i.KeyChar);
                    Console.Write(i.KeyChar);
                }

                OnTextChanged(builder.ToString(), i.Key);
            }
        }

        private void OnTextChanged(string line, ConsoleKey key)
        {
            TextChanged?.Invoke(this, new ConsoleTextChangedEventArgs(line, key));
        }
    }

    public class ConsoleTextChangedEventArgs : EventArgs
    {
        public ConsoleTextChangedEventArgs(string line, ConsoleKey consoleKey)
        {
            Line = line;
            ConsoleKey = consoleKey;
        }

        public string Line { get; }
        public ConsoleKey ConsoleKey { get; }
    }
}

那么使用的时候,则会简单很多:

var reader = new ConsoleLineReader();
reader.TextChanged += (sender, args) =>
{
    // 这里可以在用户每次输入的文本改变的时候执行。
};

while (true)
{
    // 我在这里循环执行,于是即便用户按了回车,也会继续输入。
    reader.ReadLine();
}

参考资料

03-04 2019

仅反射加载(ReflectionOnlyLoadFrom)的 .NET 程序集,如何反射获取它的 Attribute 元数据呢?

平时我们获取一个程序集或者类型的 Attribute 是非常轻松的,只需要通过 GetCustomAttribute 方法就能拿到实例然后获取其中的值。但是,有时我们仅为反射加载一些程序集的时候,获取这些元数据就不那么简单了,因为我们没有加载目标程序集中的类型。

本文介绍如何为仅反射加载的程序集读取 Attribute 元数据信息。


仅反射加载一个程序集

使用 ReflectionOnlyLoadFrom 可以仅以反射的方式加载一个程序集。

var extensionFilePath = @"C:\Users\walterlv\Desktop\Walterlv.Extension.dll";
var assembly = Assembly.ReflectionOnlyLoadFrom(extensionFilePath);

获取程序集的 Attribute(例如获取程序集版本号)

Assembly.GetCustomAttributesData() 得到的是一个 CustomAttributeData 的列表,而这个列表中的每一项都与普通反射中拿到的特性集合不同,这里拿到的只是特性的信息(以下循环中的 data 变量)。

CustomAttributeData 中有 AttributeType 属性,虽然此属性是 Type 类型的,但是实际上它只会是 RuntimeType 类型,而不会是真实的 Attribute 的类型(因为不能保证宿主程序域中已经加载了那个类型)。

var customAttributesData = assembly.GetCustomAttributesData();
foreach (CustomAttributeData data in customAttributesData)
{
    // 这里可以针对每一个拿到的慝的信息进行操作。
}

比如我们要获取这个程序集的版本号,正常我们写 assembly.GetCustomAttribute<AssemblyFileVersionAttribute>().Version,但是这里我们无法生成 AssemblyFileVersionAttribute 的实例,我们只能这么写:

var versionString = assembly.GetCustomAttributesData()
    .FirstOrDefault(x => x.AttributeType.FullName == typeof(AssemblyFileVersionAttribute).FullName)
    ?.ConstructorArguments[0].Value as string ?? "0.0";
var version = new Version(versionString);

代码解读是这样的:

  1. 我们从拿到的所有的 Attribute 元数据中找到第一个名称与 AssemblyFileVersionAttribute 相同的数据;
  2. 从数据的构造函数参数中找到传入的参数值,而这个值就是我们定义 AssemblyFileVersionAttribute 时传入的参数的实际值。

因为我们知道 AssemblyFileVersionAttribute 的构造函数只有一个,所以我们确信可以从第一个参数中拿到我们想要的值。

顺便一提,我们使用 AssemblyFileVersionAttribute 而不是使用 AssemblyVersionAttribute 是因为使用 .NET Core 新格式(基于 Microsoft.NET.Sdk)编译出来的程序集默认是不带 AssemblyVersionAttribute 的。详见:语义版本号(Semantic Versioning) - walterlv


参考资料

03-01 2019

编写 MSBuild 内联编译任务(Task)用于获取当前编译环境下的所有编译目标(Target)

我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。

本文将编写一个内联的编译任务,获取当前编译环境下的所有编译目标(Target)。获取所有的这些 Target 对我们调试一些与 MSBuild 或编译相关的问题时可能带来一些帮助。


编写纯 C# 版本编译任务获取所有编译目标(Target)的代码是这样的:

using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;

public class WalterlvGetAllTargets : Task
{
    public string ProjectFile { get; set; }

    public ITaskItem[] WalterlvTargets { get; set; }

    public override bool Execute()
    {
        var project = new Project(ProjectFile);

        var taskItems = new List<ITaskItem>(project.Targets.Count);
        foreach (KeyValuePair<string, ProjectTargetInstance> pair in project.Targets)
        {
            var target = pair.Value;
            var metadata = new Dictionary<string, string>
            {
                { "Condition", target.Condition },
                { "Inputs", target.Inputs },
                { "Outputs", target.Outputs },
                { "DependsOnTargets", target.DependsOnTargets }
            };
            taskItems.Add(new TaskItem(pair.Key, metadata));
        }

        WalterlvTargets = taskItems.ToArray();

        return true;
    }
}

那么转换成内联版本下面这样。为了方便验证,我直接把完整的 csproj 文件贴出来了。如果你希望在你的项目中去使用,可以只复制 UsingTaskTarget 两个部分。

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

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
    </PropertyGroup>

    <UsingTask TaskName="WalterlvGetAllTargets" TaskFactory="CodeTaskFactory"
               AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll" >
        <ParameterGroup>
            <!-- 内联 C# 代码的输入参数(Task 的输入属性),相当于 public string ProjectFile { get; set; } -->
            <ProjectFile ParameterType="System.String" Required="true"/>
            <!-- 内联 C# 代码的输出参数(Task 的输入属性),相当于 public ITaskItem[] WalterlvTargets { get; set; } -->
            <WalterlvTargets ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true"/>
        </ParameterGroup>
        <Task>
            <!-- 引用程序集。 -->
            <Reference Include="System.Xml"/>
            <Reference Include="Microsoft.Build"/>
            <Reference Include="Microsoft.Build.Framework"/>
            <!-- 编写 C# 代码所用到的 using。 -->
            <Using Namespace="Microsoft.Build.Evaluation"/>
            <Using Namespace="Microsoft.Build.Execution"/>
            <Using Namespace="Microsoft.Build.Utilities"/>
            <Using Namespace="Microsoft.Build.Framework"/>
            <!-- 开始插入 C# 代码。 -->
            <Code Type="Fragment" Language="cs">
        <![CDATA[
            var project = new Project(ProjectFile);

            var taskItems = new List<ITaskItem>(project.Targets.Count);
            foreach (KeyValuePair<string, ProjectTargetInstance> pair in project.Targets)
            {
                var target = pair.Value;
                var metadata = new Dictionary<string, string>
                {
                    { "Condition", target.Condition },
                    { "Inputs", target.Inputs },
                    { "Outputs", target.Outputs },
                    { "DependsOnTargets", target.DependsOnTargets }
                };
                taskItems.Add(new TaskItem(pair.Key, metadata));
            }

            WalterlvTargets = taskItems.ToArray();
        ]]>
            </Code>
        </Task>
    </UsingTask>

    <Target Name="WalterlvOutputAllTargets" AfterTargets="Build">
        <!-- 执行刚刚写的内联 Task,然后获取它的输出参数 WalterlvTargets 并填充到 TargetItems 集合中。 -->
        <WalterlvGetAllTargets ProjectFile="$(MSBuildProjectFile)">
            <Output ItemName="TargetItems" TaskParameter="WalterlvTargets"/>
        </WalterlvGetAllTargets>
        <!-- 用一个 Message 输出刚刚生成的 TargetItems 集合中每一项的 Identity 属性(集合中每一项都会输出。) -->
        <Message Text="输出的 Target:%(TargetItems.Identity)"/>
    </Target>
<Project>

现在使用 msbuild 命令进行编译,我们将看到所有 Target 的输出:

输出的所有 Target

WalterlvOutputAllTargets:
  输出的 TargetOutputAll
  输出的 Target_CheckForUnsupportedTargetFramework
  输出的 Target_CollectTargetFrameworkForTelemetry
  输出的 Target_CheckForUnsupportedNETCoreVersion
  输出的 Target_CheckForUnsupportedNETStandardVersion
  输出的 Target_CheckForUnsupportedAppHostUsage
  输出的 Target_CheckForMismatchingPlatform
  输出的 Target_CheckForNETCoreSdkIsPreview
  输出的 TargetAdjustDefaultPlatformTargetForNetFrameworkExeWithNoNativeCopyLocalItems
  输出的 TargetCreateManifestResourceNames
  输出的 TargetResolveCodeAnalysisRuleSet
  输出的 TargetXamlPreCompile
  输出的 TargetShimReferencePathsWhenCommonTargetsDoesNotUnderstandReferenceAssemblies
  输出的 Target_BeforeVBCSCoreCompile
  输出的 TargetInitializeSourceRootMappedPaths
  输出的 Target_InitializeSourceRootMappedPathsFromSourceControl
  输出的 Target_SetPathMapFromSourceRoots
  输出的 TargetCoreCompile
  输出的 TargetResolvePackageDependenciesDesignTime
  输出的 TargetCollectSDKReferencesDesignTime
  输出的 TargetCollectResolvedSDKReferencesDesignTime
  输出的 TargetCollectPackageReferences
  输出的 Target_CheckCompileDesignTimePrerequisite
  输出的 TargetCollectAnalyzersDesignTime
  输出的 TargetCollectResolvedCompilationReferencesDesignTime
  输出的 TargetCollectUpToDateCheckInputDesignTime
  输出的 TargetCollectUpToDateCheckOutputDesignTime
  输出的 TargetCollectUpToDateCheckBuiltDesignTime
  输出的 TargetCompileDesignTime
  输出的 Target_FixVCLibs120References
  输出的 Target_AddVCLibs140UniversalCrtDebugReference
  输出的 TargetInitializeSourceControlInformation
  输出的 Target_CheckForInvalidConfigurationAndPlatform
  输出的 TargetBuild
  输出的 TargetBeforeBuild
  输出的 TargetAfterBuild
  输出的 TargetCoreBuild
  输出的 TargetRebuild
  输出的 TargetBeforeRebuild
  输出的 TargetAfterRebuild
  输出的 TargetBuildGenerateSources
  输出的 TargetBuildGenerateSourcesTraverse
  输出的 TargetBuildCompile
  输出的 TargetBuildCompileTraverse
  输出的 TargetBuildLink
  输出的 TargetBuildLinkTraverse
  输出的 TargetCopyRunEnvironmentFiles
  输出的 TargetRun
  输出的 TargetBuildOnlySettings
  输出的 TargetPrepareForBuild
  输出的 TargetGetFrameworkPaths
  输出的 TargetGetReferenceAssemblyPaths
  输出的 TargetGetTargetFrameworkMoniker
  输出的 TargetGetTargetFrameworkMonikerDisplayName
  输出的 TargetGetTargetFrameworkDirectories
  输出的 TargetAssignLinkMetadata
  输出的 TargetPreBuildEvent
  输出的 TargetUnmanagedUnregistration
  输出的 TargetGetTargetFrameworkVersion
  输出的 TargetResolveReferences
  输出的 TargetBeforeResolveReferences
  输出的 TargetAfterResolveReferences
  输出的 TargetAssignProjectConfiguration
  输出的 Target_SplitProjectReferencesByFileExistence
  输出的 Target_GetProjectReferenceTargetFrameworkProperties
  输出的 TargetGetTargetFrameworks
  输出的 TargetGetTargetFrameworkProperties
  输出的 TargetPrepareProjectReferences
  输出的 TargetResolveProjectReferences
  输出的 TargetResolveProjectReferencesDesignTime
  输出的 TargetExpandSDKReferencesDesignTime
  输出的 TargetGetTargetPath
  输出的 TargetGetTargetPathWithTargetPlatformMoniker
  输出的 TargetGetNativeManifest
  输出的 TargetResolveNativeReferences
  输出的 TargetResolveAssemblyReferences
  输出的 TargetFindReferenceAssembliesForReferences
  输出的 TargetGenerateBindingRedirects
  输出的 TargetGenerateBindingRedirectsUpdateAppConfig
  输出的 TargetGetInstalledSDKLocations
  输出的 TargetResolveSDKReferences
  输出的 TargetResolveSDKReferencesDesignTime
  输出的 TargetFindInvalidProjectReferences
  输出的 TargetGetReferenceTargetPlatformMonikers
  输出的 TargetExpandSDKReferences
  输出的 TargetExportWindowsMDFile
  输出的 TargetResolveAssemblyReferencesDesignTime
  输出的 TargetDesignTimeResolveAssemblyReferences
  输出的 TargetResolveComReferences
  输出的 TargetResolveComReferencesDesignTime
  输出的 TargetPrepareResources
  输出的 TargetPrepareResourceNames
  输出的 TargetAssignTargetPaths
  输出的 TargetGetItemTargetPaths
  输出的 TargetSplitResourcesByCulture
  输出的 TargetCreateCustomManifestResourceNames
  输出的 TargetResGen
  输出的 TargetBeforeResGen
  输出的 TargetAfterResGen
  输出的 TargetCoreResGen
  输出的 TargetCompileLicxFiles
  输出的 TargetResolveKeySource
  输出的 TargetCompile
  输出的 Target_GenerateCompileInputs
  输出的 TargetGenerateTargetFrameworkMonikerAttribute
  输出的 TargetGenerateAdditionalSources
  输出的 TargetBeforeCompile
  输出的 TargetAfterCompile
  输出的 Target_TimeStampBeforeCompile
  输出的 Target_GenerateCompileDependencyCache
  输出的 Target_TimeStampAfterCompile
  输出的 Target_ComputeNonExistentFileProperty
  输出的 TargetGenerateSerializationAssemblies
  输出的 TargetCreateSatelliteAssemblies
  输出的 Target_GenerateSatelliteAssemblyInputs
  输出的 TargetGenerateSatelliteAssemblies
  输出的 TargetComputeIntermediateSatelliteAssemblies
  输出的 TargetSetWin32ManifestProperties
  输出的 Target_SetExternalWin32ManifestProperties
  输出的 Target_SetEmbeddedWin32ManifestProperties
  输出的 Target_GenerateResolvedDeploymentManifestEntryPoint
  输出的 TargetGenerateManifests
  输出的 TargetGenerateApplicationManifest
  输出的 Target_DeploymentComputeNativeManifestInfo
  输出的 Target_DeploymentComputeClickOnceManifestInfo
  输出的 Target_DeploymentGenerateTrustInfo
  输出的 TargetGenerateDeploymentManifest
  输出的 TargetPrepareForRun
  输出的 TargetCopyFilesToOutputDirectory
  输出的 Target_CopyFilesMarkedCopyLocal
  输出的 Target_CopySourceItemsToOutputDirectory
  输出的 TargetGetCopyToOutputDirectoryItems
  输出的 TargetGetCopyToPublishDirectoryItems
  输出的 Target_CopyOutOfDateSourceItemsToOutputDirectory
  输出的 Target_CopyOutOfDateSourceItemsToOutputDirectoryAlways
  输出的 Target_CopyAppConfigFile
  输出的 Target_CopyManifestFiles
  输出的 Target_CheckForCompileOutputs
  输出的 Target_SGenCheckForOutputs
  输出的 TargetUnmanagedRegistration
  输出的 TargetIncrementalClean
  输出的 Target_CleanGetCurrentAndPriorFileWrites
  输出的 TargetClean
  输出的 TargetBeforeClean
  输出的 TargetAfterClean
  输出的 TargetCleanReferencedProjects
  输出的 TargetCoreClean
  输出的 Target_CleanRecordFileWrites
  输出的 TargetCleanPublishFolder
  输出的 TargetPostBuildEvent
  输出的 TargetPublish
  输出的 Target_DeploymentUnpublishable
  输出的 TargetSetGenerateManifests
  输出的 TargetPublishOnly
  输出的 TargetBeforePublish
  输出的 TargetAfterPublish
  输出的 TargetPublishBuild
  输出的 Target_CopyFilesToPublishFolder
  输出的 Target_DeploymentGenerateBootstrapper
  输出的 Target_DeploymentSignClickOnceDeployment
  输出的 TargetAllProjectOutputGroups
  输出的 TargetBuiltProjectOutputGroup
  输出的 TargetDebugSymbolsProjectOutputGroup
  输出的 TargetDocumentationProjectOutputGroup
  输出的 TargetSatelliteDllsProjectOutputGroup
  输出的 TargetSourceFilesProjectOutputGroup
  输出的 TargetGetCompile
  输出的 TargetContentFilesProjectOutputGroup
  输出的 TargetSGenFilesOutputGroup
  输出的 TargetGetResolvedSDKReferences
  输出的 TargetCollectReferencedNuGetPackages
  输出的 TargetPriFilesOutputGroup
  输出的 TargetSDKRedistOutputGroup
  输出的 TargetAllProjectOutputGroupsDependencies
  输出的 TargetBuiltProjectOutputGroupDependencies
  输出的 TargetDebugSymbolsProjectOutputGroupDependencies
  输出的 TargetSatelliteDllsProjectOutputGroupDependencies
  输出的 TargetDocumentationProjectOutputGroupDependencies
  输出的 TargetSGenFilesOutputGroupDependencies
  输出的 TargetReferenceCopyLocalPathsOutputGroup
  输出的 TargetSetCABuildNativeEnvironmentVariables
  输出的 TargetRunCodeAnalysis
  输出的 TargetRunNativeCodeAnalysis
  输出的 TargetRunSelectedFileNativeCodeAnalysis
  输出的 TargetRunMergeNativeCodeAnalysis
  输出的 TargetImplicitlyExpandDesignTimeFacades
  输出的 TargetGetWinFXPath
  输出的 TargetDesignTimeMarkupCompilation
  输出的 TargetPrepareResourcesForSatelliteAssemblies
  输出的 Target_AfterCompileWinFXInternal
  输出的 TargetAfterCompileWinFX
  输出的 TargetAfterMarkupCompilePass1
  输出的 TargetAfterMarkupCompilePass2
  输出的 TargetMarkupCompilePass1
  输出的 TargetMarkupCompilePass2
  输出的 Target_CompileTemporaryAssembly
  输出的 TargetMarkupCompilePass2ForMainAssembly
  输出的 TargetGenerateTemporaryTargetAssembly
  输出的 TargetCleanupTemporaryTargetAssembly
  输出的 TargetAddIntermediateAssemblyToReferenceList
  输出的 TargetSatelliteOnlyMarkupCompilePass2
  输出的 TargetHostInBrowserValidation
  输出的 TargetSplashScreenValidation
  输出的 TargetResignApplicationManifest
  输出的 TargetSignDeploymentManifest
  输出的 TargetFileClassification
  输出的 TargetMainResourcesGeneration
  输出的 TargetSatelliteResourceGeneration
  输出的 TargetGenerateResourceWithCultureItem
  输出的 TargetCheckUid
  输出的 TargetUpdateUid
  输出的 TargetRemoveUid
  输出的 TargetMergeLocalizationDirectives
  输出的 TargetAssignWinFXEmbeddedResource
  输出的 TargetEntityDeploy
  输出的 TargetEntityDeploySplit
  输出的 TargetEntityDeployNonEmbeddedResources
  输出的 TargetEntityDeployEmbeddedResources
  输出的 TargetEntityClean
  输出的 TargetEntityDeploySetLogicalNames
  输出的 TargetDesignTimeXamlMarkupCompilation
  输出的 TargetInProcessXamlMarkupCompilePass1
  输出的 TargetCleanInProcessXamlGeneratedFiles
  输出的 TargetXamlMarkupCompileReadGeneratedFileList
  输出的 TargetXamlMarkupCompilePass1
  输出的 TargetXamlMarkupCompileAddFilesGenerated
  输出的 TargetXamlMarkupCompileReadPass2Flag
  输出的 TargetXamlTemporaryAssemblyGeneration
  输出的 TargetCompileTemporaryAssembly
  输出的 TargetXamlMarkupCompilePass2
  输出的 TargetXamlMarkupCompileAddExtensionFilesGenerated
  输出的 TargetGetCopyToOutputDirectoryXamlAppDefs
  输出的 TargetExpressionBuildExtension
  输出的 TargetValidationExtension
  输出的 TargetGenerateCompiledExpressionsTempFile
  输出的 TargetAddDeferredValidationErrorsFileToFileWrites
  输出的 TargetReportValidationBuildExtensionErrors
  输出的 TargetDeferredValidation
  输出的 TargetResolveTestReferences
  输出的 TargetCleanAppxPackage
  输出的 TargetGetPackagingOutputs
  输出的 TargetRestore
  输出的 TargetGenerateRestoreGraphFile
  输出的 Target_LoadRestoreGraphEntryPoints
  输出的 Target_FilterRestoreGraphProjectInputItems
  输出的 Target_GenerateRestoreGraph
  输出的 Target_GenerateRestoreGraphProjectEntry
  输出的 Target_GenerateRestoreSpecs
  输出的 Target_GenerateDotnetCliToolReferenceSpecs
  输出的 Target_GetProjectJsonPath
  输出的 Target_GetRestoreProjectStyle
  输出的 TargetEnableIntermediateOutputPathMismatchWarning
  输出的 Target_GetRestoreTargetFrameworksOutput
  输出的 Target_GetRestoreTargetFrameworksAsItems
  输出的 Target_GetRestoreSettings
  输出的 Target_GetRestoreSettingsCurrentProject
  输出的 Target_GetRestoreSettingsAllFrameworks
  输出的 Target_GetRestoreSettingsPerFramework
  输出的 Target_GenerateRestoreProjectSpec
  输出的 Target_GenerateProjectRestoreGraph
  输出的 Target_GenerateRestoreDependencies
  输出的 Target_GenerateProjectRestoreGraphAllFrameworks
  输出的 Target_GenerateProjectRestoreGraphCurrentProject
  输出的 Target_GenerateProjectRestoreGraphPerFramework
  输出的 Target_GenerateRestoreProjectPathItemsCurrentProject
  输出的 Target_GenerateRestoreProjectPathItemsPerFramework
  输出的 Target_GenerateRestoreProjectPathItems
  输出的 Target_GenerateRestoreProjectPathItemsAllFrameworks
  输出的 Target_GenerateRestoreProjectPathWalk
  输出的 Target_GetAllRestoreProjectPathItems
  输出的 Target_GetRestoreSettingsOverrides
  输出的 Target_GetRestorePackagesPathOverride
  输出的 Target_GetRestoreSourcesOverride
  输出的 Target_GetRestoreFallbackFoldersOverride
  输出的 Target_IsProjectRestoreSupported
  输出的 TargetDesktopBridgeCopyLocalOutputGroup
  输出的 TargetDesktopBridgeComFilesOutputGroup
  输出的 TargetGetDeployableContentReferenceOutputs
  输出的 TargetDockerResolveAppType
  输出的 TargetDockerUpdateComposeVsGeneratedFiles
  输出的 TargetDockerResolveTargetFramework
  输出的 TargetDockerComposeBuild
  输出的 TargetDockerPackageService
  输出的 TargetImplicitlyExpandNETStandardFacades
  输出的 Target_RemoveZipFileSuggestedRedirect
  输出的 TargetSetARM64AppxPackageInputsForInboxNetNative
  输出的 Target_CleanMdbFiles
  输出的 TargetPreXsdCodeGen
  输出的 TargetXsdCodeGen
  输出的 TargetXsdResolveReferencePath
  输出的 TargetCleanXsdCodeGen
  输出的 Target_SetTargetFrameworkMonikerAttribute
  输出的 TargetResolvePackageDependenciesForBuild
  输出的 TargetRunResolvePackageDependencies
  输出的 TargetResolvePackageAssets
  输出的 TargetFilterSatelliteResources
  输出的 TargetRunProduceContentAssets
  输出的 TargetReportAssetsLogMessages
  输出的 TargetResolveLockFileReferences
  输出的 TargetIncludeTransitiveProjectReferences
  输出的 TargetResolveLockFileAnalyzers
  输出的 Target_ComputeLockFileCopyLocal
  输出的 TargetResolveLockFileCopyLocalProjectDeps
  输出的 TargetCheckForImplicitPackageReferenceOverrides
  输出的 TargetCheckForDuplicateItems
  输出的 TargetGenerateBuildDependencyFile
  输出的 TargetGenerateBuildRuntimeConfigurationFiles
  输出的 TargetAddRuntimeConfigFileToBuiltProjectOutputGroupOutput
  输出的 Target_SdkBeforeClean
  输出的 Target_SdkBeforeRebuild
  输出的 Target_ComputeNETCoreBuildOutputFiles
  输出的 Target_ComputeReferenceAssemblies
  输出的 TargetCoreGenerateSatelliteAssemblies
  输出的 Target_GetAssemblyInfoFromTemplateFile
  输出的 Target_DefaultMicrosoftNETPlatformLibrary
  输出的 TargetGetAllRuntimeIdentifiers
  输出的 TargetGenerateAssemblyInfo
  输出的 TargetAddSourceRevisionToInformationalVersion
  输出的 TargetGetAssemblyAttributes
  输出的 TargetCreateGeneratedAssemblyInfoInputsCacheFile
  输出的 TargetCoreGenerateAssemblyInfo
  输出的 TargetGetAssemblyVersion
  输出的 TargetComposeStore
  输出的 TargetStoreWorkerMain
  输出的 TargetStoreWorkerMapper
  输出的 TargetStoreResolver
  输出的 TargetStoreWorkerPerformWork
  输出的 TargetStoreFinalizer
  输出的 Target_CopyResolvedOptimizedFiles
  输出的 TargetPrepareForComposeStore
  输出的 TargetPrepforRestoreForComposeStore
  输出的 TargetRestoreForComposeStore
  输出的 TargetComputeAndCopyFilesToStoreDirectory
  输出的 TargetCopyFilesToStoreDirectory
  输出的 Target_CopyResolvedUnOptimizedFiles
  输出的 Target_ComputeResolvedFilesToStoreTypes
  输出的 Target_SplitResolvedFiles
  输出的 Target_GetResolvedFilesToStore
  输出的 TargetComputeFilesToStore
  输出的 TargetPrepRestoreForStoreProjects
  输出的 TargetPrepOptimizer
  输出的 Target_RunOptimizer
  输出的 TargetRunCrossGen
  输出的 Target_InitializeBasicProps
  输出的 Target_GetCrossgenProps
  输出的 Target_SetupStageForCrossgen
  输出的 Target_RestoreCrossgen
  输出的 Target_CheckForObsoleteDotNetCliToolReferences
  输出的 Target_PublishBuildAlternative
  输出的 Target_PublishNoBuildAlternative
  输出的 Target_PreventProjectReferencesFromBuilding
  输出的 TargetPrepareForPublish
  输出的 TargetComputeAndCopyFilesToPublishDirectory
  输出的 TargetCopyFilesToPublishDirectory
  输出的 Target_CopyResolvedFilesToPublishPreserveNewest
  输出的 Target_CopyResolvedFilesToPublishAlways
  输出的 Target_ComputeResolvedFilesToPublishTypes
  输出的 TargetComputeFilesToPublish
  输出的 Target_ComputeNetPublishAssets
  输出的 TargetRunResolvePublishAssemblies
  输出的 TargetFilterPublishSatelliteResources
  输出的 Target_ComputeCopyToPublishDirectoryItems
  输出的 TargetDefaultCopyToPublishDirectoryMetadata
  输出的 TargetGeneratePublishDependencyFile
  输出的 Target_ComputeExcludeFromPublishPackageReferences
  输出的 Target_ParseTargetManifestFiles
  输出的 TargetGeneratePublishRuntimeConfigurationFile
  输出的 TargetDeployAppHost
  输出的 TargetPackTool
  输出的 TargetGenerateToolsSettingsFileFromBuildProperty
  输出的 TargetResolveApphostAsset
  输出的 TargetComputeDependencyFileCompilerOptions
  输出的 TargetComputeRefAssembliesToPublish
  输出的 Target_CopyReferenceOnlyAssembliesForBuild
  输出的 Target_HandlePackageFileConflicts
  输出的 Target_HandlePublishFileConflicts
  输出的 Target_GetOutputItemsFromPack
  输出的 Target_GetTargetFrameworksOutput
  输出的 Target_PackAsBuildAfterTarget
  输出的 Target_CleanPackageFiles
  输出的 Target_CalculateInputsOutputsForPack
  输出的 TargetPack
  输出的 Target_IntermediatePack
  输出的 TargetGenerateNuspec
  输出的 Target_InitializeNuspecRepositoryInformationProperties
  输出的 Target_LoadPackInputItems
  输出的 Target_GetProjectReferenceVersions
  输出的 Target_GetProjectVersion
  输出的 Target_WalkEachTargetPerFramework
  输出的 Target_GetFrameworksWithSuppressedDependencies
  输出的 Target_GetFrameworkAssemblyReferences
  输出的 Target_GetBuildOutputFilesWithTfm
  输出的 Target_GetTfmSpecificContentForPackage
  输出的 Target_GetDebugSymbolsWithTfm
  输出的 Target_AddPriFileToPackBuildOutput
  输出的 Target_GetPackageFiles

参考资料

03-01 2019

如何在 csproj 中用 C# 代码写一个内联的编译任务 Task

我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。

本文介绍非常简单的 Task 的编写方式 —— 在 csproj 文件中写内联的 Task。


前置知识

在阅读本文之前,你至少需要懂得:

  • csproj 文件的结构以及编译过程
  • Target 是什么,Task 是什么

所以如果你不懂或者理不清,则请先阅读:

关于 Task 的理解,我有一些介绍自带 Task 的博客以及如何编写 Task 的教程:

编写内联的编译任务(Task)

如果你阅读了前面的博客,那么大致知道如何写一个在编译期间执行的 Task。不过,默认你需要编写一个额外的项目来写 Task,然后将这个项目生成 dll 供编译过程通过 UsingTask 来使用。然而如果 Task 足够简单,那么依然需要那么复杂的过程显然开发成本过高。

于是现在可以编写内联的 Task:

  1. 内联任务的支持需要用到 Microsoft.Build.Tasks.v4.0.dll
  2. 我们用 <![CDATA[ ]]> 来内嵌 C# 代码;
  3. 除了用 UsingTask 编写内联的 Task 外,我们需要额外编写一个 Target 来验证我们的内联 Task 能正常工作。

下面是一个最简单的内联编译任务:

<Project Sdk="Microsoft.NET.Sdk">
    <UsingTask TaskName="WalterlvDemoTask" TaskFactory="CodeTaskFactory"
               AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
        <Task>
            <Code Type="Fragment" Language="cs">
                <![CDATA[
        Console.WriteLine("Hello Walterlv!");
                ]]>
            </Code>
        </Task>
    </UsingTask>
<Project>

为了能够测试,我把完整的 csproj 文件贴出来:

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

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net472</TargetFramework>
    </PropertyGroup>

    <UsingTask TaskName="WalterlvDemoTask" TaskFactory="CodeTaskFactory"
               AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
        <Task>
            <Code Type="Fragment" Language="cs">
                <![CDATA[
        Console.WriteLine("Hello Walterlv!");
                ]]>
            </Code>
        </Task>
    </UsingTask>

    <Target Name="WalterlvDemoTarget" AfterTargets="Build">
        <WalterlvDemoTask />
    </Target>

</Project>

目前内联编译仅适用于 MSBuild,而 dotnet build 尚不支持。现在在项目目录输入命令进行编译,可以在输出窗口看到我们内联编译中的输出内容:

msbuild

输出内容

编写更复杂的内联编译任务

阅读我的另一篇博客了解如何编写一个更复杂的内联编译任务:

02-19 2019

.NET/C# 将一个命令行参数字符串转换为命令行参数数组 args

我们通常得到的命令行参数是一个字符串数组 string[] args,以至于很多的命令行解析库也是使用数组作为解析的参数来源。

然而如我我们得到了一整个命令行字符串呢?这个时候可能我们原有代码中用于解析命令行的库或者其他辅助函数不能用了。那么如何转换成数组呢?


在 Windows 系统中有函数 CommandLineToArgvW 可以直接将一个字符串转换为命令行参数数组,我们可以直接使用这个函数。

LPWSTR * CommandLineToArgvW(
  LPCWSTR lpCmdLine,
  int     *pNumArgs
);

此函数在 shell32.dll 中,于是我们可以在 C# 中调用此函数。

为了方便使用,我将其封装成了一个静态方法。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace Walterlv
{
    public static class CommandLineExtensions
    {
        public static string[] ConvertCommandLineToArgs(string commandLine)
        {
            var argv = CommandLineToArgvW(commandLine, out var argc);
            if (argv == IntPtr.Zero)
            {
                throw new Win32Exception("在转换命令行参数的时候出现了错误。");
            }

            try
            {
                var args = new string[argc];
                for (var i = 0; i < args.Length; i++)
                {
                    var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
                    args[i] = Marshal.PtrToStringUni(p);
                }

                return args;
            }
            finally
            {
                Marshal.FreeHGlobal(argv);
            }
        }

        [DllImport("shell32.dll", SetLastError = true)]
        static extern IntPtr CommandLineToArgvW([MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine, out int pNumArgs);
    }
}

参考资料

01-27 2019

使用 Xamarin 在 iOS 真机上部署应用进行调试

虽然 Xamarin 可以在 Windows 操作系统上编写和调试,但如果开发 iOS 应用,那么我们依然需要一台安装有 XCode 和 Visual Studio for Mac 的 Mac 电脑。做真机部署不是像平时使用太阳系第一 IDE Visual Studio 那样方便。

所以本文需要介绍如何使用 Xamarin 在 iOS 真机上部署应用进行调试,然后顺便说一些注意事项。


准备一台 Mac 电脑

如果你没有 Mac 电脑,那我只能很不幸地告诉你:本文读下去已经没有什么用了,你不会成功的……当然你也可以考虑使用 Mac OS 虚拟机,但成功率太低,本文不会涉及。

在 Mac 电脑上安装以下两款必备应用:

  1. XCode:从苹果应用商店安装
  2. Visual Studio for Mac:在这里下载 https://visualstudio.microsoft.com/vs/mac/

这两款应用的体积都很大,如果你没有很好的网络代理设置,安装一整天都是可能的。所以还是强烈建议你有一个稳定的代理网络来下载。

本文接下来的内容都假设你已经安装好了这两款应用。

背景知识

你需要知道一些背景知识,不然后面真机部署的时候失败了都不知道怎么回事。

  1. 你的账号必须是苹果开发者账号
  2. 只有 XCode 才能生成开发者的 provisioning profiles
  3. 只有 XCode 才能在 iOS 真机上部署全新的应用

也就是说,你必须有一些操作是在 XCode 中完成;只使用 Visual Studio for Mac 是无法完成部署任务的。

在 XCode 中准备

  1. 在 XCode 中新建一个空白 iOS 项目(什么类型都可以),这个项目随时可以丢弃。
  2. 选择你新建的项目,会出现这个项目的信息可以填,默认在 General 标签中。
  3. *[重要] 修改 Bundle Identifier。
    • 将这个 Bundle Identifier 修改为你希望部署的应用的 Bundle Identifier。比如你在 Xamarin 的 Info.plist 中写的 Bundle Identifier 是 com.walterlv.CloudKeyboard,那么这里也必须写 com.walterlv.CloudKeyboard
  4. *[重要] 一定要让这个 Bundle Identifier 文本框失焦(比如按下 Tab 或在其他文本框中点一下)。
    • 这个时候下面的 Signing Certificate 会出现一个加载中的动画,大概持续不到一秒钟,就会生成 iPhone Developer 的信息,这个就是包含 provisioning profiles 的信息(可以在 Provisioning Profile 旁边的感叹号中看到详细信息)
  5. 在 Mac 上插入你的 iPhone,解锁 iPhone,等待左上角出现你 iPhone 的名称和图标。
  6. 点击 XCode 左上角的运行按钮,等待这个空白的应用部署到你的手机上。

在 XCode 中进行设置

*[重要] 额外的,如果你开发的是 iOS 扩展,有两个或者更多的包,那么你需要重复步骤 3 到 6。也就是不断地修改 Bundle Identifier,等待生成新的 Developer 信息,然后部署这个空的应用

在 Visual Studio for Mac 中部署

  1. *[重要] 请回到你的 iPhone 手机,删除刚刚部署的应用
    • 如果你刚刚部署了多个空白应用,那么都要删除
  2. 回到 Visual Studio for Mac 并打开你的 Xamarin 项目,然后打开准备部署的应用的 Info.plist 文件
  3. 检查 Bundle Identifier,一定要确认跟前面 XCode 中填入的是同一个 Bundle Identifier
    • 额外的,如果你是开发 iOS 扩展,有两个或更多包,那么每个包都需要进入 Info.plist 文件检查 Bundle Identifier
  4. 点击 Bundle Signing Options,选择刚刚使用 XCode 生成的开发者信息(如果你看不到,那么就是前面 XCode 的步骤没有执行正确)
  5. 在 Mac 上插入你的 iPhone,解锁 iPhone,等待左上角出现你 iPhone 的名称和图标。
    • 如果没有出现,你可能需要点击一下 Debug iPhone 区域,一定要确保选中了 iPhone 而不是 iPhone Simulator
  6. 点击 Visual Studio for Mac 左上角的运行按钮,等待你 Xamarin 的应用部署到你的手机上(可能需要数十秒到数分钟)。

检查 Bundle Identifier

设置 Bundle Signing Options

运行与部署

理论上经过以上步骤,你就可以在你的 iPhone 上看到你用 Xamarin 开发的应用了。但其实是无法运行的。

如果部署过程中发生了任何错误,请:

  1. 检查你的步骤与本文是否有出入;
  2. 参考:使用 Xamarin 开发 iOS 应用中需要注意的若干个问题

在 iPhone 上操作

  1. 打开设置 -> 通用 -> 设备管理
  2. 点开 [自己的开发者账号],点击 [信任]

如果你是首次进行此操作(实际上阅读本文操作的应该也就是首次了),那么信任自己的开发者账号可能会花比较长的时间,Visual Studio for Mac 的部署调试可能会因为等待超时而调试失败。不过这不重要,你只需要在 Visual Studio for Mac 上点击停止调试,然后再次重来就可以了。

还需要注意,如果你删除了你部署的应用,那么下次部署的时候在 iPhone 上的操作部分需要重新进行。

还需要注意,可能每过 6 天,本文所述的所有步骤都需要重新进行一遍。

01-25 2019

.NET/C# 编译期间能确定的相同字符串,在运行期间是相同的实例

我们知道,在编译期间相同的字符串,在运行期间就会是相同的字符串实例。然而,如果编译期间存在字符串的运算,那么在运行期间是否是同一个实例呢?

只要编译期间能够完全确定的字符串,就会是同一个实例。


字符串在编译期间能确定的运算包括:

  1. A + B 即字符串的拼接
  2. $"{A}" 即字符串的内插

字符串拼接

对于拼接,我们不需要运行便能知道是否是同一个实例:

private const string X = "walterlv is a";
private const string Y = "逗比";
private const string Z = X + Y;

以上这段代码是可以编译通过的,因为能够写为 const 的字符串,一定是编译期间能够确定的。

字符串内插

对于字符串内插,以上代码我们不能写成 const

错误提示

错误提示为:常量的初始化必须使用编译期间能够确定的常量。

然而,这段代码不能在编译期间确定吗?实际上我们有理由认为编译器其实是能够确定的,只是编译器这个阶段没有这么去做而已。

实际上在 2017 年就有人在 GitHub 上提出了这个问题,你可以在这里看讨论:

但是,我们写一个程序来验证这是否是同一个实例:

using System;

namespace Walterlv.Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(ReferenceEquals(A, A));
            Console.WriteLine(ReferenceEquals(C, C));
            Console.WriteLine(ReferenceEquals(E, E));
            Console.WriteLine(ReferenceEquals(G, G));
            Console.ReadKey(true);
        }

        private static string A => $"walterlv is a {B}";
        private static string B => "逗比";
        private static string C => $"walterlv is a {D}";
        private static string D = "逗比";
        private static string E => $"walterlv is a {F}";
        private static readonly string F = "逗比";
        private static string G => $"walterlv is a {H}";
        private const string H = "逗比";
    }
}

以上代码的输出为:

False
False
False
True

也就是说,对于最后一种情况,也就是内插的字符串是常量的时候,得到的字符串是同一个实例;这能间接证明编译期间完全确定了字符串 G。

注意,其他情况都不能完全确定:

  1. 属性内插时一定不确定;
  2. 静态字段内插时,无论是否是只读的,都不能确定。(谁知道有没有人去反射改掉呢?)

我们可以通过 IL 来确定前面的间接证明(代码太长,我只贴出来最重要的 G 字符串,以及一个用来比较的 E 字符串):

.method private hidebysig static specialname string
    get_G() cil managed
{
    .maxstack 8

    // [22 36 - 22 56]
    IL_0000: ldstr        "walterlv is a 逗比"
    IL_0005: ret

}
.method private hidebysig static specialname string
    get_E() cil managed
{
    .maxstack 8

    // [20 36 - 20 56]
    IL_0000: ldstr        "walterlv is a "
    IL_0005: ldsfld       string Walterlv.Demo.Roslyn.Program::F
    IL_000a: call         string [System.Runtime]System.String::Concat(string, string)
    IL_000f: ret

}

可以发现,实际上 G 已经在编译期间完全确定了。

扩展:修改编译期间的字符串

前面我们说到可以在编译期间完全确定的字符串。呃,为什么一定要抬杠额外写一节呢?

下面我们修改编译期间确定的字符串,看看会发生什么:

static unsafe void Main(string[] args)
{
    // 这里的 G 就是前面定义的那个 G。
    Console.WriteLine("walterlv is a 逗比");
    Console.WriteLine(G);
    fixed (char* ptr = "walterlv is a 逗比")
    {
        *ptr = 'W';
    }
    Console.WriteLine("walterlv is a 逗比");
    Console.WriteLine(G);

    Console.ReadKey(true);
}

运行结果是:

walterlv is a 逗比
walterlv is a 逗比
Walterlv is a 逗比
Walterlv is a 逗比

虽然我们看起来只是在修改我们自己局部定义的一个字符串,但是实际上已经修改了另一个常量以及属性 G。

少年,使用指针修改字符串是很危险的!鬼知道你会把程序改成什么样!


参考资料

01-20 2019

C# 永远不会返回的方法真的不会返回

一般情况下,如果一个方法声明了返回值,但是实际上在编写代码的时候没有返回,那么这个时候会出现编译错误。

然而,如果方法内部出现了永远也不会退出的死循环,那么这个时候就不会出现编译错误。


请看下面这一段代码,RunAndNeverReturns 方法声明了返回值 int 但实际上方法内部没有返回。这段代码是可以编译通过而且可以正常运行的。

namespace Walterlv.Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            RunAndNeverReturns();
        }

        private static int RunAndNeverReturns()
        {
            while (true)
            {
                Thread.Sleep(1000);
                Console.WriteLine("Walterlv will always appear.");
            }

            // 注意看,这个方法其实没有返回。
        }
    }
}

如果观察其 IL 代码,会发现此方法的 IL 代码里面是没有 ret 语句的。而其他正常的方法,即便返回值是 void,也是有 ret 语句的。

01-16 2019

C#/.NET 如何确认一个路径是否是合法的文件路径

很多方法要求传入一个字符串作为文件名或者文件路径,不过方法在实际执行到使用文件名的时候才会真正使用到这个文件名;于是这这种时候才会因为各种各样的异常发现文件名或者文件路径是不合法的。

有没有方法能够提前验证文件名或者文件路径是否是合法的路径呢?


这是一个不幸的结论 —— 没有!

实际上由我们自己写代码判断一个字符串是否是一个合法的文件路径是非常困难的,因为:

  1. 不同操作系统的路径格式是不同的;
  2. 同一个操作系统有各种各样不同的路径用途。

但你可能会说,就算有各种不同,也是可以穷举出来的。那么来看看穷举这些不同的情况需要多少代码吧:

看完这些代码,你是不是可以考虑放弃做 100% 精确的提前验证了?放弃是正解。

那么接下来如何验证呢?

使用 new FileInfo(string fileName) 类型和 Path.GetFullPath(string path) 方法来判断,则会使用到以上的代码,不过副作用是在路径不合法的时候抛出异常。

抛出异常

然而作为 API,验证路径的合法性也是需要抛出异常的,所以大可以继续使用这样的方法,用方法内部抛出的异常来提醒开发者传入的路径不合法。

但有时候是作为与用户的交互来判断路径或者文件名是否合法的,那么这个时候使用异常就不太合适了。毕竟 C#/.NET 的异常机制不应该参与正常的逻辑流程。

那么可以使用 Path.GetInvalidFileNameChars()GetInvalidPathChars() 来判断字符串中是否包含不合法的文件名字符或者路径字符。

以下代码来自 .NET Core 的库源码 Path.Windows.cs

public static char[] GetInvalidFileNameChars() => new char[]
{
    '\"', '<', '>', '|', '\0',
    (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10,
    (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20,
    (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30,
    (char)31, ':', '*', '?', '\\', '/'
};

public static char[] GetInvalidPathChars() => new char[]
{
    '|', '\0',
    (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10,
    (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20,
    (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30,
    (char)31
};

参考资料

01-08 2019

int? 竟然真的可以是 null!.NET/C# 确定可空值类型 Nullable 实例的真实类型

使用 Nullable<T> 我们可以为原本不可能为 null 的值类型像引用类型那样提供一个 null 值。不过注意:Nullable<T> 本身也是个 struct,是个值类型哦。这意味着你随时可以调用 .HasValue 这样的方法,而不用担心会出现 NullReferenceException

等等!除了本文提到的一些情况。


Nullable 中的 null

注意看以下的代码。我们创建了一个值为 nullint?,然后依次输出 value 的值、value.GetType()

你觉得可以得到什么结果呢?

public class Program
{
    public static void Main(string[] args)
    {
        int? value = GetValue(null);

        Console.WriteLine($"value = {value}");
        Console.WriteLine($"type  = {value.GetType()}");
        Console.WriteLine($"TYPE  = {typeof(int?)}");

        Console.ReadLine();
    }

    private static int? GetValue(int? source) => source;
}


结果是……


果是……


是……


……



崩掉了……

NullReferenceException

那么我们在 value 后面加个空传递运算符:

--  Console.WriteLine($"type  = {value.GetType()}");
++  Console.WriteLine($"type  = {value?.GetType()}");

现在再次运行,我们确认了 value?.GetType() 的值为 null;而 typeof(int?) 的类型为 Nullable<Int32>

null 的类型

然而,我们现在将 value 的值从 null 改为 1

--  int? value = GetValue(null);
++  int? value = GetValue(1);

竟然 value.GetType() 得到的类型是 Int32

1 的类型

于是我们可以得出结论:

  1. 对于可空值类型,当为 null 时,GetType() 会出现空引用异常;
  2. 对于可空值类型,当不为 null 时,GetType() 返回的是对应的基础类型,而不是可空值类型;
  3. typeof(int?) 能够得到可空值类型。

Object.GetType() 和 is 对 Nullable 的作用

docs.microsoft.com 中,有一段对此的描述:

When you call the Object.GetType method on an instance of a nullable type, the instance is boxed to Object. As boxing of a non-null instance of a nullable type is equivalent to boxing of a value of the underlying type, GetType returns a Type object that represents the underlying type of a nullable type.

意思是说,当你对一个可空值类型 Nullable<T> 调用 Object.GetType() 方法的时候,这个实例会被装箱,会被隐式转换为一个 object 对象。然而对可空值类型的装箱与对值类型本身的装箱是同样的操作,所以调用 GetType() 的时候都是返回这个对象对应的实际基础类型。例如对一个 int? 进行装箱和对 int 装箱得到的 object 对象是一样的,于是 GetType() 实际上是不能区分这两种情况的。

那什么样的装箱会使得两个不同的类型被装箱为同一个了呢?

另一篇文档描述了 Nullable<T> 装箱的过程:

  • If HasValue returns false, the null reference is produced.
  • If HasValue returns true, a value of the underlying value type T is boxed, not the instance of Nullable.
  • 如果 HasValue 返回 false,那么就装箱一个 null
  • 如果 HasValue 返回 true,那么就将 Nullable<T> 中的 T 进行装箱,而不是 Nullable<T> 的实例。

这才是为什么 GetType() 会得到以上结果的原因。

同样的,也不能使用 is 运算符来确定这个类型到底是不是可空值类型:

Console.WriteLine($"value is int  = {value is int}");
Console.WriteLine($"value is int? = {value is int?}");

最终得到两者都是 True

用 is 确定类型

应该如何判断可空值类型的真实类型

使用 Nullable.GetUnderlyingType(type) 方法,能够得到一个可空值类型中的基础类型,也就是得到 Nullable<T>T 的类型。如果得不到就返回 null

所以使用以下方法可以判断 type 的真实类型。

bool IsNullable(Type type) => Nullable.GetUnderlyingType(type) != null;

然而,这个 type 的实例怎么来呢?根据前面的示例代码,我们又不能调用 GetType() 方法。

实际上,这个 type 的实例就是拿不到,在运行时是不能确定的。我们只能在编译时确定,就像下面这样:

bool IsOfNullableType<T>(T _) => Nullable.GetUnderlyingType(typeof(T)) != null;

如果你是运行时拿到的可空值类型的实例,那么实际上此方法也是无能为力的。

public class Program
{
    public static void Main(string[] args)
    {
        Console.Title = "walterlv's demo";

        int? value = GetValue(1);
        object o = value;
        Console.WriteLine($"value is nullable? {IsOfNullableType(value)}");
        Console.WriteLine($"o     is nullable? {IsOfNullableType(o)}");

        Console.ReadLine();
    }

    private static int? GetValue(int? source) => source;

    static bool IsOfNullableType<T>(T _) => Nullable.GetUnderlyingType(typeof(T)) != null;
}

运行时是拿不到的


参考资料

01-08 2019

C# 中委托实例的命名规则

我们知道一个类中的属性应该用名词或名词性短语,方法用动词或动宾短语;但是委托的实例却似乎有一些游离。因为在 .NET 中委托代表的是一个动作,既可以把它看作是名词,也可以看作是动词。在用法上,既可以像属性和变量一样被各种传递,也可以像一个方法一样被调用。

那么委托实例的命名,应该遵循属性和变量的命名,还是遵循方法的命名呢?


委托的实例可以当作属性或者变量使用:

var action = () => Console.WriteLine("walterlv is a 逗比");

委托的实例也可以当作方法使用:

var action = () => Console.WriteLine("walterlv is a 逗比");
action();

于是委托的命名方式迁就名词还是动词呢?

在微软的官方文档 Naming Guidelines 中提到了 .NET 中约定的命名方式。对于委托的命名,实际上只在 Names of Type Members 中提到了,不过提及的实际上是事件型的委托,而不是一般的委托实例。然后,微软其他地方的官方文档中也没有单独提及委托的命名方式。

为了弄清楚第一方代码的命名规则,我去 https://source.dot.net/ 上找了一些使用了委托的代码,然后发现,对于 ActionFunc 系列委托的命名,有以下这些(部分名称只保留了后缀进行合并):

使用名词的:

  • action
  • function
  • callback
  • continuation
  • method
  • factory
  • valueFactory
  • creator
  • valueGetter
  • initializer
  • _target
  • attributeComputer
  • argumentsPromise
  • taskProvider

使用动词的:

  • getSource

使用缩略词的:

  • localInit

我把缩略词单独拿出来,是因为缩写了以下就看不出来这到底是缩自名词还是缩自动词。

基本上可以确定:

委托实例的命名是 —— 一个表示动作的名词


参考资料

01-06 2019

三值 bool? 进行与或运算后的结果

bool? 实际上是 Nullable<Boolean> 类型,可以当作三值的 bool 类型来使用。不过三值的布尔进行与或运算时的结果与二值有什么不同吗?


重载条件逻辑运算符“与”(&&)“或”(||)

在 [C# 重载条件逻辑运算符(&& 和   )](/post/overload-conditional-and-and-or-operators-in-csharp) 一文中我说明了如何重载条件逻辑运算符 &&||

这两个运算符不能直接重载,但可以通过重载 &| 运算符来间接完成。

对于 bool?,重载了这样两个运算符:

  • bool? operator &(bool? x, bool? y)
  • bool? operator |(bool? x, bool? y)

于是我们可以得到三值 bool? 的与或结果。

三值 bool? 的与或结果

x y x&y x|y
true true true true
true false false true
true null null true
false true false true
false false false false
false null false null
null true null true
null false false null
null null null null

参考资料

2018
05-22 2018

新 csproj 对 WPF/UWP 支持不太好?有第三方 SDK 可以用!MSBuild.Sdk.Extras

自从微软推出 .NET Core 以来,新的项目文件格式以其优秀的可扩展性正吸引着更多项目采用。然而——微软官方的 WPF/UWP 项目模板依然还在采用旧的 csproj 格式!

这只是因为——官方 SDK 依然对 WPF/UWP 支持不够友好。


为什么要使用第三方的 SDK?

关于项目文件格式的迁移,我和 林德熙 都写过文章:

不过,这两篇文章中的迁移方法都是手动或半自动迁移的。而且迁移完毕之后,对新增的 WPF/UWP XAML 文件的支持非常不友好——新增的 XAML 文件是看不见的,除非手工去 csproj 文件中去掉自动生成的 Remove XAML 的代码。

这确实阻碍着我们在 WPF/UWP 项目中体会到新风格 csproj 的好处。

微软在 Build 2018 大会上宣布,WPF/UWP 将能够在 .NET Core 3 中运行。想必,微软会为未来版本的 Microsoft.NET.Sdk 这样的官方 SDK 添加更多的 WPF/UWP 这类格式的支持吧!即便没有这样的原生支持,想必也会提供官方的扩展方案。

但在此之前呢?感谢小伙伴 KodamaSakuno (神樹桜乃) 提醒我第三方 SDK 的存在 —— MSBuild.Sdk.Extras。我想,在 .NET Core 3 推出之前,这是一种不错的中转方案。既能体会到新风格 csproj 格式的好处,也能在将来 .NET Core 3 官方支持后较快地迁移成官方版本。

如何使用 MSBuild.Sdk.Extras

虽说是第三方 SDK,但实际使用的方便程度却如官方般简洁!只需要将 SDK 替换成 MSBuild.Sdk.Extras/1.5.4 即可。1.5.4 是目前 MSBuild.Sdk.Extras 在 NuGet 上的最新版本,建议访问 NuGet Gallery - MSBuild.Sdk.Extras 使用最新稳定版本。

以下是最简同时支持 WPF 和 UWP 双框架的代码:

<Project Sdk="MSBuild.Sdk.Extras/1.5.4">
  <PropertyGroup>
    <TargetFrameworks>net47;uap10.0</TargetFrameworks>
  </PropertyGroup>
</Project>

没错,真的如此简单!在我们猜测的 .NET Core 3 支持 WPF/UWP 项目格式之前,这应该算是最简单的迁移方案了!

至于项目结构的效果,可以看下图所示:

net47 和 uap10.0

相比于此前的手工迁移,使用此新格式创建出来的 XAML 文件是可见的,而且 .xaml.cs 也是折叠在 .xaml 之下,且能正常编译!(当然,咱们还得考虑 UWP 和 WPF 在 XAML 书写上的细微差异)

官方提供了更多的使用方法,例如更简单的是安装 NuGet 包,而不修改 SDK。详见:onovotny/MSBuildSdkExtras: Extra properties for MSBuild SDK projects

参考资料

01-13 2018

C# 写系统日志

因为我不想使用自己写文件,我的软件是绿色的,所以把日志写到 Windows 日志。

2017
12-25 2017

WPF 编译为 AnyCPU 和 x86 有什么区别

本文告诉大家,编译为 AnyCpu 和 AnyCPU(Prefer 32-bit)和 x86 有什么区别

10-27 2017

使用 Task.Wait()?立刻死锁(deadlock)

最近读到一篇异步转同步的文章,发现其中没有考虑到异步转同步过程中发生的死锁问题,所以特地在本文说说异步转同步过程中的死锁问题。

10-23 2017

使用 ExceptionDispatchInfo 捕捉并重新抛出异常

当你跑起了一个异步线程,并用 await 异步等待时,有没有好奇为什么能够在主线程 catch 到异步线程的异常?

当你希望在代码中提前收集好异常,最后一并把收集到的异常抛出的时候,能不能做到就像在原始异常发生的地方抛出一样?

本文介绍 ExceptionDispatchInfo,专门用于重新抛出异常。它在 .NET Framework 4.5 中首次引入,并原生在 .NET Core 和 .NET Standard 中得到支持。