|
wpf
如果你的屏幕分辨率是 1920×1080,那么一个全屏的窗口程序尺寸是多少呢?想都不用想,是 1920×1080。
那么输入设备输入的坐标是多少呢?是 X∈[0, 1919]
?还是 X∈[1, 1920]
?还是 X∈[0, 1920]
?
一个有趣的问题,因为 1920×1080 分辨率的屏幕,其横向只有 1920 个像素,也就是说如果需要区分一个像素,那么只需要 1920 个数值就够了。这意味着 X∈[0, 1919]
或者 X∈[1, 1920]
的取值范围就能表示横向的所有像素了。
那么实际上最左侧的点的输入数值是多少呢?最右侧的点的输入数值是多少呢?
我写了一个最大化全屏的程序专门用来测试鼠标和触摸输入的数值是多少。
▲ 在鼠标输入的情况下,最右侧其实是 1919(我的屏幕是 2560×1080,所以最右侧是 2559)
测量的时候,鼠标是直接往右移动到底,移到不能动为止。
那么在触摸输入的时候又如何?
▲ 在触摸输入的情况下,最右侧是 1920(我的屏幕是 2560×1080,所以最右侧是 2560)
测量的时候,是让手指近乎在屏幕外触摸,不断触摸到能够在屏幕上看到的最小或最大值为止。
发现上面实验中有趣的现象了吗?明明只有 1920×1080 的屏幕分辨率,窗口明明只有 1920×1080 那么大,鼠标下收到正常范围内的输入坐标,而触摸下我们能收到超出我们窗口大小 1 像素的触摸事件!
问题并没有完——
如果说,触摸给了你超出窗口大小的坐标,那么你能如何使用这个坐标呢?虽然程序里收到什么坐标都无所谓(至少不崩),但如果你真拿它来渲染,就会在屏幕之外。
更有趣的是,虽然你能收到这个“在屏幕边缘之外”的坐标,但这个消息并不总会发送到你的程序里。更多的时候,你的程序根本就不会收到这个触摸事件,于是我们也就不能在程序里面更新窗口上显示的坐标到 1920 了,就像鼠标一样。
于是,你可能遇到的问题是:
林德熙小伙伴告诉我说可以特意把窗口的尺寸做大一个像素。我试过了,确实能够让触摸在整个屏幕上生效,但对于双屏用户来说,就能在另外一个屏幕上看到“露馅儿”了的窗口,对于我这种强迫症患者来说,显然是不能接受的。
我的建议是,并不需要对这种情况进行什么特殊的处理。
WPF 程序提供了 Focus
方法和 TraversalRequest
来在 WPF 焦点范围内转移焦点。但如果 WPF 窗口中嵌入了其他框架的 UI(比如另一个子窗口),那么就需要使用其他的方法来设置焦点了。
一个粗略的设置方法是,使用 Win32 API:
SetFocus(hwnd);
传入的是要设置焦点的窗口的句柄。
参考资料
因为 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
,此方法要求窗口句柄依然有效,然而此时窗口已经关闭,句柄已经销毁。
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) 提供的信息 |
做 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)看它的代码,实际上是为了模态计数以及引发事件的,对模态的效果没有本质上的影响。
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)
▲ 默认普通窗口
▲ 子窗口
参考资料
获取 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);
参考资料
最近总是收到一个异常 “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"
,导致看起来好像这个变换生效了一样。
此设置不仅没有效果,还会引发异常,请阅读我的另一篇博客了解:
在 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
设置一次子窗口。
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 的版本更新中出现问题:
参考资料
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 中跟进此问题:
参见:
本文介绍不那么常见的 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) |
我们有很多的调试工具可以帮助我们查看 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
的构造函数中加入了一个过滤窗口的委托。比如你可以让窗口的监听只对主要的几个窗口生效,而对一些信息提示窗口忽略等等。
默认情况下,我们打包 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.
更多制作源代码包的博客可以阅读。从简单到复杂的顺序:
基于 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
。
参考资料
本文介绍如何使用 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);
}
}
}
参考资料
当我们对 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 个文件累计;其他程序使用其他前缀使则分别累计。
另外,如果此方法无法再生成一个唯一的文件名的时候也会抛出异常。
为了解决这些异常,在用户端的解决方案是删除临时文件夹。而在程序端的解决方案是 —— 本文。
本文是为了和 林德熙 一起解决一个光标问题时提出的解决方案的一种。更多关于光标问题的内容可以阅读以下链接:
参考资料
本文介绍通过发现渲染脏区来提高渲染性能。
在计算机图形渲染中,可以每一帧绘制全部的画面,但这样对计算机的性能要求非常高。
脏区(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);
}
参考资料
我们知道,在 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;
}
}
}
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#,不需要太多额外的入门,即可玩转你身边各种你需要的技术栈,玩出各种各样你自己期望尝试开发的小东西。
如果你要在 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如果你的程序对 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 运行权限有要求,那么需要设置应用程序清单。本文介绍如何添加应用程序清单,并解释其中各项权限设置的实际效果。
对于 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>
你也可以创建一个不带应用程序清单的应用程序。方法是在属性页中将清单设置为“创建不带清单的应用程序”。
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
属性,那么他会成为一个分隔符。
参考资料
如果你写了一个 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)的原创方法,因为真的找不到资料呀!不过在找资料的过程中发现了一些没解决的文档或帖子:
在 WPF 开发时,有 Snoop 的帮助,UI 的调试将变得非常轻松。使用 Snoop,能轻松地查看 WPF 中控件的可视化树以及每一个 Visual 节点的各种属性,或者查看数据上下文,或者监听查看事件的引发。
不过,更强大的是支持使用 PowerShell 脚本。这使得它即便 UI 没有给你提供一些入口,你也能通过各种方式查看或者修改 UI。
常规 Snoop 的使用方法,将狮子瞄准镜拖出来对准要调试 UI 的 WPF 窗口松开。这里我拿 Visual Studio 2019 的窗口做试验。
在打开的新的 Snoop 窗口中我们打开 PowerShell 标签。
本文的内容将从这里开始。
在 Snoop 的 PowerShell 提示窗口中,我们可以得知有两个变量可以使用:$root
和 $selected
。包含这两个,还有其他的可以使用:
$root
拿到当前 Snoop 窗口顶层元素类型的实例$selected
拿到当前 Snoop 用鼠标或键盘选中的元素的实例$parent
拿到当前 Snoop 选中元素的可视化树父级$null
就是 .NET 中的 null当然,你也可以定义和使用其他的变量,后面会说。
# 获取属性
$selected.Visual.Content
# 将属性设置为 null
$selected.Visual.Content = $null
直接像 C# 语法那样一直在后面使用 .
可以访问实例中的属性。不需要关心实例是什么类型的,只要拥有那个属性,就可以访问到。
比如下面,上面的例子我们选中的是 MainWindow
,于是我们使用 $selected.Visual.Content
访问到 MainWindow
的 Content
属性,而后面 $selected.Visual.Content = $null
则是将 Window
的内容清空了。
# 创建对象
$button = New-Object System.Windows.Controls.Button -property @{ Content = "欢迎访问 blog.walterlv.com" }
$selected.Visual.Children.Add($button)
顶部的那个按钮就是通过上面的命令添加上去的。
调用静态方法用的是 [类名]::方法名(参数)
$button.Content = [System.Environment]::Version.ToString() + " running for blog.walterlv.com"
参考资料
本文介绍透明度叠加算法(Alpha Blending Algorithm),并用 C#/WPF 的代码,以及像素着色器的代码 HLSL 来实现它。
对于算法,我只是搬运工,可以随意搜索到。算法详情请查看:Alpha compositing - Wikipedia。
对于完全不透明的背景和带有透明度的前景,合并算法为:
float r = (foreground.r * alpha) + (background.r * (1.0 - alpha));
这是红色。然后绿色 g
和蓝色 b
通道进行一样的计算。最终合成图像的透明通道始终设置为 1。
多数 UI 框架对于颜色值的处理都是用一个 byte
赛表单个通道的一个像素。于是计算会采用 0xff 即 255。
for (int i = 0; i + 4 < length; i = i + 4)
{
var backB = background[i];
var backG = background[i + 1];
var backR = background[i + 2];
var foreB = foreground[i];
var foreG = foreground[i + 1];
var foreR = foreground[i + 2];
double alpha = foreground[i + 3];
blue = 0;
output[i] = (foreB * alpha) + (backB * (1.0 - alpha));
output[i + 1] = (foreG * alpha) + (backG * (1.0 - alpha));
output[i + 2] = (foreR * alpha) + (backR * (1.0 - alpha));
output[i + 3] = 1.0;
}
这段代码当然是跑不起来的,因为是下面两篇博客的魔改代码。你需要阅读以下两篇博客了解如何在 WPF 中按像素修改图像,然后应用上面的透明度叠加代码。
话说,一般 UI 框架都自带有透明度叠加,为什么还要自己写一份呢?
当然是因为某些场景下我们无法使用到 UI 框架的透明度叠加特性的时候。例如使用 HLSL 编写像素着色器的一个实现。
下面使用像素着色器的实现是我曾经写过的一个特效的一个小部分,我把透明度叠加的部分单独摘取出来。
以下是 HLSL 代码的实现。Background 是从采样寄存器 0 取到的颜色采样,Foreground 是从采样寄存器 1 取到的颜色采样。
这里的计算中,背景是不带透明度的,而前景是带有透明度的。
/// <description>透明度叠加效果。</description>
sampler2D Background : register(s0);
sampler2D Foreground : register(s1);
float4 main(float2 uv : TEXCOORD) : COlOR
{
float4 background = tex2D(Background, uv);
float4 foreground = tex2D(Foreground, uv);
float alpha = foreground.a;
float r = (foreground.r * alpha) + (background.r * (1.0 - alpha));
float g = (foreground.g * alpha) + (background.g * (1.0 - alpha));
float b = (foreground.b * alpha) + (background.b * (1.0 - alpha));
float a = 1.0;
return float4(r, g, b, a);
}
如果要测试的图片都是不带透明度的,那么可以通过自己设一个透明度来模拟,传入透明度值 Alpha。
/// <description>透明度叠加效果。</description>
/// <type>Double</type>
/// <summary>采样 2 的叠加透明度。</summary>
/// <minValue>0.0</minValue>
/// <maxValue>1.0</maxValue>
/// <defaultValue>0.75</defaultValue>
float Alpha : register(C0);
sampler2D Background : register(s0);
sampler2D Foreground : register(s1);
float4 main(float2 uv : TEXCOORD) : COlOR
{
float4 background = tex2D(Background, uv);
float4 foreground = tex2D(Foreground, uv);
float alpha = Alpha;
float r = (foreground.r * alpha) + (background.r * (1.0 - alpha));
float g = (foreground.g * alpha) + (background.g * (1.0 - alpha));
float b = (foreground.b * alpha) + (background.b * (1.0 - alpha));
float a = 1.0;
return float4(r, g, b, a);
}
参考资料
WPF supports multiple UI threads in its framework. You can create multiple UI thread windows or create multiple UI threads in a single window. But unfortunately, this is not really thread-safe.
There is a very low probability that WPF application will crash when you creating a multi-thread UI. In this post, I’ll tell how this happens.
Necessary conditions:
phenomenon:
- An exception is thrown and the application crashes
For example, the following is one of the exceptions:
Exception thrown: 'System.NullReferenceException' in WindowsBase.dll
Object reference not set to an instance of an object.
System.NullReferenceException: Object reference not set to an instance of an object.
at System.IO.Packaging.PackagePart.CleanUpRequestedStreamsList()
at System.IO.Packaging.PackagePart.GetStream(FileMode mode, FileAccess access)
at System.Windows.Application.LoadComponent(Object component, Uri resourceLocator)
at Walterlv.Bugs.MultiThreadedUI.SplashWindow.InitializeComponent() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\SplashWindow.xaml:line 1
at Walterlv.Bugs.MultiThreadedUI.SplashWindow..ctor() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\SplashWindow.xaml.cs:line 24
at Walterlv.Bugs.MultiThreadedUI.Program.<>c__DisplayClass1_0.<RunSplashWindow>b__0() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\Program.cs:line 33
The following image is an exception caught in WPF on .NET Core 3 that is shown in visual studio 2019:
App
and MainWindow
unchanged, we create a new window SplashWindow
.Program
class containing the Main function and set Program
as the startup object (instead of App
) in the project properties.All other files remain the same as the default code generated by Visual Studio, and the code of Program.cs is as follows:
using System;
using System.Threading;
using System.Windows.Threading;
namespace Walterlv.Bugs.MultiThreadedUI
{
public class Program
{
[STAThread]
private static void Main(string[] args)
{
for (var i = 0; i < 50; i++)
{
RunSplashWindow(i);
}
var app = new App();
app.InitializeComponent();
app.Run();
}
private static void RunSplashWindow(int index)
{
var thread = new Thread(() =>
{
var window = new SplashWindow
{
Title = $"SplashWindow {index.ToString().PadLeft(2, ' ')}",
};
window.Show();
Dispatcher.Run();
})
{
IsBackground = true,
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
}
}
Remarks: Even if you add this code just before the Splash Window creating, this exception still occurs.
SynchronizationContext.SetSynchronizationContext(
new DispatcherSynchronizationContext(
Dispatcher.CurrentDispatcher));
WPF 支持创建多个 UI 线程,跨窗口的或者窗口内的都是可以的;但是这个过程并不是线程安全的。
你有极低的概率会遇到 WPF 多线程 UI 的线程安全问题,说直接点就是崩溃。本文将讲述其线程安全问题。
必要条件:
现象:
比如下面是其中一种异常:
Exception thrown: 'System.NullReferenceException' in WindowsBase.dll
Object reference not set to an instance of an object.
System.NullReferenceException: Object reference not set to an instance of an object.
at System.IO.Packaging.PackagePart.CleanUpRequestedStreamsList()
at System.IO.Packaging.PackagePart.GetStream(FileMode mode, FileAccess access)
at System.Windows.Application.LoadComponent(Object component, Uri resourceLocator)
at Walterlv.Bugs.MultiThreadedUI.SplashWindow.InitializeComponent() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\SplashWindow.xaml:line 1
at Walterlv.Bugs.MultiThreadedUI.SplashWindow..ctor() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\SplashWindow.xaml.cs:line 24
at Walterlv.Bugs.MultiThreadedUI.Program.<>c__DisplayClass1_0.<RunSplashWindow>b__0() in C:\Users\lvyi\Desktop\Walterlv.Bugs.MultiThreadedUI\Walterlv.Bugs.MultiThreadedUI\Program.cs:line 33
下图是 .NET Core 3 版本的 WPF 中在 Visual Studio 2019 抓到的异常:
App
和 MainWindow
不变,我们额外创建一个窗口 SplashWindow
。Program
类,并在项目属性中设置 Program
为启动对象(替代 App
)。其他文件全部保持 Visual Studio 生成的默认代码不变,而 Program.cs 的代码如下:
using System;
using System.Threading;
using System.Windows.Threading;
namespace Walterlv.Bugs.MultiThreadedUI
{
public class Program
{
[STAThread]
private static void Main(string[] args)
{
for (var i = 0; i < 50; i++)
{
RunSplashWindow(i);
}
var app = new App();
app.InitializeComponent();
app.Run();
}
private static void RunSplashWindow(int index)
{
var thread = new Thread(() =>
{
var window = new SplashWindow
{
Title = $"SplashWindow {index.ToString().PadLeft(2, ' ')}",
};
window.Show();
Dispatcher.Run();
})
{
IsBackground = true,
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
}
}
说明:即便在 new SplashWindow
代码之前调用以下方法修改 SynchronizationContext
也依然会发生异常。
SynchronizationContext.SetSynchronizationContext(
new DispatcherSynchronizationContext(
Dispatcher.CurrentDispatcher));