|
csharp
如果你使用的是旧版本的 Visual Studio,那么默认的代码分析规则集是“最小建议规则集”。基于这个,写出来的代码其实只能说是能跑通过而已。随着 Roslyn 的发布,带来了越来越多更强大的代码分析器,可以为编写高质量的代码带来更多的帮助。
作为 .NET/C# 开发者,强烈建议安装本文推荐的几款代码分析器。
这里的分析器分为 Visual Studio 扩展形式的分析器和 NuGet 包形式的分析器。
Visual Studio 扩展形式的分析器可以让你一次安装对所有项目生效,但缺点是不能影响编译过程,只能作为在 Visual Studio 中编写代码时给出提示。
NuGet 包形式的分析器可以让某个项目中的所有成员享受到同样的代码分析提示(无论是否安装插件),但缺点是仅针对单个项目生效。
上图生效的分析器就是 Visual Studio 2019 自带的分析器。在可能有问题的代码上,Visual Studio 的代码编辑器会显示一些文字效果来提醒你代码问题。比如这张图就是提示私有成员 Foo
未使用。
Visual Studio 2019 自带的分析器的诊断 ID 都是以 IDE
开头,因此你可以通过这个前缀来区分是否是 Visual Studio 2019 自带的分析器提示的。
另外,自带的分析器可谓非常强大,除了以上这种提示之外,还可以提示一些重复代码的修改。比如你修改了某段代码,它会提示你相似的代码也可能需要修改。
Microsoft Code Analysis 分为两种用法,一个是 Visual Studio 扩展的形式,你可以去这里下载安装或者去 Visual Studio 的扩展管理界面搜索安装;另一个是 NuGet 包的形式,你可以直接在项目的 NuGet 管理界面安装 Microsoft.CodeAnalysis.FxCopAnalyzers。
这款分析器也是微软主推的代码分析器,可以分析 API 设计问题、全球化与本地化问题、稳定性问题、性能问题、安全性问题、代码使用问题等非常多的种类。
比如下图是稳定性的一个问题,直接 catch
了一个 Exception
基类:
虽然你可以通过配置规则严重性来消除提示,但是这样写通常代码也比较容易出现一些诡异的问题而难以定位。
Microsoft Code Analysis 分析器的诊断 ID 都是以 CA
开头,因此你可以通过这个前缀来区分是否是 Microsoft Code Analysis 分析器提示的。
Microsoft.CodeAnalysis.FxCopAnalyzers 的 NuGet 包实际上是一组分析器的合集,包括:
如果你想安装这款 NuGet 包,并不需要特别去 NuGet 包管理器中安装,也不需要命令行,只需要去项目的属性页面,选择“安装”就好了。如下图:
是第三方开发者开发的,代码已在 GitHub 上开源,社区非常活跃:
提供了 500 多个代码分析和重构。更值得推荐的一个原因是他为 Visual Studio 原本的很多报告了问题的代码提供了生成解决问题代码的能力。
Code Cracker 是第三方开发者开发的,代码已在 GitHub 上开源:
由于这款分析器的出现比 Visual Studio 2019 早很多,所以待 Visual Studio 2019 出现的时候,他们已经出现了一些规则的重复(意味着你可能同一个问题会被 Visual Studio 报一次,又被 Code Cracker 报一次)。
虽然部分重复,但 Code Cracker 依然提供了很多 Visual Studio 2019 和 Microsoft Code Analysis 都没有带的代码质量提示。
比如,如果你代码中的文档注释缺少了某个参数的注释,那么它会给出提示:
Code Cracker 支持的所有种类的代码分析都可以在这里查得到:
这款插件是对其他几款分析器的重要补充。如果说其他几款分析器可以帮你解决一些基本设计问题或者 Bug 的话,这款分析器可以帮你发现更大范围的问题。
最典型的,也是我推荐这款分析器的最大原因是 —— 区域和本地化!
你的每一个 ToString()
,每一个字符串比较,每一个字典的构造……他都提醒你需要考虑区域问题,然后提供给你区域问题的推荐代码!
你的项目中对于某项规则严重性的看法也许跟微软或其他第三方分析器不一样,因此你需要自己配置规则集的严重性。
关于如何配置代码分析严重程度,你可以阅读:
在手机打字总不是方便,于是就有了 Qpush 这个工具,通过这个工具可以快速从电脑到手机推送文字。 但是这个工具没有找到客户端,于是我就给他写了一个库,通过这个库可以快速进行开发
编写 .NET 程序的时候,我们经常会在项目的输出目录下发现一大堆的文件。除了我们项目自己生成的程序集之外,还能找到这个项目所依赖的一大堆依赖程序集。有没有什么方法可以把这些依赖和我们的程序集合并到一起呢?
本文介绍四种将程序集和依赖打包合并到一起的方法,每一种方法都有其不同的原理和优缺点。我将介绍这些方法的原理并帮助你决定哪种方法最适合你想要使用的场景。
目前我已知的将 .NET 程序集与依赖合并到一起的方法有下面四种:
如果你还知道有其他的方法,欢迎评论指出,非常感谢!
上面的第五种方法我也会做一些介绍,要么是因为无法真正完成任务或者适用场景非常有限,要么是其原理我还不理解,因此只进行简单介绍。
.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 中对于发布程序集的三种处理方式可以放在一起使用:
关于 .NET Core 3.0 中发布仅一个 exe 的方法、原理和实践,可以参见林德熙的博客:
.NET Core 在 GitHub 上开源:
在你的项目中安装一个 NuGet 包 Costura.Fody。一般来说,安装完之后,你编译的时候就会生成仅有一个 exe 的程序集了。
如果你继续留意,可以发现项目中多了一个 Fody 的专属配置文件 FodyWeavers.xml
,内容如下:
<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
<Costura/>
</Weavers>
仅仅到此为止你已经足够利用 Fody 完成程序集的合并了。
但是,如果希望对 Fody 进行更精细化的配置,可以阅读叶洪的博客:
Fody 在 GitHub 上开源:
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 基于 Mono.Ceil 来进行 IL 合并,其使用方法可以参见我的博客:
你可以在以下网址中找到 ILMerge-GUI 的下载链接:
ILMerge-GUI 工具在 Bitbucket 上开源:
可以将 .NET Core 编译为单个无依赖的 Native 程序。
你需要先安装一个预览版的 NuGet 包 Microsoft.DotNet.ILCompiler
关于 Microsoft.DotNet.ILCompiler 的使用,你可以阅读林德熙的博客:
dnSpy 支持添加一个模块到程序集,也可以创建模块,还可以将程序集转换为模块。因此,一个程序集可以包含多个模块的功能就可以被充分利用起来。
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 更好的编写和调试体验。
表格之外还有一些特别需要说明的:
最后说一下,以上所说的所有方法全部是开源的,有问题欢迎在社区讨论一起解决:
我的电脑上每天会跑一大堆控制台程序,于是管理这些程序的运行就成了一个问题。或者说你可能也在考虑启动一个控制台程序来完成某些特定的任务。
如果我们需要结束掉这个控制台程序怎么做呢?直接杀进程吗?这样很容易出问题。我正在使用的一个控制台程序会写文件,如果直接杀进程可能导致数据没能写入到文件。所以本文介绍如何使用 .NET/C# 代码向控制台程序发送 Ctrl+C
来安全地结束掉程序。
如果直接用 Process.Kill
杀掉进程,进程可能来不及保存数据。所以无论是窗口程序还是控制台程序,最好都让控制台程序自己去关闭。
▲ 使用 Process.Kill
结束程序,程序退出代码是 -1
▲ 使用 Ctrl+C
结束程序,程序退出代码是 0
Windows API 提供了方法可以将当前进程与目标控制台进程关联起来,这样我们便可以向自己发送 Ctrl+C
信号来结束掉关联的另一个控制台进程。
关联和取消关联的方法是下面这两个,AttachConsole
和 FreeConsole
:
[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)
{
}
}
参考资料
HTTP GET 请求时携带的参数直接在 URL 中,形式如 ?key1=value&key2=value&key3=value
。如果是 POST 请求时,我们可以使用一些库序列化为 json 格式作为 BODY 发送,那么 GET 请求呢?有可以直接将其序列化为 HTTP GET 请求的 query 字符串的吗?
一个典型的 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
,于是我们需要:
query.GetType().GetProperties()
property.GetValue(query, null)
string.Join("&", properties)
然而真实场景可能比这个稍微复杂一点:
DataContract
的类,按照 DataMember
来序列化所以,我写出了下面的方法:
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 包来获得更多可空引用类型契约的支持,详见:
你可能需要准确地知道一段字符串是否是域名/网址/URL。虽然可以使用 .
、/
这些来模糊匹配,但会造成误判。
实际上单纯使用正则表达式来精确匹配也是非常复杂的,通过代码来判断会简单很多。不过本文依然从域名的定义出发来尽可能匹配一段字符串是否是域名或者网址,在要求不怎么高的场合,使用本文的正则表达式写的代码会比较简单。
网址实际上是 URL(统一资源定位符),它是由协议、主机名和路径组成。不过我们通常所说的网址中的主机名通常是域名,因此我们在匹配的时候主要考虑域名。
维基百科 中关于域名的描述:
- 域名由一或多个部分组成,这些部分通常连接在一起,并由点分隔。最右边的一个标签是顶级域名,例如zh.wikipedia.org的顶级域名是org。一个域名的层次结构,从右侧到左侧隔一个点依次下降一层。每个标签可以包含1到63个八字节。域名的结尾有时候还有一点,这是保留给根节点的,书写时通常省略,在查询时由软件内部补上。
- 域名里的英文字母不区分大小写。
- 完整域名的所有字符加起来不得超过253个ASCII字符的总长度。因此,当每一级都使用单个字符时,限制为127个级别:127个字符加上126个点的总长度为253。但实际上,某些域名可能具有其他限制;也没有只有一个字符的域名后缀。
后面关于非 ASCII 字符的描述我没有贴出来。这种域名例如“.中国”。
在 中国电信网站备案自助管理系统 中,我们可以找到关于域名的描述:
域名中的标号都由英文字母和数字组成,每一个标号不超过63个字符,也不区分大小写字母。标号中除连字符(-)外不能使用其他的标点符号。级别最低的域名写在最左边,而级别最高的域名写在最右边。由多个标号组成的完整域名总共不超过255个字符。
路径是使用 /
分隔的一段一段字符串。
在确认了完整的网址 URL 的规范之后,使用正则表达式来匹配就会比较精确了。
现在,我们来尝试匹配一下域名
-
a-z
A-Z
0-9
,但是 -
不可作为开头,标签总长度 1-63 个字符,于是
[a-zA-Z0-9][-a-zA-Z0-9]{0,62}
-
,后面的字可以包含 -
(\.[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。
如果你只是打算做一些简单的检查(例如只是语法高亮),那么简单匹配即可;因为当你使用 Chrome 浏览器访问这些 URL 的时候,依然可以正常访问,Chrome 会帮你格式化一下这个 URL。
匹配 URL 跟匹配域名不一样,URL 复杂得多。严格匹配的要求是准确反应出 URL 的标准,但实际上如实反应标准编写的正则表达式会非常复杂,因此相比于 100% 准确匹配,我们还是从简了。
所以如果不是有特别要求,建议还是跳到后面的“宽松”部分来阅读吧!
我们以下面这个网址为例说明。
https://blog.walterlv.com/post/read-32bit-registry-from-x64-process.html
(http(s)?:\/\/)
?
和什么都不加的效果是一样的[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][-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 字符,使用 #
用来指代网址中的某个部分。
因此,我们最终总结应该匹配的特殊字符有 @
:
%
_
\
+
.
~
#
?
&
/
=
。
宽松一点的话,正则表达式就好写多了。
这个正则表达式可以不写 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 不能以 )
结尾;>
同理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 的一部分。
在 GitHub 上还有很多大牛们在写各种匹配 URL 的正则表达式:
最长的一个写了 1347 个字符,最短的有 38 个字符。
有人将其整理成一张表格,一图说明各种正则表达式能匹配到什么程度:
参考资料
C# 8.0 引入了可空引用类型,你可以通过 ?
为字段、属性、方法参数、返回值等添加是否可为 null 的特性。
但是如果你真的在把你原有的旧项目迁移到可空类型的时候,你就会发现情况远比你想象当中复杂,因为你写的代码可能只在部分情况下可空,部分情况下不可空;或者传入空时才可为空,传入非空时则不可为空。
在开始迁移你的项目之前,你可能需要了解如何开启项目的可空类型支持:
可空引用类型是 C# 8.0 带来的新特性。
你可能会好奇,C# 语言的可空特性为什么在编译成类库之后,依然可以被引用它的程序集识别。也许你可以理解为有什么特性 Attribute
标记了字段、属性、方法参数、返回值的可空特性,于是可空特性就被编译到程序集中了。
确实,可空特性是通过 NullableAttribute
和 NullableContextAttribute
这两个特性标记的。
但你是否好奇,即使在古老的 .NET Framework 4.5 或者 .NET Standard 2.0 中开发的时候,你也可以编译出支持可空信息的程序集出来。这些古老的框架中没有这些新出来的类型,为什么也可以携带类型的可空特性呢?
实际上反编译一下编译出来的程序集就能立刻看到结果了。
看下图,在早期版本的 .NET 框架中,可空特性实际上是被编译到程序集里面,作为 internal
的 Attribute
类型了。
所以,放心使用可空类型吧!旧版本的框架也是可以用的。
阻碍你将老项目迁移到可空类型的原因,可能还有你原来代码逻辑的问题。因为有些情况下你无法完完全全将类型迁移到可空。
例如:
null
或者获取可空类型时你能确保此时一定不为 null
(待会儿我会解释到底是什么情况);null
那种情况下返回的是非 null
;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
了。
在本文第一小节里面,我们说 Nullable
是编译到目标程序集中的,所以不需要引用什么特别的程序集就能够使用到可空引用的特性。
那么上面这些特性呢?它们并没有编译到目标程序集中怎么办?
实际上,你只需要有一个命名空间、名字和实现都相同的类型就够了。你可以写一个放到你自己的程序集中,也可以把这些类型写到一个自己公共的库中,然后引用它。当然,你也可以用我已经写好的 NuGet 包 Walterlv.NullableAttributes。
微软 .NET 官方的可空特性在这里:
我将其注释翻译成中文之后,也写了一份在这里:
如果你想简单一点,可以直接引用我的 NuGet 包:
源代码包可以在不用引入其他 dll 依赖的情况下完成引用。最终你输出的程序集是不带对此包的依赖的,详见:
参考资料
基类是 MenuItem
,子类是 WalterlvMenuItem
、FooMenuItem
。基类是 Configuration
,子类是 WalterlvConfiguration
、ExtensionConfiguration
。在代码中,我们可能会为了能够一眼看清类之间的继承(从属)关系而在子类名称后缀中带上基类的名称。但是由于这种情况下的基类不参与实际的业务,所以对外(文件/网络)的名称通常不需要带上这个后缀。
本文提供一个简单的方法,让子类中基类的后缀删掉,只取得前面的那部分。
在这段代码中,我们至少需要获得两个传入的参数,一个是基类的名称,一个是子类的名称。但是考虑到让开发者就这样传入两者名称的话会比较容易出问题,因为开发者可能根本就不会按照要求去获取类型的名称。所以我们需要自己通过类型对象来获取名称。
另外,我们还需要有一些约束,必须有一个类型是另外一个类型的子类。于是我们可能必须来使用泛型做这样的约束。
于是,我们可以写出下面的方法:
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
。
程序员不看警告!
于是 C# 8.0 带来的可空引用类型由于默认以警告的形式出现,所以实际上约束力非常弱。
本文将把 C# 8.0 的可空引用类型警告提升为错误,以提高约束力。
你需要先在你的项目中启用可空引用类型的支持,才能修改警告到错误:
在项目属性中设置是比较快捷直观的方法。
在项目上右键属性,打开“生成”标签。
在这里,可以看到“将警告视为错误”一栏:
可以看到默认选中的是“特定警告”且值是 NU1605
。
NU 是 NuGet 中发生的错误或者警告的前缀,NU1605
是大家可能平时经常见到的一个编译错误“检测到包降级”。关于这个错误的信息可以阅读官网:NuGet Warning NU1605 - Microsoft Docs,本文不需要说明。
于是,我们将我们需要视为错误的错误代码补充到后面就可以,以分号分隔。
NU1605;CS8600;CS8602;CS8603;CS8604;CS8618;CS8625
这些值的含义可以参考我的另一篇博客:
记得在改之前,把前面的配置从“活动”改为“所有配置”,这样你就不用改完之后仅在 Debug 生效,完了还要去 Release 配置再改一遍。
前面使用属性面板指定时,有一个奇怪的默认值。实际上我们直接修改将固化这个默认值,这不利于将来项目跟随 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 包的升级而更新此默认值。
这些值的含义可以参考我的另一篇博客:
参考资料
C# 8.0 引入了可为空引用类型和不可为空引用类型。由于这是语法级别的支持,所以比传统的契约式编程具有更强的约束力。更容易帮助我们消灭 null
异常。
本文将介绍如何在项目中开启 C# 8.0 的可空引用类型的支持。
如果你还在使用旧的项目文件,请先升级成 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
warnings
annotations
disable
这五个值其实是两个不同维度的设置排列组合之后的结果:
当仅仅启用警告上下文而不开启可为空注释上下文,那么编译器将仅仅识别局部变量中明显可以判定出对 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>
详见:
当启动可为空注释上下文后,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 从 2019.1.1 版本开始支持 C# 8.0,如果使用早期版本,就会到处报错。
但是,由于 C# 8.0 可空引用类型的特性总在变,所以建议使用 2019.2.3 或以上版本,这是 C# 8.0 正式版本发布之后的 ReSharper。
参考资料
WPF 程序提供了 Focus
方法和 TraversalRequest
来在 WPF 焦点范围内转移焦点。但如果 WPF 窗口中嵌入了其他框架的 UI(比如另一个子窗口),那么就需要使用其他的方法来设置焦点了。
一个粗略的设置方法是,使用 Win32 API:
SetFocus(hwnd);
传入的是要设置焦点的窗口的句柄。
参考资料
C# 8.0 引入了可为空引用类型和不可为空引用类型。当你需要给你或者团队更严格的要求时,可能需要定义这部分的警告和错误级别。
本文将介绍 C# 可空引用类型部分的警告和错误提示,便于进行个人项目或者团队项目的配置。
本文的内容本身没什么意义,但如果你试图进行一些团队配置,那么本文的示例可能能带来一些帮助。
CS8600
将 null 文本或可能的 null 值转换为非 null 类型。
string walterlv = null;
CS8601
可能的 null 引用赋值。
string Text { get; set; }
void Foo(string? text)
{
// 将可能为 null 的文本向不可为 null 的类型赋值。
Text = text;
}
CS8602
null 引用可能的取消引用。
// 当编译器判定 walterlv 可能为 null 时才会有此警告。
var value = walterlv.ToString();
CS8603
可能的 null 引用返回。
string Foo()
{
return null;
}
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)
{
}
CS8653
对于泛型 T,使用 default
设置其值。如果 T 是引用类型,那么 default
就会将这个泛型类型赋值为 null
。然而并没有将泛型 T 的使用写为 T?。
当我们在写 +=
和 -=
事件的时候,我们会在 +=
或 -=
的右边写上事件处理函数。我们可以写很多种不同的事件处理函数的形式,那么这些形式都是一样的吗?如果你不注意,可能出现内存泄漏问题。
本文将讲解事件处理函数的不同形式,理解了这些可以避免编写代码的时候出现内存相关的问题。
事件处理函数本质上是一个委托,比如 FileSystemWatcher
的 Changed
事件是这样定义的:
// 这是简化的代码。
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)
{
}
因为我们可以通过编写事件的 add
和 remove
方法来观察事件 +=
-=
传入的 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)。
然而,你平时众多的编码经验会告诉你,这里的 -=
是一定可以成功的。也就是说,+=
和 -=
时传入的委托实例即便不是同一个,也是可以成功 +=
和 -=
的。
+=
-=
是怎么做的+=
和 -=
到底是怎么做的,可以在不同实例时也能 +=
和 -=
成功呢?
+=
和 -=
实际上是调用了 Delegate
的 Combine
和 Remove
方法,并生成一个新的委托实例赋值给 +=
-=
的左边。
public event FileSystemEventHandler Changed
{
add
{
onChangedHandler = (FileSystemEventHandler)Delegate.Combine(onChangedHandler, value);
}
remove
{
onChangedHandler = (FileSystemEventHandler)Delegate.Remove(onChangedHandler, value);
}
}
而最终的判断也是通过 Delegate
的 Equals
方法来比较委托的实例是否相等的(==
和 !=
也是调用的 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);
}
于是可以看出来,判断相等就是两个关键对象的判断相等:
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
-=
于是什么样的 -=
才可以把 +=
加进去的事件处理函数减掉呢?
所以:
+=
和 -=
的时候无视哪个委托实例,都是可以减掉的;有小伙伴希望在 .NET 代码中使用指针,操作非托管资源,于是可能使用到 unsafe
fixed
关键字。但使用此关键字的前提是需要在项目中开启不安全代码。
本文介绍如何在项目中开启不安全代码。
第一步:在你需要启用不安全代码的项目上点击右键,然后选择属性:
第二步:在“生成”标签下,勾选上“允许不安全代码”:
第三步:切换到 Release 配置,再勾上一次“允许不安全代码”(确保 Debug 和 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 风格的项目文件的,详情请阅读:
我只是增加库的一个 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
类型也将无法在不确定的命名空间中找到。
因此:
using
),要么写全命名空间(从第一段开始写,不要省略任何部分),否则就容易与其他命名空间冲突;是的,即使是单纯的新增 API 也可能会导致使用库的一方在源码级不兼容。当然二进制还是兼容的。
另外,OpportunityLiu 提醒,如果命名空间是 Walterlv.B.Walterlv.A.Diagnostics.Bar
,一样可以让写全了的命名空间炸掉。呃……还是不要在库里面折腾这样的命名空间好……不然代码当中到处充斥着 global::
可是非常难受的。
我们知道,32 位程序在读取注册表的时候,会自动将注册表的路径映射到 32 位路径下,即在 Wow6432Node
子节点下。但是 64 位程序不会映射到 32 位路径下。那么 64 位程序如何读取到 32 位程序写入的注册表路径呢?
对于 32 位程序,读取注册表路径的时候,会读到 Wow6432Node
节点下的项:
这张图读取的就是前面截图中的节点。
那么怎样编译的程序是 32-bit 的程序呢?
对于 64 位程序,读取的时候就不会有 Wow6432Node
路径部分。由于我没有在那个路径放注册表项,所以会得到 null
。
那么怎样编译的程序是 64-bit 的程序呢?
前面我们的例子代码是这样的:
var value = RegistryHive.LocalMachine.Read(@"SOFTWARE\Walterlv");
可以看到,相同的代码,在 32 位和 64 位进程下得到的结果是不同的:
Wow6432Node
。那么如何在 64 位进程中读取 32 位注册表路径呢?
方法是在打开注册表项的时候,传入 RegistryView.Registry32
。
RegistryKey.OpenBaseKey(root, RegistryView.Registry32);
可以在我的 GitHub 仓库中查看完整的实现。当然,除了上面那句话,其他都不是关键代码,在哪里都可以找得到的。
参考资料
因为 Win32 的窗口句柄是可以跨进程传递的,所以可以用来实现跨进程 UI。不过,本文不会谈论跨进程 UI 的具体实现,只会提及其实现中的一个重要缓解,使用子窗口的方式。
你有可能在使用子窗口之后,发现拖拽改变窗口大小的时候,子窗口中的内容不断闪烁。如果你也遇到了这样的问题,那么正好可以阅读本文来解决。
你可以看一下下面的这张动图,感受一下窗口的闪烁:
实际上在拖动窗口的时候,是一直都在闪的,只是每次闪烁都非常快,截取 gif 的时候截不到。
如果你希望实际跑一跑项目看看,可以使用下面的代码:
我特地提取了一个提交下的代码,如果你要尝试,不能使用 master
分支,因为 master
分支修复了闪烁的问题。
后来使用 CreateWindowEx
创建了一个纯 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)
{
}
}
正在探索……
参考资料
在 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
,此方法要求窗口句柄依然有效,然而此时窗口已经关闭,句柄已经销毁。
Direct3D11 的使用通常不是应用程序唯一的部分,于是使用 Direct3D11 的代码如何与其他模块正确地组合在一起就是一个需要解决的问题。
本文介绍将 Direct3D11 在 GPU 中绘制的纹理映射到内存中,这样我们可以直接观察到此纹理是否是正确的,而不用担心是否有其他模块影响了最终的渲染过程。
本文的代码会使用到 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 的渲染纹理,不过如果你希望了解,可以:
本文接下来的内容,是在你已经获得了 SharpDX.Direct3D11.Resource
的引用,或者 SharpDX.Direct3D11.Texture2D
的前提之下。当然,如果你获得了其中任何一个实例,可以通过 COM 组件的 QueryInterface
方法获得其他实例。
var texture = resource.QueryInterface<SharpDX.Direct3D11.Texture2D>();
var resource = texture.QueryInterface<SharpDX.Direct3D11.Resource>();
要获得 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 访问权在创建纹理的时候就已经确定下来了。
如果前面你得到的纹理是自己创建的,那么恭喜你,你只需要改一下创建纹理的参数就好了。给 Texture2DDescription
的 CpuAccessFlags
属性加上 CpuAccessFlags.Read
标识。
desc.CpuAccessFlags = CpuAccessFlags.Read;
但是,如果此纹理不是由你自己创建的,那么就需要拷贝一份新的纹理了。当然,拷贝过程发生在 GPU 中,占用的也是 GPU 专用内存(即显存,如果有的话)。
拷贝需要做到两点:
Texture2DDescription
(一定要是新的实例,你不能影响原来的实例),然后修改其 CPU 访问权限为 Read
;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),可以参考:
记得打开不安全代码开关哦!详见:
参考资料
我们知道 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\
本文的代码主要参考自 jeff.brown 在 Manipulating NTFS Junction Points in .NET - CodeProject 一文中所附带的源代码。
由于随时可能更新,所以你可以前往 GitHub 仓库打开此代码:
如果希望在代码中创建目录联接,则直接使用:
JunctionPoint.Create("walterlv.demo", @"D:\Developments", true);
后面的 true
指定如果目录联接存在,则会覆盖掉原来的目录联接。
参考资料
WPF 渲染框架并没有对外提供多少可以完全控制渲染的部分,目前可以做的有:
本文将解释如何最大程度压榨 WriteableBitmap
在 WPF 下的性能。
创建一个新的 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 品质的位图,并通过以下步骤来评估:
CompositionTarget.Rendering
逐帧渲染以评估其渲染帧率于是,在 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
的位图缓存中。
虽然我们看不到任何可变的修改,不过 WriteableBitmap 可不这么认为。因为我们调用了 AddDirtyRect
将整个位图空间都加入到了脏区中,这样 WPF 会重新渲染整幅位图。
Visual Studio 中看到的 CPU 占用率大约维持在 16% 左右(跟具体机器相关);并且除了一开始启动的时候之外,完全没有 GC(这点很重要),内存稳定在一个值上不再变化。
也只有本文一开始提及的三种方法才可能做到渲染任何可能的图形的时候没有 GC
查看界面渲染帧率可以发现跑满 60 帧没有什么问题(跟具体机器相关)。
现在,我们把脏区的区域缩小为 100*100,同样看性能数据。
-- _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
++ _bitmap.AddDirtyRect(new Int32Rect(0, 0, 100, 100));
可以发现 CPU 占用降低到一半(确实是大幅降低,但是跟像素数量并不成比例);内存没有变化(废话,4K 图像是确定的);帧率没有变化(废话,只要性能够,帧率就是满的)。
现在,我们将脏区清零。
-- _bitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
++ _bitmap.AddDirtyRect(new Int32Rect(0, 0, 0, 0));
在完全没有脏区的时候,CPU 占用直接降为 0,这个性能提升还是非常恐怖的。
如果我们不把 WriteableBitmap 设置为 Image
的 Source
属性,那么无论脏区多大,CPU 占用都是 0。
从前面的测试中我们可以发现,脏区的大小在 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 占用。此占用基本上全是渲染线程的事。
如果我们分析主线程的性能分布,可以发现内存拷贝现在是性能瓶颈:
后面我们会提到 WriteableBitmap 的渲染原理,也会说到这一点。
不过,由于内存数据的拷贝和脏区渲染实际上可以分开到两个不同的线程,如果这两者不同步执行(可能执行次数还有差异)的情况下,内存拷贝也可能成为性能瓶颈的一部分。
于是我将不同的内存拷贝方法进行一个基准测试,便于大家评估使用哪种方法来为 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();
}
++ [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 |
可以发现:
CopyMemory
和 RtMoveMemory
性能是最好的,其性能差不多;综合前面两者的结论,我们可以发现:
另外,如果你有一些特殊的应用场景,可以适当调整下自己写代码的策略:
在调用 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 占用依然是这么高)。
在 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
制作透明窗口的时候设置了 ResizeMode="None"
,所以你拖动窗口在屏幕顶部和左右两边的时候,Windows 不会再帮助你最大化窗口或者靠边停靠窗口,于是你需要自行处理。不过窗口的标题栏拖动功能依然保留了下来,标题栏上的右键菜单也是可以继续使用的。
方案 | WindowChrome | AllowsTransparency |
---|---|---|
拖拽标题栏移动窗口 | 保留 | 自行实现 |
最小化最大化关闭按钮 | 丢失 | 丢失 |
拖拽边缘调整窗口大小 | 丢失 | 丢失 |
移动窗口到顶部可最大化 | 丢失 | 自行实现 |
拖拽最大化窗口标题栏还原窗口 | 保留 | 自行实现 |
移动窗口到屏幕两边可侧边停靠 | 丢失 | 自行实现 |
拖拽摇动窗口以最小化其他窗口 | 保留 | 自行实现 |
窗口打开/关闭/最小化/最大化/还原动画 | 丢失 | 丢失 |
表格中:
保留
表示此功能无需任何处理即可继续支持自行实现
表示此功能已消失,但仅需要一两行代码即可补回功能丢失
表示此功能已消失,如需实现需要编写大量代码另外,以上表格仅针对鼠标操作窗口。如果算上使用触摸来操作窗口,那么所有标记为 自行实现
的都将变为 丢失
。因为虽然你可以一句话补回功能,但在触摸操作下各种 Bug,你解不完……
这两种实现的窗口之间还有一些功能上的区别:
方案 | WindowChrome | AllowsTransparency | 说明 |
---|---|---|---|
点击穿透 | 在完全透明的部分点击依然点在自己的窗口上 | 在完全透明的部分点击会穿透到下面的其他窗口 | 感谢 nocanstillbb (huang bin bin) 提供的信息 |
随着 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 中的分析器严重性内容就像下面这样:
[*.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。
做 Windows 桌面应用开发的小伙伴们对“模态窗口”(Modal Dialog)一定不陌生。如果你希望在模态窗口之上做更多的事情,或者自己实现一套模态窗口类似的机制,那么你可能需要了解模态窗口的本质。
本文不会太深,只是从模态窗口一词出发,抵达大家都熟知的一些知识为止。
在各种系统、语言和框架中,只要有用户可以看见的界面,都存在模态窗口的概念。从交互层面来说,它的形式是在保留主界面作为环境来显示的情况下,打开一个新的界面,用户只能在新的界面上操作,完成或取消后才能返回主界面。从作用上来说,通常是要求用户必须提供某些信息后才能继续操作,或者单纯只是为了广告。
如果你希望自己搞一套模态窗口出来,那么只需要满足这三点即可。你可以随便加戏但那都无关紧要。
拿 Windows 系统中的模态对话框为例子,大概就像下面这两张图片这样:
有一个小的子界面盖住了主界面,要求用户必须进行选择。Windows 系统设置因为让背景变暗了,所以用户肯定会看得到需要进行的交互;而任务管理器没有让主界面变暗,所以用户在操作子界面的时候,模态窗口的边框和标题栏闪烁以提醒用户注意。
对于 Windows 操作系统来说,模态窗口并不是一个单一的概念,你并不能仅通过一个 API 调用就完成显示模态窗口,你需要在不同的时机调用不同的 API 来完成一个模态窗口。如果要完整实现一个自己的模态窗口,则需要编写实现以上三个特点的代码。
当然,你可能会发现实际上你显示一个模态窗口仅仅一句话调用就够了,那是因为你所用的应用程序框架帮你完成了模态窗口的一系列机制。
关于 WPF 框架是如何实现模态窗口的,可以阅读:直击本质:WPF 框架是如何实现模态窗口的
关于如何自己实现一个跨越线程/进程边界的模态窗口,可以阅读:实现 Windows 系统上跨进程/跨线程的模态窗口
如果你希望定制以上第三个特点中强提醒的动画效果,可以阅读:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园。
为了在 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 上的提醒是最简单的了,比如:
然而 Windows 和 Mac OS 这些古老的系统由于兼容性负担不能随便那么改,于是需要有其他的提醒方式。
Windows 采用的方式是让标题栏闪烁,让阴影闪烁。
而这些特效的处理,来自于子窗口需要处理一些特定的消息 WM_SETCURSOR
。
详见:WPF window 子窗口反馈效果(抖动/阴影渐变) - 唐宋元明清2188 - 博客园
通常你不需要手工处理这些消息,但是如果你完全定制了窗口样式,则可能需要自行做一个这样的模态窗口提醒效果。
想知道你在 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);
}
}
}
觉得代码还是太长?不要紧,我再简化一下:
EnumThreadWindows
获取当前线程的所有窗口可以注意到禁用掉的窗口是“当前线程”的哦。
ShowHelper
接下来的重点方法是 Window.ShowDialog
中的那句 Show()
。在 Show()
之前设置了 _showingAsDialog
为 true
,于是这里会调用 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();
}
}
可以看到,重点是 PushModal
、PopModal
以及 PushFrame
。
PushFrame
的效果就是让调用 ShowDialog
的代码看起来就像阻塞了一样(实际上就是阻塞了,只不过开了新的消息循环看起来 UI 不卡)。
关于 PushFrame
为什么能够“阻塞”你的代码的同时还能继续响应 UI 操作的原理,可以阅读:
那么 ComponentDispatcher.PushModal
和 ComponentDispatcher.PopModal
呢?可以在这里(ComponentDispatcherThread.cs)看它的代码,实际上是为了模态计数以及引发事件的,对模态的效果没有本质上的影响。
如果你希望知道某台计算机上安装了哪些版本的 .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" },
};
另外,还有一些值也是有意义的(只是不那么精确):
它们分别在注册表的这些位置:
计算机\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
如下:
字典里 Key 是不能共存的主版本,Value 是这个主版本里当前已经安装的具体版本信息。
如果直接使用 ToString()
,是可以生成我们平时经常在各大文档或者社区使用的 .NET Framework 的名称。
第二种,获取当前已安装的最新的 .NET Framework 版本名称:
var currentVersion = NdpInfo.GetCurrentVersionName();
这可以直接获取到一个字符串,比如 .NET Framework 4.8
。对于只是简单获取一下已安装名称而不用做更多处理的程序来说会比较方便。
在 Windows 应用开发中,如果需要操作其他的窗口,那么可以使用 EnumWindows
这个 API 来枚举这些窗口。
你可以使用本文编写的一个类型,查找到所有窗口中你关心的信息。
枚举所有窗口仅需要使用到 EnumWindows
,其中需要定义一个委托 WndEnumProc
作为传入参数的类型。
剩下的我们需要其他各种方法用于获取窗口的其他属性。
GetParent
获取窗口的父窗口,这可以确认找到的窗口是否是顶层窗口。(关于顶层窗口,可以延伸 使用 SetParent 跨进程设置父子窗口时的一些问题(小心卡死) - walterlv。)IsWindowVisible
判断窗口是否可见GetWindowText
获取窗口标题GetClassName
获取窗口类名GetWindowRect
获取窗口位置和尺寸,为此我们还需要定义一个结构体 LPRECT
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;
}
}
Windows 系统中有一个没什么文档的 API,SetWindowCompositionAttribute
,可以允许应用的开发者将自己窗口中的内容渲染与窗口进行组合。这可以实现很多系统中预设的窗口特效,比如 Windows 7 的毛玻璃特效,Windows 8/10 的前景色特效,Windows 10 的模糊特效,以及 Windows 10 1709 的亚克力(Acrylic)特效。而且这些组合都发生在 dwm 进程中,不会额外占用应用程序的渲染性能。
本文介绍 SetWindowCompositionAttribute
可以实现的所有效果。你可以通过阅读本文了解到与系统窗口可以组合渲染到哪些程度。
本文将创建一个简单的 WPF 程序来验证 SetWindowCompositionAttribute
能达到的各种效果。你也可以不使用 WPF,得到类似的效果。
简单的项目文件结构是这样的:
其中,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
让客户区覆盖非客户区使用 WindowChrome
,你可以用你自己的 UI 覆盖掉系统的 UI 窗口样式。关于 WindowChrome
让客户区覆盖非客户区的知识,可以阅读:
需要注意的是,WindowChrome
的 GlassFrameThickness
属性可以设置窗口边框的粗细,设置为 0
将导致窗口没有阴影,设置为负数将使得整个窗口都是边框。
我们依次来看看效果。
使用 ACCENT_DISABLED
时,GradientColor
叠加色没有任何影响,唯一影响渲染的是 WindowChrome
和操作系统。
不使用 WindowChrome
,在 Windows 10 上:
不使用 WindowChrome
在 Windows 7 上:
在 Windows 10 上,使用 WindowChrome
:
<WindowChrome.WindowChrome>
<WindowChrome />
</WindowChrome.WindowChrome>
在 Windows 7 上,使用 WindowChrome
:
当然,以上边框比较细,跟系统不搭,可以设置成其他值:
在 Windows 10 上,使用 WindowChrome
并且 GlassFrameThickness
设置为 -1
:
<WindowChrome.WindowChrome>
<WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>
而在 Windows 7 上,这就是非常绚丽的全窗口的 Aero 毛玻璃特效:
使用 ACCENT_DISABLED
时,GradientColor
叠加色会影响到最终的渲染效果。
还记得我们前面叠加的颜色是什么吗?
接下来别忘了然后把它误以为是我系统的主题色哦!
不使用 WindowChrome
,在 Windows 10 上:
另外,你会注意到左、下、右三个方向上边框会深一些。那是 Windows 10 的窗口阴影效果,因为实际上 Windows 10 叠加的阴影也是窗口区域的一部分,只是一般人看不出来而已。我们叠加了颜色之后,这里就露馅儿了。
另外,这个颜色并不是我们自己的进程绘制的哦,是 dwm 绘制的颜色。
如果不指定 GradientColor
也就是保持为 0
,你将看到上面绿色的部分全是黑色的;嗯,包括阴影的部分……
不使用 WindowChrome
在 Windows 7 上:
可以看出,在 Windows 7 上,GradientColor
被无视了。
而使用 WindowChrome
在 Windows 10 上,则可以得到整个窗口的叠加色:
<WindowChrome.WindowChrome>
<WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>
可以注意到,窗口获得焦点的时候,整个窗口都是叠加色;而窗口失去焦点的时候,指定了边框的部分颜色会更深(换其他颜色叠加可以看出来是叠加了半透明黑色)。
如果你希望失去焦点的时候,边框部分不要变深,请将边框设置为 -1
:
<WindowChrome.WindowChrome>
<WindowChrome GlassFrameThickness="-1" />
</WindowChrome.WindowChrome>
使用 WindowChrome
在 Windows 7 上,依然没有任何叠加色的效果:
使用 ACCENT_ENABLE_TRANSPARENTGRADIENT
时,GradientColor
叠加色没有任何影响,唯一影响渲染的是 WindowChrome
和操作系统。
不使用 WindowChrome
,在 Windows 10 上:
依然左、下、右三个方向上边框会深一些,那是 Windows 10 的窗口阴影效果。
不使用 WindowChrome
在 Windows 7 上:
GradientColor
也是被无视的,而且效果跟之前一样。
使用 WindowChrome
在 Windows 10 上,在获得焦点的时候整个背景是系统主题色;而失去焦点的时候是灰色,但边框部分是深色。
依然可以将边框设置为 -1
使得边框不会变深:
使用 WindowChrome
在 Windows 7 上,依然是老样子:
ACCENT_ENABLE_BLURBEHIND
可以在 Windows 10 上做出模糊效果,就跟 Windows 10 早期版本的模糊效果是一样的。你可以看我之前的一篇博客,那时亚克力效果还没出来:
使用 ACCENT_ENABLE_BLURBEHIND
时,GradientColor
叠加色没有任何影响,唯一影响渲染的是 WindowChrome
和操作系统。
在 Windows 10 上,没有使用 WindowChrome
:
你可能需要留意一下那个“诡异”的模糊范围,你会发现窗口的阴影外侧也是有模糊的!!!你能忍吗?肯定不能忍,所以还是乖乖使用 WindowChrome
吧!
在 Windows 7 上,没有使用 WindowChrome
,效果跟其他值一样,依然没有变化:
在 Windows 10 上,使用 WindowChrome
:
使用 WindowChrome
在 Windows 7 上,依然是老样子:
从 Windows 10 (1803) 开始,Win32 程序也能添加亚克力效果了,因为 SetWindowCompositionAttribute
的参数枚举新增了 ACCENT_ENABLE_ACRYLICBLURBEHIND
。
亚克力效果相信大家不陌生,那么在 Win32 应用程序里面使用的效果是什么呢?
不使用 WindowChrome
,在 Windows 10 上:
咦!等等!这不是跟之前一样吗?
嗯,下面就是不同了,亚克力效果支持与半透明的 GradientColor
叠加,所以我们需要将传入的颜色修改为半透明:
var compositor = new WindowAccentCompositor(this);
-- compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++ compositor.Composite(Color.FromArgb(0x3f, 0x18, 0xa0, 0x5e));
那么如果改为全透明会怎么样呢?
不幸的是,完全没有效果!!!
var compositor = new WindowAccentCompositor(this);
-- compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++ compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));
接下来是使用 WindowChrome
时:
<WindowChrome.WindowChrome>
<WindowChrome GlassFrameThickness="16 48 16 16" />
</WindowChrome.WindowChrome>
然而周围有一圈偏白色的渐变是什么呢?那个其实是 WindowChrome
设置的边框白,被亚克力效果模糊后得到的混合效果。
所以,如果要获得全窗口的亚克力效果,请将边框设置成比较小的值:
<WindowChrome.WindowChrome>
<WindowChrome GlassFrameThickness="0 1 0 0" />
</WindowChrome.WindowChrome>
记得不要像前面的那些效果一样,如果设置成 -1
,你将获得纯白色与设置的 Gradient
叠加色的亚克力特效,是个纯色:
你可以将叠加色的透明度设置得小一些,这样可以看出叠加的颜色:
var compositor = new WindowAccentCompositor(this);
-- compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++ compositor.Composite(Color.FromArgb(0xa0, 0x18, 0xa0, 0x5e));
那么可以设置为全透明吗?
var compositor = new WindowAccentCompositor(this);
-- compositor.Composite(Color.FromRgb(0x18, 0xa0, 0x5e));
++ compositor.Composite(Color.FromArgb(0x00, 0x18, 0xa0, 0x5e));
很不幸,最终你会完全看不到亚克力效果,而变成了毫无特效的透明窗口:
最上面那根白线,是我面前面设置边框为 0 1 0 0
导致的。
如果在这种情况下,将边框设置为 0
会怎样呢?记得前面我们说过的吗,会导致阴影消失哦!
呃……你将看到……这个……
什么都没有……
是不是找到了一条新的背景透明异形窗口的方法?
还是省点心吧,亚克力效果在 Win32 应用上的性能还是比较堪忧的……
想要背景透明,请参见:
不用考虑 Windows 7,因为大家都知道不支持。实际效果会跟前面的一模一样。
这个值其实不用说了,因为 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,
// 省略其他未使用的字段
}
}
}
WPF 中可以使用 UIElement.Focus()
将焦点设置到某个特定的控件,也可以使用 TraversalRequest
仅仅移动焦点。本文介绍如何在 WPF 程序中控制控件的焦点。
UIElement.Focus
仅仅需要在任何一个控件上调用 Focus()
方法即可将焦点设置到这个控件上。
但是需要注意,要使 Focus()
能够工作,这个元素必须满足两个条件:
Focusable
设置为 true
IsVisible
是 true
TraversalRequest
如果你并不是将焦点设置到某个特定的控件,而是希望将焦点转移,可以考虑使用 TraversalRequest
类。
比如,以下代码是将焦点转移到下一个控件,也就是按下 Tab 键时焦点会去的控件。
var traversalRequest = new TraversalRequest(FocusNavigationDirection.Next);
// view 是可视化树中的一个控件。
view.MoveFocus(traversalRequest);
键盘焦点就是你实际上按键输入和快捷键会生效的焦点,也就是当前正在工作的控件的焦点。
而 WPF 有多个焦点范围(Focus Scope),按下 Tab 键切换焦点的时候只会在当前焦点范围切焦点,不会跨范围。那么一旦跨范围切焦点的时候,焦点会去哪里呢?答案是逻辑焦点。
每个焦点范围内都有一个逻辑焦点,记录如果这个焦点范围一旦获得焦点后应该在哪个控件获得键盘焦点。
比如默认情况下 WPF 每个 Window
就是一个焦点范围,那么每个 Window
中的当前焦点就是逻辑焦点。而一旦这个 Window
激活,那么这个窗口中的逻辑焦点就会成为键盘焦点,另一个窗口当中的逻辑焦点保留,而键盘焦点则丢失。
参见我的另一篇博客:
参考资料
制作传统 Win32 程序以及 Windows Forms 程序的时候,一个用户看起来独立的窗口本就是通过各种父子窗口嵌套完成的,有大量窗口句柄,窗口之间形成父子关系。不过,对于 WPF 程序来说,一个独立的窗口实际上只有一个窗口句柄,窗口内的所有内容都是 WPF 绘制的。
如果你不熟悉 Win32 窗口中的父子窗口关系和窗口样式,那么很有可能遇到父子窗口之间“抢夺焦点”的问题,本文介绍如何解决这样的问题。
下图中的上下两个部分是两个不同的窗口,他们之间通过 SetParent
建立了父子关系。
注意看下面的窗口标题栏,当我在这些不同区域间点击的时候,窗口标题栏在黑色和灰色之间切换:
这说明当子窗口获得焦点的时候,父窗口会失去焦点并显示失去焦点的样式。
你可以在这篇博客中找到一个简单的例子:
而原因和解决方法仅有一个,就是子窗口需要有一个子窗口的样式。
具体来说,子窗口必须要有 WS_CHILD
样式。
你可以看看 Spyxx.exe 抓出来的默认普通窗口和子窗口的样式差别:
![默认普通窗口]](/static/posts/2019-09-19-10-21-31.png)
▲ 默认普通窗口
▲ 子窗口
参考资料
弱引用是 .NET 引入的概念,可以用来协助解决内存泄漏问题。然而事件也可能带来内存泄漏问题,是否有弱事件机制可以使用呢?.NET 没有自带的弱事件机制,但其中的一个子集 WPF 带了。然而我们不是什么项目都能引用 WPF 框架类库的。
本文介绍 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
,订阅了以上 Foo
的 Bar
事件,那么当 a
脱离作用范围后,将可以被垃圾回收机制回收。而如果不这么做,Foo
将始终保留对 a
实例的引用,这将阻止垃圾回收。
弱引用是 .NET 引入的概念,可以用来协助解决内存泄漏问题。然而事件也可能带来内存泄漏问题,是否有弱事件机制可以使用呢?.NET 没有自带的弱事件机制,但其中的一个子集 WPF 带了。然而我们不是什么项目都能引用 WPF 框架类库的。网上有很多弱事件的 NuGet 包,不过仅仅支持定义事件的时候写成弱事件而不支持让任意事件变成弱事件,并且存在性能问题。
本文将设计一套弱事件机制,不止可以让任意一个 CLR 事件成为弱事件,还具有近乎原生事件的性能。
系列博客:
本文主要为了设计一套弱事件机制而编写,因此如果你感兴趣,应该已经理解了我试图做什么事情。
当然,如果并不理解,可以阅读这个机制的应用篇,里面有具体的应用场景:
在我进行此设计之前,已有如下种类的弱事件机制:
WeakEventManager
WeakEventManager<TEventSource, TEventArgs>
Weak Event
的高赞回答
由于我希望编写的弱事件机制尽可能减少对非预期框架的依赖,而且具有很高的性能,所以我打算自己实现一套。
这三个原则,从上到下优先级依次降低。
要支持所有类型的 CLR 事件,意味着我的设计中必须要能够直接监听到任意事件,而不能所有代码都从我自己编写的代码开始。
要有很高的性能,就意味着我几乎不能使用“反射”,也不能使用委托的 DynamicInvoke
方法,还不能生成 IL 代码(首次生成很慢),也不能使用表达式树(首次编译很慢)。那么可以使用的也就只剩下两个了,一个是纯 C#/.NET 带的编译期就能确定执行的代码,另一个是使用 Roslyn 编译期在编译期间进行特殊处理。
类的使用者要编写极少量的代码,意味着能够抽取到框架中的代码就尽量抽取到框架中。
俗话说,一个好的名字是成功的一半。
因为我希望为任意 CLR 事件添加弱事件支持,所以其职责有点像“代理、中间人、中继、中转”,对应英文的 Proxy
Agent
Relay
Transfer
。最终我选择名称 Relay
(中继),因为好听。
对于 API 的设计,我有一个小原则:
我总结了好的 API 设计的一些原则:
不得不说,此类型设计的技术难度还是挺大的。虽然我们知道有 WeakReference<T>
可用,但依然存在很多的技术难点。于是 API 的设计可能要退而求其次优先满足前两个优先级更高的目标。
我们期望 API 足够简单,因此在几个备选方案中选择:
WeakEventRelay.Subscribe("Changed", OnChanged)
WeakEventRelay.Subscribe(o => o.Changed, OnChanged)
Action
来做,会遇到 o.Changed
必须出现在 +=
左边的编译错误o.Changed
必须出现在 +=
左边的编译错误,同时还会出现少量性能问题因此,直接一个方法就能完成事件注册是不可能的了,我们改用其他方法——继承自某个基类:
internal sealed class FileSystemWatcherWeakEventRelay : WeakEventRelay<FileSystemWatcher>
{
public event FileSystemEventHandler Changed
{
add => /*实现弱事件订阅*/;
remove => /*实现弱事件注销*/;
}
}
那么实现的难点就都在 add
和 remove
方法里面了。
我们究竟需要哪些信息才可以完成弱事件机制呢?
object sender
的形式出现在你的代码中)FileSystemWatcher.Changed
事件)add
和 remove
方法中的 value
)然而事情并没有那么简单:
一
在框架通用代码中,我不可能获取到要订阅的事件。因为事件要求只能出现在 +=
的左边,不能以任何其他形式使用(包括但不限于通过参数传递,伪装成 Lambda 表达式,伪装成表达式树)。这意味着 o.Changed += OnChanged
这样的事件订阅完全写不出来通用代码(除非牺牲性能)。
那么还能怎么做呢?只能将这段写不出来的代码留给业务编写者来编写了。
也就是说,类似于 o.Changed += OnChanged
这样的代码只能交给业务开发者来实现。与此同时也注定了 OnChanged
必须由业务开发者编写(因为无法写出通用的高性能的事件处理函数,并且还能在 +=
和 -=
的时候保持同一个实例。
二
我没有办法通过抽象的办法引发一个事件。具体来说,无法在抽象的通用代码中写出 Changed.Invoke(sender, e)
这样代码。因为委托的基类 Delegate
MultiCastDelegate
没有 Invoke
方法可以使用,只有耗性能的 DynamicInvoke
方法。各种不同的委托定义虽然可以有相同的参数和返回值类型,但是却不能相互转换,因此我也不能将传入的委托转换成 Action<TSender, TArgs>
这样的通用委托。
庆幸的是,C# 提供了将方法组隐式转换委托的方法,可以让两个参数和返回值类型相同的委托隐式转换。但注意,这是隐式转换,没有运行时代码可以高性能地完成这件事情。
在 add
和 remove
方法中,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) => /* 引发弱事件 */;
}
这已经开始让业务方的代码变得复杂起来了。
我们还需要能够注册、注销和引发弱事件,而这部分就没那么坑了。因为:
o.Changed += OnChanged
,value
,value.Invoke
都传进来了;我写了一个 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 上开源:
注意开源协议:
参考资料
弱引用是 .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
而一直存在。
一个可行的解决办法是调用 FileSystemWatcher
的 Dispose
方法。不过有些时候很难决定到底在什么时机调用 Dispose
合适。
现在,我们希望有一种方法,能够在 WalterlvDemo
的实例失去作用域后被回收,最好 FileSystemWatcher
也能够自动被 Dispose
释放掉。
如果你试图解决的是类似这样的问题,那么本文就可以帮到你。
总结一下:
FileSystemWatcher
);Dispose
);demo
变量脱离作用域。)。目前有 WPF 自带的 WeakEventManager
机制,网上也有很多可用的 NuGet 包,但是都有限制:
而 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 编译器生成,我可能接下来就会做这件事情避免你写出这些代码)。
FileSystemWatcherWeakEventRelay
,继承自库 Walterlv.WeakEvents 中的 WeakEventRelay<FileSystemWatcher>
类型。带上的泛型参数表明是针对 FileSystemWatcher
类型做弱事件中继。public FileSystemWatcherWeakEventRelay(FileSystemWatcher eventSource) : base(eventSource) { }
。这个构造函数是可以用 Visual Studio 生成的,快捷键是 Ctrl + .
或者 Alt + Enter
(快捷键功效详见:提高使用 Visual Studio 开发效率的键盘快捷键)WeakEvent<FileSystemEventArgs>
,名为 _changed
,这个就是弱事件的核心。泛型参数是事件参数的类型(注意,为了极致的性能,这里的泛型参数是事件参数的名称,而不是大多数弱事件框架中提供的事件处理委托类型)。public event FileSystemEventHandler Changed
。
add
方法固定调用 Subscribe(o => o.Changed += OnChanged, () => _changed.Add(value, value.Invoke));
。其中 Changed
是 FileSystemWatcher
中的事件,OnChanged
是我们即将定义的事件处理函数,_changed
是前面定义好的弱事件字段,而后面的 value
和 value.Invoke
是固定写法。remove
方法固定调用弱事件的 Remove
方法,即 _changed.Remove(value);
。OnChanged
,并在里面固定调用 TryInvoke(_changed, sender, e)
。OnReferenceLost
方法,用于在对象已被回收后反注册 FileSystemWatcher
中的事件。希望看了上面这 6 点之后你还能理解这些代码都是在做啥。如果依然不能理解,可以考虑:
FileSystemWatcherWeakEventRelay
的完整代码来理解哪些是可变部分哪些是不可变部分,自己替换就好;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
来监视这个文件的改变。
可以看到,在回收内存之后,将不会再监视文件的改变。当然,如果你期望一直可以监视改变,当然也不希望用到本文的弱事件。
一句话解答:为了高性能!
请参见我的另一篇博客:
参考资料
在 .NET 中创建进程时,可以传入 ProcessStartInfo
类的一个新实例。在此类型中,有一个 UseShellExecute
属性。
本文介绍 UseShellExecute
属性的作用,设为 true
和 false
时,分别有哪些进程启动行为上的差异。
Process.Start
本质上是启动一个新的子进程,不过这个属性的不同,使得启动进程的时候会调用不同的 Windows 的函数。
UseShellExecute = true
UseShellExecute = false
当然,如果你知道这两个函数的区别,那你自然也就了解此属性设置为 true
和 false
的区别了。
ShellExecute
的用途是打开程序或者文件或者其他任何能够打开的东西(如网址)。
也就是说,你可以在 Process.Start
的时候传入这些:
PATH
环境变量中的各种程序不过,此方法有一些值得注意的地方:
而 CreateProcess
则会精确查找路径来执行,不支持各种非可执行程序的打开。但是:
UseShellExecute
在 .NET Framework 中的的默认值是 true
,在 .NET Core 中的默认值是 false
。
如果有以下需求,那么建议设置此值为 false
:
如果你有以下需求,那么建议设置此值为 true
或者保持默认:
参考资料
在 .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:
你可以前往官方文档:
在其中,你可以找到这样一段话:
用户筛选的子句的表达式不受任何限制。 如果在执行用户筛选的表达式期间发生异常,则将放弃该异常,并视筛选表达式的值为 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
出来的 FileNotFoundException
的 FileName
属性会保持为 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 中甚至连 End
都没有输出!几乎可以确定,程序在 .NET Framework 4.8 中出现了致命的崩溃!
如果我们以 Visual Studio 调试启动此程序,可以看到抛出了 CLR 异常:
以下是在 Visual Studio 中单步跟踪的步骤:
由于本人金鱼般的记忆力,我竟然给微软报了三次这个 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]
请参见:
获取 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
还可能是其他一些可能延迟计算值的提供者。因此,你不能在这里获取到常规方法获取到的依赖项属性的真实类型的值。
但是,此枚举拿到的所有依赖项属性的值都是此依赖对象已经赋值过的依赖项属性的本地值。如果没有赋值过,将不会在这里的遍历中出现。
参考资料
本文介绍如何在 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);
参考资料
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 |
你可以不记住本文的其他任何快捷键,但这个你一定要记住,那就是:
当然,因为中文输入法会占用这个快捷键,所以我更喜欢将这个快捷键修改一下,改成:
修改方法可以参见:如何快速自定义 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 以前有个漂亮的中文名字,叫做“智能感知”,不过现在大多数的翻译已经与以前的另一个平淡无奇的功能结合到了一起,叫做“自动完成列表”。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 + U
或 Ctrl + 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
切换自动换行和单行模式
在 Windows 系统中,一段时间不操作键盘和鼠标,屏幕便会关闭,系统会进入睡眠状态。但有些程序(比如游戏、视频和演示文稿)在运行过程中应该阻止屏幕关闭,否则屏幕总是关闭,会导致体验会非常糟糕。
本文介绍如何编写 .NET/C# 代码临时阻止屏幕关闭以及系统进入睡眠状态。
我们需要使用到一个 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 函数、枚举和注释足够你完成你的任务了。
不过,我这里提供一些封装,以应对一些常用的场景。
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 的时候,你需要避免程序对空闲时机的控制不好,导致屏幕始终不关闭。
如果你发现无论你设置了多么短的睡眠时间和屏幕关闭时间,屏幕都不会关闭,那就是有某个程序阻止了屏幕关闭,你可以:
参考资料
最近总是收到一个异常 “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
来决定是否抛出异常,在需要抛出异常的情况下会真实求逆,也就是上面截图中我们看到的异常发生处的代码。
那么接下来我们需要验证三点:
Visual
是哪里来的;Visual
的变换矩阵什么情况下不可求逆;throwOnError
确定传入的是 true
吗。于是我们继续往上层调用代码中查看。
可以很快验证上面需要验证的两个点:
throwOnError
传入的是 true
;Visual
是 PresentationSource
的 RootVisual
。而 PresentationSource
的 RootVisual
是什么呢?PresentationSource
是承载 WPF 可视化树的一个对象,对于窗口 Window
,是通过 HwndSource
(PresentationSource
的子类)承载的;对于跨线程 WPF UI,可以通过自定义的 PresentationSource
子类来完成。这部分可以参考我之前的一些博客:
不管怎么说,这个指的就是 WPF 可视化树的根:
Window
来显示 WPF 窗口,那么根就是 Window
类;Popup
来承载一个弹出框,那么根就是 PopupRoot
类;对于绝大多数 WPF 开发者来说,只会碰到前面第一种情况,也就是仅仅有 Window
作为可视化树的根的情况。一般人很难直接给 PopupRoot
设置变换矩阵,一般 WPF 程序的代码也很少做跨线程或跨进程 UI。
于是我们几乎可以肯定,是有某处的代码让 Window
的变换矩阵不可逆了。
什么样的矩阵是不可逆的?
发生异常的代码是 WPF 中 Matrix.Invert
方法,其发生异常的代码如下:
首先判断矩阵的行列式 Determinant
是否为 0
,如果为 0
则抛出矩阵不可逆的异常。
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 呢?
其中,原矩阵在我们的场景下就是恒等的矩阵,即 Matrix.Identity
:
接下来缩放和旋转我们都不考虑变换中心的问题,因为变换中心的问题都可以等价为先进行缩放和旋转后,再单纯进行平移。由于平移对行列式的值没有影响,于是我们忽略。
缩放矩阵。如果水平和垂直分量分别缩放 \(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} \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 变换矩阵:
现在,我们寻找问题的方向已经非常明确了:
ScaleTransform
的 Window
,检查其是否给 ScaleX
或者 ScaleY
属性赋值为了 0
。然而,真正写一个 demo 程序来验证这个问题的时候,就发现没有这么简单。因为:
我们发现,不止是 ScaleX
和 ScaleY
属性不能设为 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>
不过,这段代码并不会导致每次都出现异常,而是在非常多次尝试中偶尔能出现一次异常。
Window
类是不可以设置 RenderTransform
属性的,但允许设置恒等(Matrix.Identity
)的变换;Window
类缩放分量设置为 0
,就会出现矩阵不可逆异常。不要给 Window
类设置变换,如果要做,请给 Window
内部的子元素设置。比如上面的例子中,我们给 Grid
设置就没有问题(而且可以做到类似的效果。
WPF 的 Window
类是不允许设置变换矩阵的。不过,总会有小伙伴为了能够设置一下试图绕过一些验证机制。
不要试图绕过,因为你会遇到更多问题。
当你试图给 Window
类设置变换矩阵的时候,会出现异常:
System.InvalidOperationException:“转换对于 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"
,导致看起来好像这个变换生效了一样。
此设置不仅没有效果,还会引发异常,请阅读我的另一篇博客了解:
使用 Visual Studio 开发 C#/.NET 应用程序,以前有 ReSharper 来不足其各项功能短板,后来不断将 ReSharper 的功能一点点搬过来稍微好了一些。不过直到 Visual Studio 2019,才开始渐渐可以和 ReSharper 拼一下了。
如果你使用 Visual Studio 2019,那么像本文这样配置一下,可以大大提升你的开发效率。
打开菜单 “工具” -> “选项”,然后你就打开了 Visual Studio 的选项窗口。接下来本文的所有内容都会在这里进行。
在 “文本编辑器” -> “常规” 分类中,我们关心这些设置:
使鼠标单击可执行转到定义
这样按住 Ctrl 键点击标识符的时候可以转到定义(开启此选项之后,后面有其他选项可以转到反编译后的源码)当然也有其他可以打开玩的:
查看空白
专治强迫症,可以把空白字符都显示出来,这样你可以轻易看到对齐问题以及多于的空格了在 “文本编辑器” -> “C#” -> “IntelliSense” 分类中,我们关心这些设置:
键入字符后显示完成列表
删除字符后显示完成列表
突出显示完成列表项的匹配部分
显示完成项筛选器
打开这些选项可以让智能感知列表更容易显示出来,而我们也知道智能感知列表的强大显示 unimported 命名空间中的项(实验)
这一项默认不会勾选,但强烈建议勾选上;它可以帮助我们直接输入没有 using 的命名空间中的类型,这可以避免记住大量记不住的类名在 “文本编辑器” -> “C#” -> “高级” 分类中,我们关心大量设置:
支持导航到反编译源(实验)
前面我们说可以 Ctrl + 鼠标导航到定义,如果打开了这个就可以看反编译后的源码了启用可为 null 的引用分析 IDE 功能
这个功能可能还没有完成,暂时还是无法开启的当然也有其他可以打开玩的:
启用完成解决方案分析
这是基于 Roslyn 的分析,Visual Studio 的大量重构功能都依赖于它;默认关闭也可以用,只是仅分析当前正在编辑的文件;如果打开则分析整个解决方案,你会在错误列表中看到大量的编译警告在 “文本编辑器” -> “C#” -> “代码样式” 分类,如果你关心代码的书写风格,那么这个分类底下的每一个子类别都可以考虑一个个检查一下。
Visual Studio 2019 默认安装了 IntelliCode 可以充分利用微软使用 GitHub 上开源项目训练出来的模型来帮助编写代码。这些强烈建议开启。
C# 基础模型
微软利用 GitHub 开源项目训练的基础模型XAML 基础模型
微软利用 GitHub 开源项目训练的基础模型C# 参数完成
C# 自定义模型
如果针对单个项目训练出来了模型,那么可以使用专门针对此项目训练的模型EditorConfig 推理
可以根据项目推断生成 EditorConfig 文件 可以参见在 Visual Studio 中使用 EditorConfig 统一代码风格自定义模型训练提示
如果开启,那么每个项目的规模如果达到一定程度就会提示训练一个自定义模型出来训练模型会上传一部分数据到 IntelliCode 服务器,你可以去 %TEMP%\Visual Studio IntelliCode
目录来查看到底上传了哪些数据。
当然,设置好快捷键也是高效编码的重要一步,可以参考:
在你点击 “确定” 关闭了以上窗口之后,我们还需要设置一项。
确保下图中的这个按钮处于 “非选中” 状态:
这样,当出现智能感知列表的时候,我们直接就可以按下回车键输入这个选项了;否则你还需要按上下选中再回车。
在 WPF 程序中,可能会存在 Application.Current.Dispatcher.Xxx
这样的代码让一部分逻辑回到主 UI 线程。因为发现在调用这句代码的时候出现了 NullReferenceException
,于是就有三位小伙伴告诉我说 Current
和 Dispatcher
属性都可能为 null
。
然而实际上这里只可能 Current
为 null
而此上下文的 Dispatcher
是绝对不会为 null
的。(当然我们这里讨论的是常规编程手段,如果非常规手段,你甚至可以让实例的 this
为 null
呢……)
当你的应用程序退出时,所有 UI 线程的代码都不再会执行,因此这是安全的;但所有非 UI 线程的代码依然在继续执行,此时随时可能遇到 Application.Current
属性为 null。
由于本文所述的两个部分都略长,所以拆分成两篇博客,这样更容易理解。
Application.Current
静态属性Application
类型的源代码会非常长,所以这里就不贴了,可以前往这里查看:
其中,Current
返回的是 _appInstance
的静态字段。因此 _appInstance
字段为 null
的时机就是 Application.Current
为 null
的时机。
/// <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
的赋值时机有两处:
Application
的实例构造函数(注意哦,是实例构造函数而不是静态构造函数);Application.DoShutdown
方法。在 Application
的实例构造函数中:
_appInstance
的赋值是线程安全的,这意味着多个 Application
实例的构造不会因为线程安全问题导致 _appInstance
字段的状态不正确。_appCreatedInThisAppDomain
为 true
那么,将抛出异常,组织此应用程序域中创建第二个 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.Current
为 null
风险。
那么,到底有哪些时机可能遇到 Application.Current
为 null
呢?这部分就与读者项目中所用的业务代码强相关了。
但是这部分业务代码会有一些公共特征帮助你判定你是否可能写出遭遇 Application.Current
为 null
的代码。
此特征是:此代码与 Application.Current
不在同一线程。
Application.Current
不在同一线程对于 WPF 程序,你的多数代码可能是由用户交互产生,即便有后续代码的执行,也依然是从 UI 交互产生。这样的代码不会遇到 Application.Current
为 null
的情况。
但是,如果你的代码由非 UI 线程触发,例如在 Usb
设备改变、与其他端的通信、某些异步代码的回调等等,这些代码不受 Dispatcher
是否调度影响,几乎一定会执行。因此 Application.Current
就算赋值为 null
了,它们也不知道,依然会继续执行,于是就会遭遇 Application.Current
为 null
。
这本质上是一个线程安全问题。
Invoke/BeginInvoke/InvokeAsync
的代码不会出问题Application.DoShutdown
方法被 ShutdownImpl
包装,且所有调用均从此包装进入,因此,所有可能导致 Application.Current
为 null
的代码,均会调用此方法,也就是说,会调用 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
。
CriticalInvokeShutdown
,即以 Send
优先级 Invoke
关闭方法,而 Send
优先级调用 Invoke
几乎等同于直接调用(为什么是等同而不是直接调用?因为还需要考虑回到 Dispatcher
初始化时所在的线程)。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.Current
为 null
。async
/ await
并使用默认上下文执行的代码,均不会遭遇 Application.Current
为 null
。(这意味着你没有使用 .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);
}
}
}
总结以上所有的分析:
Application
不在同一个线程的代码,都可能遭遇 Application.Current
为 null
。Application
在同一个线程的代码,都不可能遇到 Application.Current
为 null
。这其实是一个线程安全问题。用所有业务开发者都可以理解的说法描述就是:
当你的应用程序退出时,所有 UI 线程的代码都不再会执行,因此这是安全的;但所有非 UI 线程的代码依然在继续执行,此时随时可能遇到 Application.Current
属性为 null。
因此,记得所有非 UI 线程的代码,如果需要转移到 UI 线程执行,记得判空:
private void OnUsbDeviceChanged(object sender, EventArgs e)
{
// 记得这里需要判空,因为此上下文可能在非 UI 线程。
Application.Current?.InvokeAsync(() => { });
}
Application.Dispatcher
实例属性关于 Application.Dispatcher
是否可能为 null
的分析,由于比较长,请参见我的另一篇博客:
参考资料
在 WPF 程序中,可能会存在 Application.Current.Dispatcher.Xxx
这样的代码让一部分逻辑回到主 UI 线程。因为发现在调用这句代码的时候出现了 NullReferenceException
,于是就有三位小伙伴告诉我说 Current
和 Dispatcher
属性都可能为 null
。
然而实际上这里只可能 Current
为 null
而此上下文的 Dispatcher
是绝对不会为 null
的。(当然我们这里讨论的是常规编程手段,如果非常规手段,你甚至可以让实例的 this
为 null
呢……)
由于本文所述的两个部分都略长,所以拆分成两篇博客,这样更容易理解。
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
自构造起便拥有一个不为 null
的 Dispatcher
属性,其所有子类在初始化之前便会得到不为 null
的 Dispatcher
属性。
现在我们来看看在初始化完成之后,后面是否有可能将 _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
很好理解,让 DispatcherObject
跟 Dispatcher
无关。在整个 WPF 的代码中,使用此方法的仅有以下 6 处:
Freezable.Freeze
实例方法BeginStoryboard.Seal
实例方法Style.Seal
实例方法TriggerBase.Seal
实例方法StyleHelper
在 SealTemplate
静态方法中对 FrameworkTemplate
类型的实例调用此方法ResourceDictionary
在构造函数中为 DispatcherObject
类型的 DummyInheritanceContext
属性调用此方法而 Application
类型不是以上任何一个类型的子类(Application
类的直接基类是 DispatcherObject
),因此 Application
类中的 Dispatcher
属性不可能因为 DetachFromDispatcher
方法的调用而被赋值为 null
。
接下来看看 MakeSentinel
方法,此方法的作用不如上面方法那样直观,实际上它的作用仅仅为了验证某个方法调用时所在的线程是否是符合预期的(给 VerifyAccess
和 CheckAccess
使用)。
使用此方法的仅有 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
无关。
总结以上所有的分析:
Application
类型的实例在初始化之前,Dispatcher
属性就已经被赋值且不为 null
;_dispatcher
属性的常规方法均与 Application
类型无关;因此,所有常规手段均不会让 Application
类的 Dispatcher
属性拿到 null
值。如果你还说拿到了 null
,那就检查是否有逗比程序员通过反射或其他手段将 _dispatcher
字段改为了 null
吧……
Application.Current
静态属性关于 Application.Current
是否可能为 null
的分析,由于比较长,请参见我的另一篇博客:
参考资料
在微软的官方文档中,说 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
SetParent
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.)
正如有些人说的“如果你不知道后果,那么你也不足以正确地完成某件事情”。在开始破坏规则之前,您必须先成为规则的主人。
你必须清楚跨进程设置父子窗口带来的各种副作用,然后针对性地给出解决方案:
参考资料
[Is it legal to have a cross-process parent/child or owner/owned window relationship? | The Old New Thing](https://devblogs.microsoft.com/oldnewthing/?p=4683) |
当试图在 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.
此异常的原因非常简单,是 HwndSource
的 BuildWindowCore
的返回值有问题。具体来说,就是子窗口的句柄返回了 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
才是正确的。
当试图在 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();
}
}
}
当运行此代码的时候,会提示错误:
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,
++ };
最关键的是两点:
WindowStyle
为 WS_CHILD
;ParentWindow
为 hwndParent.Handle
(我们使用参数中传入的 hwndParent
作为父窗口)。现在再运行,即可正常显示此嵌套窗口:
另外,WindowStyle
属性最好加上 WS_CLIPCHILDREN
,详情请阅读:
参考资料
当试图在 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
设置一次子窗口。
我们做的公共库可能通过 nuget.org 发布,也可能是自己搭建 NuGet 服务器。但是,如果某个包正在开发中,需要快速验证其是否解决掉一些诡异的 bug 的话,除了单元测试这种间接的测试方法,还可以在本地安装未发布的 NuGet 包的方法来快速调试。
本文介绍如何本地打包发布 NuGet 包,然后通过 mklink 收集所有的本地包达到快速调试的目的。
我有另一篇博客介绍如何将本地文件夹设置称为 NuGet 包源:
在 Visual Studio 中打开 工具
-> 选项
-> NuGet 包管理器
-> 包源
可以直接将一个本地文件夹设置称为 NuGet 包源。
其他设置方法可以去那篇博客当中阅读。
如下图,是我通过 mklink 将散落在各处的 NuGet 包的调试输出目录收集了起来:
比如,点开其中的 Walterlv.Packages
可以看到 Walterlv.Packages
仓库中输出的 NuGet 包:
由于我的每一个文件夹都是指向的 Visual Studio 编译后的输出目录,所以,只需要使用 Visual Studio 重新编译一下项目,文件夹中的 NuGet 包即会更新。
于是,这相当于我在一个文件夹中,包含了我整个计算机上所有库项目的 NuGet 包,只需要将这个文件夹设置称为 NuGet 包源,即可直接调试本地任何一个公共组件库打出来的 NuGet 包。
如下图,是我将那个收集所有 NuGet 文件夹的目录设置成为了 NuGet 源:
于是,我可以在 Visual Studio 的包管理器中看到所有还没有发布的,依然处于调试状态的各种库:
基于此,我们可以在包还没有编写完的时候调试,验证速度非常快。
你可以使用 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”
需要为你的项目安装以下两个 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>
你需要引用 Microsoft.CSharp
:
于是你的项目里面会多出一项引用:
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
++ <Reference Include="Microsoft.CSharp" />
</ItemGroup>
</Project>
{0}
是类型名称,而 {1}
是使用 dynamic
访问的属性或者方法的名称。
比如,我试图从某个 Attribute
中访问到 Key
属性的时候会抛出以下异常:
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:““System.Attribute”未包含“Key”的定义”
出现此异常的原因是:
dynamic
所引用的对象里面,没有签名相同的 public
的属性或者方法于是,如果你确认你的类型里面是有这个属性或者方法的话,那么就需要注意需要将此成员改成 public
才可以访问。
参考资料
我们有弱引用 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[]
、Count
、IsReadOnly
、Contains
、CopyTo
、IndexOf
、GetEnumerator
这些都是在获取状态,Add
、Clear
、Remove
是在修改状态,而 Insert
、RemoveAt
会在修改状态的同时读取状态。
这么多的获取和修改状态的方法,如果提供出去,还指望使用者能够正常使用,简直是做梦!违背以上两个原则。
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();
}
不过,Add
和 Remove
方法可能我们会考虑留下来,但这就不能是继承自 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 + 鼠标点击的方式打开类型的源代码,而不需要进行反编译。
.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,或者说你需要编写的类型带有很强的不确定性(类型状态的变化可能发生在任何一行代码上),那么你需要遵循一些设计原则才能确保安全。
比如要为缓存设计一个获取可用实例的方法,可以使用:
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;
}
}
}
}
这段代码的完全解读:
Run
方法的时候,先判断当前是否已经在跑其他的任务:
isRunning
为 0
表示当前一定没有在跑其他任务,我们使用原则操作立刻将其修改为 1
;isRunning
为 1
表示当前不确定是否在跑其他任务;isRunning
为 1
的时候状态不确定,于是我们加锁来判断其是否真的有任务在跑:
lock
环境中确认 _isRunning
字段而非变量为 1
则说明真的有任务在跑,此时等待任务完成即可,这里就可以退出了;lock
环境中发现 _isRunning
字段而非变量为 0
则说明实际上是没有任务在跑的(刚刚判断为 1
只是因为这两次判断之间,并发的任务刚刚在结束的过程中),于是需要跟一开始判断为 0
一样,进入到后面的循环中;while
循环第一次是一定能进去的,于是我们暂且不谈;while
内循环中,我们依次检查并发队列 _queue
中是否还有任务要执行,如果有要执行的,就执行:
queue
中的所有任务执行完毕,我们将进入一个 lock
区间:
lock
区间里面我们再次确认任务是否已经完成,如果没有完成,我们靠最外层的 while
循环重新回到内层 while
循环中继续任务;lock
区间里面我们发现任务已经完成了,就设置 _isRunning
为 0
,表示任务真的已经完成,随后退出 while
循环;你可以注意到我们的 lock
是用来确认一开始 isRunning
为 1
时的那个不确定的状态的。因为我们需要多次访问这个状态,所以必须加锁来确认状态是同步的。
在了解了上面的用法指导后,API 设计指导也呼之欲出了:
对于多线程并发导致的不确定性,使用方虽然可以通过 lock
来规避以上第二条问题,但设计方最好在设计之初就避免问题,以便让 API 更好使用。
关于通用 API 设计指导,你可以阅读我的另一篇双语博客:
WPF 框架自己实现了一套触摸机制,但同一窗口只能支持一套触摸机制,于是这会禁用系统的触摸消息(WM_TOUCH
)。这能够很大程度提升 WPF 程序的触摸响应速度,但是很多时候又会产生一些 Bug。
如果你有需要,可以考虑禁用 WPF 的内置的实时触摸(RealTimeStylus)。本文介绍禁用方法,使用 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 的版本更新中出现问题:
参考资料
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.
做库的时候,需要一定程度上保持 API 的兼容性
首先打开你的库项目,或者如果你希望从零开始也可以直接新建一个项目。这里为了博客阅读的简单,我创建一个全新的项目来演示。
然后,为主要的库项目安装 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>
在你的项目内创建两个文件:
这就是两个普通的文本文件。创建纯文本文件的方法是在项目上右键 -> 添加
-> 新建项...
,然后在打开的模板中选择 文本文件
,使用上面指定的名称即可(要创建两个)。
然后,编辑项目文件,我们需要将这两个文件加入到项目中来。
如果你看不到上图中的“编辑项目文件”选项,则需要升级项目文件到 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>
如果你把这两个文件放到了其他的路径,那么上面也需要改成对应的路径。
这时,这两个文件内容还是空的。
这个时候,你会看到库中的 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
现在,我们将 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>
前面我们都是在 PublicAPI.Unshipped.txt
文件中追踪 API。但是如果我们的库需要发布一个版本的时候,我们就需要跟上一个版本比较 API 的差异。
上一个发布版本的 API 就记录在 PublicAPI.Shipped.txt
文件中,这两个文件的差异即是这两个版本的 API 差异。在一个新的版本发布后,就需要将 API 归档到 PublicAPI.Shipped.txt
文件中。
参考资料
如果某天改了一点代码但是没有完成,我们可能会在注释里面加上 // 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);
}
}
}
}
}
}
只是报错的话,开发者看到错误可能会一脸懵逼,因为从未见过注释还会报告编译错误的,不知道怎么改。
于是我们需要编写一个代码修改器以便自动完成注释的修改,添加负责人和截止日期。我这里代码修改器修改后的结果就像下面这样:
生成一个新的注释字符串然后替换即可:
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:yyyy年M月d日} {oldComment}";
var newTrivia = SyntaxFactory.ParseTrailingTrivia(comment);
var newRoot = root.ReplaceTrivia(oldTrivia, newTrivia);
return document.WithSyntaxRoot(newRoot);
}
}
}
有小伙伴看到我有时写了 #if
有时写了 [Conditional]
问我两个不是一样的吗,何必多此一举。然而实际上两者的编译处理是不同的,因此也有不同的应用场景。
于是我写到这篇文章当中。
我们有时会使用 #if DEBUG
或者 [Conditional("DEBUG")]
来让我们的代码仅在特定的条件下编译。
而这里的 DEBUG
是什么呢?
本文要讨论的是 #if
和 Conditional
的使用,这是在 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 使用——让目标项目(或者程序集)仅在目标项目特定的配置下才会编译。
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.
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
.
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;
}
}
}
}
We know that the VisualBrush
shows and stretch the whole Visual
so we can predicate only two results:
Rectangle
is visible with Visibility
property Visible
, the Border
background which contains the VisualBrush
will be exactly the same with the Grid
.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:
The animation picture below shows the result when the Rectangle
is invisible as the startup:
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.
I’ve fired this issue into GitHub and this is the link:
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
可见(Visibility
为 Visible
),那么 Border
中以 VisualBrush
显示的内容将完全和下面重叠(因为大小相同,拉伸后正好重叠)。Rectangle
不可见(Visibility
为 Collapsed
),那么 Border
中以 VisualBrush
显示的内容将仅有文字且拉伸到整个 Border
范围。然而实际运行真的是这样子吗?
下面的动图是 Rectangle
初始状态可见时,窗口运行后的结果:
下面的动图是 Rectangle
初始状态不可见时,窗口运行后的结果:
注意到了吗?
只有初始状态才能正确反应我们之前预估出的结果,而无论后面怎么再改变可见性,布局都不会再刷新了。只是——后面 VisualBrush
的内容始终重叠。这意味着 VisualBrush
中目标 Visual
的范围增大之后不会再缩小了。
这是问题吗?
于是在以下 issue 中跟进此问题:
参见:
什么时候该抛出异常,抛出什么异常?什么时候该捕获异常,捕获之后怎么处理异常?你可能已经使用异常一段时间了,但对 .NET/C# 的异常机制依然有一些疑惑。那么,可以阅读本文。
本文适用于已经入门 .NET/C# 开发,已经开始在实践中抛出和捕获异常,但是对 .NET 异常机制的用法以及原则比较模糊的小伙伴。通过阅读本文,小伙伴们可以迅速在项目中使用比较推荐的异常处理原则来处理异常。
我们大多数小伙伴可能更多的使用 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
属性的值是一个句子,用于描述异常发生的原因。堆栈跟踪从引发异常的语句开始,到捕获异常的 catch
语句结束。
利用这一点,你可以迅速找到引发异常的那个方法,也能找到是哪个方法中的 catch
捕捉到的这个异常。
我们第一个要了解的异常处理原则是——明确 try
catch
finally
的用途!
try
块中,编写可能会发生异常的代码。
最好的情况是,你只将可能会发生异常的代码放到 try
块中,当然实际应用的时候可能会需要额外放入一些相关代码。但是如果你将多个可能发生异常的代码放到一个 try
块中,那么将来定位问题的时候你就会很抓狂(尤其是多个异常还是一个类别的时候)。
catch
块的作用是用来 “恢复错误” 的,是用来 “恢复错误” 的,是用来 “恢复错误” 的。
如果你在 try
块中先更改了类的状态,随后出了异常,那么最好能将状态改回来——这可以避免这个类型或者应用程序的其他状态出现不一致——这很容易造成应用程序“雪崩”。举一个例子:我们写一个程序有简洁模式和专业模式,在从简洁模式切换到专业模式的时候,我们设置 IsProfessionalMode
为 true
,但随后出现了异常导致没有成功切换为专业模式;然而接下来所有的代码在执行时都判断 IsProfessionalMode
为 true
状态不正确,于是执行了一些非预期的操作,甚至可能用到了很多专业模式中才会初始化的类型实例(然而没有完成初始化),产生大量的额外异常;我们说程序雪崩了,多数功能再也无法正常使用了。
当然如果任务已全部完成,仅仅在对外通知的时候出现了异常,那么这个时候不需要恢复状态,因为实际上已经完成了任务。
你可能会有些担心如果我没有任何手段可以恢复错误怎么办?那这个时候就不要处理异常!——如果不知道如何恢复错误,请不要处理异常!让异常交给更上一层的模块处理,或者交给整个应用程序全局异常处理模块进行统一处理(这个后面会讲到)。
另外,异常不能用于在正常执行过程中更改程序的流程。异常只能用于报告和处理错误条件。
finally
块的作用是清理资源。
虽然 .NET 的垃圾回收机制可以在回收类型实例的时候帮助我们回收托管资源(例如 FileStream
类打开的文件),但那个时机不可控。因此我们需要在 finally
块中确保资源可被回收,这样当重新使用这个文件的时候能够立刻使用而不会被占用。
一段异常处理代码中可能没有 catch
块而有 finally
块,这个时候的重点是清理资源,通常也不知道如何正确处理这个错误。
一段异常处理代码中也可能 try
块留空,而只在 finally
里面写代码,这是为了“线程终止”安全考虑。在 .NET Core 中由于不支持线程终止因此可以不用这么写。详情可以参考:.NET/C# 异常处理:写一个空的 try 块代码,而把重要代码写到 finally 中(Constrained Execution Regions) - walterlv。
什么情况下该引发异常?答案是——这真的是一个异常情况!
于是,我们可能需要知道什么是“异常情况”。
一个可以参考的判断方法是——判断这件事发生的频率:
例如这些情况都应该认为是异常:
null
时但传入了 null
而下面这些情况则不应该认为是异常:
对于这些不应该认为是异常的情况,编写的代码就应该尽可能避免异常。
有两种方法来避免异常:
TryDo
模式来完成,例如字符串转数字中的 TryParse
方法,字典中的 TryGetValue
方法。null
(或默认值),而不是引发异常。极其常见的错误案例可被视为常规控制流。通过在这些情况下返回 NULL(或默认值),可最大程度地减小对应用的性能产生的影响。(后面会专门说 null)而当存在下列一种或多种情况时,应引发异常:
请勿有意从自己的源代码中引发 System.Exception、System.SystemException、System.NullReferenceException 或 System.IndexOutOfRangeException。
在前面 try-catch-finally 小节中,我们提到了 catch
块中应该写哪些代码,那里其实已经说明了哪些情况下应该处理异常,哪些情况下不应该处理异常。一句总结性的话是——如果知道如何从错误中恢复,那么就捕获并处理异常,否则交给更上层的业务去捕获异常;如果所有层都不知道如何处理异常,就交给全局异常处理模块进行处理。
对于 .NET 程序,无论是 .NET Framework 还是 .NET Core,都有下面这三个可以全局处理的异常。这三个都是事件,可以自行监听。
AppDomain.UnhandledException
AppDomain.FirstChanceException
throw
块到 catch
块之间的所有帧,而在第一次机会异常事件中,只是刚刚 throw
出来,还没有被任何 catch
块捕捉,因此在这个事件中堆栈信息永远只会包含一帧(不过可以稍微变通一下在第一次机会异常 FirstChanceException 中获取比较完整的异常堆栈)catch
也会引发,因为它引发在 catch
之前catch
就万事大吉可以无视这个事件了。前面我们说过异常仅在真的是异常的情况才应该引发,因此如果这个事件中引发了异常,通常也真的意味着发生了错误(差别只是我们能否从错误中恢复而已)。如果你经常在正常的操作中发现可以通过此事件监听到第一次机会异常,那么一定是应用程序或框架中的异常设计出了问题(可能把正常应该处理的流程当作了异常,可能内部实现代码错误,可能出现了使用错误),这种情况一定是要改代码修 Bug 的。而一些被认为是异常的情况下收到此事件则是正常的。TaskScheduler.UnobservedTaskException
async
/ await
关键字编写异步代码的时候,如果一直有 await
传递,那么异常始终可以被处理到;但中间有异步任务没有 await
导致异常没有被传递的时候,就会引发此事件。async
/ await
的使用(要么应该修改实现避免异常,要么应该正确处理异常并从中恢复错误)对于 GUI 应用程序,还可以监听 UI 线程上专属的全局异常:
Application.DispatcherUnhandledException
或者 Dispatcher.UnhandledException
Application.ThreadException
关于这些全局异常的处理方式和示例代码,可以参阅博客:
任何情况下都不应该抛出这些异常:
Exception
这可是顶级基类,这都抛出来了,使用者再也无法正确地处理此异常了SystemException
这是各种异常的基类,本身并没有明确的意义ApplicationException
这是各种异常的基类,本身并没有明确的意义NullReferenceException
试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了IndexOutOfRangeException
使用索引的时候超出了边界InvalidCastException
表示试图对某个类型进行强转但类型不匹配StackOverflow
表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归OutOfMemoryException
表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了AccessViolationException
这说明使用非托管内存时发生了错误BadImageFormatException
这说明了加载的 dll 并不是期望中的托管 dllTypeLoadException
表示类型初始化的时候发生了错误FormatException
因为当它抛出来时无法准确描述到底什么错了首先是你自己不应该抛出这样的异常。其次,你如果在运行中捕获到了上面这些异常,那么代码一定是写得有问题。
如果是捕获到了上面 CLR 的异常,那么有两种可能:
而一旦捕获到了上面其他种类的异常,那就找到抛这个异常的人,然后对它一帧狂扁即可。
其他的异常则是可以抛出的,只要你可以准确地表明错误原因。
另外,尽量不要考虑抛出聚合异常 AggregateException
,而是优先使用 ExceptionDispatchInfo
抛出其内部异常。详见:使用 ExceptionDispatchInfo 捕捉并重新抛出异常 - walterlv。
在 该不该引发异常 小节中我们说到一个异常会被引发,是因为某个方法声称的任务没有成功完成(失败),而失败的原因有四种:
简单说来,就是:使用错误,实现错误、环境错误。
使用错误:
ArgumentException
表示参数使用错了ArgumentNullException
表示参数不应该传入 nullArgumentOutOfRangeException
表示参数中的序号超出了范围InvalidEnumArgumentException
表示参数中的枚举值不正确InvalidOperationException
表示当前状态下不允许进行此操作(也就是说存在着允许进行此操作的另一种状态)ObjectDisposedException
表示对象已经 Dispose 过了,不能再使用了NotSupportedException
表示不支持进行此操作(这是在说不要再试图对这种类型的对象调用此方法了,不支持)PlatformNotSupportedException
表示在此平台下不支持(如果程序跨平台的话)NotImplementedException
表示此功能尚在开发中,暂时请勿使用实现错误:
前面由 CLR 抛出的异常代码主要都是实现错误
NullReferenceException
试图在空引用上执行某些方法,除了告诉实现者出现了意料之外的 null 之外,没有什么其它价值了IndexOutOfRangeException
使用索引的时候超出了边界InvalidCastException
表示试图对某个类型进行强转但类型不匹配StackOverflowException
表示栈溢出,这通常说明实现代码的时候写了不正确的显式或隐式的递归OutOfMemoryException
表示托管堆中已无法分出期望的内存空间,或程序已经没有更多内存可用了AccessViolationException
这说明使用非托管内存时发生了错误BadImageFormatException
这说明了加载的 dll 并不是期望中的托管 dllTypeLoadException
表示类型初始化的时候发生了错误环境错误:
IOException
下的各种子类Win32Exception
下的各种子类另外,还剩下一些不应该抛出的异常,例如过于抽象的异常和已经过时的异常,这在前面一小结中有说明。
在平时的开发当中,你可能会遇到这样一些异常,它不像是自己代码中抛出的那些常见的异常,但也不包含我们自己的异常堆栈。
这里介绍一些常见这些异常的原因和解决办法。
当出现此异常时,说明非托管内存中发生了错误。如果要解决问题,需要从非托管代码中着手调查。
这个异常是访问了不允许的内存时引发的。在原因上会类似于托管中的 NullReferenceException
。
参考资料
本文介绍不那么常见的 WPF 相关的知识。
大多数时候我们只需要在 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)中的光照效果,使用附加属性来管理此行为则完全不用担心内存泄漏问题:
如果你有一些非 WPF 的对象需要做类似 WPF 那种附加属性,那么可以考虑使用 ConditionalWeakTable
来实现,Key 是那个对象,而 Value 是你需要附加的属性或者行为。
这里的引用关系是 Key 引用着 Value,如果 Key 被回收,那么 Value 也可以被回收。
WPF 默认情况下的触摸是通过 COM 组件 PimcManager
获取到的,在禁用实时触摸后会启用系统的 TOUCH
消息获取到,如果开启了 Pointer 消息那么会使用 POINTER
消息。
我们可以继承自 TouchDevice
来模拟触摸,详见:
在现有的 Windowing API 下,系统中看起来非常接近系统级的窗口样式可能都是用不同技术模拟实现的,只是模拟得很像而已。
如果要将 WPF 模拟得很像 UWP,可以参考我的这两篇博客:
目前 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) |
本文只谈论 ReSharper 的那些常用功能中,Visual Studio 2019 能还原多少,主要提供给那些正在考虑不使用 ReSharper 插件的 Visual Studio 用户作为参考。毕竟 ReSharper 如此强大的功能是建立在每年缴纳不少的费用以及噩梦般占用 Visual Studio 性能的基础之上的。然而使用 Visual Studio 2019 社区版不搭配 ReSharper 则可以免费为开源社区做贡献。
本文的内容分为三个部分:
默认情况下,Visual Studio 只在你刚开始打字或者输入 .
和 (
的时候才出现智能感知提示,但是如果你使用 ReSharper 开发,你会发现智能感知提示无处不在(所以那么卡?)。
实际上你也可以配置 Visual Studio 的智能感知在更多的情况下出现,请打开下面“工具”->“选项”->“文本编辑器”->“C#”->“IntelliSense”:
打开“键入字符后显示完成列表”和“删除字符后显示完成列表”。这样,你只要正在编辑,都会显示智能感知提示。
另外,如果你当前需要打开智能感知提示,默认情况下使用 Ctrl + 空格键
可以打开。当然你也可以将其修改为 ReSharper 中常见的快捷键 Alt + 右箭头
。方法是修改键盘快捷键中的 “” 项。
修改快捷键方法详见:
另外,在 IntelliCode
部分,可以选择打开更多的 IntelliSense
完成项:
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 自带的代码片段就弱很多,只能支持最基本的宏。
不过可以通过下面一些插件通过数量来补足功能上的一些短板:
Roslyn 是 .NET 平台下十分强大的编译器,其提供的 API 也非常丰富好用。本文将基于 Roslyn 开发一个 C# 代码分析器,你不止可以将分析器作为 Visual Studio 代码分析和重构插件发布,还可以作为 NuGet 包发布。不管哪一种,都可以让我们编写的 C# 代码分析器工作起来并真正起到代码建议和重构的作用。
本文将教大家如何从零开始开发一个基于 Roslyn 的 C# 源代码分析器 Analyzer 和修改器 CodeFixProvider。可以作为 Visual Studio 插件安装和使用,也可以作为 NuGet 包安装到项目中使用(无需安装插件)。无论哪一种,你都可以在支持 Roslyn 分析器扩展的 IDE(如 Visual Studio)中获得如下面动图所展示的效果。
你需要先安装 Visual Studio 的扩展开发工作负载,如果你还没有安装,那么请先阅读以下博客安装:
启动 Visual Studio,新建项目,然后在项目模板中找到 “Analyzer with Code Fix (.NET Standard)”,下一步。
随后,取好项目名字之后,点击“创建”,你将来到 Visual Studio 的主界面。
我为项目取的名称是 Walterlv.Demo.Analyzers
,接下来都将以此名称作为示例。你如果使用了别的名称,建议你自己找到名称的对应关系。
在创建完项目之后,你可选可以更新一下项目的 .NET Standard 版本(默认是 1.3,建议更新为 2.0)以及几个 NuGet 包。
如果你现在按下 F5,那么将会启动一个 Visual Studio 的实验实例用于调试。
由于我们是一个分析器项目,所以我们需要在第一次启动实验实例的时候新建一个专门用来测试的小型项目。
简单起见,我新建一个 .NET Core 控制台项目。新建的项目如下:
我们目前只是基于模板创建了一个分析器,而模板中自带的分析器功能是 “只要类型名称中有任何一个字符是小写的,就给出建议将其改为全部大写”。
于是我们看到 Program
类名底下标了绿色的波浪线,我们将光标定位到 Program
类名上,可以看到出现了一个 “小灯泡” 提示。按下重构快捷键(默认是 Ctrl + .
)后可以发现,我们的分析器项目提供的 “Make uppercase” 建议显示了出来。于是我们可以快速地将类名修改为全部大写。
因为我们在前面安装了 Visual Studio 扩展开发的工作负载,所以可以在 “视图”->“其他窗口” 中找到并打开 Syntax Visualizer 窗格。现在,请将它打开,因为接下来我们的代码分析会用得到这个窗格。
如果体验完毕,可以关闭 Visual Studio;当然也可以在我们的分析器项目中 Shift + F5 强制结束调试。
下次调试的时候,我们不需要再次新建项目了,因为我们刚刚新建的项目还在我们新建的文件夹下。下次调试只要像下面那样再次打开这个项目测试就好了。
在创建完项目之后,你会发现解决方案中有三个项目:
在项目内部:
别看我们分析器文件中的代码很长,但实际上关键的信息并不多。
我们现在还没有自行修改 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();
}
最关键的点:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
override SupportedDiagnostics
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)
{
// 省略实现。
// 在模板自带的实现中,这里判断类名是否包含小写字母,如果包含则创建一个新的诊断建议以改为大写字母。
}
代码修改器文件中的代码更长,但关键信息也没有增加多少。
我们现在也没有自行修改 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();
}
最关键的点:
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WalterlvDemoAnalyzersCodeFixProvider)), Shared]
override FixableDiagnosticIds
WalterlvDemoAnalyzersAnalyzer
类型中有一个公共字段 DiagnosticId
吗?在这里返回,可以为那里分析器找到的代码提供修改建议override GetFixAllProvider
BatchFixer
,其他种类的 FixAllProvider
我将通过其他博客进行说明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)
{
// 省略实现。
// 将类名改为全大写,然后返回解决方案。
}
作为示例,我们写一个属性转换分析器,将自动属性转换为可通知属性。
就是像以下上面的一种属性转换成下面的一种:
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
参数。
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);
}
}
可以发现代码并不多,现在运行,可以在光标落在属性声明的行时看到修改建议。如下图所示:
你可能会觉得有些不满,看起来似乎只有我们写的那些标题和描述在工作。但实际上你还应该注意到这些:
DiagnosticId
、_messageFormat
、_description
已经工作起来了;CodeFixProvider
没有写呢,你现在看到的依然还在修改大小写的部分代码是那个类(WalterlvDemoAnalyzersCodeFixProvider
)里的。现在,我们开始进行代码修改,将 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 启动调试,可以发现我们已经可以完成一个自动属性的修改了,可以按照预期改成一个可通知属性。
你可以再看看下面的动图:
前往我们分析器主项目 Walterlv.Demo.Analyzers 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug
文件夹下。我们可以找到每次编译产生的 NuGet 包。
如果你不知道如何将此 NuGet 包发布到 nuget.org,请在文本中回复,也许我需要再写一篇博客讲解如何推送。
前往我们分析器的 Visual Studio 插件项目 Walterlv.Demo.Analyzers.Vsix 项目的输出目录,因为本文没有改输出路径,所以在项目的 bin\Debug
文件夹下。我们可以找到每次编译产生的 Visual Studio 插件安装包。
如果你不知道如何将此 Visual Studio 插件发布到 Visual Studio Marketplace,请在文本中回复,也许我需要再写一篇博客讲解如何推送。
前面我们提到了 SetValue
这个方法,这是为了写一个可通知对象。为了拥有这个方法,请在我们的测试项目中添加下面这两个文件:
一个可通知类文件 NotificationObject.cs:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Walterlv.TestForAnalyzer
{
public class NotificationObject : INotifyPropertyChanged
{
protected bool SetValue<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
public event PropertyChangedEventHandler PropertyChanged;
}
}
一个用于分析器测试的类 Demo.cs:
namespace Walterlv.TestForAnalyzer
{
class Demo : NotificationObject
{
public string Foo { get; set; }
}
}
代码仓库在我的 Demo 项目中,注意协议是 996.ICU 哟!
别忘了我们一开始创建仓库的时候有一个单元测试项目,而我们全文都没有讨论如何充分利用其中的单元测试。我将在其他的博客中说明如何编写和使用分析器项目的单元测试。
参考资料
使用 Visual Studio 的代码片段功能,我们可以快速根据已有模板创建出大量常用的代码出来。ReSharper 已经自带了一份非常好用的代码片段工具,不过使用 ReSharper 创建出来的代码片段只能用在 ReSharper 插件中。如果团队当中有一些小伙伴没有 ReSharper(毕竟很贵),那么也可以使用到 Visual Studio 原生的代码片段。
Visual Studio 的官方文档有演示如何创建 Visual Studio 的代码片段,不过上手成本真的很高。本文介绍如何快速创建 Visual Studio 代码片段,并不需要那么麻烦。
Visual Studio 中代码片段管理器的入口在“工具”中。你可以参照下图找到代码片段管理器的入口。
在打开代码片段管理器之后,你可以选择自己熟悉的语言。里面会列出当前语言中可以插入的各种代码片段的源。
不过,Visual Studio 并没有提供创建代码片段的方法。在这个管理器里面,你只能导入已经存在的代码片段,并不能直接进行编辑。
官方文档提供了创建代码片段的方法,就在这里:
你只需要看一看就知道这其实是非常繁琐的创建方式,你几乎在手工编写本来是给机器阅读的代码。
我们创建代码片段其实只是关注代码片段本身,那么有什么更快速的方法呢?
方法是安装插件。
请去 Visual Studio 的扩展管理器中安装插件,或者去 Visual Studio 的插件市场中下载安装插件:
在安装完插件之后(需要重新启动 Visual Studio 以完成安装),你就可以直接在 Visual Studio 中创建和编辑代码片段了。
你需要去 Visual Studio 的“文件”->“新建”->“新建文件”中打开的模板选择列表中选择“Code Snippet”。
下面,我演示创建一个 Debug.WriteLine
代码片段的创建方法。
我将一段最简单的代码编写到了代码编辑窗格中:
Debug.WriteLine("[section] text");
实际上,这段代码中的 section
和 text
应该是占位符。那么如何插入占位符呢?
选中需要成为占位符的文本,在这里是 section
,然后鼠标右键,选择“Make Replacement”。
这样,在下方的列表中就会出现一个新的占位符。
现在我们设置这个占位符的更多细节。比如在下图中,我设置了工具提示(即我们使用此代码片段的时候 Visual Studio 如何提示我们编写这个代码片段),设置了默认值(即没有写时应该是什么值)。设置了这只是一个文本文字,没有其他特别含义。设置这是可以编辑的。
用通常的方法,设置 text
也是一个占位符。
如果我们只是这样创建一个代码片段,而目标代码可能没有引用 System.Diagnostics
命名空间,那么插入完之后手动引用这个命名空间体验可不好。那么如何让 Debug
类可以带命名空间地插入呢?
我们需要将 Debug
也设置成占位符。
但是这是可以自动生成的占位符,不需要用户输入,于是我们将其设置为不可编辑。同时,在“Function”一栏填写这是一个类型名称:
SimpleTypeName(global::System.Diagnostics.Debug)
$
符号实际上用于调试的话,代码越简单功能越全越好。于是我希望 Debug.WriteLine
上能够有一个字符串内插符号 $
。
那么问题来了,$
符号是表示代码片段中占位符的符号,那么如何输入呢?
方法是——写两遍 $
。于是我们的代码片段现在是这样的:
Debug.WriteLine($$"[$section$] $text$");
你可以随时按下 Ctrl+S 保存这个新建的代码片段。插件一个很棒的设计是,默认所在的文件夹就是 Visual Studio 中用来存放代码片段的文件夹。于是,你刚刚保存完就可以立刻在 Visual Studio 中看到效果了。
如果你将代码片段保存在插件给你的默认的位置,那么你根本不需要导入任何代码片段。但如果你曾经导出过代码片段或者保存在了其他的地方,那么就需要在代码片段管理器中导入这些代码片段文件了。
如果你前面使用了默认的保存路径,那么现在直接就可以开始使用了。
使用我们在 Shortcut 中设置的字母组合可以插入代码片段:
在插入完成之后,我们注意到此类型可以使用导入的命名空间前缀 System.Diagnostics
。如果没有导入此命名空间前缀,代码片段会自动加入。
按下 Tab 键可以在多个占位符之间跳转,而使用回车键可以确认这个代码片段。
在 Visual Studio 视图菜单的其他窗口中,可以找到“Snippet Explorer”,打开它可以管理已有的代码片段,包括 Visual Studio 中内置的那些片段。
推荐另一款插件 Snippetica:
前者适用于 Visual Studio,后者适用于 Visual Studio Code。
它自带了很多的 C# 代码片段,可以很大程度补充 Visual Studio 原生代码片段存在感低的问题。
参考资料
[Walkthrough: Create a code snippet - Visual Studio | Microsoft Docs](https://docs.microsoft.com/en-us/visualstudio/ide/walkthrough-creating-a-code-snippet) |
使用 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
设置此属性可以让断点不会出现在写的这几个方法中
DebuggerNonUserCode
设置此代码非用户编写的代码
前面的代码中,我们做了一个判断 Debugger.IsAttached
。这是在判断,如果当前没有附加调试器,那么就附加一个。
于是这段代码可以运行在非 Visual Studio 的环境中,当出现了异常的时候,还可以补救选择一个调试器。
当然,实际上附加到 Visual Studio 进行调试也是最佳的方法。只不过,我们不需要一定通过 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");
}
}
}
现在,我们为这段会出异常的代码加上 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
块处理掉了此异常。
如果需要恢复设置,点击上面的恢复成默认的按钮即可。
当然,你也可以不需要全部打勾,而是只勾选你期望诊断问题的那几个异常。你可以试试,这其实是一个非常繁琐的工作,你会在大量的异常名称中失去眼神而再也无法直视任何异常了。
所以更推荐的做法不是仅设置特定异常时中断,而是反过来设置——设置发生所有异常时中断,除了特定的一些异常之外。
方法是:
Common Language Runtime Exceptions
打勾如果程序并不是在 Visual Studio 中运行,那么有没有方法进行中断呢?
一个做法是调用 Debugger.Launch()
,但这样的话中断的地方就是在 Debugger.Launch()
所在的代码处,可能异常还没发生或者已经发生过了。
有没有方法可以在异常发生的那一刻中断呢?请阅读我的另一篇博客:
在扩展 MSBuild 编译的时候,我们一般的处理的路径都是临时路径或者输出路径,那么发布路径在哪里呢?
我曾经在下面这一篇博客中说到可以通过阅读 Microsoft.NET.Sdk 的源码来探索我们想得知的扩展编译的答案:
于是,我们可以搜索 "Publish"
这样的关键字找到我们希望找到的编译目标,于是找到在 Microsoft.NET.Sdk.Publish.targets 文件中,有很多的 PublishDir
属性存在,这可以很大概率猜测这个就是发布路径。不过我只能在这个文件中找到这个路径的再次赋值,找不到初值。
如果全 Sdk 查找,可以找到更多赋初值和使用它复制和生成文件的地方。
于是可以确认,这个就是最终的发布路径,只不过不同类型的项目,其发布路径都是不同的。
比如默认是:
<PublishDir Condition="'$(PublishDir)'==''">$(OutputPath)app.publish\</PublishDir>
还有:
<_DeploymentApplicationDir>$(PublishDir)$(_DeploymentApplicationFolderName)\</_DeploymentApplicationDir>
和其他。
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.
本文使用 多种语言 编写,请选择你想阅读的语言:
when
in the official documentYou 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.
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.
The pictures below show the actual output of the demo code above in .NET Core 3.0 and in .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.
This animated picture below shows how the code goes step by step.
对于稍微大一点的 .NET 解决方案来说,编译时间通常都会长一些。如果项目结构和差量编译优化的好,可能编译完也就 5~30 秒,但如果没有优化好,那么出现 1~3 分钟都是可能的。
如果能够在编译出错的第一时间停止编译,那么我们能够更快地去找编译错误的原因,也能从更少的编译错误列表中找到出错的关键原因。
如果你只是觉得你的项目或解决方案编译很慢而不知道原因,我推荐你安装 Parallel Builds Monitor 插件来调查一下。你可以阅读我的一篇博客来了解它:
一个优化比较差的解决方案可能是下面这个样子的:
明明没有多少个项目,但是项目之间的依赖几乎是一条直线,于是不可能开启项目的并行编译。
图中这个项目的编译时长有 1 分 30 秒。可想而知,如果你的改动导致非常靠前的项目编译错误,而默认情况下编译的时候会继续尝试编译下去,于是你需要花非常长的时间才能等待编译完毕,然后从一大堆项目中出现的编译错误中找到最开始出现错误的那个(通常也是编译失败的本质原因)。
现在,推荐使用插件 VSColorOutput。
它的主要功能是给你的输出窗格加上颜色,可以让你更快速地区分调试信息、输出、警告和错误。
不过,也正是因为它是通过匹配输出来上色的,于是它可以得知你的项目出现了编译错误,可以采取措施。
在你安装了这款插件之后,你可以在 Visual Studio 的“工具”->“设置”中找到 VSColorOutput 的设置。其中有一项是“Stop Build on First Error”,打开之后,再出现了错误的话,将第一时间会停止。你也可以发现你的 Visual Studio 错误列表中的错误数量非常少了,这些错误都是导致编译失败的最早出现的错误,利于你定位问题。
.NET 提供了一个简单的 API 来移动一个文件夹 Directory.Move(string sourceDirName, string destDirName)
。不过如果你稍微尝试一下这个 API 就会发现其实相当不实用。
在使用 Directory.Move(string sourceDirName, string destDirName)
这个 API 来移动文件夹的时候,比如我们需要将 A 文件夹移动成 B 文件夹(也可以理解成重命名成 B)。
一旦 B 文件夹是存在的,那么这个时候会抛出异常。
然而实际上我们可能希望这两个文件夹能够合并。
.NET 的 API 没有原生提供合并两个文件夹的方法,所以我们需要自己实现。
方法是递归遍历里面的所有文件,然后将源文件夹中的文件依次移动到目标文件夹中。为了应对复杂的文件夹层次结构,我写的方法中也包含了递归。
private static void MoveDirectory(string sourceDirectory, string targetDirectory)
{
MoveDirectory(sourceDirectory, targetDirectory, 0);
void MoveDirectory(string source, string target, int depth)
{
if (!Directory.Exists(source))
{
return;
}
if (!Directory.Exists(target))
{
Directory.CreateDirectory(target);
}
var sourceFolder = new DirectoryInfo(source);
foreach (var fileInfo in sourceFolder.EnumerateFiles("*", SearchOption.TopDirectoryOnly))
{
var targetFile = Path.Combine(target, fileInfo.Name);
if (File.Exists(targetFile))
{
File.Delete(targetFile);
}
File.Move(fileInfo.FullName, targetFile);
}
foreach (var directoryInfo in sourceFolder.EnumerateDirectories("*", SearchOption.TopDirectoryOnly))
{
var back = string.Join("\\", Enumerable.Repeat("..", depth));
MoveDirectory(directoryInfo.FullName,
Path.GetFullPath(Path.Combine(target, back, directoryInfo.Name)), depth + 1);
}
Directory.Delete(source);
}
}
depth
是一个整型,表示递归深度。我在计算文件需要移动到的新文件夹的路径的时候,需要使用到这个递归深度,以便回溯到最开始需要移动的那个文件夹上。
我们有很多的调试工具可以帮助我们查看 WPF 窗口中当前获得键盘焦点的元素。本文介绍监控当前键盘焦点元素的方法,并且提供一个不需要任何调试工具的自己绘制键盘焦点元素的方法。
Visual Studio 带有实时可视化树的功能,使用此功能调试 WPF 程序的 UI 非常方便。
在打开实时可视化树后,我们可以略微认识一下这里的几个常用按钮:
这里,我们需要打开两个按钮:
这样,只要你的应用程序当前获得焦点的元素发生了变化,就会有一个表示这个元素所在位置和边距的叠加层显示在窗口之上。
你可能已经注意到了,Visual Studio 附带的这一叠加层会导致鼠标无法穿透操作真正具有焦点的元素。这显然不能让这一功能一直打开使用,这是非常不方便的。
我们打算在代码中编写追踪焦点的逻辑。这可以规避 Visual Studio 中叠加层中的一些问题,同时还可以在任何环境下使用,而不用担心有没有装 Visual Studio。
获取当前获得键盘焦点的元素:
var focusedElement = Keyboard.FocusedElement;
不过只是拿到这个值并没有多少意义,我们需要:
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;
}
}
这样,只要将这个控件放到窗口中,这个控件就会一直跟踪窗口中的当前获得了键盘焦点的元素。当然,为了最好的显示效果,你需要将这个控件放到最顶层。
如果我们需要监听应用程序中所有窗口中的当前获得键盘焦点的元素怎么办呢?我们需要给所有当前激活的窗口监听 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 应用程序中所有窗口中的键盘焦点。
由于 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_Activated
和 Window_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
的构造函数中加入了一个过滤窗口的委托。比如你可以让窗口的监听只对主要的几个窗口生效,而对一些信息提示窗口忽略等等。
我们有多种工具可以将程序集合并成为一个。打包成一个程序集可以避免分发程序的时候带上一堆依赖而出问题。
ILMerge 可以用来将多个程序集合并成一个程序集。本文介绍使用 ILMerge 工具和其 NuGet 工具包来合并程序集和其依赖。
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=""$(ILMergeConsolePath)" /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=""$(ILMergeConsolePath)" /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 已经开源,你可以在 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
这个编译任务了,而不是在 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 在追踪此问题:
参考资料
我们有多种工具可以将程序集合并成为一个。比如 ILMerge、Mono.Merge。前者不可定制、运行缓慢、消耗资源(不过好消息是现在开源了);后者已被弃用、不受支持且基于旧版本的 Mono.Cecil。
而本文介绍用来替代它们的 ILRepack,使用 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=""$(ILRepack)" /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=""$(ILRepack)" /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>
最终生成的输出目录下只有我们最终期望生成的程序集:
相比于 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'
那么你需要做以下两种事情中的任何一种:
将所有依赖合并指的是将缺少的依赖也一起作为命令行参数传入要合并的程序集中。
而另一种是增加一个参数 /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
如果使用新的基于 Sdk 的项目文件,那么默认生成的 PDB 是 Portable PDB,但是 ILRepack 暂时不支持 Portable PDB,其在内部捕获了异常以至于可以完成合并但不会生成 PDB 文件。
目前此问题在 ILRepack 中还处于打开状态,且持续两年都没关闭了。同时很早就有支持 Portable PDB 的拉取请求,但至今未合并。
以下是 GitHub 社区中的讨论:
参考资料
默认情况下,我们打包 NuGet 包时,目标项目安装我们的 NuGet 包会引用我们生成的库文件(dll)。除此之外,我们也可以专门做 NuGet 工具包,还可以做 NuGet 源代码包。然而做源代码包可能是其中最困难的一种了,目标项目安装完后,这些源码将直接随目标项目一起编译。
本文将从零开始,教你制作一个支持 .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 文件
我们编辑项目文件的目的,是让我们前一步创建的项目文件夹结构真正成为 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>
接下来,我们将编写编译文件 props 和 targets。注意,我们需要写的是四个文件的内容,不要弄错了。
如果我们做好的 NuGet 源码包被其他项目使用,那么这四个文件中的其中一对会在目标项目被自动导入(Import)。在你理解 理解 C# 项目 csproj 文件格式的本质和编译流程 一文内容之前,你可能不明白“导入”是什么意思。但作为从零开始的入门博客,你也不需要真的理解导入是什么意思,只要知道这四个文件中的代码将在目标项目编译期间运行就好。
你只需要将下面的代码拷贝到 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 文件即可。注意将包名换成你自己的包名,也就是项目名。
<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 文件的全部内容。可以注意到我们几乎没有任何实质性的代码在里面。即便我们在此文件中还没有写任何代码,依然需要创建这个文件,因为后面第五步我们将添加更复杂的代码时将再次用到这个文件完成里面的内容。
现在,保持你的文件中的内容与下面一模一样就好。
<Project>
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
</PropertyGroup>
</Project>
下面是 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 文件夹中的两个编译文件。
如果你对这个属性不熟悉,请回到第一步看我们一开始创建的代码,你会看到这个属性的设置的。如果还不清楚,请阅读博客:
也许你已经从本文拷贝了很多代码过去了,但直到目前我们还没有看到这些代码的任何效果,那么现在我们就可以来看看了。这可算是一个阶段性成果呢!
先编译生成一下我们一直在完善的项目,我们就可以在解决方案目录的 bin\Debug
目录下找到一个 NuGet 包。
现在,我们要打开这个 NuGet 包看看里面的内容。你需要先去应用商店下载 NuGet Package Explorer,装完之后你就可以开始直接双击 NuGet 包文件,也就是 nupkg 文件。现在我们双击打开看看。
我们的体验到此为止。如果你希望在真实的项目当中测试,可以阅读其他博客了解如何在本地测试 NuGet 包。
截至目前,我们只是在源代码包中引入了 C# 代码。如果我们需要加入到源代码包中的代码包含 WPF 的 XAML 文件,或者安装我们源代码包的目标项目包含 WPF 的 XAML 文件,那么这个 NuGet 源代码包直接会导致无法编译通过。至于原因,你需要阅读我的另一篇博客来了解:
即便你不懂 WPF 程序的编译过程,你也可以继续完成本文的所有内容,但可能就不会明白为什么接下来我们要那样去修改我们之前创建的文件。
接下来我们将修改这些文件:
在这个文件中,我们将新增一个属性 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>
请按照下面的差异说明来修改你的 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 相关的文件。
请特别注意:
Link
属性,并且将其指定为 %(_WalterlvDemoPage.FileName).xaml
。这意味着我们会把所有的 XAML 文件都当作在项目根目录中生成,如果你在其他的项目中用到了相对或绝对的 XAML 文件的路径,这显然会改变路径。但是,我们没有其他的方法来根据 XAML 文件所在的目录层级来自定指定 Link
属性让其在正确的层级上,所以这里才写死在根目录中。
Link
属性(例如改为 Views\%(_WalterlvDemoPage.FileName).xaml
),这意味着需要在此项目编译期间执行一段代码,把 Package.targets 文件中为所有的 XAML 文件生成正确的 Link
属性。本文暂时不考虑这个问题,但你可以参考 dotnet-campus/SourceYard 项目来了解如何动态生成 Link
。_WalterlvDemoPage
集合中转地存了 XAML 文件,这是必要的。因为这样才能正确通过 %
符号获取到 FileName
属性。而 _WalterlvDemoImportInWpfTempProject
这个编译目标就不那么好理解了,而这个也是完美支持 WPF 项目源代码包的关键编译目标!这个编译目标指定在 MarkupCompilePass1
之后,GenerateTemporaryTargetAssembly
之前执行。GenerateTemporaryTargetAssembly
编译目标的作用是生成一个临时的项目,用于让 WPF 的 XAML 文件能够依赖同项目的 .NET 类型而编译。然而此临时项目编译期间是不会导入任何 NuGet 的 props 或 targets 文件的,这意味着我们特别添加的所有 C# 源代码在这个临时项目当中都是不存在的——如果项目使用到了我们源代码包中的源代码,那么必然因为类型不存在而无法编译通过——临时项目没有编译通过,那么整个项目也就无法编译通过。但是,我们通过在 MarkupCompilePass1
和 GenerateTemporaryTargetAssembly
之间将我们源代码包中的所有源代码加入到 _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,为其提供支持 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.
更多制作源代码包的博客可以阅读。从简单到复杂的顺序:
我们编写的 .NET 应用程序会使用到各种各样的依赖库。我们都知道 CLR 会在一些路径下帮助我们程序找到依赖,但如果我们需要手动控制程序集加载路径的话,需要了解程序集加载上下文。
如果你不了解程序集加载上下文,你可能会发现你加载了程序集却不能使用其中的类型;或者把同一个程序集加载了两次,导致使用到两个明明是一样的类型时却抛出异常提示不是同一个类型的问题。
当你向应用程序域中加载一个程序集时,可能会加载到以下四种不同的上下文中的一种:
你需要了解这些加载上下文,因为跨不同加载上下文加载的程序集是不能访问其中的类型的。
ApplicationBase 和 PrivateBinPath 这两个属性虽然允许被设置,但它们只对新生成的 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 的依赖问题,在创建新应用程序域的时候设置此应用程序域的探测路径。
但是,我们其实有其他的方法依然在原来的应用程序域中解决依赖问题。
AppDomain
有一个已经被遗弃的 API AppendPrivatePath
,可以将一个路径加入到探测路径列表中。这样,我们不需要考虑去任意路径加载程序集的问题了,因为我们可以将任意路径设置成探测路径。
// 注意,这是一个被遗弃的 API。
AppDomain.CurrentDomain.AppendPrivatePath(folder);
关于此 API 为什么会被遗弃,你可以阅读微软的官方博客:Why is AppDomain.AppendPrivatePath Obsolete? - .NET Blog。因为你随时可以指定应用程序的探测路径,所以它可能让你的程序以各种不确定的方式加载程序集,于是你的程序将变得很不稳定;可能完全崩溃到你无法预知的程度。
另外,.NET Core 中已经不能使用此 API 了,这非常好!
前面我们说过,加载位置上下文中的程序集可以依赖默认加载上下文中的程序集,而反过来却不行。通常默认加载上下文中的程序集是我们的主程序程序集和附属程序集,而加载位置上下文中加载的程序是插件程序集。
如果插件程序集依赖了一些主程序没有的依赖,那么插件可以考虑将所有的依赖合并入插件单个程序集中,避免依赖其他程序集,导致不得不去非探测路径加载程序集。
关于使用 ILRepack 合并依赖的内容,可以阅读我的另一篇博客:
首先推荐使用 ILRepack 来进行合并,如果你愿意,也可以使用 ILMerge:
参考资料
基于 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 的源码可以查看:
WPF 在编译期间会执行以下这些 Target,当然 Target 里面实际用于执行任务的是 Task。
知道 Target 名称的话,你可以扩展 WPF 的编译过程;而知道 Task 名称的话,可以帮助理解编译过程实际做的事情。
本文都会列举出来。
FileClassification
FileClassifier
用于将资源嵌入到程序集。如果资源没有本地化,则嵌入到主程序集;如果有本地化,则嵌入到附属程序集。
在 WPF 项目中,这个 Target 是一定会执行的;但里面的 Task 则是有 Resource
类型的编译项的时候才会执行。
Target 名称和 Task 名称相同,都是 GenerateTemporaryTargetAssembly
。
只要项目当中包含任何一个生成类型为 Page 的 XAML 文件,则会执行此 Target。
关于生成临时程序集的原因比较复杂,可以阅读本文后面的 WPF 程序的编译过程部分来了解。
Target 名称和 Task 名称相同,都是 MarkupCompilePass1
。
将非本地化的 XAML 文件编译成二进制格式。
Target 名称和 Task 名称相同,都是 MarkupCompilePass2
。
对 XAML 文件进行第二轮编译,而这一次会引用同一个程序集中的类型。
这是一个仅在有设计器执行时才会执行的 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>
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
,分别负责主资源生成和附属资源生成。ResourcesGenerator
将一个或多个资源(二进制格式的 .jpg、.ico、.bmp、XAML 以及其他扩展名类型)嵌入 .resources 文件中。
CheckUid
、UpdateUid
和 RemoveUid
,分别负责主资源生成和附属资源生成。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>
当编译基于 XAML 的浏览器项目的时候,会给 manifest 文件中添加一个配置 <hostInBrowser />
。
上面列举出来的那些 Target 主要是 WPF 几个关键的 Target,在实际编译时会有更多编译 Target 执行。另外有些也不在常规的编译过程中,而是被专门的编译过程执行。
图的阅读方法是这样的:
CoreCompile
有一个指向 DesignTimeMarkupCompilation
的箭头,表示 CoreCompile
执行前会确保 DesignTimeMarkupCompilation
执行完毕;PrepareResources
指向了多个 Target MarkupCompilePass1
、GenerateTemporaryTargetAssembly
、MarkupCompilePass2
、AfterMarkupCompilePass2
、CleanupTemporaryTargetAssembly
,那么在 PrepareResources
执行之前,如果还有没有执行的依赖,会按顺序依次执行;各种颜色代表的含义:
我们都知道 XAML 是可以引用 CLR 类型的;如果 XAML 所引用的 CLR 类型在其他被引用的程序集,那么编译 XAML 的时候就可以直接引用这些程序集,因为他们已经编译好了。
但是我们也知道,XAML 还能引用同一个程序集中的 CLR 类型,而此时这个程序集还没有编译,XAML 编译过程并不知道可以如何使用这些类型。同时我们也知道 CLR 类型可是使用 XAML 生成的类型,如果 XAML 没有编译,那么 CLR 类型也无法正常完成编译。这是矛盾的,这也是 WPF 扩展的编译过程会比较复杂的原因之一。
WPF 编译过程有两个编译传递,MarkupCompilePass1
和 MarkupCompilePass2
。
MarkupCompilePass1
的作用是将 XAML 编译成二进制格式。如果 XAML 文件包含 x:Class
属性,那么就会根据语言生成一份代码文件;对于 C# 语言,会生成“文件名.g.cs”文件。但是 XAML 文件中也有可能包含对同一个程序集中的 CLR 类型的引用,然而这一编译阶段 CLR 类型还没有开始编译,因此无法提供程序集引用。所以如果这个 XAML 文件包含对同一个程序集中 CLR 类型的引用,则这个编译会被推迟到 MarkupCompilePass2
中继续。而在 MarkupCompilePass1
和 MarkupCompilePass2
之间,则插入了 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>
我们需要关注这些点:
_CompileTargetNameForLocalType
这个私有属性来决定;_CompileTargetNameForLocalType
没有指定时,会设置其默认值为 _CompileTemporaryAssembly
这个编译目标;_CompileTemporaryAssembly
这个编译目标执行时,仅会执行三个依赖的编译目标,BuildOnlySettings
、ResolveKeySource
、CoreCompile
,至于这些依赖目标所依赖的其他编译目标,则会根据新生成的项目文件动态计算。_CompileTargetNameForLocalType
来执行,而不能直接调用这个编译目标或者设置编译目标的依赖。新生成的临时项目文件相比于原来的项目文件,包含了这些修改:
MarkupCompilePass1
)时生成的 .g.cs 文件;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 造成了不小的困扰:
参考资料
在使用 NuGet 包来分发源代码时,如果目标项目是 WPF 项目,那么会有一大堆的问题。
本文将这些问题列举出来并进行分析。
源代码包不是 NuGet 官方的概念,而是林德熙和我在 GitHub 上做的一个项目,目的是将你的项目以源代码的形式发布成 NuGet 包。在安装此 NuGet 包后,目标项目将获得这些源代码。
你可以通过以下博客了解如何制作一个源代码包。
这可以避免因为安装 NuGet 包后带来的大量程序集引用,因为程序集数量太多对程序的启动性能有很大的影响:
然而制作一个 NuGet 的坑很多,详见:
为了让 NuGet 源代码包对 WPF 项目问题暴露得更彻底一些,我们需要一个最简单的例子来说明这一问题。我将它放在了我的 Demo 项目中:
但为了让博客理解起来更顺畅,我还是将关键的源代码贴出来。
为了尽可能避免其他因素的影响,我们这个源码包只做这些事情:
具体来说,我们的目录结构是这样的:
- 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。
现在,我们新建另一个简单的控制台项目用于验证这个 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
当然,贴一张图片可能更能体现编译通过:
上面的输出非常多,但我们提取一下关键的点:
CoreClean
-> PrepareForRebuild
-> GenerateBindingRedirects
-> GenerateTargetFrameworkMonikerAttribute
-> CoreCompile
-> _CopyAppConfigFile
-> CopyFilesToOutputDirectory
。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 项目。编辑项目文件。
-- <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
:
我们几乎可以认为,当项目是编译成 WPF 时,执行了不同的编译流程。
要了解问题到底出在哪里了,我们需要知道 WPF 究竟在编译过程中做了哪些额外的事情。WPF 额外的编译任务主要在 Microsoft.WinFX.targets 文件中。在了解了 WPF 的编译过程之后,这个临时的程序集将非常容易理解。
我写了一篇讲解 WPF 编译过程的博客,在解决这个问题之前,建议阅读这篇博客了解 WPF 是如何进行编译的:
在了解了 WPF 程序的编译过程之后,我们知道了前面一些疑问的答案:
在那篇博客中,我们解释到新生成的项目文件会使用 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 造成了不小的困扰:
这个问题解决起来其实并不如想象当中那么简单,因为:
MarkupCompilePass1
和 MarkupCompilePass2
之间的 GenerateTemporaryTargetAssembly
编译目标时,会插入一段临时项目文件的编译;_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 的时机,那么我们只能另寻它法:
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 就会重复导入。
Directory.Build.props 和 Directory.Build.targets 也是可以被自动 Import 的文件,这也是在 Microsoft.NET.Sdk 中将其自动导入的。
关于这两个文件的自动导入,可以阅读博客:
但是,如果我们使用这两个文件帮助自动导入,将造成导入循环,这会形成编译错误!
GenerateTemporaryTargetAssembly
的代码如下:
<GenerateTemporaryTargetAssembly
CurrentProject="$(MSBuildProjectFullPath)"
MSBuildBinPath="$(MSBuildBinPath)"
ReferencePathTypeName="ReferencePath"
CompileTypeName="Compile"
GeneratedCodeFiles="@(_GeneratedCodeFiles)"
ReferencePath="@(ReferencePath)"
IntermediateOutputPath="$(IntermediateOutputPath)"
AssemblyName="$(AssemblyName)"
CompileTargetName="$(_CompileTargetNameForLocalType)"
GenerateTemporaryTargetAssemblyDebuggingInformation="$(GenerateTemporaryTargetAssemblyDebuggingInformation)"
>
</GenerateTemporaryTargetAssembly>
可以看到它的的参数有:
$(MSBuildProjectFullPath)
,表示项目文件的完全路径,修改无效。$(MSBuildBinPath)
,表示 MSBuild 程序的完全路径,修改无效。ReferencePath
,这是为了在生成临时项目文件时使用正确的引用路径项的名称。Compile
,这是为了在生成临时项目文件时使用正确的编译项的名称。@(_GeneratedCodeFiles)
,包含生成的代码文件,也就是那些 .g.cs 文件。@(ReferencePath)
,也就是目前已收集到的所有引用文件的路径。$(IntermediateOutputPath)
,表示临时输出路径,当使用临时项目文件编译时,生成的临时程序集将放在这个目录中。$(AssemblyName)
,表示程序集名称,当生成临时程序集的时候,将参考这个程序集名称。$(_CompileTargetNameForLocalType)
,表示当生成了新的项目文件后,要使用哪个编译目标来编译这个项目。$(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>
现在重新再编译,我们本文一开始疑惑的各种问题,现在终于无警告无错误地解决掉了。
如果你觉得本文略长,希望立刻获得解决办法,可以:
参考资料
在使用 Win32 / WPF / Windows Forms 的打开或保存文件对话框的时候,多数情况下我们都会考虑编写文件过滤器。UWP 中有 FileTypeFilter
集合可以添加不同的文件种类,但 Win32 中却是一个按一定规则组合而成的字符串。
因为其包含一定的格式,所以可能写错。本文介绍如何编写 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
。
参考资料
当我们不再使用某个对象的时候,此对象会被 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
,编译期间能确定的字符串在上述代码中也是不会被垃圾回收的。
参考资料
本文介绍 .NET 中的字符串暂存池。
.NET 的 CLR 运行时会在运行期间管理一个字符串暂存池(string intern pool),在字符串暂存池中的字符串只有一个实例。
例如,在下面的代码中,变量 a
、b
、c
都是同一个实例:
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));
在这段代码中,虽然 a
、b
、c
三个字符串的值都是相等的,但 a
、b
两个字符串是不同的实例,而 a
、c
两个字符串是相同的实例。
我们使用了 string.Intern
方法从字符串池中取出了一个字符串的实例。
另外,string
类型还提供了 string.IsInterned
来判断一个字符串是否在字符串暂存池中。
你可以在程序集中标记 CompilationRelaxations.NoStringInterning
,这样,此程序集中的字符串就不会被池化。即便是在编译期间写下的字符串也会在运行时生成新的实例。
方法是在一个 C# 代码文件中添加特性标记。
[assembly: CompilationRelaxations(CompilationRelaxations.NoStringInterning)]
在字符串暂存池中的字符串不会被垃圾回收,你可以阅读另一篇博客:
参考资料
延迟计算属性的值,应该很多小伙伴都经常使用。比如在属性的 get
方法中判断是否已初始化,如果没有初始化则立即开始初始化。
但这样的写法存在一个很大的问题——如果你使用 Visual Studio 调试,当你把鼠标划到对象的实例上的时候,属性就会立刻开始进行初始化。而此时对你的代码来说可能就过早初始化了。我们不应该让调试器非预期地影响到我们程序的执行结果。
本文介绍如何避免调试器不小心提前计算本应延迟计算的值。
方法是在属性上添加一个特性 DebuggerBrowsableAttribute
。
private Walterlv _foo;
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
public Walterlv Walterlv => _foo ?? (_foo = new Walterlv());
public bool IsInitialized => !(_foo is null);
当指定为不再显示的话,在调试器中查看此实例的属性的时候就看不到这个属性了,也就不会因为鼠标划过导致提前计算了值。
当然,如果你希望为你的类型定制更多的调试器显示方式,可以参考我的另一篇博客:
参考资料
本文介绍如何使用 Windows 的 AppBar 相关 API 实现固定停靠在桌面上的特殊窗口。
你可能并不明白停靠窗口是什么意思。
看下图,你可能使用过 OneNote 的停靠窗口功能。当打开一个新的 OneNote 停靠窗口之后,这个新的 OneNote 窗口将固定显示在桌面的右侧,其他的窗口就算最大化也只会占据剩余的空间。
OneNote 的这种功能可以让你在一边浏览网页或做其他事情的时候,以便能够做笔记。同时又不用担心其他窗口最大化的时候会占据记笔记的一部分空间。
这其实也是 Windows 任务栏所使用的方法。
OneNote 中给出的名称叫做“停靠窗口”,于是这可以代表微软希望用户对这个概念的理解名词。
只是,这个概念在 Windows API 中的名称叫做 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);
}
}
}
参考资料
如果你使用过 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
字段从而获得更好的设计。但这样就引入了一个静态字典从而使得所有的 Foo
和 Bar
的实例无法得到释放。我们想当然希望拥有一个弱引用字典来解决问题。然而这是一个 X-Y 问题。
实际上 .NET 中提供了 ConditionalWeakTable<TKey,TValue>
帮我们解决了最本质的问题——在部分场景下期望为 Foo
类添加一个字段。虽然它不是弱引用字典,但能解决此类问题,同时也能当作一个弱引用字典来使用,仅此而已。
你需要注意的是,ConditionalWeakTable<TKey,TValue>
并不实现 IDictionary<TKey,TValue>
接口,只是里面有一些像 IDictionary<TKey, TValue>
的方法,可以当作字典使用,也可以遍历取出剩下的所有值。
ConditionalWeakTable<TKey,TValue>
中的所有 Key 和所有的 Value 都是弱引用的,并且会在其 Key 被回收或者 Key 和 Value 都被回收之后自动从集合中消失。这意味着当你使用它来为一个类型附加一些字段或者属性的时候完全不用担心内存泄漏的问题。
下面我写了一段代码用于验证其内存泄漏问题:
ConditionalWeakTable<TKey,TValue>
中添加了三个键值对;key
设为 null
;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();
}
}
这段代码的运行结果如下图:
从中我们可以发现:
ConditionalWeakTable<TKey,TValue>
中就没有那一项键值对了;ConditionalWeakTable<TKey,TValue>
中的 Value 依然还会存在。另外,我们这里在调查内存泄漏问题,你需要在 Release 配置下执行此代码才能得到最符合预期的结果。
参考资料
当我们对 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
{
// 这不是一个窗口,需要抛出异常。
}
Windows 系统提供了一个在 Windows 单个用户下全局的 Temp 文件夹,用于给各种不同的应用程序提供一个临时目录。但是,直到 Windows 10 推出存储感知功能之前,这个文件夹都一直只归各个应用程序自己管理,应用自己需要删除里面的文件。另外,进程多了,临时文件也会互相影响(例如个数过多、进程读写竞争等等)。
本文介绍将自己当前进程的 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 个文件累计;其他程序使用其他前缀使则分别累计。
另外,如果此方法无法再生成一个唯一的文件名的时候也会抛出异常。
为了解决这些异常,在用户端的解决方案是删除临时文件夹。而在程序端的解决方案是 —— 本文。
本文是为了和 林德熙 一起解决一个光标问题时提出的解决方案的一种。更多关于光标问题的内容可以阅读以下链接:
参考资料
当你升级到 C# 8.0 和 .NET Core 3.0 之后,你就可以开始使用默认接口实现的功能了。
从现在开始,你可以在接口里面添加一些默认实现的成员,避免在接口中添加成员导致大量对此接口的实现崩溃。
要写出并且正常使用接口的默认实现,你需要:
对于预览版的 Visual Studio 2019 来说,.NET Core 的预览版是默认打开且无法关闭的,所以不需要关心。
请设置你项目的属性,修改 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);
}
然而我们都知道,这样的修改是破坏性的:
那么现在,我们可以这样来新增此方法:
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);
++ }
++ }
参考资料
使用 Directory Opus 替代 Windows 自带的文件资源管理器来管理你计算机上的文件可以极大地提高你的文件处理效率。
由于我自己的 Windows 10 系统使用的是暗色主题,所以我希望 Directory Opus 也能搭配我系统的纯暗色主题。
本文介绍如何将 Directory Opus 打造成搭配 Windows 10 的暗色主题。
Directory Opus 在安装完之后的默认主题样式是下面这样的:
然而,我的 Windows 10 的主要界面都是暗黑色的:
那么,请在 Directory Opus 顶部菜单中选择 设置
-> 主题
:
然后点击左下角的下载主题去网上下载一款主题。
你可以直接使用下面的链接下载 Windows 10 暗色风格的主题:
然后,依然进入我们一开始说的 设置
-> 主题
对话框中,导入刚刚我们下载好的主题:
点击“应用”,随后 Directory Opus 会重新启动,你将看到全新的 Windows 10 暗色风格主题。
等等!为什么重启之后看起来样式怪怪的?有一些文件的文字其实在暗色主题下看不太清。
这当然是主题设计者没有考虑到所有的情况导致的,实际上你下载的任何一款主题可能都有各种考虑不周的情况,那么如何修复这些考虑不周的细节呢?
我们需要前往 设置
-> 选项
中微调这些细节。在“选项”对话框中,选择“颜色和字体”标签。
在我一开始的暗色主题应用后,我们注意到我的文件是分组的,组标题是深蓝色,看不清。于是修改“文件组标题”中的颜色:
另外,我的多数文件是加入了 NTFS 压缩的,这部分文件被主题设置了很难看清的深紫色,我将它改为其他的颜色:
里面还有大量可以微调的部件,如果你遇到了不符合你要求的颜色设置,则将其修改即可。
以下是我进行了微调之后的主题效果预览:
你可能会注意到在主题选择窗格中只有我们刚刚下载的那一个主题,我们不能选择回默认的主题样式。那如果一个主题被我们改残了,或者就是想重新体验原生效果的时候该如何做呢?
我们依然需要进入到 设置
-> 选项
中,然后选择“颜色和字体”标签。
这时,选择顶部的 文件
-> 重置该页到默认值
。于是,我们的主题就会还原到最初没有修改任何字体和颜色的版本。
如果主题涉及到图标等其他资源,也需要进入对应的标签页然后还原对应标签页的设置。
参考资料
本文介绍通过发现渲染脏区来提高渲染性能。
在计算机图形渲染中,可以每一帧绘制全部的画面,但这样对计算机的性能要求非常高。
脏区(Dirty Region)的引入便是为了降低渲染对计算机性能的要求。每一帧绘制的时候,仅仅绘制改变的部分,在软件中可以节省大量的渲染资源。而每一帧渲染时,改变了需要重绘的部分就是脏区。
以下是我的一款 WPF 程序 Walterlv.CloudKeyboard 随着交互的进行不断需要重绘的脏区。
可以看到,脏区几乎涉及到整个界面,而且刷新非常频繁。这显然对渲染性能而言是不利的。
当然这个程序很小,就算一直全部重新渲染性能也是可以接受的。不过当程序中存在比较复杂的部分,如大量的 Geometry
以及 3D 图形的时候,重新渲染这一部分将带来严重的性能问题。
先下载 WPF 性能套件:
启动 WPF Performance Suite,选择工具 Perforator,然后在 Action 菜单中启动一个待分析的 WPF 进程。虽然工具很久没有更新,但依然可以支持基于 .NET Core 3 版本的 WPF 程序。
当程序运行起来后,可以看到 WPF 程序的各种性能数据图表。
现在将 Show dirty-region update overlay
选项打勾即可看到本文一开始的脏区叠加层的显示。
与脏区有关的选项有三个:
一开始的程序中,因为我使用了模拟 UWP 的高光效果,导致大量的控件在重绘高光部分,这是导致每一帧都在重新渲染的罪魁祸首。
于是我将高光渲染关闭,脏区的重新渲染将仅仅几种在控件样式改变的时候(例如焦点改变):
光照效果可以参见我的另一篇博客:
参考资料
在 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);
参考资料
本文介绍不那么常见的 XAML 相关的知识。
当你用设计器修改元素的 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
来转换字符串,所以键盘快捷键多值枚举使用的位或运算用的是加号(+
)。
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")]
详情请阅读博客:
此写法要生效,定义的组件与使用的组件不能在同一程序集。
WPF/UWP XAML 编译器的命名空间前缀是 x
。如果你写了自己的控件,希望给控件指定一个默认的命名空间前缀,那么可以通过在库中写如下特性(Attribute)实现:
using System.Windows.Markup;
[assembly: XmlnsPrefix("http://walterlv.github.io/demo", "w")]
这样,当 XAML 设计器帮助你自动添加命名空间时,将会使用 w
前缀。虽然实际上你也能随便改。
详情请阅读博客:
此写法要生效,定义的组件与使用的组件不能在同一程序集。
自己写了一个 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")]
此写法要生效,定义的组件与使用的组件不能在同一程序集。
在 WPF 程序中,我们有 Mouse.GetPosition(IInputElement relativeTo)
方法可以拿到鼠标当前相对于某个 WPF 控件的位置,也可以通过在 MouseMove
事件中通过 e.GetPosition(IInputElement relativeTo)
方法拿到同样的信息。不过,在任意时刻去获取鼠标位置的时候,如果鼠标在窗口之外,将获取到什么点呢?
本文将介绍鼠标在窗口之外时获取到的鼠标位置。
直接使用 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);
}
参考资料
如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。
如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!
搜索的时候,推荐使用 OmniSharp
关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。
对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。
准备一个空的文件夹,这个文件夹将会成为我们解决方案所在的文件夹,也就是 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 文件:
如果不能生成所需的文件,你可以使用以下博客中的方法,手动添加这两个文件:
在经过以上两篇博客中的方法之后,你将可以跑起来你的程序。
如果遇到了编译错误……呃这么简单的程序怎么可能遇到编译错误呢?一定是因为之前的操作有问题。可以考虑删除 bin
和 obj
文件夹,然后输入以下命令自行编译:
> 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 包。
> dotnet add Walterlv.Demo package Newtonsoft.Json
这样可以给 Walterlv.Demo
项目引用 Newtonsoft.Json
包。
接下来就像前面一节我们所描述的那样使用这个包里面的类就好了。
.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。
在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。
我找到了两种临时调试而不用编译的方法:
新建一个普通的类库项目,右击项目,属性,打开属性设置页面。进入“调试”标签:
现在,将默认的启动从“项目”改为“可执行文件”,然后将我们本来调试时输出的程序路径贴上去。
现在,如果你不希望编译大项目而直接进行调试,那么将启动项目改为这个小项目即可。
.NET 托管程序的编译速度比非托管程序要快非常多,即便是 .NET Core,只要不编译成 Native 程序,编译速度也是很快的。然而总是有一些逗比大项目编译速度非常缓慢(我指的是分钟级别的),而且还没做好差量编译;于是每一次编译都需要等待几十秒到数分钟。这显然是非常影响效率的。
在解决完项目的编译速度问题之前,如何能够临时进行快速调试改错呢?本文将介绍在 Visual Studio 中不进行编译就调试的方法。
我找到了两种临时调试而不用编译的方法:
有时候只是为了定位 Bug 不断重复运行以调试程序,并没有修改代码。然而如果 Visual Studio 的差量编译因为逗比项目失效的话,就需要手动告诉 Visual Studio 不需要进行编译,直接进行调试。
进入 工具
-> 选项
-> 项目和解决方案
-> 生成并运行
。
“当项目过期时”,选择“从不生成”。
顺便附中文版截图:
这时,你再点击运行你的项目的时候,就不会再编译了,而是直接进入调试状态。
这特别适合用来定位 Bug,因为这时基本不改什么代码,都是在尝试复现问题以及查看各种程序的中间状态。
我们知道,在 WPF 中的坐标单位不是屏幕像素单位,所以如果需要知道某个控件的像素尺寸,以便做一些与屏幕像素尺寸相关的操作,就需要经过一些计算(例如得到屏幕的 DPI)。
更繁琐的是,我们的控件可能外面有一些其他的控件做了 RenderTransform
进行了一些缩放,于是了解到屏幕像素单位就更不容易了。
本文将提供一套计算方法,帮助计算某个 WPF 控件相比于屏幕像素尺寸的缩放比例,用于进行屏幕像素级别的渲染控制。
如下图,我画了一个屏幕,屏幕里面有一个 WPF 窗口,WPF 窗口里面有一个或者多个 ViewBox 或者设置了 RenderTransform
这样的缩放的控件,一层层嵌套下有我们的最终控件。
于是,我们的控件如何得知此时相比于屏幕像素的缩放比呢?换句话说,如何得知此时此控件的显示占了多少个屏幕像素的宽高呢?
从上面的图中,我们可以得知,有两种不同种类的缩放:
我们知道 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 提供了方法 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;
}
}
}
我们可以在命令行中操作 git,但是作为一名程序员,如果在大量重复的时候还手动敲命令行,那就太笨了。
本文介绍使用 C# 编写一个 .NET 程序来自动化地使用 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
表示不要为这个命令单独创建一个控制台窗口
RedirectStandardOutput
进行输出的重定向
true
的属性,因为我们希望拿到命令的输出结果。WorkingDirectory
设置工作路径
git
命令来说,一般都是对一个已有的 git 仓库进行操作,所以当然要指定一个合理的 git 仓库了。UseShellExecute
设置为 false
表示不要使用 ShellExecute
函数创建进程
false
,因为要重定向输出的话,这是唯一有效值。顺便一提,此属性如果不设置,默认值是 true
。为了方便起见,我将全部运行一个命令的代码封装到了一个 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();
}
}
}
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,左侧会默认选中 Sample Shaders 即着色器示例,对于不了解像素着色器能够做到什么效果的小伙伴来说,仅浏览这里面的特效就能够学到很多好玩的东西。
旁边是 Tutorial 教程,这里的教程是配合 HLSL and Pixel Shaders for XAML Developers 这本书来食用的,所以如果希望能够系统地学习 HLSL,那么读一读这本书跟着学习里面的代码吧!
左边的另一个标签是 Your Folder,可以放平时学习 HLSL 时的各种代码,也可以是你的项目代码,这里会过滤出 .fx
文件用于编写 HLSL 代码。
如果你打开关于界面,你可以看到这款软件很用心地在关于窗口背后使用了 TelescopicBlur 特效,这是一个 PS_3 特效,后面会解释其含义。
依然在左侧,可以选择 Settings 设置。
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。
默认是 Shazzam,实际上在接入到你的项目的时候,这个命名空间肯定是要改的,所以建议改成你项目中需要使用到的命名空间。比如我的是 Walterlv.Effects
。
改好之后,如果你编译你的 .fx
文件,也就是编写了 HLSL 代码的文件,那么顺便也会生成一份使用 Walterlv.Effects
命名空间的 C# 代码便于你将此特效接入到你的 WPF 应用程序中。
默认的缩进是 Tab,非常不清真,建议改成四个空格。
如果你的特效是为了制作动画(实际上在 Shazzam 中编写的 HLSL,任何一个寄存器(变量)都可以拿来做动画),那么此值将给动画设置一个默认的时长。
相比于前面的所有设置,这个设置不会影响到你的任何代码,只是决定你预览动画效果时的时长,所以设置多少都没有影响。
实际上本文不会教你编写任何 HLSL 代码,也不会进行任何语法入门之类的,我们只需要了解 Shazzam 是如何帮助我们为 WPF 程序编写像素着色器代码的。
将你的视线移至下方富含代码的窗格,这里标记着 XXX.fx 的标签就是 HLSL 代码了。大致浏览一下,你会觉得这风格就是 C 系列的语言风格,所以从学校里出来的各位应该很有亲切感,上手难度不高。
按下 F5,即可立即编译你的 HLSL 代码,并在界面上方看到预览效果。别说你没有 HLSL 代码,前面我们可是打开了那么多个示例教程呀。
确保你刚刚使用 F5 编译了你的 HLSL 代码。这样,你就能在这个窗格看到各种预览调节选项。
你可以直接拉动拉杆调节参数范围,也可以直接开启一个动画预览各种值的连续变化效果。
继续切换一个标签,你可以看到 Shazzam 为你生成的 C# 代码。实际上稍后你就可以直接使用这份代码驱动起你刚刚编写的特效。
代码风格使用了我们刚刚设置的一些全局参数。
将像素着色器放到 WPF 项目中需要经过两个步骤:
我们需要将两个文件加入到你的 WPF 程序中:
.ps
文件,即刚刚的 .fx
文件编译后的像素着色器文件;这些文件都可以使用以下方法找到:
%LocalAppData%\Shazzam\GeneratedShaders
文件夹;.fx
文件命名为 walterlv.fx
,那么生成的文件就会在 WalterlvEffect
文件夹下.ps
文件随后,将这两份文件一并加入到你的 WPF 项目工程文件中。
但是,请特别注意路径!留意你的 C# 代码,里面是编写了像素着色器的路径的:
Walterlv.Effects
的部分改成你的程序集名称;/WalterlvEffect.ps
的前面加上子文件夹。// 记得修改程序集名称,以及 .ps 文件所在的文件夹路径!切记!
pixelShader.UriSource = new Uri("/Walterlv.Effects;component/WalterlvEffect.ps", UriKind.Relative);
需要使用 Resource
方式编译此 .ps
文件到 WPF 项目中。
如果你使用的是旧的项目格式,则右键此 .ps
文件的时候选择属性,你可以在 Visual Studio 的属性窗格的生成操作中将其设置为 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 程序中使用这个特效,则设置控件的 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 特效加入到我的云键盘窗口中,给整个窗口带来的视觉效果。
本文毕竟是一篇入门文章,没有涉及到任何的技术细节。你可以按照以下问题检查是否入门成功:
参考资料
每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。
带起你的好奇心,本文将使用 C# 开发各种各样好玩的东西。
本文内容已加入 2019 年 4 月 13 日的广州 .NET 俱乐部第 2 届线下沙龙。
每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。
比如这件事:
在好奇心的驱使下,我们立刻 尝试 我们的想法。
我们需要用电脑打字,手机端出字;于是我们需要开发的是一款云输入法。而一个最简单的云驱动的软件需要至少一个 Web 后端、一个桌面端和一个移动端。
还没开始呢,就这么复杂。
摆在我们面前的,有两条路可以选:
如果先搞起来,那么我们能够迅速出效果,出产品,出玩具,那么这种成就感会鼓励我们继续完善我们的代码,继续去做更多好玩的东西。
而如果是先掌握所有理论知识再实践,这是我们从学校带来的学习方式,我们中的多数人在校期间就是这么学习的。虽然对学霸来说可以无视,但对于我们这样大多数的小伙伴来说,简直就是“从入门到放弃”。
如果先搞起来呢?如果我们连“入门”都不需要呢?是不是就不需要放弃了!
怎么才能够先搞起来?我们需要调整一下心态——我们不是在学,而是在玩!
我们需要做的是降低学习成本,甚至入门不学习,那么立刻就能玩起来!
我们有 C#,还有什么不能马上搞起来!
打开 Visual Studio 2019,我们先搞起来!
对于简单的云服务来说,使用 Asp.NET Core 开发是非常简单快速的。你可以阅读林德熙的博客入门 Asp.NET Core 开发:
我们是要玩的呀,什么东西好玩。我们自己就是用户,用户看得到的部分才是最具有可玩性的。这就是指客户端或者 Web 前端。
我们现在要拿 C# 写客户端,一般 C# 或者 .NET 的开发者拿什么来写桌面客户端呢?
我们现在已经有至少两个端了。由于我们是同一个软件系统,所以实际上非常容易出现公共代码。典型的就是一些数据模型的定义,以及 Web API 的访问代码,还有一些业务需要的其他公共代码等等。
所以,我们最好使用一个新的项目将这些代码整合起来。
我们选用 .NET Standard 项目来存放这些代码,这样可以在各种 .NET 中使用这些库。
由于我们多数的代码都可以放到 .NET Standard 类库中,以确保绝大多数的代码都是平台和框架无关的,所以实际上我们在其他各个端项目中的代码会是很少的。
这个时候,写一个控制台程序来测试我们的项目,控制台程序的部分其实只需要很少的用于控制控制台输入输出的代码,其他多数的代码例如用来访问 Web API 的代码都是不需要放在控制台项目中的,放到 .NET Standard 的类库中编写就可以做到最大程度的共用了。
接下来要完成这个云键盘程序,我们还需要开发一个移动端。使用 Xamarin 可以帮助我们完成这样的任务。
关于使用 Xamarin.Forms 开发一个键盘扩展,可以阅读我的另一篇博客:
于是,我们仅仅使用 C# 还有客户端开发者熟悉的 XAML 就开发出了三个端了。
这三个端中,有两个都是客户端,于是就会存在向用户分发客户端的问题。虽然可以让用户去商店下载,但是提供一个官方下载页面可以让用户在一处地方找到所有端的下载和部署方法。
这需要使用到前端。然而如何使用 C# 代码来编写去前端呢?
使用 CSHTML5!
你可以前往 CSHTML5 的官网 下载 Visual Studio 的插件,这样你就可以在 Visual Studio 中编写 CSHTML5 的代码了,还有设计器的支持。
于是我们使用 XAML + C# 就编写出了各个端了。
如果没有 GUI,那么跨平台将是非常容易的一件事情。例如我们想要在 Mac 电脑上也做一个打字发送的一方,那么一个控制台应用也是能够直接完成的。
不过,这并不是说,我们只能通过控制台来开发桌面端应用。
我们还有:
利用这些平台,我们能开发其他桌面平台的 GUI 客户端。
另外,利用 ML.NET,我们还能用 C# 进行机器学习。可参见:Bean.Hsiang - 博客园。
利用 Roslyn,我们还能用直接做编译器,然后你还有什么不能做的?关于 Roslyn 的入门,可以阅读:从零开始学习 dotnet 编译过程和 Roslyn 源码分析 - walterlv。
还有 IoT。
还有其他……
每个人都拥有 好奇心,好奇心驱使着我们总是去尝试做一些有趣的事情。
使用你熟悉的语言 C#,不需要太多额外的入门,即可玩转你身边各种你需要的技术栈,玩出各种各样你自己期望尝试开发的小东西。
我们都知道可以通过在 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)
互为默认值,任何一个先设置都会影响另一个;此值即 x86
、x64
等标识符。可以通过 $(AppendRuntimeIdentifierToOutputPath)
属性指定是否将此加入到输出路径中。
$(TargetFramework)
这是在 csproj 文件中强制要求指定的,如果不设置的话项目是无法编译的;可以通过 $(AppendTargetFrameworkToOutputPath)
属性指定是否将此加入到输出路径中。
现在,你应该可以更轻松地设置你的输出路径,而不用担心总会出现各种意料之外的子文件夹了吧!
因为我使用 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 文件。
打开它,然后删掉刚刚的逗比行为,添加 "nativeDebugging": true
。这时,你的 lauchsettings.json 文件影响像下面这样:
{
"profiles": {
"Walterlv.Debugging": {
"commandName": "Project",
"nativeDebugging": true
}
}
}
这时你就可以开启本机代码调试了。当然,新的项目格式支持设置多个这样的启动项,于是你可以分别配置本机和非本机的多种配置:
{
"profiles": {
"Walterlv.Debugging": {
"commandName": "Project"
},
"本机调试": {
"commandName": "Project",
"nativeDebugging": true
}
}
}
现在,你可以选择你项目的启动方式了,其中一个是开启了本机代码调试的方式。
关于这些配置的更多博客,你可以阅读:VisualStudio 使用多个环境进行调试 - 林德熙。
参考资料
如果你要在 WPF 程序中使用线程池完成一个特殊的任务,那么使用 .NET 的 API Task.Run
并传入一个 Lambda 表达式可以完成。不过,使用 Lambda 表达式会带来变量捕获的一些问题,比如说你需要区分一个变量作用于是在 Lambda 表达式中,还是当前上下文全局(被 Lambda 表达式捕获到的变量)。然后,在静态分析的时候,也难以知道此 Lambda 表达式在整个方法中的执行先后顺序,不利于分析潜在的 Bug。
在使用 async
/await
关键字编写异步代码的时候,虽然说实质上也是捕获变量,但这时没有显式写一个 Lambda 表达式,所有的变量都是被隐式捕获的变量,写起来就像在一个同步方法一样,便于理解。
以下 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 的版本变得很不好理解了。
我们现在编写一个自己的 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 后来在另一篇博客中也编写了一份 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());
}
}
参考资料
在 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 的命令只会在以下时机刷新可用性:
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();
}
然而,并不是只在这些时机进行刷新,还有其他的时机,比如这些:
Menu
菜单的子菜单项打开的时候(参见 MenuItem.OnIsSubmenuOpenChanged)DataGridCell
的只读属性改变的时候(参见 DataGridCell.OnNotifyIsReadOnlyChanged)DataGrid
中的各种各样的操作中(参见 DataGrid)JournalNavigationScope
向后导航的时候(参见 JournalNavigationScope.OnBackForwardStateChange)InvalidateRequerySuggested
查看:InvalidateRequerySuggestedWhen 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:
<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;
Call this method after you want to update your command states if it won’t update:
CommandManager.InvalidateRequerySuggested();
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:
DataGridCell
. DataGridCell.OnNotifyIsReadOnlyChangedDataGrid
. DataGridJournalNavigationScope
. JournalNavigationScope.OnBackForwardStateChangeInvalidateRequerySuggested
: InvalidateRequerySuggested在 .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()
中我们可以得知,它包含三个部分:
Type.FullName
Exception.Message
Exception.StackTrace
考虑到 Message
部分受多语言影响非常严重,很难作为关键异常特征,所以我们在提取关键异常特征的时候,需要将这一部分去掉,只能作为此次异常的附加信息,而不能作为关键特征。
所以我们的关键特征就是:
Type.FullName
比如本文一开始列举出来的异常堆栈,我们应该提取成:
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# 代码来做这样的事情:
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
类型来完成异常特征提取的事情。这个类同时重写了相等方法,这样可以直接使用相等方法来判断两个异常的关键信息是否表示的是同一个异常。
源码可以在这里找到: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);
}
}
}
参考资料
在 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
就好。因为我们只需要当前调用堆栈中的异常处理执行完成即可。
关于等待时间,可以阅读我的另一篇博客:
如果需要对此异常进行后续的分析,可以参考我的另一篇博客:
本文主要说的是 .NET 客户端应用,可以是只能在 Windows 端运行的基于 .NET Framework 或基于 .NET Core 的 WPF / Windows Forms 应用,也可以是其他基于 .NET Core 的跨平台应用。但是不是那些更新权限受到严格控制的 UWP / iOS / Android 应用。
本文将编写一个简单的程序,这个程序初次运行的时候会安装自己,如果已安装旧版本会更新自己,如果已安装最新则直接运行。
简单的安装过程实际上是 解压 + 复制 + 配置 + 外部命令
。这里,我只做 复制 + 配置 + 外部命令
,并且把 配置 + 外部命令
合为一个步骤。
于是:
于是我写了一个简单的类型用来做自安装。创建完 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,
}
}
如果你的程序对 Windows 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项 UAC 权限设置的实际效果。
阅读本文之前,你可能需要了解如何创建应用程序清单文件。阅读我的另一篇博客可以了解:
从默认生成的应用程序清单中,我们可以很容易的知道有四种不同的设置:
asInvoker
requireAdministrator
highestAvailable
requestedExecutionLevel
元素 (不要忘了还可以删除)当然这里我们是没有考虑 uiAccess
的。你可以阅读我的另一篇博客了解 uiAccess
的一项应用:
父进程是什么权限级别,那么此应用程序作为子进程运行时就是什么权限级别。
默认情况下用户启动应用程序都是使用 Windows 资源管理器(explorer.exe)运行的;在开启了 UAC 的情况下,资源管理器是以标准用户权限运行的。于是对于用户点击打开的应用程序,默认就是以标准用户权限运行的。
如果已经以管理员权限启动了一个程序,那么这个程序启动的子进程也会是管理员权限。典型的情况是一个应用程序安装包安装的时候使用管理员权限运行,于是这个安装程序在安装完成后启动的这个应用程序进程实例就是管理员权限的。有时候这种设定会出现问题,你可以阅读 在 Windows 系统上降低 UAC 权限运行程序(从管理员权限降权到普通用户权限)。
此程序需要以管理员权限运行。
在资源管理器中可以看到这样的程序图标的右下角会有一个盾牌图标。
用户在资源管理器中双击启动此程序,或者在程序中使用 Process.Start
启动此程序,会弹出 UAC 提示框。点击“是”会提权,点击“否”则操作取消。
此程序将以当前用户能获取的最高权限来运行。
这个概念可能会跟前面说的 requireAdministrator
弄混淆。
要更好的理解这两个概念的区别,你可能需要对 UAC 用户账户控制有一个初步的了解,可以阅读我的另一篇博客:
接下来的内容,都假设你已经了解了上文所述的 UAC 用户账户控制。
如果你指定为 highestAvailable
:
Process.Start
启动此程序会弹出 UAC 提示框。在用户同意后,你的程序将获得完全访问令牌(Full Access Token)。highestAvailable
已经达到了要求。资源管理器上不会出现盾牌图标,双击或使用 Process.Start
启动此程序也不会出现 UAC 提示框,此程序将以受限权限执行。下图是一个例子。lvyi 是我安装系统时创建的管理员账号,但是我使用的是 walterlv 标准账号。正常是在 walterlv 账号下启动程序,但以管理员权限运行时,会要求输入 lvyi 账号的密码来提权,于是就会以 lvyi 的身份运行这个程序。这种情况下,那个管理员权限运行的程序会以为当前运行在 lvyi 这个账户下,程序员需要小心这里的坑,因为拿到的用户路径以及注册表不是你所期望的 walterlv 这个账号下的。
删除 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 虚拟化。
我将这个节点删除后,运行我的 Demo 程序后 UAC 虚拟化将启用。默认这里是“已禁用”的。
不过在以下任意一种情况下,UAC 虚拟化即便删了 requestedExecutionLevel
也是不会开启的:
这部分的列表你可以在这里查询到:Registry Virtualization - Windows applications - Microsoft Docs。
asInvoker
是默认情况下的首选。如果你的程序没有什么特殊的需求,就使用 asInvoker
;就算你的程序需要管理员程序做一些特殊的任务,那最好也写成 asInvoker
,仅在必要的时候才进行管理员权限提升。requireAdministrator
,只有当你的程序大量进行需要管理员权限的操作的时候才建议使用 requireAdministrator
值,例如你正在做安装程序。highestAvailable
,当你的程序需要管理员权限,但又要求仅对当前用户修改时设置为此值。因为标准用户申请 UAC 提权之后会以其他用户的身份运行进程,这就不是对当前用户的操作了;使用 highestAvailable
来确保以当前用户运行。在我的另一篇博客 Windows 中的 UAC 用户账户控制 中说到了访问令牌。
UWP 程序只能获得受限访问令牌,没得选,所以也就不需要指定 UAC 清单选项了。这也是为什么当你关闭 UAC 之后,UWP 程序将全部闪退的重要原因。
参考资料
在默认情况下,Windows 系统中启动一个进程会继承父进程的令牌。如果父进程是管理员权限,那么子进程就是管理员权限;如果父进程是标准用户权限,那么子进程也是标准用户权限。
我们也知道,可以使用一些方法为自己的应用程序提权。但是有没有方法可以任意指定一个权限然后运行呢?本文将介绍 Windows 下指定权限运行的做法。
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,然后准备在这两个窗口里面分别启动我的检测管理员权限的程序。
0x20000 是标准用户权限,现在运行命令:
> runas /trustlevel:0x20000 .\Walterlv.Demo.exe
运行发现,两个进程现在都是标准用户权限。即使是管理员的 PowerShell 中运行的也都是非管理员权限。
0x40000 是管理员权限,现在运行命令:
> runas /trustlevel:0x40000 .\Walterlv.Demo.exe
运行发现,非管理员的 PowerShell 启动的是非管理员权限的进程;而管理员的 PowerShell 启动的是管理员权限的进程。
使用 C# 代码,就是要将下面这一句翻译成 C#。
> runas /trustlevel:0x20000 .\Walterlv.Demo.exe
所以其实非常简单,就是 Process.Start
传入参数即可。
Process.Start("runas.exe", $"/trustlevel:0x20000 Walterlv.Demo.exe");
关于更多降权运行的方法,可以参考我的另一篇博客:
参考资料
在 Windows 系统中,管理员权限和非管理员权限运行的程序之间不能使用 Windows 提供的通信机制进行通信。对于部分文件夹(ProgramData),管理员权限创建的文件是不能以非管理员权限修改和删除的。
然而,一个进程运行之后启动的子进程,会继承当前进程的 UAC 权限;于是有时我们会有降权运行的需要。本文将介绍 Windows 系统上降权运行的几种方法。
本文的降权运行指的是:
通过下面的代码,可以获得当前进程的 UAC 权限。
var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
而如果要判断是否是管理员权限,则使用:
if (principal.IsInRole(WindowsBuiltInRole.Administrator))
{
// 当前正在以管理员权限运行。
}
此代码如果在 .NET Core 中编写,需要额外安装 Windows 兼容包:Microsoft.Windows.Compatibility。
使用 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 相同权限的。
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
中有 UserName
和 Password
属性,设置此属性可以以此计算机上的另一个用户身份启动此进程。如果这个用户是普通用户,那么就会以普通权限运行此进程。
var processInfo = new ProcessStartInfo
{
Verb = "runas",
FileName = "walterlv.exe",
UserName = "walterlv",
Password = ReadPassword(),
UseShellExecute = false,
LoadUserProfile = true
};
Process.Start(processInfo);
上面的 ReadPassword
函数来自我的另一篇博客:如何让 .NET Core 命令行程序接受密码的输入而不显示密码明文 - walterlv。
然而,此方法最大的问题在于——产品级的程序,不可能也不应该知道用户的密码!所以实际上这样的方法并不实用。
此方法需要较多的 Windows API 调用,我没有尝试过这种方法,但是你可以自行尝试下面的链接:
参考资料
如果你的程序对 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>
你也可以创建一个不带应用程序清单的应用程序。方法是在属性页中将清单设置为“创建不带清单的应用程序”。
本文介绍如何结束掉一个进程。
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
,在结束掉自己的时候记录自己的错误日志。
如果你是开发个人项目,那就直接用 Visual Studio Community 版本吧,对个人免费,对小团体免费,不需要这么折腾。
如果你是 Mac / Linux 用户,不想用 Visual Studio for Mac 版;或者不想用 Visual Studio for Windows 版那么重磅的 IDE 来开发简单的 .NET Core 程序;或者你就是想像我这么折腾,那我们就开始吧!
搜索的时候,推荐使用 OmniSharp
关键字,因为这可以得到唯一的结果,你不会弄混淆。如果你使用 C# 作为关键字,那需要小心,你得找到名字只有 C#,点开之后是 C# for Visual Studio Code 的那款插件。因为可能装错,所以我不推荐这么做。
对于新版的 Visual Studio Code,装完会自动启用,所以你不用担心。我们可以后续步骤了。
本文不会讲解如何使用 VSCode 创建 .NET Core 项目,因为这不是本文的重点。
也许你可以参考我还没有写的另一篇博客。
现在假设你已经有一个现成的能用 Visual Studio 跑起来的 .NET Core 控制台项目了(可能是刚克隆下来的,也可能就是用我另一篇博客中的教程创建的),于是我们就在这个项目上进行开发。
本文以我的自动化测试程序 Walterlv.InfinityStartupTest 为例进行说明。如果你找不到合适的例子,可以使用这篇博客创建一个。
在这个文件夹的根目录下右键,然后 使用 Code 打开
。
正常情况下,当你用 Visual Studio Code 打开一个包含 .NET Core 项目的文件夹时,C# 插件会在右下角弹出通知提示,问你要不要为这个项目创建编译和调试文件,当然选择“Yes”。
这个提示一段时间不点会消失的,但是右下角会有一个小铃铛(上面的图片也可以看得到的),点开可以看到刚刚消失的提示,然后继续操作。
这时,你的项目文件夹中会多出两个文件,都在 .vscode 文件夹中。tasks.json
是编译文件,指导如何进行编译;launch.json
是调试文件,指导如何进行调试。
现在,你只需要按下 F5(就是平时 Visual Studio 调试按烂的那个),你就能使用熟悉的调试方式在 Visual Studio Code 中来调试 .NET Core 程序了。
下图是调试进行中各个界面的功能分区。如果你没看到这个界面,请点击左侧那只被圈在圆圈里面的小虫子。
当你按照本文操作,在按下 F5 后有各种报错,那么原因只有一个——你的这个项目本身就是编译不过的,你自己用命令行也会编译不过。你需要解决编译问题,而本文只是入门教程,不会说如何解决编译问题。
如果自动创建的这两个文件有问题,或者你根本就找不到自动创建的入口,可以考虑手工创建这两个文件。
请参见博客:
还补充一句,本文说编译文件和调试文件是不对的,因为在 Visual Studio Code 中没有编译这个概念,编译只是任务中的一种而已。
如果 C# for Visual Studio Code 没有办法自动为你生成正确的 tasks.json 和 launch.json 文件,那么可以考虑阅读本文手工创建他们。
你需要安装 .NET Core Sdk、Visual Studio Code 和 C# for Visual Studio Code,然后打开一个 .NET Core 的项目。如果你没有准备,请先阅读:
本文主要处理自动生成的配置文件无法满足要求,手工生成。
这依然是个偷懒的好方案,我喜欢。
你不需要再做什么其他的工作了,这时再按下 F5 你已经可以开始调试了。
tasks.json 定义一组任务。其中我们需要的是编译任务,通常编译一个项目使用的动词是 build
。比如 dotnet build
命令就是这样的动词。
于是定义一个名字为 build
的任务,对应 label
标签。command
和 args
对应我们在命令行中编译一个项目时使用的命令行和参数。type
为 process
表示此任务是启动一个进程。
{
"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}"
}
]
}
这样自己手写的方式更灵活但是也更难。
Windows 系统以及很多应用程序会考虑使用系统的环境变量来传递一些公共的参数或者配置。Windows 资源管理器使用 %var%
来使用环境变量,那么我们能否在 Visual Studio 的项目文件中使用环境变量呢?
本文介绍如何在 csproj 文件中使用环境变量。
在 Windows 资源管理器中,我们可以使用 %AppData%
进入到用户的漫游路径。我正在为 希沃白板5 为互动教学而生 - 课件制作神器 编写插件,于是需要将插件放到指定目录:
%AppData%\Seewo\EasiNote5\Walterlv.Presentation
在 Windows 资源管理器中可以直接输入以上文字进入对应的目录(当然需要确保存在)。
更多关于路径的信息可以参考:UWP 中的各种文件路径(用户、缓存、漫游、安装……) - walterlv
然而,为了调试方便,我最好在 Visual Studio 中编写的时候就能直接输出到插件目录。
于是,我需要将 Visual Studio 的调试目录设置为以上目录,但是以上目录中包含环境变量 %AppData%
如果直接在 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
出现在了目标输出路径中。你可以阅读我的另一篇博客了解更多关于输出路径的问题:
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
参数,于是点击之后会带里面设置的参数启动自己;同时设置了 IconResourcePath
和 IconResourceIndex
用于指定图标。
这种图标的指定方式是 Windows 系统中非常常用的方式。你可以在我的另一篇博客中找到各种各样系统自带的图标;至于序号,则是自己去数。
JumpList
有两个属性 ShowRecentCategory
和 ShowFrequentCategory
,如果指定为 true
则表示操作系统会自动为我们保存此程序最近使用的文件的最频繁使用的文件。
Windows 的跳转列表有两种不同的列表项,一种是“任务”,另一种是文件。至于这两种不同的列表项如何在跳转列表中安排,则是操作系统的事情。
这两种不同的列表项对应的类型分别是:
JumpTask
JumpPath
JumpTask
可以理解为这就是一个应用程序的快捷方式,可以指定应用程序的路径(ApplicationPath
)、工作目录(WorkingDirectory
)、启动参数(Arguments
)和图标(IconResourcePath
、IconResourceIndex
)。如果不指定路径,那么就默认为当前程序。也可以指定显示的名称(Title
)和鼠标移上去可以看的描述(Description
)。
JumpPath
则是一个路径,可以是文件或者文件夹的路径。通常用来作为最近使用文件的展示。特别说明:你必须关联某种文件类型这种类型的文件才会显示到 JumpPath
中。
另外,JumpTask
和 JumpPath
都有一个 CustomCategory
属性可以指定类别。对于 JumpTask
,如果不指定类别,那么就会在默认的“任务”(Task)类别中。对于 JumpPath
,如果不指定类别,就在最近的文件中。
JumpTask
如果不指定 Title
和 CustomCategory
属性,那么他会成为一个分隔符。
参考资料
程序如何自己更新自己呢?你可能会想到启动一个新的程序或者脚本来更新自己。然而 Windows 操作系统允许一个应用程序在运行期间修改自己的名称甚至移动自己到另一个文件夹中。利用这一点,我们可以很简单直接地做程序的 OTA 自动更新。
本文将介绍示例程序运行期间改名并解释其原理。
我们写一个简单的程序。
将它运行起来,然后删除。我们会发现无法删除它。
但是,我们却可以很轻松地在资源管理器中对它进行改名,甚至将它从一个文件夹中移动到另一个文件夹中。
值得注意的是,你不能跨驱动器移动此文件。
实际上,不止是 exe 文件,在 exe 程序运行期间,即使用到了某些 dll 文件,这些 dll 文件也是可以改名的。
当然,一个 exe 的运行不一定在启动期间就加载好了所有的 dll,所以如果你在 exe 启动之后,某个 dll 加载之前改了那个 dll 的名称,那么会出现找不到 dll 的情况,可能导致程序崩溃。
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 程序除外)。
参考资料
最近我大幅度重构了我一个库的项目结构,使之使用最新的项目文件格式(基于 Microsoft.NET.Sdk)并使用 SourceYard 源码包来打包其中的一些公共代码。不过,最终生成了一个新的 dll 之后却心有余悸,不知道我是否删除或者修改了某些 API,是否可能导致我原有库的使用者出现意料之外的兼容性问题。
另外,准备为一个产品级项目更新某个依赖库,但不知道更新此库对我们的影响有多大,希望知道目前版本和希望更新的版本之间的 API 差异。
索性发现了 JustAssembly 可以帮助我们分析程序集 API 的变化。本文将介绍如何使用 JustAssembly 来分析不同版本程序集 API 的变化。
JustAssembly 是 Telerik 开源的一款程序集分析工具。
你可以去它的官网下载并安装:Assembly Diff Tool for .NET - JustAssembly。
启动 JustAssembly,在一开始丑陋(逃)的界面中选择旧的和新的 dll 文件,然后点击 Load
。
然后,你就能看到新版本的 API 相比于旧版本的差异了。
在差异界面中,差异有以下几种显示:
+
符号显示-
符号显示~
符号显示这里可能需要说明一下“部分差异”:由于差异是以树状结构显示的,所以如果子节点有新增,那么父节点因为既有新增又存在未修改的节点,所以会以“有部分差异”的方式显示。
对于每一个差异,双击可以去看差异的代码详情。
上图我的 SourceFusion 项目在版本更新的时候只有新增的 API,没有修改和删除的 API,所以还是一个比较健康的 API 更新。
参考资料
使用 .NET 的反射 API 时,通常会要求我们传入一个 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
InternalsVisibleTo("B")
),那么 B 在反射查找 A 的时候,internal
成员的查找应该使用 Public
还是 NonPublic
标记呢?
NonPublic
标记。从上面的解释中可以发现,这个类型的设计其实是有问题的,不符合单一职责原则。所以我们会在不同的使用场景下使用不同区域的组合。
查找,也就是获取一个类型中的字段、属性、方法等的时候使用的。
拿到所有成员:
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance
实际上 RuntimeReflectionExtensions.Everything
属性就是这么写的。
拿到公有的实例成员:
BindingFlags.Public | BindingFlags.Instance
[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
}
参考资料
很多库都会在 nuget.org 上发布预览版本,不过一般来说这个预览版本也是大多可用的。然而想要体验日构建版本,这个就没有了,毕竟要照顾绝大多数开发者嘛……
本文介绍如何使用 MyGet 这个激进的 NuGet 源,介绍如何使用框架级别的库的预览版本如 .NET Standard 的预览版本。
添加 NuGet 源的方法在我和林德熙的博客中都有说明:
简单点,就是在 Visual Studio 中打开 工具
-> 选项
-> NuGet 包管理器
-> 包源
:
然后把 MyGet 的源添加进去:
如果你想添加其他的 NuGet 源,可以参见我的另一篇博客:我收集的各种公有 NuGet 源 - 吕毅。
因为我们在使用 .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 的正式版本库。
在自己的进程内部,我们可以通过 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>
然后,我们使用 ManagementObjectSearcher
和 ManagementBaseObject
来获取命令行参数。
为了简便,我将其封装成一个扩展方法,其中包括对于一些异常的简单处理。
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() ?? "";
}
}
}
}
}
使用此方法得到的命令行参数是一个字符串,而不是我们通常使用字符串时的字符串数组。如果你需要将其转换为字符串数组,可以使用我在另一篇博客中使用的方法:
参考资料
如果你写了一个 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();
现在虽然可以编译通过,但是我们会遇到两个问题:
ValueProperty
的变更通知的回调函数中,我们只能找到 _dependencyObject
的实例,而无法找到外面的类型 WalterlvExtension
的实例;这几乎使得 Value
的变更通知完全失效。Value
的 set
方法中得到的 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);
}
}
这段代码的意思是这样的:
owner
参数完全没有用,我只是拿来备用,你可以删掉。attachedProperty
参数是需要定义的附加属性。
valueChangeCallback
参数是为了指定变更通知的,因为前面我们说变更通知不好做,于是就这样代理做变更通知。GetValue
和 SetValue
这两个方法是用来代替 DependencyObject
自带的 GetValue
和 SetValue
的,目的是执行我们希望特别执行的方法。SetValue
中我们需要自己考虑绑定对象,如果发现是绑定,那么就真的进行一次绑定。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;
}
}
对于一个属性来说,代码确实多了些,这实在是让人难受。可是,这可以达成目的呀!
解释一下:
_valueExchanger
,就是在使用我们刚刚写的那个新类。_valueExchanger
进行初始化,因为要传入 this
和一个实例方法 OnValueChanged
,所以只能在构造函数中初始化。ClrBindingExchanger.ValueChangeCallback
Value
GetValue
方法要换成我们自定义的 GetValue
哦SetValue
方法也要换成我们自定义的 SetValue
哦,这样绑定才可以生效OnValueChanged
就是我们实际的变更通知,这里得到的 oldValue
和 newValue
就是你期望的值,而不是我面前面奇怪的绑定实例。于是,绑定就这么在一个普通的类型和一个普通的 CLR 属性中生效了,而且还获得了变更通知。
参考资料
本文没有任何参考资料,所有方法都是我(walterlv)的原创方法,因为真的找不到资料呀!不过在找资料的过程中发现了一些没解决的文档或帖子:
本文介绍四种不同的获取可执行程序文件路径的方法。适用于 .NET Core 以及 .NET Framework。
var executablePath = Assembly.GetEntryAssembly().Location;
这种方式的思路是获取入口程序集所在的路径。不过 Assembly.GetEntryAssembly()
能获取到的程序集是入口托管程序集;使用此方法会返回第一个托管程序集。
只有 .NET Framework 程序的入口才是托管程序(exe)。而对于 .NET Core 程序,如果直接发布成带环境依赖声明的 dll,那么实际运行的进程是 dotnet.exe;而如果发布成自包含的 exe 程序,其主 exe 也是一个非托管的 CLR 启动器而已,并不是托管程序集。
所以此方法适用条件:
var executablePath = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
这种方式的思路是获取当前 AppDomain 所在的文件夹。不过此方法也只是获取到文件夹而已,不包含文件名。
所以此方法适用条件:
当然,此方法因为不涉及到托管和非托管程序集,所以与编译结果无关,适用于 .NET Core 和 .NET Framework 程序。
var executablePath = Process.GetCurrentProcess().MainModule.FileName;
这种方式的思路是获取当前进程可执行程序的完全路径。
对于 .NET Framework 程序,其 exe 就是这个路径。
对于 .NET Core 程序来说:
所以此方法适用条件:
我在另一篇博客中提到命令行参数中包含应用程序路径:
于是我们也可以通过命令行参数来获取到可执行程序的路径。
var executablePath = Environment.GetCommandLineArgs()[0];
这种方法的效果和前面使用进程信息获取的效果是相同的,会获取到相同的可执行程序路径。
通过以上方法的说明,我们可以知道目前没有 100% 可靠的获取当前可执行程序文件路径的方法,不过可以组合多种方法达到 100% 可靠的目的。
AppDomain.CurrentDomain.SetupInformation.ApplicationBase
Process.GetCurrentProcess().MainModule.FileName
Environment.GetCommandLineArgs()[0]
dotnet.exe
,那么再通过程序集信息获取
Assembly.GetEntryAssembly().Location
另外,关于以上方法的性能对比,你可以参阅林德熙的博客:dotnet 获取路径各种方法的性能对比。
使用 C# 语言编写字符串常量的时候,你可能会发现可以使用 ""
而不能使用 string.Empty
。进一步可以发现 string.Empty
实际上是一个静态只读字段,而不是一个常量。
为什么这个看起来最适合是常量的 string.Empty
,竟然使用静态只读字段呢?
这个问题,我们需要去看 .NET Core 的源码(当然 .NET Framework 也是一样的)。
[Intrinsic]
public static readonly string Empty;
值得注意的是上面的 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
字段并不是一个普通的字段,对它的调用会被特殊处理。但是是如何特殊处理呢?
对 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 的构造函数,以便编译器不会将其标记为文字。将其标记为文字将意味着它不会显示为我们可以从本机代码访问的字段。
说明一下:
""
时使用的 IL 是 ldstr ""
(Load String Literal)string.Empty
时使用的 IL 是 ldsfld string [mscorlib]System.String::Empty
(Load Static Field)""
和 string.Empty
时生成的 IL 不同,但是在 JIT 编译成本机代码的时候,生成的代码完全一样。
""
会造成性能问题,因为实际上 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
的注释描述中可以知道:
ldstr ""
,而这种方式不会调用到 String
类的构造函数(注意不是静态构造函数,String
类的静态构造函数是特殊处理不会调用的);ldsfld string [mscorlib]System.String::Empty
,这在首次执行时会触发 String
类的构造函数,并在本机代码(非托管代码)中完成初始化。当然,事实上编译器也可以针对此场景做特殊处理,但为什么不是在编译这一层进行特殊处理,我已经找不到出处了。
不行!
实际上,在 .NET Framework 4.0 及以前是可以反射修改其值的,这会造成相当多的基础组件不能正常工作,在 .NET Framework 4.5 和以后的版本,以及 .NET Core 中,CLR 运行时已经不允许你做出这么出格儿的事了。
不过,如果你使用不安全代码(unsafe
)来修改这个字段的值就当我没说。关于使用不安全代码转换字符串的方法可以参见:
""
和 string.Empty
到底有什么区别?从前文你可以得知,在运行时级别,这两者 没有任何区别。
于是,当你需要一个代表 “空字符串” 含义的时候,使用 string.Empty
;而当你必须要一个常量时,就使用 ""
。
参考资料
使用 Visual Studio 调试 .NET 程序的时候,在局部变量窗格或者用鼠标划到变量上就能查看变量的各个字段和属性的值。默认显示的是对象 ToString()
方法调用之后返回的字符串,不过如果 ToString()
已经被占作它用,或者我们只是希望在调试的时候得到我们最希望关心的信息,则需要使用 .NET 中调试器相关的特性。
本文介绍使用 DebuggerDisplayAttribute
和 DebuggerTypeProxyAttribute
来自定义调试信息的显示。(同时隐藏我们在背后做的这些见不得人的事儿。)
比如我们有一个名为 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 层才能知道命令行参数究竟是什么。这样的调试效率显然是太低了!
使用 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
的属性,而这个属性我们只是调试使用,这是个垃圾属性,并不应该影响我们的查看。
我们使用 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)来去掉最终显示的引号。
虽然我们使用了 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
:
别忘了我们还需要禁止 _owner
在调试器中显示,然后把 [DebuggerTypeProxy(typeof(CommandLineDebugView))]
加到 CommandLine
类型上。
这样,最终的显示效果是这样的:
点击 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();
}
}
}
参考资料
作为一位 .NET 技术的死忠,开发 iOS 应用当然要使用 Xamarin 啦!
本文用我的阅读的文档和实践为素材,介绍如何使用 Xamarin 开发一个 iOS 的键盘扩展。
你可以在 Walterlv.CloudKeyboard 仓库中获得本文所述的全部源代码。
本文不会花篇幅来讲如何搭建 Xamarin iOS 开发的环境,不然这篇文章就没有重点。
于是,请阅读这一篇来了解如何搭建 Xamarin iOS 的开发环境:
了解以下背景知识,有助于我们接下来开发的时候少踩一些坑。当然我不会在这里说 iOS 应用开发的所有背景知识,只会说与 iOS 键盘扩展相关的部分。
这个不用太在意里面的实现,因为它只是我们的“容器项目”(前面有介绍)。实际上在本文我们完全不会碰这个项目里面的代码,只是为了配置我们的 iOS 应用包而已。未来你可以在这个容器应用里面做键盘的个性化设置。
然后,选择 iOS 平台。
我们只需要 iOS 端。因为对于键盘,不同系统的实现差异很大,之间共享的代码只能是非键盘部分的代码了。
当你创建完之后,你会看到三个不同的项目。
你可能发现 Walterlv.KeyboardExtension.Keyboard 项目有些奇怪,里面有 Main 函数和 AppDelegate,按道理这是一个主程序包。然而实际测试中单独有这个项目是跑不起来的(这可能是一个 Bug,如果修复了,请在下面评论或者邮件告知我,谢谢了)。
于是,Main 和 AppDelegate 这两个文件是可以删除的。如果你强迫症,就删掉吧。当然不删掉也不影响,不过我删掉了。
在 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 里面双击这个文件。
按照下图这样配置:
说明:
至此,你的项目可以直接编译了。如果你有真机部署环境,都可以直接部署到真机上看效果了。
本文不会花篇幅来讲如何真机部署调试,不然这篇文章就没有重点。
但是你可以阅读:使用 Xamarin 在 iOS 真机上部署应用进行调试
当然这是 Mac 版本的(毕竟我在 Windows 上实际也没有成功真机调试过,我是 git 同步到 Mac 上用 Visual Studio for Mac 来真机调试的)。
只是你需要注意做这些内容:
下面是我部署到真机上之后,在亮暗两种不同的界面下的键盘截图(就是上面的项目,没有改任何代码):
我们把 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
之后,再次部署,你将可以在你的键盘设置里面看到“允许完全访问”的设置项。开启之后,你就能在你的键盘里面访问网络了。
一般来说你不用阅读这一小节的内容。因为现在基本上各种服务都已经是 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 了。
如果你还遇到了一些其他诡异的问题:
参考资料
[iOS 8 Custom Keyboard Tutorial: How to Create A Third-Party Keyboard Extension | iPhone and iOS App UI Design Templates](http://www.appdesignvault.com/ios-8-custom-keyboard-extension/#a_aid=mdev) |
对于 Windows 桌面应用来说,让应用关联一种或多种文件类型是通过修改注册表来实现的。
本文介绍如何为你的应用关联自定义的文件类型或者关联被广泛使用的文件类型。
Windows 上的文件关联是通过文件的扩展名来实现的。有些文件类型是被广泛使用的公共类型,例如 .txt、.png、.mp4 文件;有些则是你自己的应用程序使用的私有类型,例如我自己定义一个 .lvyi 扩展名的文件类型。
我们会关联这些广泛使用的类型可能是因为我们自己写了一个自己的文本编辑器,于是我们会关联 .txt 或者 .md 类型。而我们关联自定义的文件类型是因为我们需要为我们自己的应用生态产生一些文件数据。
那么问题来了,我怎么知道我现在准备使用的扩展名是不是已经被广泛使用的公共类型呢?请进入此网站查看:Media Types。
要在 Windows 系统上注册一个文件类型,你需要做三个步骤:
没错,我说的就是取名字,而且要求在 Windows 系统上全局唯一;所以这里取名字也是有讲究的。关于应用程序标识符的相关内容,可以阅读微软的官方文档:Programmatic Identifiers - Windows applications - Microsoft Docs。
微软建议的 ProgID 的取名方式是这样的:
厂商名.应用名.版本号
这里的版本号通常是指的大版本号。例如版本号为 1.6.0.97
的应用,通常只取第一位,即 1
。一个典型的建议的取名示例是这样的:
Walterlv.Foo.1
还是看微软自己的命名示例会更权威一点:
竟然取一个名字也能写这么多篇幅,看来程序员的命名果然是世界上的一大难题呀!赶紧试用一下我的命名神器吧 —— 点击下载,其原理可阅读 冷算法:自动生成代码标识符(类名、方法名、变量名) - 吕毅。
你需要在注册表的 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"
前面的 .walv
和 lvyi
是我自己定义的两种文件类型,我将它们的 (Default)
值设置成 Walterlv.Foo.1
;而 Walterlv.Foo.1
就是前面说的应用程序标识符(ProgID)。后面的又新建了一个 Walterlv.Foo.1
的键,其 (Default)
值设置成了我们这个应用关联时使用的名称,也就是资源管理器中显示这个文件的时候使用的名称。
只要我们完成了以上的步骤,我们就能在资源管理器中看到我们的文件关联(虽然双击打不开):
关于注册表路径的说明:
HKEY_LOCAL_MACHINE
主键是此计算机上的所有用户共享的注册表键值,而 HKEY_CURRENT_USER
是当前用户使用的注册表键值。而我们在注册表的 HKEY_CLASSES_ROOT
中也可以看到跟 HKEY_LOCAL_MACHINE\Software\Classes
和 HKEY_CURRENT_USER\Software\Classes
中一样的文件关联项,是因为 HKEY_CLASSES_ROOT
是 HKEY_LOCAL_MACHINE\Software\Classes
和 HKEY_CURRENT_USER\Software\Classes
合并之后的一个视图,其中用户键值会覆盖此计算机上的相同键值。
也就是说,如果你试图修改文件关联,那么需要去 HKEY_LOCAL_MACHINE\Software\Classes
和 HKEY_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"
参考资料
这真是一个诡异的需求。为什么我需要在命令行中得知用户输入文字的改变啊!实际上我希望实现的是:在命令行中输入一段文字,然后不断地将这段文字发往其他地方。
本文将介绍如何监听用户在命令行中输入文本的改变。
在命令行中输入有三种不同的方法:
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();
}
参考资料
平时我们获取一个程序集或者类型的 Attribute
是非常轻松的,只需要通过 GetCustomAttribute
方法就能拿到实例然后获取其中的值。但是,有时我们仅为反射加载一些程序集的时候,获取这些元数据就不那么简单了,因为我们没有加载目标程序集中的类型。
本文介绍如何为仅反射加载的程序集读取 Attribute 元数据信息。
使用 ReflectionOnlyLoadFrom
可以仅以反射的方式加载一个程序集。
var extensionFilePath = @"C:\Users\walterlv\Desktop\Walterlv.Extension.dll";
var assembly = Assembly.ReflectionOnlyLoadFrom(extensionFilePath);
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);
代码解读是这样的:
Attribute
元数据中找到第一个名称与 AssemblyFileVersionAttribute
相同的数据;AssemblyFileVersionAttribute
时传入的参数的实际值。因为我们知道 AssemblyFileVersionAttribute
的构造函数只有一个,所以我们确信可以从第一个参数中拿到我们想要的值。
顺便一提,我们使用 AssemblyFileVersionAttribute
而不是使用 AssemblyVersionAttribute
是因为使用 .NET Core 新格式(基于 Microsoft.NET.Sdk)编译出来的程序集默认是不带 AssemblyVersionAttribute
的。详见:语义版本号(Semantic Versioning) - walterlv。
参考资料
我之前写过一些改变 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 文件贴出来了。如果你希望在你的项目中去使用,可以只复制 UsingTask
和 Target
两个部分。
<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 的输出:
WalterlvOutputAllTargets:
输出的 Target:OutputAll
输出的 Target:_CheckForUnsupportedTargetFramework
输出的 Target:_CollectTargetFrameworkForTelemetry
输出的 Target:_CheckForUnsupportedNETCoreVersion
输出的 Target:_CheckForUnsupportedNETStandardVersion
输出的 Target:_CheckForUnsupportedAppHostUsage
输出的 Target:_CheckForMismatchingPlatform
输出的 Target:_CheckForNETCoreSdkIsPreview
输出的 Target:AdjustDefaultPlatformTargetForNetFrameworkExeWithNoNativeCopyLocalItems
输出的 Target:CreateManifestResourceNames
输出的 Target:ResolveCodeAnalysisRuleSet
输出的 Target:XamlPreCompile
输出的 Target:ShimReferencePathsWhenCommonTargetsDoesNotUnderstandReferenceAssemblies
输出的 Target:_BeforeVBCSCoreCompile
输出的 Target:InitializeSourceRootMappedPaths
输出的 Target:_InitializeSourceRootMappedPathsFromSourceControl
输出的 Target:_SetPathMapFromSourceRoots
输出的 Target:CoreCompile
输出的 Target:ResolvePackageDependenciesDesignTime
输出的 Target:CollectSDKReferencesDesignTime
输出的 Target:CollectResolvedSDKReferencesDesignTime
输出的 Target:CollectPackageReferences
输出的 Target:_CheckCompileDesignTimePrerequisite
输出的 Target:CollectAnalyzersDesignTime
输出的 Target:CollectResolvedCompilationReferencesDesignTime
输出的 Target:CollectUpToDateCheckInputDesignTime
输出的 Target:CollectUpToDateCheckOutputDesignTime
输出的 Target:CollectUpToDateCheckBuiltDesignTime
输出的 Target:CompileDesignTime
输出的 Target:_FixVCLibs120References
输出的 Target:_AddVCLibs140UniversalCrtDebugReference
输出的 Target:InitializeSourceControlInformation
输出的 Target:_CheckForInvalidConfigurationAndPlatform
输出的 Target:Build
输出的 Target:BeforeBuild
输出的 Target:AfterBuild
输出的 Target:CoreBuild
输出的 Target:Rebuild
输出的 Target:BeforeRebuild
输出的 Target:AfterRebuild
输出的 Target:BuildGenerateSources
输出的 Target:BuildGenerateSourcesTraverse
输出的 Target:BuildCompile
输出的 Target:BuildCompileTraverse
输出的 Target:BuildLink
输出的 Target:BuildLinkTraverse
输出的 Target:CopyRunEnvironmentFiles
输出的 Target:Run
输出的 Target:BuildOnlySettings
输出的 Target:PrepareForBuild
输出的 Target:GetFrameworkPaths
输出的 Target:GetReferenceAssemblyPaths
输出的 Target:GetTargetFrameworkMoniker
输出的 Target:GetTargetFrameworkMonikerDisplayName
输出的 Target:GetTargetFrameworkDirectories
输出的 Target:AssignLinkMetadata
输出的 Target:PreBuildEvent
输出的 Target:UnmanagedUnregistration
输出的 Target:GetTargetFrameworkVersion
输出的 Target:ResolveReferences
输出的 Target:BeforeResolveReferences
输出的 Target:AfterResolveReferences
输出的 Target:AssignProjectConfiguration
输出的 Target:_SplitProjectReferencesByFileExistence
输出的 Target:_GetProjectReferenceTargetFrameworkProperties
输出的 Target:GetTargetFrameworks
输出的 Target:GetTargetFrameworkProperties
输出的 Target:PrepareProjectReferences
输出的 Target:ResolveProjectReferences
输出的 Target:ResolveProjectReferencesDesignTime
输出的 Target:ExpandSDKReferencesDesignTime
输出的 Target:GetTargetPath
输出的 Target:GetTargetPathWithTargetPlatformMoniker
输出的 Target:GetNativeManifest
输出的 Target:ResolveNativeReferences
输出的 Target:ResolveAssemblyReferences
输出的 Target:FindReferenceAssembliesForReferences
输出的 Target:GenerateBindingRedirects
输出的 Target:GenerateBindingRedirectsUpdateAppConfig
输出的 Target:GetInstalledSDKLocations
输出的 Target:ResolveSDKReferences
输出的 Target:ResolveSDKReferencesDesignTime
输出的 Target:FindInvalidProjectReferences
输出的 Target:GetReferenceTargetPlatformMonikers
输出的 Target:ExpandSDKReferences
输出的 Target:ExportWindowsMDFile
输出的 Target:ResolveAssemblyReferencesDesignTime
输出的 Target:DesignTimeResolveAssemblyReferences
输出的 Target:ResolveComReferences
输出的 Target:ResolveComReferencesDesignTime
输出的 Target:PrepareResources
输出的 Target:PrepareResourceNames
输出的 Target:AssignTargetPaths
输出的 Target:GetItemTargetPaths
输出的 Target:SplitResourcesByCulture
输出的 Target:CreateCustomManifestResourceNames
输出的 Target:ResGen
输出的 Target:BeforeResGen
输出的 Target:AfterResGen
输出的 Target:CoreResGen
输出的 Target:CompileLicxFiles
输出的 Target:ResolveKeySource
输出的 Target:Compile
输出的 Target:_GenerateCompileInputs
输出的 Target:GenerateTargetFrameworkMonikerAttribute
输出的 Target:GenerateAdditionalSources
输出的 Target:BeforeCompile
输出的 Target:AfterCompile
输出的 Target:_TimeStampBeforeCompile
输出的 Target:_GenerateCompileDependencyCache
输出的 Target:_TimeStampAfterCompile
输出的 Target:_ComputeNonExistentFileProperty
输出的 Target:GenerateSerializationAssemblies
输出的 Target:CreateSatelliteAssemblies
输出的 Target:_GenerateSatelliteAssemblyInputs
输出的 Target:GenerateSatelliteAssemblies
输出的 Target:ComputeIntermediateSatelliteAssemblies
输出的 Target:SetWin32ManifestProperties
输出的 Target:_SetExternalWin32ManifestProperties
输出的 Target:_SetEmbeddedWin32ManifestProperties
输出的 Target:_GenerateResolvedDeploymentManifestEntryPoint
输出的 Target:GenerateManifests
输出的 Target:GenerateApplicationManifest
输出的 Target:_DeploymentComputeNativeManifestInfo
输出的 Target:_DeploymentComputeClickOnceManifestInfo
输出的 Target:_DeploymentGenerateTrustInfo
输出的 Target:GenerateDeploymentManifest
输出的 Target:PrepareForRun
输出的 Target:CopyFilesToOutputDirectory
输出的 Target:_CopyFilesMarkedCopyLocal
输出的 Target:_CopySourceItemsToOutputDirectory
输出的 Target:GetCopyToOutputDirectoryItems
输出的 Target:GetCopyToPublishDirectoryItems
输出的 Target:_CopyOutOfDateSourceItemsToOutputDirectory
输出的 Target:_CopyOutOfDateSourceItemsToOutputDirectoryAlways
输出的 Target:_CopyAppConfigFile
输出的 Target:_CopyManifestFiles
输出的 Target:_CheckForCompileOutputs
输出的 Target:_SGenCheckForOutputs
输出的 Target:UnmanagedRegistration
输出的 Target:IncrementalClean
输出的 Target:_CleanGetCurrentAndPriorFileWrites
输出的 Target:Clean
输出的 Target:BeforeClean
输出的 Target:AfterClean
输出的 Target:CleanReferencedProjects
输出的 Target:CoreClean
输出的 Target:_CleanRecordFileWrites
输出的 Target:CleanPublishFolder
输出的 Target:PostBuildEvent
输出的 Target:Publish
输出的 Target:_DeploymentUnpublishable
输出的 Target:SetGenerateManifests
输出的 Target:PublishOnly
输出的 Target:BeforePublish
输出的 Target:AfterPublish
输出的 Target:PublishBuild
输出的 Target:_CopyFilesToPublishFolder
输出的 Target:_DeploymentGenerateBootstrapper
输出的 Target:_DeploymentSignClickOnceDeployment
输出的 Target:AllProjectOutputGroups
输出的 Target:BuiltProjectOutputGroup
输出的 Target:DebugSymbolsProjectOutputGroup
输出的 Target:DocumentationProjectOutputGroup
输出的 Target:SatelliteDllsProjectOutputGroup
输出的 Target:SourceFilesProjectOutputGroup
输出的 Target:GetCompile
输出的 Target:ContentFilesProjectOutputGroup
输出的 Target:SGenFilesOutputGroup
输出的 Target:GetResolvedSDKReferences
输出的 Target:CollectReferencedNuGetPackages
输出的 Target:PriFilesOutputGroup
输出的 Target:SDKRedistOutputGroup
输出的 Target:AllProjectOutputGroupsDependencies
输出的 Target:BuiltProjectOutputGroupDependencies
输出的 Target:DebugSymbolsProjectOutputGroupDependencies
输出的 Target:SatelliteDllsProjectOutputGroupDependencies
输出的 Target:DocumentationProjectOutputGroupDependencies
输出的 Target:SGenFilesOutputGroupDependencies
输出的 Target:ReferenceCopyLocalPathsOutputGroup
输出的 Target:SetCABuildNativeEnvironmentVariables
输出的 Target:RunCodeAnalysis
输出的 Target:RunNativeCodeAnalysis
输出的 Target:RunSelectedFileNativeCodeAnalysis
输出的 Target:RunMergeNativeCodeAnalysis
输出的 Target:ImplicitlyExpandDesignTimeFacades
输出的 Target:GetWinFXPath
输出的 Target:DesignTimeMarkupCompilation
输出的 Target:PrepareResourcesForSatelliteAssemblies
输出的 Target:_AfterCompileWinFXInternal
输出的 Target:AfterCompileWinFX
输出的 Target:AfterMarkupCompilePass1
输出的 Target:AfterMarkupCompilePass2
输出的 Target:MarkupCompilePass1
输出的 Target:MarkupCompilePass2
输出的 Target:_CompileTemporaryAssembly
输出的 Target:MarkupCompilePass2ForMainAssembly
输出的 Target:GenerateTemporaryTargetAssembly
输出的 Target:CleanupTemporaryTargetAssembly
输出的 Target:AddIntermediateAssemblyToReferenceList
输出的 Target:SatelliteOnlyMarkupCompilePass2
输出的 Target:HostInBrowserValidation
输出的 Target:SplashScreenValidation
输出的 Target:ResignApplicationManifest
输出的 Target:SignDeploymentManifest
输出的 Target:FileClassification
输出的 Target:MainResourcesGeneration
输出的 Target:SatelliteResourceGeneration
输出的 Target:GenerateResourceWithCultureItem
输出的 Target:CheckUid
输出的 Target:UpdateUid
输出的 Target:RemoveUid
输出的 Target:MergeLocalizationDirectives
输出的 Target:AssignWinFXEmbeddedResource
输出的 Target:EntityDeploy
输出的 Target:EntityDeploySplit
输出的 Target:EntityDeployNonEmbeddedResources
输出的 Target:EntityDeployEmbeddedResources
输出的 Target:EntityClean
输出的 Target:EntityDeploySetLogicalNames
输出的 Target:DesignTimeXamlMarkupCompilation
输出的 Target:InProcessXamlMarkupCompilePass1
输出的 Target:CleanInProcessXamlGeneratedFiles
输出的 Target:XamlMarkupCompileReadGeneratedFileList
输出的 Target:XamlMarkupCompilePass1
输出的 Target:XamlMarkupCompileAddFilesGenerated
输出的 Target:XamlMarkupCompileReadPass2Flag
输出的 Target:XamlTemporaryAssemblyGeneration
输出的 Target:CompileTemporaryAssembly
输出的 Target:XamlMarkupCompilePass2
输出的 Target:XamlMarkupCompileAddExtensionFilesGenerated
输出的 Target:GetCopyToOutputDirectoryXamlAppDefs
输出的 Target:ExpressionBuildExtension
输出的 Target:ValidationExtension
输出的 Target:GenerateCompiledExpressionsTempFile
输出的 Target:AddDeferredValidationErrorsFileToFileWrites
输出的 Target:ReportValidationBuildExtensionErrors
输出的 Target:DeferredValidation
输出的 Target:ResolveTestReferences
输出的 Target:CleanAppxPackage
输出的 Target:GetPackagingOutputs
输出的 Target:Restore
输出的 Target:GenerateRestoreGraphFile
输出的 Target:_LoadRestoreGraphEntryPoints
输出的 Target:_FilterRestoreGraphProjectInputItems
输出的 Target:_GenerateRestoreGraph
输出的 Target:_GenerateRestoreGraphProjectEntry
输出的 Target:_GenerateRestoreSpecs
输出的 Target:_GenerateDotnetCliToolReferenceSpecs
输出的 Target:_GetProjectJsonPath
输出的 Target:_GetRestoreProjectStyle
输出的 Target:EnableIntermediateOutputPathMismatchWarning
输出的 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
输出的 Target:DesktopBridgeCopyLocalOutputGroup
输出的 Target:DesktopBridgeComFilesOutputGroup
输出的 Target:GetDeployableContentReferenceOutputs
输出的 Target:DockerResolveAppType
输出的 Target:DockerUpdateComposeVsGeneratedFiles
输出的 Target:DockerResolveTargetFramework
输出的 Target:DockerComposeBuild
输出的 Target:DockerPackageService
输出的 Target:ImplicitlyExpandNETStandardFacades
输出的 Target:_RemoveZipFileSuggestedRedirect
输出的 Target:SetARM64AppxPackageInputsForInboxNetNative
输出的 Target:_CleanMdbFiles
输出的 Target:PreXsdCodeGen
输出的 Target:XsdCodeGen
输出的 Target:XsdResolveReferencePath
输出的 Target:CleanXsdCodeGen
输出的 Target:_SetTargetFrameworkMonikerAttribute
输出的 Target:ResolvePackageDependenciesForBuild
输出的 Target:RunResolvePackageDependencies
输出的 Target:ResolvePackageAssets
输出的 Target:FilterSatelliteResources
输出的 Target:RunProduceContentAssets
输出的 Target:ReportAssetsLogMessages
输出的 Target:ResolveLockFileReferences
输出的 Target:IncludeTransitiveProjectReferences
输出的 Target:ResolveLockFileAnalyzers
输出的 Target:_ComputeLockFileCopyLocal
输出的 Target:ResolveLockFileCopyLocalProjectDeps
输出的 Target:CheckForImplicitPackageReferenceOverrides
输出的 Target:CheckForDuplicateItems
输出的 Target:GenerateBuildDependencyFile
输出的 Target:GenerateBuildRuntimeConfigurationFiles
输出的 Target:AddRuntimeConfigFileToBuiltProjectOutputGroupOutput
输出的 Target:_SdkBeforeClean
输出的 Target:_SdkBeforeRebuild
输出的 Target:_ComputeNETCoreBuildOutputFiles
输出的 Target:_ComputeReferenceAssemblies
输出的 Target:CoreGenerateSatelliteAssemblies
输出的 Target:_GetAssemblyInfoFromTemplateFile
输出的 Target:_DefaultMicrosoftNETPlatformLibrary
输出的 Target:GetAllRuntimeIdentifiers
输出的 Target:GenerateAssemblyInfo
输出的 Target:AddSourceRevisionToInformationalVersion
输出的 Target:GetAssemblyAttributes
输出的 Target:CreateGeneratedAssemblyInfoInputsCacheFile
输出的 Target:CoreGenerateAssemblyInfo
输出的 Target:GetAssemblyVersion
输出的 Target:ComposeStore
输出的 Target:StoreWorkerMain
输出的 Target:StoreWorkerMapper
输出的 Target:StoreResolver
输出的 Target:StoreWorkerPerformWork
输出的 Target:StoreFinalizer
输出的 Target:_CopyResolvedOptimizedFiles
输出的 Target:PrepareForComposeStore
输出的 Target:PrepforRestoreForComposeStore
输出的 Target:RestoreForComposeStore
输出的 Target:ComputeAndCopyFilesToStoreDirectory
输出的 Target:CopyFilesToStoreDirectory
输出的 Target:_CopyResolvedUnOptimizedFiles
输出的 Target:_ComputeResolvedFilesToStoreTypes
输出的 Target:_SplitResolvedFiles
输出的 Target:_GetResolvedFilesToStore
输出的 Target:ComputeFilesToStore
输出的 Target:PrepRestoreForStoreProjects
输出的 Target:PrepOptimizer
输出的 Target:_RunOptimizer
输出的 Target:RunCrossGen
输出的 Target:_InitializeBasicProps
输出的 Target:_GetCrossgenProps
输出的 Target:_SetupStageForCrossgen
输出的 Target:_RestoreCrossgen
输出的 Target:_CheckForObsoleteDotNetCliToolReferences
输出的 Target:_PublishBuildAlternative
输出的 Target:_PublishNoBuildAlternative
输出的 Target:_PreventProjectReferencesFromBuilding
输出的 Target:PrepareForPublish
输出的 Target:ComputeAndCopyFilesToPublishDirectory
输出的 Target:CopyFilesToPublishDirectory
输出的 Target:_CopyResolvedFilesToPublishPreserveNewest
输出的 Target:_CopyResolvedFilesToPublishAlways
输出的 Target:_ComputeResolvedFilesToPublishTypes
输出的 Target:ComputeFilesToPublish
输出的 Target:_ComputeNetPublishAssets
输出的 Target:RunResolvePublishAssemblies
输出的 Target:FilterPublishSatelliteResources
输出的 Target:_ComputeCopyToPublishDirectoryItems
输出的 Target:DefaultCopyToPublishDirectoryMetadata
输出的 Target:GeneratePublishDependencyFile
输出的 Target:_ComputeExcludeFromPublishPackageReferences
输出的 Target:_ParseTargetManifestFiles
输出的 Target:GeneratePublishRuntimeConfigurationFile
输出的 Target:DeployAppHost
输出的 Target:PackTool
输出的 Target:GenerateToolsSettingsFileFromBuildProperty
输出的 Target:ResolveApphostAsset
输出的 Target:ComputeDependencyFileCompilerOptions
输出的 Target:ComputeRefAssembliesToPublish
输出的 Target:_CopyReferenceOnlyAssembliesForBuild
输出的 Target:_HandlePackageFileConflicts
输出的 Target:_HandlePublishFileConflicts
输出的 Target:_GetOutputItemsFromPack
输出的 Target:_GetTargetFrameworksOutput
输出的 Target:_PackAsBuildAfterTarget
输出的 Target:_CleanPackageFiles
输出的 Target:_CalculateInputsOutputsForPack
输出的 Target:Pack
输出的 Target:_IntermediatePack
输出的 Target:GenerateNuspec
输出的 Target:_InitializeNuspecRepositoryInformationProperties
输出的 Target:_LoadPackInputItems
输出的 Target:_GetProjectReferenceVersions
输出的 Target:_GetProjectVersion
输出的 Target:_WalkEachTargetPerFramework
输出的 Target:_GetFrameworksWithSuppressedDependencies
输出的 Target:_GetFrameworkAssemblyReferences
输出的 Target:_GetBuildOutputFilesWithTfm
输出的 Target:_GetTfmSpecificContentForPackage
输出的 Target:_GetDebugSymbolsWithTfm
输出的 Target:_AddPriFileToPackBuildOutput
输出的 Target:_GetPackageFiles
参考资料
我之前写过一些改变 MSBuild 编译过程的一些博客,包括利用 Microsoft.NET.Sdk 中各种自带的 Task 来执行各种各样的编译任务。更复杂的任务难以直接利用自带的 Task 实现,需要自己写 Task。
本文介绍非常简单的 Task 的编写方式 —— 在 csproj 文件中写内联的 Task。
在阅读本文之前,你至少需要懂得:
所以如果你不懂或者理不清,则请先阅读:
关于 Task 的理解,我有一些介绍自带 Task 的博客以及如何编写 Task 的教程:
如果你阅读了前面的博客,那么大致知道如何写一个在编译期间执行的 Task。不过,默认你需要编写一个额外的项目来写 Task,然后将这个项目生成 dll 供编译过程通过 UsingTask
来使用。然而如果 Task 足够简单,那么依然需要那么复杂的过程显然开发成本过高。
于是现在可以编写内联的 Task:
Microsoft.Build.Tasks.v4.0.dll
;<![CDATA[ ]]>
来内嵌 C# 代码;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
阅读我的另一篇博客了解如何编写一个更复杂的内联编译任务:
我们通常得到的命令行参数是一个字符串数组 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);
}
}
参考资料
虽然 Xamarin 可以在 Windows 操作系统上编写和调试,但如果开发 iOS 应用,那么我们依然需要一台安装有 XCode 和 Visual Studio for Mac 的 Mac 电脑。做真机部署不是像平时使用太阳系第一 IDE Visual Studio 那样方便。
所以本文需要介绍如何使用 Xamarin 在 iOS 真机上部署应用进行调试,然后顺便说一些注意事项。
如果你没有 Mac 电脑,那我只能很不幸地告诉你:本文读下去已经没有什么用了,你不会成功的……当然你也可以考虑使用 Mac OS 虚拟机,但成功率太低,本文不会涉及。
在 Mac 电脑上安装以下两款必备应用:
这两款应用的体积都很大,如果你没有很好的网络代理设置,安装一整天都是可能的。所以还是强烈建议你有一个稳定的代理网络来下载。
本文接下来的内容都假设你已经安装好了这两款应用。
你需要知道一些背景知识,不然后面真机部署的时候失败了都不知道怎么回事。
也就是说,你必须有一些操作是在 XCode 中完成;只使用 Visual Studio for Mac 是无法完成部署任务的。
com.walterlv.CloudKeyboard
,那么这里也必须写 com.walterlv.CloudKeyboard
。*[重要] 额外的,如果你开发的是 iOS 扩展,有两个或者更多的包,那么你需要重复步骤 3 到 6。也就是不断地修改 Bundle Identifier,等待生成新的 Developer 信息,然后部署这个空的应用
如果没有出现,你可能需要点击一下 Debug | iPhone 区域,一定要确保选中了 iPhone 而不是 iPhone Simulator |
理论上经过以上步骤,你就可以在你的 iPhone 上看到你用 Xamarin 开发的应用了。但其实是无法运行的。
如果部署过程中发生了任何错误,请:
如果你是首次进行此操作(实际上阅读本文操作的应该也就是首次了),那么信任自己的开发者账号可能会花比较长的时间,Visual Studio for Mac 的部署调试可能会因为等待超时而调试失败。不过这不重要,你只需要在 Visual Studio for Mac 上点击停止调试,然后再次重来就可以了。
还需要注意,如果你删除了你部署的应用,那么下次部署的时候在 iPhone 上的操作部分需要重新进行。
还需要注意,可能每过 6 天,本文所述的所有步骤都需要重新进行一遍。
我们知道,在编译期间相同的字符串,在运行期间就会是相同的字符串实例。然而,如果编译期间存在字符串的运算,那么在运行期间是否是同一个实例呢?
只要编译期间能够完全确定的字符串,就会是同一个实例。
字符串在编译期间能确定的运算包括:
A + B
即字符串的拼接$"{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。
注意,其他情况都不能完全确定:
我们可以通过 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。
少年,使用指针修改字符串是很危险的!鬼知道你会把程序改成什么样!
参考资料
一般情况下,如果一个方法声明了返回值,但是实际上在编写代码的时候没有返回,那么这个时候会出现编译错误。
然而,如果方法内部出现了永远也不会退出的死循环,那么这个时候就不会出现编译错误。
请看下面这一段代码,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
语句的。
很多方法要求传入一个字符串作为文件名或者文件路径,不过方法在实际执行到使用文件名的时候才会真正使用到这个文件名;于是这这种时候才会因为各种各样的异常发现文件名或者文件路径是不合法的。
有没有方法能够提前验证文件名或者文件路径是否是合法的路径呢?
这是一个不幸的结论 —— 没有!
实际上由我们自己写代码判断一个字符串是否是一个合法的文件路径是非常困难的,因为:
但你可能会说,就算有各种不同,也是可以穷举出来的。那么来看看穷举这些不同的情况需要多少代码吧:
看完这些代码,你是不是可以考虑放弃做 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
};
参考资料
使用 Nullable<T>
我们可以为原本不可能为 null
的值类型像引用类型那样提供一个 null
值。不过注意:Nullable<T>
本身也是个 struct
,是个值类型哦。这意味着你随时可以调用 .HasValue
这样的方法,而不用担心会出现 NullReferenceException
。
等等!除了本文提到的一些情况。
注意看以下的代码。我们创建了一个值为 null
的 int?
,然后依次输出 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;
}
结果是……
果是……
是……
……
…
崩掉了……
那么我们在 value
后面加个空传递运算符:
-- Console.WriteLine($"type = {value.GetType()}");
++ Console.WriteLine($"type = {value?.GetType()}");
现在再次运行,我们确认了 value?.GetType()
的值为 null
;而 typeof(int?)
的类型为 Nullable<Int32>
。
然而,我们现在将 value
的值从 null
改为 1
:
-- int? value = GetValue(null);
++ int? value = GetValue(1);
竟然 value.GetType()
得到的类型是 Int32
。
于是我们可以得出结论:
null
时,GetType()
会出现空引用异常;null
时,GetType()
返回的是对应的基础类型,而不是可空值类型;typeof(int?)
能够得到可空值类型。在 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
。
使用 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;
}
参考资料
我们知道一个类中的属性应该用名词或名词性短语,方法用动词或动宾短语;但是委托的实例却似乎有一些游离。因为在 .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/ 上找了一些使用了委托的代码,然后发现,对于 Action
和 Func
系列委托的命名,有以下这些(部分名称只保留了后缀进行合并):
使用名词的:
使用动词的:
使用缩略词的:
我把缩略词单独拿出来,是因为缩写了以下就看不出来这到底是缩自名词还是缩自动词。
基本上可以确定:
委托实例的命名是 —— 一个表示动作的名词!
参考资料
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?
的与或结果。
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 |
参考资料
自从微软推出 .NET Core 以来,新的项目文件格式以其优秀的可扩展性正吸引着更多项目采用。然而——微软官方的 WPF/UWP 项目模板依然还在采用旧的 csproj 格式!
这只是因为——官方 SDK 依然对 WPF/UWP 支持不够友好。
关于项目文件格式的迁移,我和 林德熙 都写过文章:
不过,这两篇文章中的迁移方法都是手动或半自动迁移的。而且迁移完毕之后,对新增的 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 官方支持后较快地迁移成官方版本。
虽说是第三方 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 项目格式之前,这应该算是最简单的迁移方案了!
至于项目结构的效果,可以看下图所示:
相比于此前的手工迁移,使用此新格式创建出来的 XAML 文件是可见的,而且 .xaml.cs 也是折叠在 .xaml 之下,且能正常编译!(当然,咱们还得考虑 UWP 和 WPF 在 XAML 书写上的细微差异)
官方提供了更多的使用方法,例如更简单的是安装 NuGet 包,而不修改 SDK。详见:onovotny/MSBuildSdkExtras: Extra properties for MSBuild SDK projects。
最近读到一篇异步转同步的文章,发现其中没有考虑到异步转同步过程中发生的死锁问题,所以特地在本文说说异步转同步过程中的死锁问题。
当你跑起了一个异步线程,并用 await
异步等待时,有没有好奇为什么能够在主线程 catch
到异步线程的异常?
当你希望在代码中提前收集好异常,最后一并把收集到的异常抛出的时候,能不能做到就像在原始异常发生的地方抛出一样?
本文介绍 ExceptionDispatchInfo
,专门用于重新抛出异常。它在 .NET Framework 4.5 中首次引入,并原生在 .NET Core 和 .NET Standard 中得到支持。