【炫麗】從0開始做一個WPF+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
2.3 添加wwwroot\index.html
文件
和Vue
、React
一樣,需要一個html
文件承載Razor
組件,頁面內容類似:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPFBlazorChat</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="WpfBlazor.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
app.css
文件在下面給出定義。- 看
<div id="app">Loading...</div>
,這裡是承載Razor
組件的地方,後面所有載入的Razor
組件都是在這裡渲染出來的。 - 其他暫時不管。
2.4 添加wwwroot\css\app.css
文件
頁面的基本樣式,通用的樣式可放在這個文件:
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
2.5 添加一個Razor組件
加一個Razor的經典組件Counter.razor
,Blazor
的Hello World
程式就有這麼一個組件,文件路徑:/RazorViews/Counter.razor
,之所以放RazorViews
目錄,是為了和WPF常用的Views
目錄區分,該組件內容如下:
<h1>Counter</h1>
<p>好開心,你點我了,現在是:<span style="color: red;">@currentCount</span></p>
<button class="btn btn-primary" @onclick="IncrementCount">快快點我</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
一個按鈕【快快點我】,點擊@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默認窗體邊框
<Window
x:Class="WPFBlazorChat.MainWindow"
xmlns="//schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="//schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:d="//schemas.microsoft.com/expression/blend/2008"
xmlns:mc="//schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
Title="MainWindow"
Width="800"
Height="450"
AllowsTransparency="True"
Background="Transparent"
WindowStyle="None"
mc:Ignorable="d">
<Grid>
<blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
</blazor:BlazorWebView.RootComponents>
</Grid>
</Window>
上面的程式碼只是隱藏了WPF默認窗體的邊框,運行程式如下:
看上圖,點擊窗體中的按鈕(其實是Razor組件的按鈕),但未執行按鈕點擊事件,且窗體消失了,這是怎麼回事?您可以嘗試研究下為什麼,我沒有研究個所以然來,暫時加個背景處理BlazorWebView
穿透的問題。
簡單的WPF自定義窗體樣式
我們加上自定義窗體的基本樣式看看:
MainWindow.xaml
程式碼如下:
<Window
x:Class="WPFBlazorChat.MainWindow"
xmlns="//schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="//schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:d="//schemas.microsoft.com/expression/blend/2008"
xmlns:mc="//schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
Title="MainWindow"
Width="800"
Height="450"
AllowsTransparency="True" Background="Transparent" WindowStyle="None"
mc:Ignorable="d">
<Window.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Width" Value="35" />
<Setter Property="Height" Value="25" />
<Setter Property="Margin" Value="2" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
</Style>
</Window.Resources>
<Border Background="#7160E8" CornerRadius="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="35" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Background="#7160E8" CornerRadius="5 5 0 0" MouseLeftButtonDown="MoveWindow_MouseLeftButtonDown">
<Grid>
<TextBlock
Margin="10,10,5,5"
Foreground="White"
Text="這裡是窗體標題欄,左側可放Logo、標題,右側放窗體操作按鈕:最小化、最大化、關閉等" />
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button Click="MinimizeWindow_Click" Content="―" />
<Button Click="MaximizeWindow_Click" Content="口" />
<Button Click="CloseWindow_Click" Content="X" />
</StackPanel>
</Grid>
</Border>
<blazor:BlazorWebView Grid.Row="1" HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Grid>
</Border>
</Window>
我們給整個窗體客戶端區域加了一個背景Border
(您可以去掉Border背景色,點擊介面按鈕試試),然後又套了一個Grid,用於放置自定義的標題欄(標題和窗體控制按鈕)和BlazorWebView
(用於渲染Razor組件的瀏覽器組件),下面是窗體控制按鈕的響應事件:
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
namespace WPFBlazorChat;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
var serviceCollection = new ServiceCollection();
serviceCollection.AddWpfBlazorWebView();
Resources.Add("services", serviceCollection.BuildServiceProvider());
}
private void MoveWindow_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.ClickCount == 1)
{
this.DragMove();
}
else
{
MaximizeWindow_Click(null, null);
}
}
private void CloseWindow_Click(object sender, RoutedEventArgs e)
{
this.Close();
}
private void MinimizeWindow_Click(object sender, RoutedEventArgs e)
{
this.WindowState = WindowState.Minimized;
}
private void MaximizeWindow_Click(object sender, RoutedEventArgs e)
{
this.WindowState = this.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
}
}
程式碼簡單,處理了窗體最小化、窗體最大化(還原)、關閉、標題欄雙擊窗體最大化(還原),上面的實現不是一個完美的自定義窗體實現,至少有這兩個問題:
- 當您嘗試最大化後,窗體鋪滿了整個作業系統桌面(連任務欄區域也佔用了);
- 窗體任務欄兩個圓角未生效(紅色矩形框選的部分),即窗體下面的兩個圓角,站長未找到讓
BlazorWebView
出現圓角的屬性或其他方法;標題欄區域(綠色矩形框選的部分)是WPF控制項,所以圓角顯示正常。
在後面的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 開源項目實現。
我們把標題欄做到Counter.razor
組件,即標題欄、客戶區放一個組件里,當然你也可以分離,這裡我們方便演示:
Counter.razor
@using WPFBlazorChat.Services
<div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
<img src="svg/navigation.svg" />
</button>
<div class="window-title">
測試窗體標題
</div>
<div style="flex-grow:1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg" />
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg" />
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg" />
}
else
{
<img src="svg/maximize.svg" />
}
</button>
<button class="titlebar-cbtn" @onclick="()=>WindowService.Close(false)">
<img src="svg/dismiss.svg" />
</button>
</div>
<p>好開心,你點我了,現在是:<span style="color: red;">@currentCount</span></p>
<button class="btn btn-primary" @onclick="IncrementCount">快快點我</button>
@code {
private int currentCount = 0;
protected override void OnInitialized()
{
WindowService.Init();
base.OnInitialized();
}
private void IncrementCount()
{
currentCount++;
}
}
下面給出程式碼簡單說明:
- 第一個
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
using System;
using System.Linq;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Threading;
using Application = System.Windows.Application;
namespace WPFBlazorChat.Services;
public class WindowService
{
private static bool _isMoving;
private static double _startMouseX;
private static double _startMouseY;
private static double _startWindLeft;
private static double _startWindTop;
public static void Init()
{
DispatcherTimer dispatcherTimer = new();
dispatcherTimer.Tick += UpdateWindowPos;
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(17);
dispatcherTimer.Start();
}
public static void StartMove()
{
_isMoving = true;
_startMouseX = GetX();
_startMouseY = GetY();
var window = GetActiveWindow();
if (window == null)
{
return;
}
_startWindLeft = window.Left;
_startWindTop = window.Top;
}
public static void StopMove()
{
_isMoving = false;
}
public static void Minimize()
{
var window = GetActiveWindow();
if (window != null)
{
window.WindowState = WindowState.Minimized;
}
}
public static void Maximize()
{
var window = GetActiveWindow();
if (window != null)
{
window.WindowState =
window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
}
}
public static bool IsMaximized()
{
var window = GetActiveWindow();
if (window != null)
{
return window.WindowState == WindowState.Maximized;
}
return false;
}
public static void Close(bool allWindow = false)
{
if (allWindow)
{
Application.Current?.Shutdown();
return;
}
var window = GetActiveWindow();
if (window != null)
{
window.Close();
}
}
private static void UpdateWindowPos(object? sender, EventArgs e)
{
if (!_isMoving)
{
return;
}
double moveX = GetX() - _startMouseX;
double moveY = GetY() - _startMouseY;
Window? window = GetActiveWindow();
if (window == null)
{
return;
}
window.Left = _startWindLeft + moveX;
window.Top = _startWindTop + moveY;
}
private static int GetX()
{
return Control.MousePosition.X;
}
private static int GetY()
{
return Control.MousePosition.Y;
}
private static Window? GetActiveWindow()
{
return Application.Current.Windows.Cast<Window>().FirstOrDefault(currentWindow => currentWindow.IsActive);
}
}
上面的程式碼用於窗體的最小化、最大化(還原)、關閉等實現,需要在Razor
組件里正確的調用這些方法:
-
Counter.razor
組件的OnInitialized
初始化生命周期方法里調用WindowService.Init();
,如上程式碼,這個方法開啟定時器,定時調用UpdateWindowPos
方法檢查滑鼠是否按下,如果按下,檢查間隔內窗體的位置變化範圍,然後修改窗體位置,從而實現窗體位置移動(移動窗體無法使用WPF的DragMove
方法,您可以嘗試使用看看它報什麼錯),移動窗體有更好的方法歡迎留言。 -
Razor
組件里窗體控制按鈕的使用看上面的程式碼不難理解,不過多解釋。
上面效果的樣式文件修改如下,wwwroot\css\app.css
:
/*
BlazorDesktopWPF-CustomTitleBar - © Copyright 2021 - Jam-Es.com
Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
*/
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
padding: 0;
margin: 0;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.content-container {
padding: 0px 20px 20px 20px;
flex-grow: 1;
overflow-y: scroll;
}
.titlebar {
width: 100%;
height: 32px;
min-height: 32px;
background-color: #7160E8;
display: flex;
flex-direction: row;
}
.titlebar-btn, .titlebar-cbtn {
width: 46px;
background-color: #7160E8;
color: white;
border: none;
border-radius: 0;
}
.titlebar-btn:hover {
background-color: #5A5A5A;
}
.titlebar-btn:focus, .titlebar-cbtn:focus {
outline: 0;
}
.titlebar-cbtn:hover {
background-color: #E81123;
}
.window-title {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: 5px;
color: white;
}
上面的一些程式碼即實現了由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控制項的樣式:
<Application x:Class="WPFBlazorChat.App"
xmlns="//schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="//schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="//schemas.modernwpf.com/2019"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemeResources />
<ui:XamlControlsResources />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
最後打開MainWindow.xaml
,修改如下(主要是引入的幾個屬性ui:xxxxx
):
<Window x:Class="WPFBlazorChat.MainWindow"
xmlns="//schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="//schemas.microsoft.com/expression/blend/2008"
xmlns:x="//schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="//schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="//schemas.modernwpf.com/2019"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
ui:TitleBar.ExtendViewIntoTitleBar="True"
ui:TitleBar.IsBackButtonVisible="False"
ui:TitleBar.Style="{DynamicResource AppTitleBarStyle}"
ui:WindowHelper.UseModernWindowStyle="True">
<Border Background="#7160E8" CornerRadius="5">
<blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app" ComponentType="{x:Type razorViews:Counter}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Border>
</Window>
就上面三處修改,我們運行看看:
是不是和3.3
效果一樣?其實仔細看,窗體下面的圓角也有了:
最終還是WPF解決了所有問題…
具體怎麼實現的窗體最大化未占作業系統的任務欄,以及窗體圓角問題的解決(竟然能讓BlazorWebView
部分透明了)可以查看該組件相關程式碼,本文不過多深究。
另外,WPF熟手可能比較清楚,前面的程式碼還不能正常的拖動改變窗體大小(不知道你發現沒,我當你沒發現。),使用該庫後也解決了:
本小節源碼在這解決圓角和最大化問題,下面開始本文的下半部分了,好累,終於到這了。
4. 添加第三方Blazor組件
工欲善其事,必先利其器!
鑒於大部分同學前端基礎可能不是太好,即使使用Blazor可以少用或者不用JavaScript,但有那麼一款漂亮、便捷的Blazor
組件庫,這不是如虎添翼嗎?本文使用Masa Blazor做示例展示,如今Blazor
組件庫眾多,選擇自己喜歡的、順手的就成:
站長前些日子介紹過MAUI使用Masa blazor組件庫一文,本小節思路也是類似,且看我表演。
打開Masa Blazor文檔站點://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>
節點添加如下資源:
<link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />
<link href="//cdn.masastack.com/npm/@mdi/[email protected]/css/materialdesignicons.min.css" rel="stylesheet">
<link href="//cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
<link href="//cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">
<script src="_content/BlazorComponent/js/blazor-component.js"></script>
完整程式碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPFBlazorChat</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="WpfBlazor.styles.css" rel="stylesheet" />
<link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />
<link href="//cdn.masastack.com/npm/@mdi/[email protected]/css/materialdesignicons.min.css" rel="stylesheet">
<link href="//cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
<link href="//cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">
<script src="_content/BlazorComponent/js/blazor-component.js"></script>
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
4.3 引入Masa.Blazor命名空間
打開_Imports.razor
文件,修改如下:
@using Microsoft.AspNetCore.Components.Web
@using Masa.Blazor
@using BlazorComponent
4.4 Razor組件添加Masa.Blazor
打開MainWindow.xaml.cs
,添加一行程式碼 serviceCollection.AddMasaBlazor();
4.5 嘗試Masa.Blazor案例
上面4步的準備工作做好後,我們簡單來使用下Masa.Blazor
組件。
打開Tab組件鏈接://blazor.masastack.com/components/tabs,嘗試這個Demo:
Demo的程式碼我幾乎不變的引入,打開RazorViews\Counter.razor
文件,保留3.4
節的標題欄,替換了客戶區域內容,程式碼如下:
@using WPFBlazorChat.Services
<MApp>
<!--上一小節的標題欄開始-->
<div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
<img src="svg/navigation.svg"/>
</button>
<div class="window-title">
測試窗體標題
</div>
<div style="flex-grow: 1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg"/>
}
else
{
<img src="svg/maximize.svg"/>
}
</button>
<button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
<img src="svg/dismiss.svg"/>
</button>
</div>
<!--上一小節的標題欄結束-->
<!--新增的Masa.Blazor Tab案例程式碼開始-->
<MCard>
<MToolbar Color="cyan" Dark Flat>
<ChildContent>
<MAppBarNavIcon></MAppBarNavIcon>
<MToolbarTitle>Your Dashboard</MToolbarTitle>
<MSpacer></MSpacer>
<MButton Icon>
<MIcon>mdi-magnify</MIcon>
</MButton>
<MButton Icon>
<MIcon>mdi-dots-vertical</MIcon>
</MButton>
</ChildContent>
<ExtensionContent>
<MTabs @bind-Value="tab"
AlignWithTitle
SliderColor="yellow">
@foreach (var item in items)
{
<MTab Value="item">
@item
</MTab>
}
</MTabs>
</ExtensionContent>
</MToolbar>
<MTabsItems @bind-Value="tab">
@foreach (var item in items)
{
<MTabItem Value="item">
<MCard Flat>
<MCardText>@text</MCardText>
</MCard>
</MTabItem>
}
</MTabsItems>
</MCard>
<!--新增的Masa.Blazor Tab案例程式碼結束-->
</MApp>
@code {
#region Masa.Blazor Tab案例C#程式碼
StringNumber tab;
List<string> items = new()
{
"web", "shopping", "videos", "images", "news",
};
string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
#endregion
protected override void OnInitialized()
{
WindowService.Init();
base.OnInitialized();
}
}
運行效果如下:
是不是有那味兒了?再嘗試把Tab移到標題欄,前面有提過的效果:
上面的效果,程式碼修改如下,刪除了原標題欄程式碼,將窗體操作按鈕放到了MToolbar
裡面,並使用MToolbar
添加了雙擊事件、滑鼠按下、釋放事件實現窗體拖動:
<MApp>
<!--新增的Masa.Blazor Tab案例程式碼開始-->
<MCard>
<MToolbar Color="cyan" Dark Flat @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<MTabs @bind-Value="tab"
AlignWithTitle
SliderColor="yellow">
@foreach (var item in items)
{
<MTab Value="item">
@item
</MTab>
}
</MTabs>
<div style="flex-grow: 1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg"/>
}
else
{
<img src="svg/maximize.svg"/>
}
</button>
<button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
<img src="svg/dismiss.svg"/>
</button>
</MToolbar>
<MTabsItems @bind-Value="tab">
@foreach (var item in items)
{
<MTabItem Value="item">
<MCard Flat>
<MCardText>@text</MCardText>
</MCard>
</MTabItem>
}
</MTabsItems>
</MCard>
<!--新增的Masa.Blazor Tab案例程式碼結束-->
</MApp>
窗體操作按鈕的背景色也做部分修改:
其實上面的窗體效果還是有點瑕疵,注意到窗體右側的豎直滾動條了嗎?在沒引入Masa.Blazor
之前,右側正常顯示,引入後多了一個豎直滾動條:
這個想去掉也簡單,在wwwroot\css\app.css
追加樣式(當時也是折騰了好一會兒,最後在Masa.Blazor
群里群友給出了解決方案,十分感謝):
問題解決css
程式碼:
::-webkit-scrollbar {
width: 0px;
}
因為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
。
using System;
namespace WPFBlazorChat.Messages;
public abstract class Message
{
protected Message(object sender)
{
this.Sender = sender ?? throw new ArgumentNullException(nameof(sender));
}
public object Sender { get; }
}
IMessenger
消息介面,只定義了三個介面:
- Subscribe:消息訂閱
- Unsubscribe:取消消息訂閱
- Publish:消息發送
using System;
namespace WPFBlazorChat.Messages;
public interface IMessenger
{
void Subscribe<TMessage>(object recipient, Action<TMessage> action,
ThreadOption threadOption = ThreadOption.PublisherThread) where TMessage : Message;
void Unsubscribe<TMessage>(object recipient, Action<TMessage>? action = null) where TMessage : Message;
void Publish<TMessage>(object sender, TMessage message) where TMessage : Message;
}
public enum ThreadOption
{
PublisherThread,
BackgroundThread,
UiThread
}
Messenger
消息的管理,消息中轉等實現:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace WPFBlazorChat.Messages;
public class Messenger : IMessenger
{
public static readonly Messenger Default = new Messenger();
private readonly object registerLock = new object();
private Dictionary<Type, List<WeakActionAndToken>>? recipientsOfSubclassesAction;
public void Subscribe<TMessage>(object recipient, Action<TMessage> action, ThreadOption threadOption)
where TMessage : Message
{
lock (this.registerLock)
{
var messageType = typeof(TMessage);
this.recipientsOfSubclassesAction ??= new Dictionary<Type, List<WeakActionAndToken>>();
List<WeakActionAndToken> list;
if (!this.recipientsOfSubclassesAction.ContainsKey(messageType))
{
list = new List<WeakActionAndToken>();
this.recipientsOfSubclassesAction.Add(messageType, list);
}
else
{
list = this.recipientsOfSubclassesAction[messageType];
}
var item = new WeakActionAndToken
{ Recipient = recipient, ThreadOption = threadOption, Action = action };
list.Add(item);
}
}
public void Unsubscribe<TMessage>(object? recipient, Action<TMessage>? action) where TMessage : Message
{
var messageType = typeof(TMessage);
if (recipient == null || this.recipientsOfSubclassesAction == null ||
this.recipientsOfSubclassesAction.Count == 0 || !this.recipientsOfSubclassesAction.ContainsKey(messageType))
{
return;
}
var lstActions = this.recipientsOfSubclassesAction[messageType];
for (var i = lstActions.Count - 1; i >= 0; i--)
{
var item = lstActions[i];
var pastAction = item.Action;
if (pastAction != null
&& recipient == pastAction.Target
&& (action == null || action.Method.Name == pastAction.Method.Name))
{
lstActions.Remove(item);
}
}
}
public void Publish<TMessage>(object sender, TMessage message) where TMessage : Message
{
var messageType = typeof(TMessage);
if (this.recipientsOfSubclassesAction != null)
{
var listClone = this.recipientsOfSubclassesAction.Keys.Take(this.recipientsOfSubclassesAction.Count)
.ToList();
foreach (var type in listClone)
{
List<WeakActionAndToken>? list = null;
if (messageType == type || messageType.IsSubclassOf(type) || type.IsAssignableFrom(messageType))
{
list = this.recipientsOfSubclassesAction[type]
.Take(this.recipientsOfSubclassesAction[type].Count)
.ToList();
}
if (list is { Count: > 0 })
{
this.SendToList(message, list);
}
}
}
}
private void SendToList<TMessage>(TMessage message, IEnumerable<WeakActionAndToken> weakActionsAndTokens)
where TMessage : Message
{
var list = weakActionsAndTokens.ToList();
var listClone = list.Take(list.Count()).ToList();
foreach (var item in listClone)
{
if (item.Action is { Target: { } })
{
switch (item.ThreadOption)
{
case ThreadOption.BackgroundThread:
Task.Run(() => { item.ExecuteWithObject(message); });
break;
case ThreadOption.UiThread:
SynchronizationContext.Current!.Post(_ => { item.ExecuteWithObject(message); }, null);
break;
default:
item.ExecuteWithObject(message);
break;
}
}
}
}
}
public class WeakActionAndToken
{
public object? Recipient { get; set; }
public ThreadOption ThreadOption { get; set; }
public Delegate? Action { get; set; }
public string? Tag { get; set; }
public void ExecuteWithObject<TMessage>(TMessage message) where TMessage : Message
{
if (this.Action is Action<TMessage> factAction)
{
factAction.Invoke(message);
}
}
}
有興趣的看上面的程式碼,封裝程式碼上面簡單全部給上,後面的消息通知都是基於上面的三個類實現的,比較核心。
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的【Android】圖標按鈕,給主窗體A響應了消息
ReceivedResponseMessage
,主窗體收到後彈出一個對話框。
三個消息類定義如下:
public class OpenSecondViewMessage : Message
{
public OpenSecondViewMessage(object sender) : base(sender)
{
}
}
public class SendRandomDataMessage : Message
{
public SendRandomDataMessage(object sender, int number) : base(sender)
{
Number = number;
}
public int Number { get; set; }
}
public class ReceivedResponseMessage : Message
{
public ReceivedResponseMessage(object sender) : base(sender)
{
}
}
除了SendRandomDataMessage
傳遞了一個業務Number
屬性,另兩個消息只是起到通知作用(所以沒有額外屬性定義),實際開發時可能需要傳遞業務數據。
5.3.1 打開多窗體
即上面的第一個操作:點擊主窗體A的【+】按鈕,發送了OpenSecondViewMessage
消息,打開子窗體B。
在RazorViews\MainView.razor
中執行按鈕點擊,發送打開子窗體消息:
...
<MCol>
<MButton class="mx-2" Fab Dark Color="indigo" OnClick="OpenNewSecondView">
<MIcon>mdi-plus</MIcon>
</MButton>
</MCol>
...
@code{
...
void OpenNewSecondView()
{
Messenger.Default.Publish(this, new OpenSecondViewMessage(this));
}
...
}
在App.xaml.cs
里訂閱打開子窗體消息:
public partial class App : Application
{
public App()
{
// 訂閱打開子窗口消息,在主窗口點擊【+】按鈕
Messenger.Default.Subscribe<OpenSecondViewMessage>(this, msg =>
{
var chatWin = new SecondWindowView();
chatWin.Show();
}, ThreadOption.UiThread);
}
}
實際開發可能情況更複雜,發送的消息OpenSecondViewMessage
裡帶WPF窗體路由(定義的一套路徑規則尋找窗體或ViewModel
),訂閱的地方也可能不在主程式,在子模組的Module
類里。
5.3.2 發送業務數據
即第二個操作:打開子窗體B後,再點擊主窗體A的【桃心】按鈕,發送了SendRandomDataMessage
消息,子窗體B的第二個TabItem Header
顯示了消息傳來的數字。
- 在
RazorViews\MainView.razor
中執行按鈕點擊,發送業務消息(就當前時間的Millisecond
):
...
<MCol>
<MButton class="mx-2" Fab Small Dark Color="pink" OnClick="SendNumber">
<MIcon>mdi-heart</MIcon>
</MButton>
</MCol>
...
@code{
...
void SendNumber()
{
Messenger.Default.Publish(this, new SendRandomDataMessage(this, DateTime.Now.Millisecond));
}
...
}
- 在
RazorViews\SecondView.razor
的OnInitialized()
方法里訂閱業務消息通知:
@using WPFBlazorChat.Messages
<MApp>
<MToolbar>
<MTabs BackgroundColor="primary" Grow Dark>
<MTab>
<MBadge Color="pink" Dot>
Item One
</MBadge>
</MTab>
<MTab>
<MBadge Color="green" Content="tagCount">
Item Two
</MBadge>
</MTab>
<MTab>
<MBadge Color="deep-purple accent-4" Icon="mi-masa">
Item Three
</MBadge>
</MTab>
</MTabs>
</MToolbar>
<MRow>
<MButton class="mx-2" Fab Dark Large Color="purple" OnClick="ReponseMessage">
<MIcon>
mdi-android
</MIcon>
</MButton>
</MRow>
</MApp>
@code
{
private int tagCount = 6;
protected override void OnInitialized()
{
// 訂閱業務消息,在主窗口點擊桃心按鈕時觸發
Messenger.Default.Subscribe<SendRandomDataMessage>(this, msg =>
{
this.InvokeAsync(() => { this.tagCount = msg.Number; });
this.StateHasChanged();
}, ThreadOption.UiThread);
}
void ReponseMessage()
{
// 通知主窗體,我已經收到消息,請不要再發
Messenger.Default.Publish(this, new ReceivedResponseMessage(this));
}
}
注意看,上面收到消息時有兩個方法要簡單說一下,看OnInitialized()
里的程式碼:
- InvokeAsync:將
Number
賦值給變數tagCount
的程式碼是在InvokeAsync
方法里執行的,這個和WPF里的Dispatcher.Invoke
是一個意思,相當於接收數據是在子執行緒,而賦值這個操作會即時的綁定到<MBadge Color="green" Content="tagCount">
上,就需要UI執行緒同步。 - StateHasChanged:相當於WPF MVVM里的
PropertyChanged
事件通知,通知UI
這裡有值變化了,請你刷新一下,我要看看最新值。
上面的程式碼把子窗體消息回應也貼上了,即點擊Android圖標按鈕時發送了ReceivedResponseMessage
消息,在主窗體RazorViews\MainView.razor
里也訂閱了這個消息,和上面的程式碼類似:
...
<!--確認對話框開始-->
<PConfirm Visible="_showComfirmDialog"
Title="子窗體來回應了"
Type="AlertTypes.Warning"
OnCancel="() => _showComfirmDialog = false"
OnOk="() => _showComfirmDialog = false">
說你別沒事一直發,它們煩!
</PConfirm>
<!--確認對話框結束-->
</MApp>
@code{
...
// 是否顯示確認對話框
bool _showComfirmDialog;
protected override void OnInitialized()
{
WindowService.Init();
// 訂閱子窗體響應的消息,它已經收到消息了,我可以休息下再發
Messenger.Default.Subscribe<ReceivedResponseMessage>(this, msg =>
{
this.InvokeAsync(() => { _showComfirmDialog = true; });
this.StateHasChanged();
}, ThreadOption.UiThread);
base.OnInitialized();
}
...
}
在OnInitialized()
方法里訂閱消息ReceivedResponseMessage
,收到後將變數_showComfirmDialog
置為true
,即上面對話框的屬性Visible
綁定的值,同理需要在InvokeAsync()
中處理數據接收,也需要調用StateHasChanged
通知UI數據變化。
上面說了部分程式碼,可能講的不太清楚,可以看本節示例源碼:多窗體消息通知。
6. 本文示例
本來想寫完整Demo說明的,發現上面把基本要點都拉了一遍,再粘貼一些重複程式碼有點沒完沒了了,有興趣的拉源碼WPF與Blazor混合開發Demo查看、運行,下面是項目程式碼結構:
下面是最後的示例效果圖,前面部分文章已經發過,再發一次,哈哈:
用戶列表窗口
打開子窗口
聊天窗口
演示發送消息
7. Click Once發布嘗試
上一篇文章鏈接:快速創建軟體安裝包-ClickOnce,本文示例Click Once安裝頁面://dotnet9.com/WPFBlazorChat
8. Q&A
8.1 為啥要在WPF里使用Blazor?吃飽了撐的?
WPF
雖然相較Winform
做出比較好看的UI相對容易一些,但比起Blazor
,或者直接說html
開發介面,還是差了一點點,更何況html
的資源更多一點,嘗試一下為何不可?
8.2 WPF + Blazor支援哪些作業系統
最低支援Windows 7 SP1
吧,有群友已經嘗試在Windows 7
正常運行成功,這是本文示例Click Once安裝頁面://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 本文示例程式碼?
文中各小節程式碼、最後的示例程式碼都給出了相應鏈接,您可返回查看。