來翻新老專案 (2) - MapleLooker - WinForm 遷移到 WPF

既上次升級 .NET Core 之後
這次打算把專案的 WinForm 改用 WPF 來呈現
這算是我第一次正式寫 WPF,之前都只有開個專案拉幾個 Control 而已
原本想說應該不難,寫之後才發現事情沒我想的那麼簡單 🙃

為什麼選 WPF?

本來想看轉 WPF 後能不能脫離 Visual Studio 改用 Visual Studio Code,然而我需要的 Designer 功能依然只有 Visual Studio 可以使用…

最初本來考慮使用 UWP + WinUI3,但是 WinUI3 只能 Hot Reload ,沒辦法用 Designer ,而 WinUI2 給我的感覺並沒有 WinUI3 來得好
後來在網路上找到了 WPF UI 這東西,想說還不錯,就使用 WPF + WPF UI 了

其實我並不清楚 WPF 跟 UWP 的差別,我以為基底都是 WPF,但從官方文件來看,兩者只能說是相似而不是一樣的東西

WinForm 遷移 Wpf

如果開新專案可能還比較簡單,但我是選擇原地升級 Wpf,因此需要做一些處理把專案的 WPF 啟用,並慢慢的將 WinForm 跟 UserControl 轉成 WPF 形式

由於 WinForm 的操作邏輯與 Wpf 差異較大
所以這等於是將原本大量操作 UI 的程式碼進行大改,過程有些痛苦…

專案啟用 Wpf

在專案的 .csproj 檔案裡需要使用 UseWPF 開啟 WPF 功能

MapleLooker.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- ... -->
<UseWPF>True</UseWPF>
</PropertyGroup>
<!-- ... -->
</Project>

Application 遷移

Wpf 跟 WinForm 不同的點在於:傳統 WinForm 使用最原始的 Main 作為進入點,並以 Application 為基礎掛上 MainForm 啟動應用程式。

而 Wpf 的部份,根據我自己從範本建立的專案,它會使用 xaml 來描述 Application 本身,並且不需要指定 Main 進入點

下面是原本 Form 的進入點:

Program.cs
using System;
using System.Collections.Generic;
using System.Windows.Forms;
using Un4seen.Bass;

namespace MapleLooker
{
static class Program
{
/// <summary>
/// 應用程式的主要進入點。
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new StartForm());
Bass.FreeMe();
}
}
}

而 Wpf 則變成以 App.xaml 來設計應用程式的基底,並另外撰寫 C# 程式來開啟視窗:

App.xaml
<Application
x:Class="MapleLooker.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Startup="OnStartup">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

Application 啟動事件的定義

App.xaml.cs
using System;
using Un4seen.Bass;

namespace MapleLooker;

public partial class App
{
private void OnStartup(object sender, System.Windows.StartupEventArgs e) {
bool init = Bass.BASS_Init(-1, 44100, BASSInit.BASS_DEVICE_DEFAULT, IntPtr.Zero);
if (!init) {
var reason = Bass.BASS_ErrorGetCode();
throw new Exception($"Bass Initialize error: {reason}");
}
var main = new MainForm();
main.Show();
}
}

撰寫好 App.xaml 之後,把 Program.cs 刪掉並且移除 Startup 類別便可以改啟動 Wpf 應用程式

目前的初始化方式是透過觀察 Wpf 專案範本刻出來的
不知道有沒有其他初始化 Application 的方式

Form、UserControl 遷移

這個階段主要是重刻一個 Wpf 的 UI ,並將以前的事件逐一遷移到新的 UI 上面

Wpf 的佈局上會比起以前 WinForm 提供的佈局更有彈性一些
一開始拉會有點卡卡的,習慣之後,其實設計起來會比 WinForm 還要輕鬆,呈現的效果以及響應也比 WinForm 來得好

除了拉控制項的部份有變化以外,對控制項的處理也有很大的變化

控制項變數

WinForm 的控制項打從拉進去的時候就已經自動依照序號命名變數名稱,比如 textBox1
而 Wpf 則是要透過 x:Name 這個屬性來為控制項命名變數名稱,比如:

<TextBox x:Name="textBox1"></TextBox>

如果不使用這個屬性,一般是沒辦法直接存取控制項本身

資料綁定

原本使用 WinForm 的寫法是手動去修改控制項的資料做內容更新
而 Wpf 主要則比較常使用資料綁定的方式來動態呈現資料在介面上

WinForm 其實也有資料綁定的設計,只是我以前根本沒碰這塊
當時主要還是以手動修改控制項的資料為主

我覺得初學 Wpf 困難的地方在於資料綁定
這部份我覺得文件不太好翻閱,很常找到非常老舊的資料,或是找到 MVVM (Model-View-ViewModel) 架構的資料
目前主要是透過探索 Github 上一些有使用 Wpf 的專案才大概知道有什麼使用方式…

依照目前的了解,大概知道下面幾種綁定的基礎用法:

  • Binding
  • Resource
  • DataContext

Binding

Binding 是最基礎的綁定單位,可以將某個物件綁定到特定屬性上
常見綁定的方式有 OnyWay, TwoWay 等等

這部份我目前只知道一些基礎的用法,像是綁定相對資源,綁定元素

舉個例子,綁定上層 BorderBorderBrush

<Border BorderBrush="Aqua">
<CheckBox BorderBrush="{Binding BorderBrush, RelativeSource={RelativeSource AncestorType=Border}}">
</CheckBox>
</Border>

透過 ElementName ,可以綁定有設定 x:Name 的元素
舉個例子,綁定元素 border1BorderBrush

<Border x:Name="border1" BorderBrush="Aqua">
<CheckBox BorderBrush="{Binding BorderBrush, ElementName=border1}">
</CheckBox>
</Border>

