【WPF】Command 的一些使用方案

东邪独孤發表於2024-07-28

Command,即命令,具體而言,指的是實現了 ICommand 介面的物件。此介面要求實現者包含這些成員:

1、CanExecute 方法:確定該命令是否可以執行,若可,返回 true;若不可,返回 false;

2、CanExecuteChanged 事件:傳送命令(命令源)的控制元件可以訂閱此事件,當命令的可執行性改變時能得到通知;

3、Execute 方法:執行命令時呼叫此方法。可以將命令邏輯寫在此方法中。

命令源(ICommandSource)

傳送命令的控制元件就是命令源,例如常見的選單項、按鈕等。即命令是怎麼觸發的,這肯定與使用者互動有關的。無互動功能的控制元件一般不需要傳送命令。有命令源就會有命令目標,若命令源是傳送者,那麼命令目標就是命令的接收者(命令最終作用在誰身上)。比如,單擊 K 按鈕後清空 T 控制元件中的文字。則,K是命令源,T就是命令目標。這樣舉例相信大夥伴們能夠理解,老周就不說太多,理論部分越簡單越好懂。這裡沒什麼玄的,只要你分清角色就行,誰發出,誰接收。

命令必須有觸發者,所以,源是必須的,並且,作為命令源的控制元件要實現 ICommandSource 介面,並實現三個成員:

1、Command: 要傳送的命令物件;

2、CommandParameter:命令引數。這個是任意物件,由你自己決定它是啥,比如,你的命令是刪除某位員工的資料記錄,那麼,這個引數可能是員工ID。這個引數是可選的,當你的命令邏輯需要額外資料時才用到,不用預設為 null 就行了;

3、CommandTarget:目標。命令要作用在哪個控制元件上。其實這個也是可選的,命令可以無目標控制元件。比如,刪除個員工記錄,如果知道要刪除哪記錄,那這裡不需要目標控制元件。當然,如果你的邏輯是要清空文字框的文字,那目標控制元件是 TextBox。這個取決你的程式碼邏輯。

像 Button、MenuItem 這些控制元件,就是命令源,都實現 ICommandSource 介面。

命令邏輯

命令邏輯就是你的命令要乾的活。咱們做個演示。

下面示例將透過命令來刪除一條學生記錄。Student 類的定義如下:

    public class Student
    {
        public string? Name { get; set; } = string.Empty;
        public int ID { get; set; }
        public int Age { get; set; }
        public string Major { get; set; } = string.Empty;
    }

    public class StudentViewManager
    {
        private static readonly ObservableCollection<Student> _students = new ObservableCollection<Student>();

        static StudentViewManager()
        {
            _students.Add(new Student()
            {
                ID = 1,
                Name = "小陳",
                Age = 20,
                Major = "打老虎專業"
            });
            _students.Add(new Student()
            {
                ID = 2,
                Name = "小張",
                Age = 21,
                Major = "鋪地磚專業"
            });
            _students.Add(new Student()
            {
                ID = 3,
                Name = "呂布",
                Age = 23,
                Major = "坑義父專業戶"
            });
        }

        public static ObservableCollection<Student> Students
        {
            get { return _students; }
        }
    }

然後,定義一個實現 ICommand 介面的類。

    public class DelStuCommand : ICommand
    {
        public event EventHandler? CanExecuteChanged;

        public bool CanExecute(object? parameter)
        {
            return !(StudentViewManager.Students.Count == 0);
        }

        public void Execute(object? parameter)
        {
            Student? s = parameter as Student;
            if (s == null)
                return;

            StudentViewManager.Students.Remove(s);
        }
    }

執行此命令需要引數,好讓它知道要刪除哪條學生記錄。

