WPF命令
目錄
簡介
這是一篇記錄筆者早期閱讀學習劉鐵猛老師的《深入淺出WPF》的讀書筆記,如果文中內容閱讀不暢,推薦購買正版書籍詳細閱讀。
什麼是命令
諸葛亮的”錦囊妙計“就是命令,錦囊是精美的“包裝”,妙計是“內容”。
為什麼需要命令
因為事件不具有約束力,所以需要命令來提供有效的約束。命令不僅可以約束程式碼,還可以約束步驟邏輯。
實際程式設計工作中就算只使用事件、不適用命令,程式的邏輯也一樣可以被驅動得很好,但我們不能阻止程式設計師按自己得習慣去編寫程式碼。比如儲存事件得處理器,程式設計師們可以寫Save()、SaveHandler()、SaveDocument()······這些都符合程式碼規範,但遲早有一天整個專案會變得無法被讀懂,新來得程式設計師或修改bug的程式設計師會很抓狂。如果使用命令,情況會號很多—當Save命令到達某個元件時,命令會主動去呼叫元件的Save()方法,而這個方法可能被定義在基類或者接口裡(即保證了這個方法一定是存在的),這就在程式碼結構和命名上做了約束。不但如此,命令還可以控制接收者“先做校驗、再儲存、然後關閉”,也就是說,命令除了可以約束程式碼,還可以約束步驟邏輯
此前我們學習了WPF的路由事件,而在本節將學習一個更為抽象且鬆耦合的事件版本,即命令。最明顯的區別是,事件是與使用者動作相關聯的,而命令是那些與使用者介面想分離的動作,例如我們最熟悉的剪下(Cut)、複製(Copy)和貼上(Paste)命令。這帶來的好處是:命令可以實現複用,減少了程式碼量,從而可以在不破壞後臺邏輯的條件下,更加靈活地控制你的使用者介面。在WPF之前使用命令是一件很煩瑣的事情,因為需要考慮狀態間的同步問題。WPF為了解決這個問題,增加了兩個重要特性:一是將事件委託到適當的命令;二是將控制元件的啟用狀態與相應命令的狀態保持一致。
命令的基本元素與關係
命令系統的基本元素
WPF的命令系統由幾個基本要素構成,它們是:
- 命令(Command):WPF的命令實際上就是實現了ICommand介面的類,平時使用最多的就是RoutedCommand類。我們還會學習使用自定義命令。
- 命令源(Command Source):即命令的傳送者,是實現了ICommandSource介面的類。像Button、MenuItem等介面元素都實現了這個介面,單擊它們都會執行繫結的命令。
- 命令目標(Command Target):即命令將傳送給誰,或者說命令將作用在誰身上。命令目標必須是實現了實現了IInputElement介面的類。
- 命令關聯(Command Binding):負責把一些外圍邏輯和命令關聯起來,比如執行之前對命令是否可以執行進行判斷、命令執行之後還有那些後續工作等。
基本元素之間的關係
命令的使用步驟:
- 建立命令類:即獲得一個實現ICommand介面的類,如果命令與具體業務邏輯無關則使用WPF類庫中的RoutedCommand類即可。如果想得到與業務邏輯相關的專有命令,則需建立RoutedCommand(或者ICommand介面)的派生類。
- 宣告命令例項:使用命令時需要建立命令類的例項。一般情況下程式中某種操作只需要一個命令例項與之對應即可。比如對應“儲存”這個操作,你可以拿同一個例項去命令每個元件執行其儲存功能,因此程式中的命令多使用單件模式(Singletone Pattern)以減少程式碼的複雜度。
- 指定命令的源:即指定由誰來發送這個命令。如果把命令看作炮彈,那麼命令源就相當於火炮。同一個命令可以有多個源。比如儲存命令,既可以由選單中的儲存項來發送,也可以由工具欄中的儲存圖示傳送。
- 指定命令目標:命令目標並不是命令的屬性而是命令源的屬性,指定命令目標時告訴命令源向那個元件發哦是那個命令,無論這個元件是否擁有焦點它都會收到這個命令。如果沒有為命令源指定命令目標,則WPF系統認為當擁有焦點的物件就是命令目標。這個步驟有點像為火炮指定目標。
- 設定命令關聯:炮兵時不能單獨戰鬥的,就像炮兵需要偵察兵在射擊前觀察敵情、判斷髮射時機,在射擊後觀測射擊效果、幫助修正一樣,WPF命令需要CommandBinding在執行前幫助判斷是不是可以執行、在執行後做一些是事情來“打掃戰場”。
在命令和命令關之之間還有一個微妙的關係。一旦某個UI元件被命令源“瞄上”,命令源就會不停的向命令目標“投石問路”,命令目標就會不停地傳送可路由的PreviewCanExecute和CanExecute附加事件,事件就會沿著UI元素樹項上傳遞並被命令關聯所捕捉,命令關聯捕捉到這些事件後就會把命令能不能傳送實時報告給命令。類似的,如果命令被髮送出來併到達命令目標,命令目標就會發送PreviewExecute和Execute兩個附加事件,這兩個事件也會沿著UI元素樹向上傳遞並被命令關聯所捕捉,命令關聯會完成一些後續的任務。別小看是“後續任務”,對於那些於業務邏輯無關的通用命令,這些後續任務才是最重要的。
PreviewCanExecute、CanExecute、PreviewExecute、Execute,這4個事件都是附加事件,是被CommandManager類“附加”給命令目標的。另外PreviewCanExecute、CanExecute的執行時機不由程式設計師控制,而且執行頻率比較高,這不但會給系統性能帶來血降低,偶爾還會引入幾個意想不到的bug並且比較難除錯,務請多加小心。
下圖所示是WPF命令基本元素的關係圖:
小試命令
需求:定義一個命令,使用Button來發送這個命令,當命令到達TextBox時TextBox會被清空(如果TextBox中沒有文字則命令不可被髮送)
執行程式,在TextBox中輸入文字後Button在命令可執行狀態的影響下變為可用,此時單擊按鈕,TextBox都會被清空。
注意事項:
第一,使用命令可以避免自己寫程式碼判斷Button是否可用以及新增快捷鍵
第二,RoutedCommand是一個與業務邏輯無關的類,只負責程式中“跑腿”而並不對命令目標做任何操作,TextBox並不是由它清空的,而是由CommandBinding清空的。因為無論是探測命令是否執行還是命令送達目標,都會激發命令目標傳送路由事件,這些路由事件沿UI樹項上傳遞最終被CommandBinding所捕捉。本例中,CommandBinding被安裝在外圍的StackPanel上,CommandBinding"站在高處"起一個偵聽器的作用,而且專門針對clearCmd命令捕捉於其相關的路由事件。本例中,當CommandBinding捕捉到CanExecute事件就會呼叫cb_CanExecute方法(判斷命令執行的條件是否滿足,並反饋給命令供其影響命令源的狀態);當捕捉到的是Executed事件(表示命令的Executed方法已經執行了,或說命令已經作用在了命令目標上,RoutedCommand的Execute方法不包含業務邏輯,只負責讓命令目標激發Executed),則呼叫cb_Executed方法。
第三,因為CanExecute事件的激發頻率比較高,為了避免降低效能,在處理完後建議把e.Handled設為true。
第四,CommandBinding一定要設定在命令目標的外圍空間上,不然無法捕捉到CanExecute和Executed等路由事件
XAML介面如下:
<Grid>
<StackPanel x:Name="stackPanel">
<Button x:Name="button1" Content="傳送命令" Margin="5"/>
<TextBox x:Name="textboxA" Margin="5" Height="100"/>
</StackPanel>
</Grid>
後臺程式碼為:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
InitializeCommand();
}
//01 宣告並定義命令
private RoutedCommand clerCmd = new RoutedCommand("Clear",typeof(MainWindow));
private void InitializeCommand()
{
//02 把命令賦值給命令源(傳送者)並指定快捷鍵
this.button1.Command = this.clerCmd;
this.clerCmd.InputGestures.Add(new KeyGesture(Key.C,ModifierKeys.Alt));
//03 指定命令目標
this.button1.CommandTarget = this.textboxA;
//04 建立命令關聯
CommandBinding cb = new CommandBinding();
cb.Command = this.clerCmd; //只關注與clearCmd相關的事件
cb.CanExecute += new CanExecuteRoutedEventHandler(Cb_CanExecute) ;
cb.Executed += new ExecutedRoutedEventHandler(Cb_Executed);
//把命令關聯安置在外圍控制元件上
this.stackPanel.CommandBindings.Add(cb);
}
//當命令到達目標後,此方法被呼叫
private void Cb_Executed(object sender, ExecutedRoutedEventArgs e)
{
this.textboxA.Clear();
e.Handled = true;
}
//當探測命令是否可以執行時,此方法被呼叫
private void Cb_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
if (string.IsNullOrEmpty(this.textboxA.Text))
{
e.CanExecute = false;
}
else
{
//避免繼續向上傳而降低程式效能
e.CanExecute = true;
}
e.Handled = true;
}
}
WPF的命令庫
命令具有“一處宣告、處處使用”的特點,因此WPF類庫準備了一些便捷的命令庫,這些命令庫包括:
- ApplicationCommands
- CommponentCommands
- NavigationCommands
- MediaCommands
- EditingCommands
它們都是靜態類,而命令就是用這些類的靜態只讀屬性以單件模式暴露出來的。
public static class ApplicationCommands
{
// 獲取表示“屬性”命令的值。
public static RoutedUICommand Properties { get; }
// 獲取表示“列印預覽”命令的值。
public static RoutedUICommand PrintPreview { get; }
// 獲取表示“列印”命令的值。
public static RoutedUICommand Print { get; }
// 獲取表示“貼上”命令的值。
public static RoutedUICommand Paste { get; }
// 獲取表示“停止”命令的值。
public static RoutedUICommand Stop { get; }
// 獲取表示“開啟”命令的值。
public static RoutedUICommand Open { get; }
// 獲取表示 New 命令的值。
public static RoutedUICommand New { get; }
// 獲取表示 Help 命令的值。
public static RoutedUICommand Help { get; }
// 獲取表示 Find 命令的值。
public static RoutedUICommand Find { get; }
// 獲取表示“刪除”命令的值。
public static RoutedUICommand Delete { get; }
// 獲取表示“剪下”命令的值。
public static RoutedUICommand Cut { get; }
// 獲取表示“更正列表”命令的值。
public static RoutedUICommand CorrectionList { get; }
// 獲取表示“複製”命令的值。
public static RoutedUICommand Copy { get; }
//···········
}
命令引數
命令源一定是實現了ICommandSource介面的物件,而ICommandSource有一個屬性就是CommandPrameter,如果把命令看作飛向目標的炮彈,那麼CommandPrameter就相當於裝載在炮彈肚子裡的“訊息”。
示例XAML程式碼:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="24"/>
<RowDefinition Height="4"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!--命令和命令引數-->
<TextBlock Text="Name:" VerticalAlignment="Center" HorizontalAlignment="Left" Grid.Row="0"/>
<TextBox x:Name="nameTextBox" Margin="60,0,0,0" Grid.Row="0"/>
<Button Content="New Teacher" Command="New" CommandParameter="Teacher" Grid.Row="2"/>
<Button Content="New Student" Command="New" CommandParameter="Student" Grid.Row="4"/>
<ListBox x:Name="listBoxNewItems" Grid.Row="6"/>
</Grid>
<!--為窗體新增CommandBinding-->
<Window.CommandBindings>
<CommandBinding Command="New" CanExecute="New_CanExecute" Executed="New_Executed"/>
</Window.CommandBindings>
後臺程式碼:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void New_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
if (string.IsNullOrEmpty(this.nameTextBox.Text))
{
e.CanExecute = false;
}
else
{
e.CanExecute = true;
}
}
private void New_Executed(object sender, ExecutedRoutedEventArgs e)
{
string name = this.nameTextBox.Text;
if (e.Parameter.ToString()=="Teacher")
{
this.listBoxNewItems.Items.Add(string.Format($"New Teacher:{name},學而不厭、誨人不倦。"));
}
if (e.Parameter.ToString()=="Student")
{
this.listBoxNewItems.Items.Add(string.Format($"New Student:{name},好好學習、天天向上。"));
}
}
}
命令與Binding的結合
控制元件有很多事件,可以讓我們進行各種各樣不同的操作,可控制元件只有一個Command屬性、而命令庫中卻有數十中命令,這樣怎麼可能使用這個唯一的Command屬性來呼叫那麼多種命令呢?答案是:使用Binding,Binding作為一種間接的、不固定的賦值手段,可以讓你有機會選擇在某個條件下為目標賦特定的值(有時候需要藉助Converter)
例如,如果一個Button所關聯命令有可能根據某些條件而改變,我們可以把程式碼寫成這樣:
<Button x:Name="dynamicCmdBtn" Command="{Binding Path=ppp,Source=sss}"Content="Command"/>
因為大多數命令按鈕都有相對應的圖示來表示固定的含義,所以日常工作中一個控制元件的命令一經確定就很少改變。
近觀命令
一般情況下,程式中使用與邏輯無關的RoutedCommand來跑跑龍套就足夠了,但為了使程式的結構更簡潔(比如去掉外圍的CommandBinding和與之相關的事件處理器),常需要定義自己的命令。
ICommand介面與RoutedCommand
WPF的命令使實現了ICommand介面的類,ICommand介面非常簡單,只包含兩個方法和一個事件:
- Execute方法:執行命令,或者說命令作用於命令目標之上。
- CanExecute方法:是否執行,在執行前用來探知命令是否可被執行。
- CanExecuteChanged事件:當命令可執行狀態發生改變時,可激發此事件來通知其他物件。
RoutedCommand就是這樣一個實現了ICommand介面的類,但是並未向Execute和CanExecute方法中新增任何邏輯,它是通用的、與具體業務邏輯無關的。
從外部來看,ApplicationCommands命令庫裡的命令們,它們具體執行Copy還是Cut(即業務邏輯)不是有命令決定的,而是外圍的CommandBinding捕捉到命令目標受命令激發而傳送的路由事件後在其Executed事件處理器中完成的。
public static class ApplicationCommands
{
// 獲取表示“屬性”命令的值。
public static RoutedUICommand Properties { get; }
// 獲取表示“列印預覽”命令的值。
public static RoutedUICommand PrintPreview { get; }
// 獲取表示“列印”命令的值。
public static RoutedUICommand Print { get; }
// 獲取表示“剪下”命令的值。
public static RoutedUICommand Cut { get; }
// 獲取表示“更正列表”命令的值。
public static RoutedUICommand CorrectionList { get; }
// 獲取表示“複製”命令的值。
public static RoutedUICommand Copy { get; }
//···········
}
從內部看,從ICommand介面繼承來的Execute並沒有被公開(甚至可以說是廢棄不同了),僅僅是呼叫了新宣告的帶兩個引數的Execute方法。新宣告的帶兩個引數的Execute方法是對外公開的,可以使用第一個引數向命令傳遞一些資料,第二個引數是命令的目標,如果目標為null,Execute方法就把當前擁有焦點的控制元件作為命令的目標。新的Execute方法會呼叫命令執行邏輯的核心—ExecuteImpl方法,而這個方法內部並沒有什麼神祕的東西,就是“借用”命令目標的RaiseEvent把RoutedEvent傳送出去。顯然這個事件會被外圍的CommandBinding捕獲到然後執行程式設計師預設的與業務相關的邏輯。
自定義Command
與業務邏輯無關的命令,使用 RoutedCommand,業務邏輯要依靠外圍的CommandBinding來實現。這樣一來,如果對CommandBinding管理不善就可能造成程式碼混亂無章,畢竟一個CommandBinding要牽扯到誰是它的宿主以及它的兩的事件處理器。
建立自定義Command
-
為了簡化使用CommandBinding來處理業務邏輯的程式結構,我們可能會希望把業務邏輯移入命令的Execute方法內。比如,我們可以自定義一個名為Save的命令,當命令到達命令目標的時候先通過命令目標的IsChanged屬性判斷命令目標的內容是否已經被改變,如果已經改變則命令可以執行,命令的執行會直接呼叫命令目標的Save方法、驅動命令目標以自己的方式儲存資料。很顯然,這回是命令直接在命令目標上起作用了,而不像RoutedCommand那樣現在命令目標上激發處路由事件等外圍控制元件捕捉到事件後再“翻過頭來”對命令目標加以處理。你可能會問:“如果命令目標不包含IsChanged和Save方法怎麼版?“這就要靠介面來約束了,在程式中定義這樣一個介面:
並且要求每個需要接受命令的元件都必須實現這個介面,這樣就確保了命令可以成功地對它們執行操作。
public interface IView
{
//屬性
bool IsChanged { get; set; }
//方法
void SetBinding();
void Refresh();
void Clear();
void Save();
}
- 實現ICommand介面,建立一個專門作用於IView派生類的命令
在實現這個方法時,我們將這個方法唯一的引數作為命令的目標,如果目標是IView介面的派生類則呼叫其Clear方法—顯然,我們已經把業務邏輯引入了命令的Execute方法中。
public class ClearCommand : ICommand
{
//當命令可執行狀態發生改變時,應當被啟用
public event EventHandler CanExecuteChanged;
//用於判斷命令是否可以執行(暫不實現)
public bool CanExecute(object parameter)
{
throw new NotImplementedException();
}
//命令執行,帶有與業務相關的Clear邏輯
public void Execute(object parameter)
{
IView view = parameter as IView;
if (view!=null)
{
view.Clear();
}
}
}
- 建立命令源,我們有了自定義命令,我們拿什麼命令源來”發射“它呢?WPF命令系統的命令源是專門為RoutedCommand準備的並且不能重寫,所以我們智慧通過實現ICommandSource介面來建立自己的命令源:
ICommandSource介面只包含Command、CommandParameter、CommandTarget三個屬性,至於這三個屬性之間有什麼樣的關係就要看我們怎麼實現了。在本例中,CommandParameter完全沒有被用到,而CommandTarget被當作引數傳遞給了Command的Execute方法。命令不會自己被髮出,所以一定要為命令的執行選擇一個合適的時機,本例中我們在控制元件被單擊時執行命令。
//自定義命令源
public class MyCommandSource : UserControl, ICommandSource
{
//繼承自ICommandSource的三個屬性
public ICommand Command { get; set; }
public object CommandParameter { get; set; }
public IInputElement CommandTarget { get; set; }
//在元件被單擊時連帶執行命令
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
//在命令目標上執行命令,或稱讓命令用於命令目標
if (this.CommandTarget!=null)
{
this.Command.Execute(this.CommandTarget);
}
}
}
- 可用的命令目標,因為我們的ClearCommand專門作用於IView的派生類,所以合格的ClearCommand命令目標必須實現IView介面。設計這種既有UI有需要實現介面的類可以先用XAML編譯器實現其UI部分再找到他的後臺C#程式碼實現介面。
XAML程式碼
<Border CornerRadius="5" BorderBrush="LawnGreen" BorderThickness="2">
<StackPanel>
<TextBox x:Name="textBox1" Margin="5"/>
<TextBox x:Name="textBox2" Margin="5,0"/>
<TextBox x:Name="textBox3" Margin="5"/>
<TextBox x:Name="textBox4" Margin="5,0"/>
</StackPanel>
</Border>
後臺C#程式碼
public partial class MiniView : UserControl,IView
{
public MiniView()
{
InitializeComponent();
}
//繼承自IView的成員們
public bool IsChanged { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public void Refresh()
{
throw new NotImplementedException();
}
public void Save()
{
throw new NotImplementedException();
}
public void SetBinding()
{
throw new NotImplementedException();
}
//用於清楚內容的業務邏輯
public void Clear()
{
this.textBox1.Clear();
this.textBox2.Clear();
this.textBox3.Clear();
this.textBox4.Clear();
}
}
- 把自定義的命令、命令源和命令目標集合起來。
<StackPanel>
<local:MyCommandSource x:Name="ctrlClear" Margin="10">
<TextBlock Text="清除" FontSize="16" TextAlignment="Center" Background="LightBlue" Width="80"/>
</local:MyCommandSource>
<local:MiniView x:Name="miniView"/>
</StackPanel>
本例中使用了簡單的文字作為命令源的顯示內容,實際工作中可以使用圖示、按鈕等內容來填充它,但要注意適當更改激發命令的發時。比如你打算放置一個按鈕,那麼就不要用重寫OnMouseLeftButtonDown的方法來執行命令了,而應該捕捉Button.Click事件並在事件處理器中執行方法(Mouse事件會被Button"吃掉")。
後臺C#程式碼如下:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
//宣告命令並使命令源和目標與之關聯
//宣告命令
ClearCommand clearCommand = new ClearCommand();
//命令源命令
this.ctrlClear.Command = clearCommand;
//命令源目標
this.ctrlClear.CommandTarget = this.miniView;
}
}
我們建立了一個ClearCommand命令的例項並把它賦值給自定義命令源的Command屬性,自定義命令源的CommandTarget屬性值是自定義命令目標MiniView的例項。提醒一句:為了講解清晰才把命令宣告放在這裡,正規的方法應該是把命令宣告在靜態全域性的地方供所有物件使用。