【炫丽】从0开始做一个WPF+Blazor对话小程序
从一个WPF Hello World程序开始,逐渐引入Blazor,做个免费能看的对话小程序耍耍。
大家好,我是沙漠尽头的狼。
.NET是免费,跨平台,开源,用于构建所有应用的开发人员平台。
本文演示如何在WPF中使用Blazor开发漂亮的UI,为客户端开发注入新活力。
注
要使WPF支持Blazor,.NET版本必须是 6.0 或更高版本,本文所有示例使用的.NET 7.0,版本要求见链接,截图看如下文字:

1. WPF默认程序
本文从创建WPF Hello World
开发:
使用WPF模板创建一个默认程序,取名【WPFBlazorChat】,项目组织结构如下:

运行项目,一个空白窗口:

接着往下看,我们添加Blazor支持,本小节代码在这WPF默认程序源码。
2. 添加Blazor支持
依然使用上面的工程,添加Blazor支持,此部分参考微软文档生成 Windows Presentation Foundation (WPF) Blazor 应用,本小节快速略过。
2.1 编辑工程文件
双击工程文件WPFBlazorChat.csproj
,修改处如下:

在项目文件的顶部,将 SDK 更改为
Microsoft.NET.Sdk.Razor
。添加节点
<RootNameSpace>WPFBlazorChat</RootNameSpace>
,将项目命名空间WPFBlazorChat
设置为应用的根命名空间。添加
Nuget
包Microsoft.AspNetCore.Components.WebView.Wpf
,版本看你选择的.NET
版本而定。
2.2 添加_Imports.razor
文件
_Imports.razor
文件类似一个Global
using文件,专门给Razor
组件使用,放置一些用的比较多的全局的命名空间,精简代码。
内容如下,引入了一个命名空间Microsoft.AspNetCore.Components.Web
,这是Razor
常用命名空间,包含用于向 Blazor
框架提供有关浏览器事件的信息的类型。:
@using Microsoft.AspNetCore.Components.Web
HTML
Copy
2.3 添加wwwroot\index.html
文件
和Vue
、React
一样,需要一个html
文件承载Razor
组件,页面内容类似:
app.css
文件在下面给出定义。看
<div id="app">Loading...</div>
,这里是承载Razor
组件的地方,后面所有加载的Razor
组件都是在这里渲染出来的。其他暂时不管。
2.4 添加wwwroot\css\app.css
文件
页面的基本样式,通用的样式可放在这个文件:
2.5 添加一个Razor组件
加一个Razor的经典组件Counter.razor
,Blazor
的Hello World
程序就有这么一个组件,文件路径:/RazorViews/Counter.razor
,之所以放RazorViews
目录,是为了和WPF常用的Views
目录区分,该组件内容如下:
一个按钮【快快点我】,点击@onclick="IncrementCount"
使变量currentCount
自增,同时页面显示此变量值,相信你能看懂。
2.6 Blazor与WPF窗体关联
这是两者产生关系的关键一步,打开窗体MainWindow.xaml
,修改如下:

如上代码,要点如下:
添加上面引入的
Nuget
包Microsoft.AspNetCore.Components.WebView.Wpf
的命名空间,命名为blazor
,主要是要使用BlazorWebView
组件;BlazorWebView
组件属性HostPage
指定承载的html文件,Services
指定razor组件的Ioc
容器,看下面MainWindow()
里标红的代码;RootComponent
的Selector="#app"
属性指示Razor
组件渲染的位置,看index.html
中id为app
的html元素,ComponentType
指示需要在#app
中渲染的Razor
组件类型。
打开MainWindow.xaml.cs
,修改如下:
在WPF里可以使用Prism等框架提供的Unity
、DryIoc
等Ioc
容器实现视图与服务的注入;Razor
组件这里,默认使用ASP.NET Core
的IServiceCollection
容器;如果WPF窗体与Razor组件需要共享数据,可以通过后面要说的Messager
发送消息,也可以通过Ioc
容器注入的方式实现,比如从WPF窗体中注入的数据(通过MainWindow
构造函数注入),通过IServiceCollection
容器再注入Razor
组件使用,这里后面也有提到。