下面 XAML 中,ListBox 控制元件顯示學生列表,按鈕引用上述命令物件。

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.Resources>
            <local:DelStuCommand x:Key="cmd"/>
        </Grid.Resources>
        <Button Content="刪除" Grid.Row="1" Command="{StaticResource cmd}"
                CommandParameter="{Binding ElementName=tc, Path=SelectedItem}"/>
        <ListBox x:Name="tc" Grid.Row="0">
            <ItemsControl.ItemTemplate>
                <DataTemplate DataType="local:Student">
                    <TextBlock>
                        <Run Text="{Binding Name}"/>
                        <Span> | </Span>
                        <Run Text="{Binding Major}" Foreground="Blue"/>
                    </TextBlock>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ListBox>               
    </Grid>

Button 類實現了 ICommandSource 介面,透過 CommandParameter 屬性指定要傳遞給命令的引數。

執行程式後,在 ListBox 中選擇一項,然後點“刪除”按鈕。

刪除後,只剩下兩項。重複以下操作,當所有記錄都刪除後,“刪除”按鈕就會被禁用。

從這個示例可以瞭解到,命令可以把某種行為封裝為一個單獨的整體。這樣能增加其可複用性,按鈕、選單、工具欄按鈕都可以使用同一個命令,實現相同的功能。

路由命令與 CommandBinding

實現 ICommand 介面雖然簡單易用,但它也有一個問題:如果我的程式裡有很多命令邏輯,那我就要定義很多命令類。比如像這樣的,你豈不是要定義幾十個命令類。

這樣就引出 RoutedCommand 類的用途了。

RoutedCommand 類實現了 ICommand 介面,它封裝了一些通用邏輯,具體邏輯將以事件的方式處理。RoutedCommand 類的事件均來自 CommandManager 類所註冊的路由(隧道)事件。即

1、CanExecute 和 PreviewCanExecute 事件:當要確定命令是否能夠執行時會發生該事件。Preview 開頭的表示隧道事件。可能有大夥伴不太記得這個名詞。其實,路由事件和隧道事件本質一樣,只是傳遞的方向不同。挖隧道的時候是不是從外頭往裡面鑽?所以,隧道事件就是從外層元素往裡面傳播;路由事件就相反,從裡向外傳播。

2、Executed 和 PreviewExecuted 事件:咱們可以處理這事件,然後將自己要實現的命令邏輯寫上即可。

可見,有了 RoutedCommand,咱們就不需要定義一堆命令類了,而是全用它,程式碼邏輯在 Executed 事件中寫。這裡也包括 RoutedUICommand 命令,這個類只不過多了個 Text 屬性,用來指定關聯的文字罷了,文字會顯示在選單上。

不過,咱們在使用時不會直接去處理 RoutedCommand 類的事件,而是配合另一個類—— CommandBinding 來使用。有了它,事件才能冒泡(或下沉),也就是可向上或向下傳播。傳播的路徑是從目標物件(Command Target)開始,到最後能捕捉到事件的 CommandBindings 結束。這個不理解不重要,後面咱們用例子說明。

下面咱們再做一個示例。這個例子中,咱們用四個選單項來改變矩形的顏色。

由於現在用的是 RoutedCommand 類,我們不需要定義命令類了,所以能在 XAML 文件中直接把命令宣告在資源中。

    <Window.Resources>
        <!--命令列表-->
        <RoutedCommand x:Key="greenCmd" />
        <RoutedCommand x:Key="silverCmd" />
        <RoutedCommand x:Key="redCmd" />
        <RoutedCommand x:Key="blackCmd" />
    </Window.Resources>

我們定義一組選單,以及一個矩形。

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Menu>
            <MenuItem Header="顏色">
                <MenuItem Header="綠色" Command="{StaticResource greenCmd}" CommandTarget="{Binding ElementName=rect}"/>
                <MenuItem Header="銀色" Command="{StaticResource silverCmd}" CommandTarget="{Binding ElementName=rect}"/>
                <MenuItem Header="紅色" Command="{StaticResource redCmd}" CommandTarget="{Binding ElementName=rect}"/>
                <MenuItem Header="黑色" Command="{StaticResource blackCmd}" CommandTarget="{Binding ElementName=rect}"/>
            </MenuItem>
        </Menu>
        <Rectangle Grid.Row="1" Height="80" Width="100" Name="rect" Fill="Blue" />
    </Grid>

網格分兩行,上面是選單,下面是矩形。每個選單項的 Command 屬性已經引用了所需的命令物件。CommandTarget 屬性透過繫結引用矩形物件。這裡要注意,Target 要求的是實現 IInputElement 介面的型別。可見,不是所有物件都能充當目標的。Rectangle 類可以作為命令目標。

這時不要直接處理 RoutedCommand 類的事件,而是要藉助 CommandBinding。UIElement 的子類都繼承 CommandBindings 集合,所以放心用,大部分介面元素都可以用。本例中,我們在 Grid 上寫 CommandBinding。

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.CommandBindings>
            <CommandBinding Command="{StaticResource greenCmd}" 
                                CanExecute="OnRectCanExecut"
                                Executed="OnGreenCmdExe"/>
            <CommandBinding Command="{StaticResource silverCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnSilverCmdExe"/>
            <CommandBinding Command="{StaticResource redCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnRedCmdExe"/>
            <CommandBinding Command="{StaticResource blackCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnBlackCmdExe" />
        </Grid.CommandBindings>
        <Menu>
            ……
            </MenuItem>
        </Menu>
        <Rectangle Grid.Row="1" Height="80" Width="100" Name="rect" Fill="Blue" />
    </Grid>

在使用 CommandBinding 時,注意 Command 所引用的命令時你要用的,這裡就是要和四個選單項所引用的命令一致,不然,CanExecute 和 Executed 事件不起作用(命令不能正確觸發)。如果事件邏輯相同,可以共用一個 handler,比如上面的,CanExecute 事件就共用一個處理方法。

接下來,我們處理一下這些事件。

private void OnGreenCmdExe(object sender, ExecutedRoutedEventArgs e)
{
    Rectangle rect = (Rectangle)e.OriginalSource;
    rect.Fill = new SolidColorBrush(Colors.Green);
}

private void OnSilverCmdExe(object sender, ExecutedRoutedEventArgs e)
{
    Rectangle rect = (Rectangle)e.OriginalSource;
    rect.Fill = new SolidColorBrush(Colors.Silver);
}

private void OnRedCmdExe(object sender, ExecutedRoutedEventArgs e)
{
    Rectangle rect = (Rectangle)e.OriginalSource;
    rect.Fill = new SolidColorBrush(Colors.Red);
}

private void OnBlackCmdExe(object sender, ExecutedRoutedEventArgs e)
{
    Rectangle rect = (Rectangle)e.OriginalSource;
    rect.Fill = new SolidColorBrush(Colors.Black);
}

private void OnRectCanExecut(object sender, CanExecuteRoutedEventArgs e)
{
    e.CanExecute = (e.OriginalSource != null && e.OriginalSource is Rectangle);
}

在 OnRectCanExecut 方法,本例的判斷方式是隻要命令目標不為空,並且是矩形物件,就允許執行命令。e.CanExecute 屬性就是用來設定一個布林值,以表示能不能執行命令。

程式碼很簡單,老周不多解釋了。重點說的是,引發這些事件的源頭是 Command Target。即 OriginalSource 引用的就是 Rectangle。事件路徑是從目標物件開始向上冒泡的——說人話就是從 Rectangle 開始向上找 CommandBinding,不管是哪個層次上的 CommandBinding,只要事件和命令是匹配的,就會觸發。

我們不妨這樣改,把 Grid 下的後兩個 CommandBinding 向上移,移到 Window 物件下。

    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource redCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnRedCmdExe"/>
        <CommandBinding Command="{StaticResource blackCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnBlackCmdExe" />
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.CommandBindings>
            <CommandBinding Command="{StaticResource greenCmd}" 
                                CanExecute="OnRectCanExecut"
                                Executed="OnGreenCmdExe"/>
            <CommandBinding Command="{StaticResource silverCmd}"
                                CanExecute="OnRectCanExecut"
                                Executed="OnSilverCmdExe"/>
        </Grid.CommandBindings>
        <Menu>
            ……
        </Menu>
        ……
    </Grid>