Resource

在 xaml 上可以為某個元素定義一些預設的資源
這些資源可以透過 StaticResource 做連接,如果是在程式上設定的 Resource,則可以透過 DynamicResource 連接

這邊以 StaticResource 做範例:

<Border>
<Border.Resources>
<SolidColorBrush x:Key="bgColor" Color="#323232"/>
</Border.Resources>
<CheckBox Background="{StaticResource bgColor}">
</CheckBox>
</Border>

DataContext

DataContext 主要是提供資料的上下文,也就是提供基礎的資料來源供自己以及子元素做綁定

比如說我在 RadioButton 上綁定上下文為 checkbox1,那我可以在其他屬性直接綁定他的 IsChecked:

<CheckBox x:Name="checkbox1">
</CheckBox>
<RadioButton
DataContext="{Binding ElementName=checkbox1}"
IsChecked="{Binding IsChecked}">
</RadioButton>

資料轉換

前面設定的資料綁定並不能直接寫 Code 在裡面
如果要對設定的數值做一些處理,需要經過 convertor 轉換才行

我原先有寫一個節點排序功能,可以勾選是要自動排序還是手動排序
當自動排序開啟時,手動排序的按鈕就必須反灰

為此,我寫了一個 InverseBoolConvertor 來反轉 Boolean 達到這個效果:

InverseBoolConvertor.cs
using System;
using System.Globalization;
using System.Windows.Data;

namespace MapleLooker.Converters;

[ValueConversion(typeof(bool), typeof(bool))]
class InverseBoolConvertor : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
return !(bool)value;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
return !(bool)value;
}
}
<!-- 綁定 namespace `convert` -->
<ui:FluentWindow
...
xmlns:convert="clr-namespace:MapleLooker.Converters">
<!-- 建立 inverseBool 資源 -->
<ui:FluentWindow.Resources>
<!-- ... -->

<convert:InverseBoolConvertor x:Key="inverseBool"/>
</ui:FluentWindow.Resources>

<!-- ... -->

<!-- Button_Sort 的 IsEnabled 綁定 Check_AutoSort 的 IsChecked,並且經過 inverseBool 做反向 -->
<ui:Button
x:Name="Button_Sort"
Content="排序"
FontSize="12"
Margin="10,10,0,10"
IsEnabled="{Binding IsChecked, Converter={StaticResource inverseBool}, ElementName=Check_AutoSort, FallbackValue=false}"
Width="80"
Grid.RowSpan="1"/>
<ui:ToggleSwitch
x:Name="Check_AutoSort"
Content="自動排序"
Checked="WzTree_Event_SortAuto" HorizontalAlignment="Right"
Margin="0,0,10,0"
Grid.RowSpan="1"/>
</ui:FluentWindow>

設定後的效果:
MapleLooker - Main

事件轉移、去除設定控制項相關程式

由於用了資料綁定,因此一些手動設定資料的程式碼就可以進行移除

事件的部份,則需要更新事件方法的定義
大部分的事件參數都從 EventArgs 轉為 RoutedEventArgs
理論上以 EventArgs 作為參數的事件可以直接沿用,但我偏好使用正確的事件定義,所以全部都改成 RoutedEventArgs

TreeView 遷移

TreeView 的節點在 Wpf 上叫 TreeViewItem 而不是 TreeNode
TreeView 的 Children 也從 TreeNode 改為可以接受各種元件

較大的不同是,以前節點排序、事件、選取都是在 TreeView 設定
但現在則是在 TreeViewItem 各自設定
排序的部份則需要透過對綁定資料排序來達成,因為目前是採用手動添加控制項的方式來建立節點,所以這部份還沒完成

跨執行緒操作

以前有透過 Thread 對資料進行載入,主要是處理 TreeNode 量太大導致 UI 凍結的問題
在 Wpf 沒辦法使用 .InvokeRequired.Invoke() 來跨執行緒操作 UI
取而代之,要使用 .CheckAccess().Dispatcher.Invoke()

目前的遷移進度

除了上面提到的項目以外,轉移的過程還踩了不少坑
像是 TrackBar 在 Wpf 等同功能 Slider 就有一些事件沒辦法遷移,需要改原本的程式。或是因為 UI 庫與 Windows 的元件類別名稱重複,導致我需要用全名來寫類別名稱

目前除了少部份功能還沒遷移完成,其他大部分事件以及 UI 都成功從 WinForm 遷移過來了 🎉

原本的 UI:
maple-looker-old-look-screenshot-20250131.png

新的 UI:
maple-looker-new-look-screenshot-20250131.png

結語

Wpf 比我想像的還要難寫許多,以前 WinForm 寫習慣了,轉到 Wpf 完全不知道該如何下手

專案本身轉 Wpf 是一個問題,翻了一下官方文件也沒找到相關的條目,因為專案範本預設好太多東西了,導致我完全不知道建立一個 Wpf 專案需要具備哪些東西

資料綁定比想像中還要難使用,沒辦法直接套用 Web 前端的概念
還好理解之後,寫起來還算簡單,UI 的呈現效果也比想像中好,整個程式的外在質感也提昇不少呢 🙂

目前看官方跟網路上的 Wpf 教學,都傾向使用 MVVM (Model-View-ViewModel) 架構,但依照目前的 WinForm 程式,短時間還無法以這個架構去寫 Wpf,所以目前還沒使用 MVVM 架構

UI 遷移的部份先在這裡告一個段落,接著會開始去改善以前那一堆醜醜的 Code ,除了讓程式更簡潔以外,也方便未來直接對 TreeView 做資料綁定,去除手動建立控制項的部份