上面步骤做完后,运行程序:

OK,WPF
与Blazor
集成成功,打完收工?
等等,还没完呢,本小节源码在这WPF中添加Blazor,接着往下看。
3. 自定义窗体

看上图,窗体边框是WPF默认的样式,有时会感觉比较丑,或者不丑,设计师有其他的窗体风格设计,往往我们要自定义窗体,本节分享部分WPF与Blazor的自定义窗体实现,更多定制化功能可能需要您自行研究。
3.1 WPF自定义窗体
一般实现是设置窗体的三个属性WindowStyle="None" AllowsTransparency="True" Background="Transparent"
,即可隐藏默认窗体的边框,然后在内容区自己画标题栏、最小化、最大化、关闭按钮、客户区等。
MainWindow.xaml:隐藏WPF默认窗体边框
上面的代码只是隐藏了WPF默认窗体的边框,运行程序如下:

看上图,点击窗体中的按钮(其实是Razor组件的按钮),但未执行按钮点击事件,且窗体消失了,这是怎么回事?您可以尝试研究下为什么,我没有研究个所以然来,暂时加个背景处理BlazorWebView
穿透的问题。
简单的WPF自定义窗体样式
我们加上自定义窗体的基本样式看看:

MainWindow.xaml
代码如下:
我们给整个窗体客户端区域加了一个背景Border
(您可以去掉Border背景色,点击界面按钮试试),然后又套了一个Grid,用于放置自定义的标题栏(标题和窗体控制按钮)和BlazorWebView
(用于渲染Razor组件的浏览器组件),下面是窗体控制按钮的响应事件:
代码简单,处理了窗体最小化、窗体最大化(还原)、关闭、标题栏双击窗体最大化(还原),上面的实现不是一个完美的自定义窗体实现,至少有这两个问题:
当您尝试最大化后,窗体铺满了整个操作系统桌面(也霸占了任务栏区域);
窗体任务栏两个圆角未生效(标题栏区域是WPF控件,所以正常),即窗体下面的两个圆角,站长未找到让
BlazorWebView
出现圆角的属性或其他方法。

在后面的3.4
小节,站长使用一个第三库实现了窗体圆角问题,更多比较好的WPF自定义窗体实现可看这篇文章:WPF三种自定义窗体的实现,本小节中示例源码在这WPF自定义窗体。
3.2 WPF异形窗体
异形窗体的需求,使用WPF实现是比较方便的,本来打算写写的,感觉偏离主题太远了,给篇文章自行看看吧:WPF异形窗体演示,文中异形窗体效果如下:

下面介绍将窗体的标题栏也放Razor
组件中实现的方式。
3.3 Blazor实现自定义窗体效果
上面使用了WPF
制作自定义窗体,有没有这种需求,把菜单放置到标题栏?这个简单,WPF能很好实现。
如果放Tab类控件呢?Tab Header是在标题栏显示,TabItem是在客户端区域,Tab Header与TabItem风格统一,在一套代码里面实现和维护也方便,那么在WPF+Blazor混合开发的情景怎么实现呢?相信通过本节Razor
实现标题栏的介绍,你能做出来。
MainWindow.xaml
恢复代码,只设置隐藏WPF默认窗体边框,并给BlazorWebView套一层背景:

后面的代码有参考[BlazorDesktopWPF-CustomTitleBar](https://github.com/James231/BlazorDesktopWPF-CustomTitleBar)实现。
我们把标题栏做到Counter.razor
组件,即标题栏、客户区放一个组件里,当然你也可以分离,这里我们方便演示:
Counter.razor
下面给出代码简单说明:
第一个
div
充做窗体的标题栏区域,注册了双击事件调用窗体最大化(还原)方法、鼠标按下与释放调用窗体的移动开始与结束方法;在第一个
div
里,其中有3个按钮,即窗体的控制按钮,调用窗体最小化、最大化(还原)、关闭方法调用;另有两个按钮,演示单击调用
JavaScript
的alert
方法弹出消息。

运行效果如下:

实现这个效果,还有一些代码:
上面的代码调用了一些方法实现窗体操作最小化、关闭等,代码如下;
因为是
Razor
组件,即html
实现的界面,界面的html
元素也定义了一些css
样式,代码也一并给出。标题栏的按钮使用了一些
svg
图片,在仓库里,可自行获取。
窗体拖动
首先添加Nuget
包Simplify.Windows.Forms
,用于获取鼠标光标的位置:
<PackageReference Include="Simplify.Windows.Forms" Version="1.1.2" />
添加窗体帮助类:Services\WindowService.cs
上面的代码用于窗体的最小化、最大化(还原)、关闭等实现,需要在Razor
组件里正确的调用这些方法:
Counter.razor
组件的OnInitialized
初始化生命周期方法里调用WindowService.Init();
,如上代码,这个方法开启定时器,定时调用UpdateWindowPos
方法检查鼠标是否按下,如果按下,检查间隔内窗体的位置变化,然后修改窗体位置,从而实现窗体位置移动(移动窗体无法使用WPF的DragMove
方法,您可以尝试使用看看它报什么错)。Razor
组件里窗体控制按钮的使用看上面的代码不难理解,不过多解释。
上面效果的样式文件修改如下,wwwroot\css\app.css
:
上面的一些代码即实现了由Razor
组件实现窗体的标题显示、窗体的最小化、最大化(还原)、关闭、移动等操作,然而还是会有3.1
结尾出现的问题,即窗体圆角和窗体最大化铺满操作系统桌面任务栏的问题,下面一小节我们尝试解决他。
小节总结:通过上面的代码,如果放Tab控件铺满整个窗体,是不是有思路了?
本小节源码在这Razor组件实现窗体标题栏功能
3.4 Blazor与WPF比较完美的实现效果
其实上面的代码可以当做学习,即使有不小瑕疵(哈哈),本小节我们还是使用第三包解决窗体圆角和最大化问题。
首先添加Nuget
包ModernWpfUI
,该WPF控件库本站介绍链接开源WPF控件库:ModernWpf:
<PackageReference Include="ModernWpfUI" Version="0.9.7-preview.2" />
然后打开App.xaml
,引用上面开源WPF控件的样式:
最后打开MainWindow.xaml
,修改如下:
就上面三处修改,我们运行看看:

是不是和3.3
效果一样?其实仔细看,窗体下面的圆角也有了:

最终还是WPF解决了所有问题

具体怎么实现的窗体最大化未占操作系统的任务栏,以及窗体圆角问题的解决(竟然能让BlazorWebView
部分透明了)可以查看该组件相关代码,本文不过多深究。
另外,WPF熟手可能比较清楚,前面的代码不能正常的拖动改变窗体大小(不知道你发现没,我当你没发现。),使用该库后也解决了:

本小节源码在这解决圆角和最大化问题,下面开始本文的下半部分了,好累,终于到这了。

4. 添加第三方Blazor组件
工欲善其事,必先利其器!
鉴于大部分同学前端基础可能不是太好,即使使用Blazor可以少用或者不用JavaScript,那么有那么一款漂亮、便捷的Blazor
组件库,这不是如虎添翼吗?本文使用Masa Blazor做示例显示,如今Blazor组件库众多,选择自己喜欢的、顺手的就成:

站长前些日子介绍过MAUI使用Masa blazor组件库,本小节思路也是类似,且看我表演。

打开Masa Blazor文档站点:https://blazor.masastack.com/getting-started/installation,一起来往WPF中引入这款Blazor组件库吧。
4.1 引入Masa.Blazor包
打开工程文件WPFBlazorChat.csproj
直接复制下面的包版本,或通过NuGet
包管理器搜索Masa.Blazor安装
:
<PackageReference Include="Masa.Blazor" Version="0.6.0" />
4.2 添加Masa.Blazor带来的资源
打开wwwroot\index.html
,在<head></head>
节点添加如下资源:
完整代码如下:
4.3 引入Masa.Blazor命名空间
打开_Imports.razor
文件,修改如下:
4.4 Razor组件添加Masa.Blazor
打开MainWindow.xaml.cs
,添加一行代码 serviceCollection.AddMasaBlazor();

4.5 尝试Masa.Blazor案例
上面4步的准备工作做好后,我们简单来使用下Masa.Blazor
组件。
打开Tab组件链接:https://blazor.masastack.com/components/tabs,尝试这个Demo:

Demo的代码我几乎不变的引入,打开RazorViews\Counter.razor
文件,保留3.4节的标题栏,替换了客户区域内容,代码如下:
运行效果如下:

是不是有那味儿了?再尝试把Tab移到标题栏,前面有提过的效果:

上面的效果,代码修改如下,删除了原标题栏代码,将窗体操作按钮放到了MToolbar
里面,并使用MToolbar
添加了双击事件、鼠标按下、释放事件实现窗体拖动:
窗体操作按钮的背景色也做部分修改:

其实上面的窗体效果还是有点瑕疵,注意到窗体右侧的竖直滚动条了吗?在没引入Masa.Blazor
之前都是没有的:

这个想去掉也简单,在`wwwroot\css\app.css`追加样式(当时也是折腾了好一会儿,最后在`Masa.Blazor`群里群友给出了解决方案,十分感谢):

问题解决`css`代码:
因为Razor
组件是在BlazorWebView
里渲染的,即BlazorWebView
就是个小型的浏览器呀,上面的样式即把浏览器的滚动条宽度设置为0,它不就没有了吗?现在效果如下,是不是舒服了?

添加Masa.Blazor就介绍到这里,本小节示例代码在这里WPF中使用Masa.Blazor,下面讲解WPF与Blazor混合开发后多窗体消息通知问题。
5. 多窗体消息通知
一般C/S
窗体之间通信使用委托、事件,而在WPF
开发中,可以使用一些框架提供的抽象事件订阅\发布
组件,比如Prism
的事件聚集器IEventAggregator
,或MvvmLight
的Messager
。在B/S
开发中,进程内事件通知可能就使用MediatR
组件居多了,不论是在C/S
还是B/S
开发,这些组件在一定程度上,各大程序模板可以通用的,更不用说分布式的消息队列RabbitMQ
和 Kafka
是万能的进程间通信标准选择了。
上面是一些套话,站长根据Prism
的事件聚集器和MvvmLight
的Messager源码阅读,简单封装了一个Messager
,可以适用于一般的业务需求。
5.1 Messager封装
本来不想贴代码直接给源码链接的,想想代码也不多,直接上吧。
Message
消息抽象类,用于定义消息类型,具体的消息需要继承该类,比如后面的打开子窗体消息OpenSecondViewMessage
。
IMessenger
消息接口,只定义了三个接口:
Subscribe:消息订阅
Unsubscribe:取消消息订阅
Publish:消息发送
Messenger
消息的管理,消息中转等实现:
有兴趣的看上面的代码,封装代码上面简单全部给上。
5.2 代码整理
第 5 节涉及到多窗体及多Razor
组件了,需要创建一些目录存放这些文件,方便分类管理。

A:放Message,即一些消息通知类;
B:放Razor组件,如果需要与Maui\Blazor Server(Wasm)等共享Razor组件,可以创建Razor类库存储;
C:放通用服务,这里只放了一个窗体管理静态类,实际情况可以放Redis服务、RabbitMQ消息服务等;
D:放WPF视图,本示例WPF窗体只是一个壳,承载BlazorWebView使用;
5.3 示例及代码说明
先看本示例效果,再给出相关代码说明:

图中有三个操作:
点击主窗体A的【+】按钮,发送了
OpenSecondViewMessage
消息,打开子窗体B;打开子窗体B后,再点击主窗体A的【桃心】按钮,发送了
SendRandomDataMessage
消息,子窗体B的第二个TabItem Header显示了消息传来的数字;点击子窗体B的【安卓】图标按钮,给主窗体A响应了消息
ReceivedResponseMessage
,主窗体收到后弹出一个对话框。
三个消息类定义如下:
除了SendRandomDataMessage
传递了一个业务Number
属性,另两个消息只是起到通知作用,实际开发时可能需要传递业务数据。
打开多窗体
即上面的第一个操作:点击主窗体A的【+】按钮,发送了OpenSecondViewMessage
消息,打开子窗体B。
在RazorViews\MainView.razor
中执行按钮点击,发送打开子窗体消息:
在App.xaml.cs
里订阅打开子窗体消息:
实际开发可能情况更复杂,发送的消息OpenSecondViewMessage
里带WPF窗体路由(定义的一套路径规则寻找窗体或ViewModel
),订阅的地方也可能不在主程序,在子模块的Module
类里。
发送业务数据
即第二个操作:打开子窗体B后,再点击主窗体A的【桃心】按钮,发送了SendRandomDataMessage
消息,子窗体B的第二个TabItem Header显示了消息传来的数字。
在
RazorViews\MainView.razor
中执行按钮点击,发送业务消息(就当前时间的Millisecond):
在
RazorViews\SecondView.razor
的OnInitialized()
方法里订阅业务消息通知:
注意看,上面收到消息时有两个方法要简单说一下,看OnInitialized()
里的代码:
InvokeAsync:将Number赋值给变量
tagCount
的代码是在InvokeAsync
方法里执行的,这个和WPF里的Dispatcher.Invoke
是一个意思,相当于接收数据是在子线程,而赋值这个操作会即时的绑定到<MBadge Color="green" Content="tagCount">
上,也需要UI线程同步。StateHasChanged:相当于MVVM里的
PropertyChanged
事件通知,通知UI这里有值变化了,请你刷新一下,我要看看最新值。
上面的代码把子窗体消息回应也贴上了,即点击安卓图标按钮时发送了ReceivedResponseMessage
消息,在主窗体RazorViews\MainView.razor
里也订阅了这个消息,和上面的代码类似:
在OnInitialized()
方法里订阅消息ReceivedResponseMessage
,收到后将变化_showComfirmDialog
置为true
,即上面对话框的属性Visible
绑定的值,同理需要在InvokeAsync()
中处理数据接收,也需要调用StateHasChanged
通知UI数据变化。
上面说了部分代码,可能讲的不太清楚,可以看示例源码:多窗体消息通知。
6. 本文示例
本来想写完整Demo的,发现上面把基本要点都拉了一遍,再粘贴一些重复代码有点没完没了了,有兴趣的拉源码WPF与Blazor混合开发Demo,下面是项目代码结构大概:

下面是最后的示例效果图,前面部分文章已经发过,再发一次,哈哈:
用户列表窗口

打开子窗口

聊天窗口

演示发送消息

7. Click Once发布尝试
上一篇文章链接:快速创建软件安装包-ClickOnce
8. Q&A
8.1 为啥要在WPF里使用Blazor?吃饱了撑的?
WPF虽然相较Winform做出比较好看的UI相对容易一些,但比起Blazor,或者直接说html开发界面,还是差了一点点,更何况html的资源更多一点,尝试一下为何不可?
8.2 WPF + Blazor支持哪些操作系统
最低支持Windows 7 SP1吧,有群友已经尝试正常运行成功,这是本文示例Click Once安装页面:https://dotnet9.com/WPFBlazorChat
8.3 Blazor 混合开发还支持哪些已有框架?
Blazor混合开发的话,除了WPF,还有MAUI(跨平台框架,支持平台包括Windows\Mac\Linux\Android\iOS等)、Winform(同WPF,只能在Windows平台运行)等,建议阅读微软文档学习:

8.4 Blazor组件除了Masa.Blazor还有哪些?
开源的Blazor组件:Ant Design Blazor、Bootstrap Blazor、MudBlazor、Blazorise,以及微软自家的FAST Blazor等,当然还有不少开源的Blazor组件。
收费的Blazor组件:DevExpress、Telerik、Syncfusion等
8.5 本文示例代码?
文中各小节代码、最后的示例代码都给出了相应链接。