執行後,你會發現,四個選單都能用。

從 Rectangle 開始向上冒泡,先是在 Grid 元素上找到兩個 CommandBinding,匹配,用之;再往上,在 Window 元素上又找到兩個,匹配,用之。所以,最後就是四個都能用。因此,路由是以 Rectangle 為起點向上冒泡,直到 Window 物件。

其實,上面幾個 Executed 事件也可以合併到一個方法中處理,只要用 CommandParameter 區分哪種顏色就行。

 private void OnCmdExecuted(object sender, ExecutedRoutedEventArgs e)
 {
     Rectangle rect = (Rectangle)e.OriginalSource;
     // 獲取引數值
     int val = Convert.ToInt32(e.Parameter);
     // 根據引數選擇顏色
     SolidColorBrush brush = new();
     switch (val)
     {
         case 0:
             brush.Color = Colors.Green;
             break;
         case 1:
             brush.Color = Colors.Silver;
             break;
         case 2:
             brush.Color = Colors.Red;
             break;
         case 3:
             brush.Color = Colors.Black;
             break;
         default:
             brush.Color = Colors.Blue;
             break;
     }
     rect.Fill = brush;
 }

在 XAML 文件中,替換前面設定的事件 handler,並在選單項中設定 CommandParameter。

<CommandBinding Command="{StaticResource redCmd}"
                        CanExecute="OnRectCanExecut"
                        Executed="OnCmdExecuted"/>
<CommandBinding Command="{StaticResource blackCmd}"
                        CanExecute="OnRectCanExecut"
                        Executed="OnCmdExecuted" />
<CommandBinding Command="{StaticResource greenCmd}" 
                    CanExecute="OnRectCanExecut"
                    Executed="OnCmdExecuted"/>
<CommandBinding Command="{StaticResource silverCmd}"
                    CanExecute="OnRectCanExecut"
                    Executed="OnCmdExecuted"/>
<MenuItem Header="綠色" Command="{StaticResource greenCmd}" CommandTarget="{Binding ElementName=rect}" CommandParameter="0"/>
<MenuItem Header="銀色" Command="{StaticResource silverCmd}" CommandTarget="{Binding ElementName=rect}" CommandParameter="1"/>
<MenuItem Header="紅色" Command="{StaticResource redCmd}" CommandTarget="{Binding ElementName=rect}" CommandParameter="2"/>
<MenuItem Header="黑色" Command="{StaticResource blackCmd}" CommandTarget="{Binding ElementName=rect}" CommandParameter="3"/>

指定快捷按鍵

命令的好處不只是可以多個源共享程式碼邏輯,還支援快捷鍵繫結。這就要用到 InputBinding 物件了,仔細看,發現這個類實現了 ICommandSource 介面。

public class InputBinding : System.Windows.Freezable, System.Windows.Input.ICommandSource

因此,它也可以與命令關聯,只要 InputBinding 被觸發,關聯的命令也會執行。下面咱們為上面的示例新增快捷鍵。

<Window.InputBindings>
    <KeyBinding Gesture="ctrl+shift+1" 
                    Command="{StaticResource greenCmd}"
                    CommandTarget="{Binding ElementName=rect}"
                    CommandParameter="0"/>
    <KeyBinding Gesture="ctrl+shift+2"
                    Command="{StaticResource silverCmd}"
                    CommandTarget="{Binding ElementName=rect}"
                    CommandParameter="1"/>
    <KeyBinding Gesture="ctrl+shift+3"
                    Command="{StaticResource redCmd}"
                    CommandTarget="{Binding ElementName=rect}"
                    CommandParameter="2"/>
    <KeyBinding Gesture="CTRL+SHIFT+4"
                    Command="{StaticResource blackCmd}"
                    CommandTarget="{Binding ElementName=rect}"
                    CommandParameter="3"/>
</Window.InputBindings>

UIElement 類的派生類都繼承了 InputBindings 集合,通常我們是把 InputBinding 放到視窗的集合中。實際上這裡可以把 InputBinding 寫在 Grid.InputBindings 中。前面咱們提過,事件是從 Target 物件向上冒泡的,所以在視窗上定義 InputBinding 或 CommandBinding,可以儘可能地捕捉到命令事件。

InputBinding 只是基類,它有兩個派生類—— KeyBinding,MouseBinding。不用老周解釋,看名識類,你都猜到它們是幹嗎用的了。示例中用到的是快捷鍵,所以用 KeyBinding。快捷鍵在 XAML 中有兩種宣告方法:

1、如本例所示,直接設定 Gesture 屬性。使用按鍵的字串形式,不分大小寫,按鍵之間用“+”連線,如 Ctrl + C。這種方法把修改鍵和普通鍵一起定義,方便好用;

2、修改鍵和按鍵分開定義。即使用 Key 和 Modifiers 屬性,Key 指定普通鍵,如“G”;Modifiers 指定修改鍵,如 "Ctrl + Alt"。因此,本示例的快捷鍵也可以這樣定義:

<KeyBinding Modifiers="Ctrl+Shift"
            Key="D4"
                Command="{StaticResource blackCmd}"
                CommandTarget="{Binding ElementName=rect}"
                CommandParameter="3"/>

這裡的 Key 屬性比較特別,不能直接寫“4”,因為無法從字串“4”轉換為 Key 列舉,會報錯,可以指定為“D4”、“D5”等。這裡所指定的數字鍵是大鍵盤區域的數字(QWERTYUIOP 上面那排),不是右邊小鍵盤的數字鍵。小鍵盤要用"NumPad4"。小數字鍵盤跟有些修改鍵組合後無效,經老周測試,Shift、Alt、Win這些鍵都無效,Ctrl 可以。所以,還是用字母鍵靠譜些,也不用區分大小鍵盤區域。

重點:Key + Modifiers 方式與 Gesture 方式只能二選一,不能同時使用,會產生歧義

CommandTarget 為什麼是可選的

前面提到,命令目標是可選的,可以不指定,為什麼呢?這就要看命令源的處理方式了。我們可以看看 WPF 內部的處理。

internal static bool CanExecuteCommandSource(ICommandSource commandSource)
{
    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;
            }
            return routed.CanExecute(parameter, target);
        }
        else
        {
            return command.CanExecute(parameter);
        }
    }

    return false;
}

如果命令是 RoutedCommand,且目標是存在的,就觸發 CanExe 事件;如果未指定目標,則將命令源作為目標。

如果命令不是 RoutedCommand,則直接無視目標。

所以,總的來說,Target 就是可選的。不過,對於非路由的命令,預設會把鍵盤焦點所在的控制元件視為目標。

現在,老周相信大夥伴們都會使用命令了。在實際使用中,你還可以把命令直接封裝進 Model 型別中,比如作為一個公共屬性。MVVM不是很喜歡這樣用的嗎?這樣封裝確實很方便的,尤其是你有N個視窗,這些視窗可能都出現一個“編輯員工資訊”的選單或按鈕。如果你的員工資訊模型中直接封裝了命令,在命令的邏輯中開啟編輯對話方塊。這樣就省了許多重複程式碼了,而且這 N 個視窗的程式碼也變得簡潔了,你甚至都不用給按鈕們處理 Click 事件。

----------------------------------------------------------------------------------------------------------------------------------------------

最後,解釋一下老周最近寫水文為什麼效率這麼低。因為老周最近很光榮,經朋友介紹,以 A 公司員工的名義,被派遣到 B 集團總部的開發部門。就類似於外包之類了吧,就是過去那裡幹一段時間。這關係很複雜吧。其實老周本來是不想去的,但還是給朋友 45% 的面子(唉,人最可悲的就是總覺得面子可以當飯吃),就答應了,順便賺點生活費。包吃不包住,來回就用網約車。因為這“一段時間”太模糊,租房子不好弄,交押金什麼的,時間又不確定,咋整。所以,只好叫車,費用找他們公司報銷。

如果你常被外派的話,可能知道這活是不好乾的。你想想,人家為什麼要找你上門?就是因為他們自己解決不了問題,你過去就是負責啃硬骨頭的。由於簽了保密協議,老周不能說是什麼專案。總之專案很大,TM的複雜,主要幫他們做最佳化。他們的辦公室跟菜市場似的,每天很熱鬧,上班可以走來走去,聊天扯蛋。氛圍不錯,你到處逛領導也不管,反正你得完成進度。老周粗略估算,一張桌子坐 8 個人,辦公室很大,有6列17行,能坐 6*17*8 個人,整棟樓有 2313 人(聽見他們廣播中是這樣說的),不知道算不算我們外包人員。想想他們的開發團隊有多大了。

畢竟是大集團公司,在東南亞和歐洲有很多個生產基地。所以他們的開發團隊本來設立是為子公司的工廠開發軟體系統的。不過,在食堂聽內部人員說,這幾年他們除了自己集團內的專案,外面的雜七雜八的專案也接,專案很多,而且很亂,大家都乾得很無語,經常都分不清哪個專案跟哪個專案。一個專案還建了很多分支,很多版本。剛到那裡的時候,也把老周整得很無語,專案名稱都是【三個字母+數字+一個字母】表示,最後一個字母表示版本分支。看任務文件,然後在原始碼伺服器上找專案都找得頭暈。

本來以為這樣的大公司,程式碼應該寫得很規範的。誰曾想,他們完全就是“能執行就好,其他免談”,程式碼是真的寫得一團亂,甚至都不知道經過多少手了,看裡面的註釋,最早有 2013 年的修改記錄。而且註釋裡面是繁、中文,英文,日文,還有其他不知道什麼鳥文的都有。起初我還以為是亂碼。估計什麼越南語都有。這實打實的是混血程式碼。

說實話,外派到別人的大團隊裡真的很鬱悶。他們自己人一個圈子,喜歡欺負新人。當然,不是物理上的欺負,畢竟老周小時候跟江湖騙子練過兩年的,真打起來的話,老周可以一打五。老周指的是他們總把些難搞的任務交給你做——也是意料之中的。所以,根據老周多年忽悠人的經驗,外派到其他公司一定要學會“裝糊塗”。

啥意思呢?不是叫你裝傻子,而且要裝菜。你不能表現得像個大神,不然他們會丟更多的硬骨頭讓你啃(人不如狗,現在狗都不啃骨頭了)。所以你要裝成菜鳥,但不能太菜。派出公司在介紹時肯定會吹牛你有多少個世紀的開發經驗的,如果裝得太小白,他們就會發現你是不想幹活,故意裝。裝要裝得有點菜,不能太菜。比如,某個東西老周其實用 30 分鐘就能做出來的,我硬要做他個2小時。本來一天能完事的,非要做個兩天。如果經理問,就說“這個 RadGridView 控制元件和 WinForm 是不相容的,如果換 UI,要處理1、2、3、4……” 總之,有很大的難度,需要不可預估的時間去完成。三天能搞好的,就說一個星期。拖拖進度,可以減輕負擔。因為老周很累,白天叫車過去幫別人搞專案,晚上回去還要改另外兩個專案。白天腦子嗡嗡響,晚上腦子嗞嗞響。

放慢速度來做,等派遣約定的時間到了,直接閃退。反正一兩個月,拖拖拉拉就扛過了。沒必要玩命給別人好印象的,反正跟他們沒混熟就走人了,那個菜市場一樣的辦公室那麼大,誰記得你啊。

今天的水文就寫到這兒了,明天又要去菜市場混日子了,還有一個半月,很快就熬過去。

相關文章