命令系統的基本元素
WPF的命令系統由幾個基本要素構成:
- 命令(Command):WPF的命令實際上就是實現了ICommand接口的類,平時使用最多的是RoutedCommand類,還可以使用自定義命令。
- 命令源(Command Source):即命令的發送者,是實現了ICommandSource接口的類,很多界面元素都實現了這個接口,如Button、Menultem、ListBoxltem等。
- 命令目標(Command Target):即命令將發送給誰,或者說命令將作用在誰身上,命令目標必須是實現了IInputElement接口的類。
- 命令關聯(Command Binding):負責把一些外圍邏輯與命令關聯起來,比如執行之前對命令是否可以執行進行判斷、命令執行之后還有哪些后續工作等。
基本元素之間的關系
基本元素之間的關系體現在使用命令的過程中,命令的使用大概分為如下幾步:
- 創建命令類:即獲得一個實現ICommand接口的類,命令與具體業務邏輯無關則使用WPF類庫中的RoutedCommand類即可,與業務邏輯相關則需創建RoutedCommand(或者ICommand接口)的派生類。
- 聲明命令實例:使用命令時需要創建命令類的實例,一般情況下程序中某種操作只需要一個命令實例與之對應即可,程序中的命令多使用單件模式(Singletone Pattern)。
- 指定命令的源:即指定由誰來發送這個命令,同一個命令可以有多個源,一旦把命令指派給命令源,那么命令源就會受命令的影響,各種控件發送命令的方法也不盡相同(如Buton是在單擊時發送命令、ListBoxltme雙擊時發送命令)。
- 指定命令目標:命令目標並不是命令的屬性而是命令源的屬性,指定命令目標是告訴命令源向哪個組件發送命令,無論這個組件是否擁有焦點它都會收到這個命令,沒有為命令源指定命令目標則WPF系統認為當前擁有焦點的對象就是命令目標。
- 設置命令關聯:WPF命令需要CommandBinding在執行前來幫助判斷是不是可以執行、在執行后做一些事件來“打掃戰場”。
一旦某個UI組件被命令源“瞄上”,命令源就會不停地向命令目標“投石問路”,命令目標就會不停地發送出可路由的PreviewCanExecute和CanExecute附加事件,事件會沿着UI元素樹向上傳遞並被命令關聯所捕捉,命令關聯捕捉到這些事件后會把命令能不能發送實時報告給命令。
如果命令被發送出來並到達命令目標,命令目標就會發送PreviewExecuted和Executed兩個附加事件,這兩個事件也會沿着UI元素樹向上傳遞並被命令關聯所捕捉,命令關聯會完成一些后續的任務。對於那些與業務邏輯無關的通用命令,這些后續任務才是最重要的。
命令目標發出的PreviewCanExecute、CanExecute、PreviewExecuted和Executed這4個事件都是附加事件,是被CommandManager類“附加”給命令目標的,PreviewCanExecute和CanExecute的執行時機不由程序員控制,且執行頻率比較高,會給降低系統性能、引入比較難調試的bug。
WPF命令系統基本元素的關系圖如下:
小試命令
定義一個命令,使用Button來發送這個命令,當命令送達TextBox時TextBox會被清空(如果TextBox中沒有文字則命令不可被發送)。
XAML界面代碼如下:
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="179.464" Width="438.393">
<StackPanel x:Name="stackPanel">
<Button x:Name="button1" Content="Send Command" Margin="5"/>
<TextBox x:Name="textBoxA" Margin="5,0" Height="100"/>
</StackPanel>
</Window>
C#后台代碼如下:
public MainWindow()
{
InitializeComponent();
InitializeCommand();
}
//聲明並定義命令
private RoutedCommand clearCmd = new RoutedCommand("Clear",typeof(MainWindow));
private void InitializeCommand()
{
//把命令賦值給命令源(發送者)並指定快捷鍵
this.button1.Command=this.clearCmd;
this.clearCmd.InputGestures.Add(new KeyGesture(Key.C, ModifierKeys.Alt));
//指定命令目標
this.button1.CommandTarget = this.textBoxA;
//創建命令關聯
CommandBinding cb = new CommandBinding();
cb.Command = this.clearCmd;
//只關注與clearCmd相關的事件
cb.CanExecute += new CanExecuteRoutedEventHandler(cb_CanExecute);
cb.Executed += new ExecutedRoutedEventHandler(cb_Executed);
//把命令關聯安置在外圍控件上
this.stackPanel.CommandBindings.Add(cb);
}
//當探測命令是否可以執行時,此方法被調用
void cb_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
if (string.IsNullOrEmpty(this.textBoxA.Text))
{
e.CanExecute = false;
}
else
{
e.CanExecute = true;
}
// 避免繼續向上傳而降低程序性能
e.Handled = true;
}
//當命令送達目標后,此方法被調用
void cb_Executed(object sender, ExecutedRoutedEventArgs e)
{
this.textBoxA.Clear();
// 避免繼續向上傳而降低程序性能
e.Handled = true;
}
運行程序,在TextBox中輸入文字后Button在命令可執行狀態的影響下變為可用,此時單擊Buton 或者按Alt+C鍵,TextBox都會被清空,效果如下:
對於上面代碼有幾點需要注意的地方:
- 使用命令可以避免自己寫代碼判斷Button是否可用以及添加快捷鍵。
- RoutedCommand是一個與業務邏輯無關的類,只負責在程序中“跑腿”而並不對命令目標做任何操作,TextBox是由CommandBinding清空的。
- 因為CanExecute事件的激發頻率比較高,為了避免降低性能,在處理完后建議把e.Handled設為true。
- CommandBinding一定要設置在命令目標的外圍控件上,不然無法捕捉到CanExecute和Executed等路由事件。
WPF的命令庫
上面的例子中聲明定義了一個命令:
private RoutedCommand clearCmd = new RoutedCommand("Clear",typeof(MainWindow));
命令具有“一處聲明、處處使用”的特點,比如Save命令在程序的任何地方它都表示要求命令目標保存數據。微軟在WPF類庫里准備了一些便捷的命令庫,這些命令庫包括:
- ApplicationCommands:提供一組標准的與應用程序相關的命令,參考ApplicationCommands。
- ComponentCommands:提供一組標准的與組件相關的命令,參考ComponentCommands。
- NavigationCommands:提供一組標准的與導航相關的命令,參考NavigationCommands。
- MediaCommands:提供一組標准的與媒體相關的命令,參考MediaCommands。
- EditingCommands:提供一組標准的與編輯相關的命令,參考EditingCommands。
它們都是靜態類,而命令就是用這些類的靜態只讀屬性以單件模式暴露出來的。如ApplicationCommands類的源碼如下:
public static class ApplicationCommands
{
public static RoutedUICommand Cut { get; }
public static RoutedUICommand Stop { get; }
public static RoutedUICommand ContextMenu { get; }
public static RoutedUICommand Properties { get; }
public static RoutedUICommand PrintPreview { get; }
public static RoutedUICommand CancelPrint { get; }
public static RoutedUICommand Print { get; }
public static RoutedUICommand SaveAs { get; }
public static RoutedUICommand Save { get; }
public static RoutedUICommand Close { get; }
public static RoutedUICommand CorrectionList { get; }
public static RoutedUICommand Open { get; }
public static RoutedUICommand Help { get; }
public static RoutedUICommand SelectAll { get; }
public static RoutedUICommand Replace { get; }
public static RoutedUICommand Find { get; }
public static RoutedUICommand Redo { get; }
public static RoutedUICommand Undo { get; }
public static RoutedUICommand Delete { get; }
public static RoutedUICommand Paste { get; }
public static RoutedUICommand Copy { get; }
public static RoutedUICommand New { get; }
public static RoutedUICommand NotACommand { get; }
}
其他幾個命令庫也與之類似,標准命令不用自己聲明,直接使用命令庫即可。
命令參數
命令源一定是實現了ICommandSource接口的對象,而ICommandSource有一個屬性就是CommandPrameter,CommandPrameter就相當於命令里的“消息”。
實現一個需求,當TextBox中沒有內容時兩個按鈕均不可用;當輸入文字后按鈕變為可用,單擊按鈕,ListBox會加入不同條目。
XAML代碼如下:
<Grid Margin="6">
<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="listBoxNewltems" Grid.Row="6"/>
</Grid>
<!--為窗體添加CommandBinding-->
<Window.CommandBindings>
<CommandBinding Command="New" CanExecute="New_CanExecute" Executed="New_Executed"/>
</Window.CommandBindings>
CommandBinding的兩個事件處理器代碼如下:
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.listBoxNewltems.Items.Add(string.Format("New Teacher:{0},學而不厭、海人不倦。",name));
}
if (e.Parameter.ToString() == "Student")
{
this.listBoxNewltems.Items.Add(string.Format("New Student:{0},好好學習、天天向上。", name));
}
}
效果如下:
命令與Binding的結合
控件有很多事件可以進行各種各樣不同的操作,可控件只有一個Command屬性,而命令庫中卻有數十種命令,使用Binding可以使用Command屬性來調用多種命令。
例如,如果一個Buton所關聯命令有可能根據某些條件而改變,可以把代碼寫成這樣:
<Buton x:Name="dynamicCmdBtn" Command="{Binding Path=ppp,Source=sss}" Content="Command"/>
大多數命令按鈕都有相對應的圖標來表示固定的含義,日常工作中一個控件的命令一經確定就很少改變。
近觀命令
一般情況下,程序中使用與邏輯無關的RoutedCommand就足夠了,但為了使程序的結構更加簡潔(比如去掉外圍的CommandBinding和與之相關的事件處理器),常需要定義自己的命令。
接下來,先由剖析RoutedCommand入手,再創建自己的命令。
ICommand接口與RoutedCommand
WPF的命令是實現了ICommand接口的類,ICommand接口只包含兩個方法和一個事件:
- Execute方法:命令執行,或者說命令作用於命令目標之上。
- CanExecute方法:在執行之前用來探知命令是否可被執行。
- CanExecuteChanged事件:當命令可執行狀態發生改變時,可激發此事件來通知其他對象。
RoutedCommand在實現ICommand接口時,並未向Execute和CanExecute方法中添加任何邏輯,它是通用的、與具體業務邏輯無關的。
從外部來看,當一個命令到達命令目標后,具體是執行Copy還是Cut(即業務邏輯)不是由命令決定的,而是外圍的CommandBinding捕獲到命令目標受命令激發而發送的路由事件后在其Executed事件處理器中完成。
從內部分析,RoutedCommand類與命令執行相關的代碼簡化如下:
public class RoutedCommand : ICommand
{
//由lCommand繼承而來,僅供內部使用
private void ICommand.Execute(object parameter)
{
Execute(parameter, FilterInputElement(Keyboard.FocusedElement));
}
//新定義的方法,可由外部調用
//第一個參數向命令傳遞一些數據,第二個參數是命令的目標
public void Execute(object parameter, IInputElement target)
{
//命令目標為空,選定當前具有焦點的控件作為目標
if ((target != null) && !InputElement.IsValid(target))
{
throw new InvalidOperationException(SR.Get(SRID.Invalid_IInputElement, target.GetType()));
}
if (target == null)
{
target = FilterInputElement(Keyboard.FocusedElement);
}
//真正執行命令的邏輯
ExecuteImpl(parameter, target, false);
}
//真正執行命令的邏輯,僅供內部使用
private bool ExecuteImpl(object parameter, IInputElement target, bool userInitiated)
{
//..
UIElement targetUIElement = target as UIElement;
//..
ExecutedRoutedEventArgs args = new ExecutedRoutedEventArgs(this, parameter);
args.RoutedEvent = CommandManager.PreviewExecutedEvent;
if (targetUIElement != null)
{
targetUIElement.RaiseEvent(args, userInitiated);
}
//..
return false;
}
//另一個調用Executelmpl方法的函數,依序集級別可用
internal bool ExecuteCore(object parameter, IInputElement target, bool userInitiated)
{
if (target == null)
{
target = FilterInputElement(Keyboard.FocusedElement);
}
return ExecuteImpl(parameter, target, userInitiated);
}
}
從ICommand接口繼承來的Execute並沒有被公開(可以說是廢棄了),僅僅是調用新聲明的帶兩個參數的Execute方法。新的Execute方法會調用命令執行邏輯的核心——Executelmpl方法(Executelmpl是Execute Implement的縮寫),這個方法“借用”命令目標的RaiseEvent把RoutedEvent發送出去,事件會被外圍的CommandBinding捕獲到然后執行程序員預設的與業務相關的邏輯。
以ButtonBase為例,ButtonBase是在Click事件發生時發送命令的,而Click事件的激發是放在OnClick方法里。ButonBase的OnClick方法如下:
public class ButtonBase : ContentControl, ICommandSource
{
//激發Click路由事件,然后發送命令
protected virtual void OnClick()
{
RoutedEventArgs newEvent = new RoutedEventArgs(BuonBase.ClickEvent, this);
RaiseEvent(newEvent);
//調用內部類CommandHelpers的ExecuteCommandSource方法
MS.Internal.Commands.CommandHelpers.ExecuteCommandSource(this);
}
}
ButonBase 調用了一個.NET Framework內部類(這個類沒有向程序員暴露)CommandHelpers的ExecuteCommandSource方法,並把ButtonBase對象自己當作參數傳了進去。
ExecuteCommandSource方法實際上是把傳進來的參數當作命令源、調用命令源的ExecuteCore 方法(本質上是調用其Executelmpl方法)、獲取命令源的CommandTarget屬性值(命令目標)並使命令作用於命令目標之上。
CommandHelpers部分源碼如下:
internal static class CommandHelpers
{
//..
internal static void ExecuteCommandSource(ICommandSource commandSource)
{
CriticalExecuteCommandSource(commandSource, false);
}
internal static void CriticalExecuteCommandSource(ICommandSource commandSource, bool userInitiated)
{
ICommand command = commandSource.Command;
if (command != null)
{
object parameter = commandSource.CommandParameter;
IInputElement target = commandSource.CommandTarget;
RoutedCommand routed = command as RoutedCommand;
if (routed != null)
{
if (target == null)
{
target = commandSource as IInputElement;
}
if (routed.CanExecute(parameter, target))
{
routed.ExecuteCore(parameter, target, userInitiated);
}
}
else if (command.CanExecute(parameter))
{
command.Execute(parameter);
}
}
}
}
自定義Command
“自定義命令”可以分兩個層次來理解:
- 第一個層次是指的是當WPF命令庫中沒有包含想要的命令時聲明定義自己的RoutedCommand實例,如定義一個名為Laugh的RoutedCommand實例,實際是對RoutedCommand的使用。
- 第二個層次是指實現ICommand接口、定義自己的命令並且把某些業務邏輯也包含在命令之中,真正意義上的自定義命令。
WPF自帶的命令源和CommandBinding就是專門為RoutedCommand而編寫的,如果想使用自己的ICommand派生類就必須連命令源一起實現(即實現ICommandSource接口),需要根據項目的實際情況進行權衡。
下面自定義一個名為Clear的命令,當命令到達命令目標的時候先通過命令目標的IsChanged屬性判斷命令目標的內容是否已經被改變,如果已經改變則命令可以執行,命令的執行會直接調用命令目標的Clear方法、驅動命令目標以自己的方式清除數據(同時改變IsChanged屬性值)。
命令直接在命令目標上起作用,而不像RoutedCommand那樣先在命令目標上激發出路由事件等外圍控件捕捉到事件后再“翻過頭來”對命令目標加以處理。
定義命令目標接口(IView )
在程序中定義這樣一個接口:
public interface IView
{
//屬性
bool IsChanged{get; set;}
//方法
void SetBinding();
void Refresh();
void Clear();
void Save();
//...
}
要求每個需要接受命令的組件都必須實現這個接口,確保命令可以成功地對它們執行操作。
定義命令(實現ICommand接口)
接下來實現ICommand接口,創建一個專門作用於IView派生類的命令:
//自定義命令
public class ClearCommand : ICommand
{
//當命令可執行狀態發送改變時,應當被激發
public event EventHandler CanExecuteChanged;
//用來判斷命令是否可以執行
public bool CanExecute(object parameter)
{
bool canExecute = false;
IView view = parameter as IView;
if (view != null)
{
canExecute = view.IsChanged;
}
return canExecute;
}
//命令執行時,帶有與業務相關的Clear邏輯
public void Execute(object parameter)
{
IView view = parameter as IView;
if (view != null)
{
view.Clear();
}
}
}
命令實現了ICommand接口並繼承了CanExecuteChanged事件、CanExecute方法和Execute方法,在實現CanExecute方法和Execute方法時將唯一的參數作為命令的目標:
- Execute方法中,如果目標是IView接口的派生類則調用其Clear方法(把業務邏輯引入了命令的Execute方法中)。
- CanExecute方法中,如果目標是IView接口的派生類則返回其IsChanged屬性值(根據項目需求定義)。
定義命令源(實現ICommandSource)
WPF命令系統的命令源是專門為RoutedCommand准備的並且不能重寫,所以只能通過實現ICommandSource接口來創建自己的命令源。代碼如下:
//自定義命令源
public partial class MyCommandSource : UserControl, ICommandSource
{
// 繼承自ICommand的3個屬性
public ICommand Command { get; set; }
public object CommandParameter { get; set; }
public IInputElement CommandTarget { get; set; }
//構造函數
public MyCommandSource()
{
//命令刷新的時機
CommandManager.RequerySuggested += RequeryCanExecute;
//如果初次刷新不及時,可在此手動調用一次
RequeryCanExecute(null, null);
}
//在組件被單擊時連帶執行命令
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
if (this.CommandTarget != null)
{
if (this.Command.CanExecute(CommandTarget))
{
this.Command.Execute(CommandTarget);
}
}
}
//查詢並顯示命令目標的可執行狀態,顯示方式根據實際需求定義
private void RequeryCanExecute(object sender, EventArgs e)
{
if (this.CommandTarget != null)
{
if (this.Command.CanExecute(CommandTarget))
{
this.Background = System.Windows.Media.Brushes.Green;
}
else
{
this.Background = System.Windows.Media.Brushes.Orange;
}
}
}
}
ICommandSource接口只包含Command、CommandParameter和CommandTarget三個屬性,三個屬性之間的關系取決於實現。
在本例中,CommandParameter完全沒有被用到,而CommandTarget被當作參數傳遞給了Command的Execute、CanExecute方法,在控件被左單擊時執行命令。
定義命令目標(實現IView接口)
ClearCommand專門作用於IView的派生類,合格的ClearCommand命令目標必須實現IView接口。
設計這種既有UI又需要實現接口的類可以先用XAML編輯器實現其UI部分再找到它的后台C#代碼實現接口(WPF會自動為UI元素類添加partial關鍵字修飾),XAML代碼會被翻譯成類的一個部分,后台代碼是類的另一個部分(甚至可以再多添加幾個部分),可以在后台代碼部分指定基類或實現接口,最終這些部分會被編譯到一起。
組件的XAML部分如下:
<UserControl x:Class="WpfApp.MniView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Height="114" Width="200">
<Border CornerRadius="5" BorderBrush="GreenYellow" BorderThickness="2">
<StackPanel TextBoxBase.TextChanged="TextBoxBase_TextChanged">
<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>
</UserControl>
組件的后台代碼部分如下:
public partial class MniView : UserControl, IView
{
public MniView()
{
InitializeComponent();
}
//繼承自IView的成員們
public bool IsChanged { get; set; }
public void SetBinding(){}
public void Refresh(){}
public void Save() {}
/// <summary>
/// 用於清除內容的業務邏輯
/// </summary>
public void Clear()
{
this.textBox1.Clear();
this.textBox2.Clear();
this.textBox3.Clear();
this.textBox4.Clear();
IsChanged = false;
}
private void TextBoxBase_TextChanged(object sender, TextChangedEventArgs e)
{
IsChanged = true;
}
}
當Clear方法被調用的時候,它的幾個TextBox會被清空。
使用自定義命令
把自定義命令、命令源、命令目標集成起來,窗體的XAML代碼如下:
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfApp"
Title="MainWindow" Height="171.464" Width="297.06">
<StackPanel>
<local:MyCommandSource x:Name="ctrlClear">
<TextBlock Text="清除" Margin="10" Width="80" FontSize="16" TextAlignment="Center" Background="LightGreen"/>
</local:MyCommandSource>
<local:MniView x:Name="mniView1" />
</StackPanel>
</Window>
本例中使用簡單的文本作為命令源的顯示內容,用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 = mniView1;
}
}
首先創建了一個ClearCommand實例並把它賦值給自定義命令源的Command屬性(正規的方法應該是把命令聲明為靜態全局的地方供所有對象調用),自定義命令源的CommandTarget屬性目標是MiniView的實例。
運行程序,在TextBox里輸入然后再單擊清除控件,效果如下圖: