WPF之事件


就像屬性系統在WPF中得到升級、進化為依賴屬性一樣,事件系統在WPF中也被升級一進化成為路由事件(Routed Event),並在其基礎上衍生出命令傳遞機制

WPF的樹形結構

WPF中有兩種“樹”:一種叫邏輯樹(Logical Tree);一種叫可視元素樹(Visual Tree)
前面見到的所有樹形結構都是Logical Tree,Logical Tree最顯著的特點就是它完全由布局組件和控件構成(包括列表類控件中的條目元素),它的每個結點不是布局組件就是控件。
每個WPF控件本身也是一棵由更細微級別的組件(它們不是控件,而是一些可視化組件,派生自Visual類)組成的樹,使用Blend可以解剖並觀察一個控件的模板(Template)是怎樣的,可以把Template理解為控件的骨架。把Logical Tree延伸至Template組件級別,得到的就是Visual Tree

注:如果你的程序需要借助Visual Tree來完成一些與業務邏輯(而不是純表現邏輯)相關的功能,多半是由程序設計不良而造成的,請重新考慮邏輯、功能和數據類型方面的設計。

如果想在Logical Tree 上導航或查找元素,可以借助LogicalTreeHelper類的static方法來實現:

  • BringIntoView:把選定元素帶進用戶可視區域,經常用於可滾動的視圖。
  • FindLogicalNode:按給定名稱(Name屬性值)查找元素,包括子級樹上的元素。
  • GetChildren:獲取所有直接子級元素。
  • GetParent:獲取直接父級元素。

如果想在Visual Tree 上導航或查找元素,則可借助VisualTreeHelper類的static方法來實現。

事件

事件的前身是消息(Message)。消息本質就是一條數據,這條數據里記載着消息的類別,必要的時候還記載一些消息參數(如WM_LBUTTONDOWN消息所攜帶的參數——鼠標單擊處的X、Y坐標),也有些消息是不用攜帶參數的(如按鈕被單擊的消息——程序員並不關心鼠標點在按鈕的哪個位置上了)。
隨着微軟面向對象開發平台日趨成熟,微軟把消息機制封裝成了更容易讓人理解的事件模型。事件模型隱藏了消息機制的很多細節,消息驅動機制在事件模型中被簡化為3個關鍵點

  • 事件的擁有者:即消息的發送者。事件的宿主可以在某些條件下激發它擁有的事件,即事件被觸發。事件被觸發則消息被發送。
  • 事件的響應者:即消息的接收者、處理者。事件接收者使用其事件處理器(Event Handler)對事件做出響應。
  • 事件的訂閱關系:事件的擁有者可以隨時激發事件,但事件發生后會不會得到響應要看有沒有事件的響應者(事件是否被關注)。

事件的響應者通過訂閱關系直接關聯在事件擁有者的事件上,為了與WPF的路由事件模型區分開,把這種事件模型稱為直接事件模型或者CLR事件模型。在CLR直接事件模型中,事件的擁有者就是消息的發送者(sender)。

只要支持事件的委托與影響事件的方法在簽名上保持一致(即參數列表和返回值一致),則一個事件可以由多個事件處理器來響應(多播事件)、一個事件處理器也可以用來響應多個事件。

直接事件模型並不完美——事件的響應者與事件擁有者之間必須建立事件訂閱這個“專線聯系”,至少有兩個弊端:

  • 每對消息是“發送一響應”關系,必須建立顯式的點對點訂閱關系。
  • 事件的宿主必須能夠直接訪問事件的響應者,不然無法建立訂閱關系。

直接事件模型的弱點會在下面兩種情況中顯露出來:

  • 程序運行期在容器中動態生成一組相同控件,每個控件的同一個事件都使用同一個事件處理器來響應,在動態生成控件的同時就需要顯式書寫事件訂閱代碼
  • 用戶控件的內部事件不能被外界所訂閱,必須為用戶控件定義新的事件用以向外界暴露內部事件,如果想讓很外層的容器訂閱深層控件的某個事件就需要為每一層組件定義用於暴露內部事件的事件、形成事件鏈

路由事件

路由(Roule):起點與終點間有若干個中轉站,從起點出發后經過每個中轉站時要做出選擇,最終以正確(比如最短或者最快)的路徑到達終點。

從Windows AP1開發到傳統的.NE開發,消息的傳遞(或者說事件的激發與響應)都是直接模式的,即消息直接由發送者交給接收者(或者說事件宿主發生的事件直接由事件響應者的事件處理器來處理)。
WPF把這種直接消息模型升級為可傳遞的消息模型——WPF的UI是由布局組件和控件構最的樹形結構,當這棵樹上的某個結點激發出某個事件時,程序員可以選擇以傳統的直接事件模式讓響應者來響應之,也可以讓這個事件在UI組件樹沿着一定的方向傳遞且路過多個中轉結點,並在這個路由過程中被恰當地處理。

路由事件與直接事件的區別在於:

  • 直接事件激發時,發送者直接將消息通過事件訂閱交送給事件響應者,事件響應者使用其事件處理器方法對事件的發生做出響應、驅動程序邏輯按客戶需求運行;
  • 路由事件的事件擁有者和事件響應者之間則沒有直接顯式的訂閱關系,事件的擁有者只負責激發事件,事件將由誰響應它並不知道,事件的響應者則安裝有事件偵聽器,針對某類事件進行偵聽,當有此類事件傳遞至此時事件響應者就使用事件處理器來響應事件並決定事件是否可以繼續傳遞。

盡管WPF推出了路由事件機制,但它仍然支持傳統的直接事件模型。

注:WPF的UI可以表示為Logical Tree和Visual Tree,當一個路由事件被激發后是沿着Visual Tree傳遞的——只有這樣,“藏”在Template里的控件才能把消息送出來

使用WPF內置路由事件

WPF系統中的大多數事件都是可路由事件,以Button的Click事件來說明路由事件的使用,XAML代碼如下:

<Grid x:Name="gridRoot" Background="Lime">
    <Grid x:Name="gridA" Margin="10" Background="Blue">
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Canvas x:Name="canvasLeft" Grid.Column="0" Background="Red" Margin="10">
            <Button x:Name="buttonLeft" Content="Left" Width="40" Height="100" Margin="10"/>
        </Canvas>
        <Canvas x:Name="canvasRight" Grid.Column="1" Background="Yellow" Margin="10">
            <Button x:Name="butonRight" Content="Right" Width="40" Height="100" Margin="10"/>
        </Canvas>
    </Grid>
</Grid>

下面為gridRoot安裝針對Button.Click事件的偵聽器,C#代碼如下:

//AddHandler方法源自UIElement類,所有UI控件都具有這個方法
this.gridRoot.AddHandler(Button.ClickEvent, new RoutedEventHandler(this.ButtonClicked));

WPF的事件系統也使用了與屬性系統類似的“靜態字段一包裝器”的策略,路由事件本身是一個RoutedEvent 類型的靜態成員變量(Button.ClickEvent),Button還有一個與之對應的Click事件(CLR包裝)專門用於對外界暴露這個事件。效仿依賴屬性,把路由事件的CLR包裝稱為“CLR事件”。就像每個依賴屬性擁有自己的CLR屬性包裝一樣,每個路由事件都擁有自己的CLR事件。

在XAML里也可以完成,代碼如下:

<Grid x:Name="gridRoot" Background="Lime" ButtonBase.Click="ButtonClicked">
     <!--原有內容-->   
</Grid>

建議使用ButtonBase.Click而不是Button.Click,因為ClickEvent這個路由事件是ButonBase類的靜態成員變量(Button類是通過繼承獲得它的),而XAML編輯器只認得包含ClickEvent字段定義的類。

上面的代碼讓最外層的Grid(gridRoot)能夠捕捉到從內部“飄”出來的按鈕單擊事件,捕捉到后會用this.ButonClicked方法來進行響應處理,ButtonClicked方法代碼如下:

private void ButtonClicked(object sender,RoutedEventArgs e)
{
    MessageBox.Show((e.OriginalSource as FrameworkElement).Name);
}

傳入ButtonClicked方法的參數sender實際上是gridRoot而不是被單擊的Button,如果想查看事件的源頭(最初發起者)可使用e.OriginalSource,使用它的時候需要使用as/is操作符或者強制類型轉換把它識別/轉換為正確的類型。

運行程序並單擊右邊的按鈕,效果如下:

自定義路由事件

創建自定義路由事件大體可以分為三個步驟:

  • 聲明並注冊路由事件。
  • 為路由事件添加CLR事件包裝。
  • 創建可以激發路由事件的方法。

ButtonBase類的Click路由事件

下面以從ButtonBase類中抽取出的代碼為例來展示這3個步驟,此處對代碼做了些簡化:

public abstract class ButtonBase : ContentControl,ICommandSource
{
    //聲明並注冊路由事件
    public static readonly RoutedEvent ClickEvent = EventManager.RegisterRoutedEvent
        ("Click", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ButtonBase));

    //為路由事件添加CLR事件包裝器
    public event RoutedEventHandler Click
    {
        add { this.AddHandler(ClickEvent, value); }
        remove { this.RemoveHandler(ClickEvent, value);}
    }

    //激發路由事件的方法。此方法在用戶單擊鼠標時會被Windows系統調用
    protected virtual void OnClick() 
    {
        RoutedEventArgs newEvent = new RoutedEventArgs(ButtonBase.ClickEvent,this);
        this.RaiseEvent(newEvent);
        //..
    }

    //..
}

定義路由事件:為類聲明一個由public static readonly修飾的RoutedEvent 類型字段,然后使用EventManager類的RegisterRoutedEvent方法進行注冊。

為路由事件添加CLR事件包裝:與使用CLR屬性包裝依賴屬性的代碼格式非常相近,只是關鍵字get和set被替換為add和remove:

  • 當使用操作符(+=)添加對路由事件的偵聽處理時,add分支的代碼會被調用。
  • 當使用操作符(-=)移除對此事件的偵聽處理時,remove分支的代碼會被調用。
    注:CLR事件只是“看上去像”一個直接事件,本質上不過是在當前元素(路由的第一站)上調用AddHandler和RemoveHandler而已,XAML編輯器也是靠這個CLR事件包裝器來產生自動提示。

激發路由事件:首先創建需要讓事件攜帶的消息(RoutedEventArgs類的實例)並把它與路由事件關聯,然后調用元素的RaiseEvent方法(繼承自UIElement類)把事件發送出去。
注:傳統直接事件的激發是通過調用CLR事件的Invoke方法實現的,而路由事件的激發與作為其包裝器的CLR事件毫不相干

EventManager.RegisterRoutedEvent方法的四個參數:

  • 第一個參數:為string類型,被稱為路由事件的名稱,這個字符串應該與RoutedEvent變量的前綴和CLR事件包裝器的名稱一致,字符串不能為空(需要使用這個字符串去生成用於注冊路由事件的Hash Code)。
  • 第二個參數:稱為路由事件的策略,是一個RoutingStrategy枚舉值。
  • 第三個參數:用於指定事件處理器的類型,事件處理器的返回值類型和參數列表必須與此參數指定的委托保持一致,不然會導致在編譯時拋出異常。
  • 第四個參數:用於指明路由事件的宿主(擁有者)是哪個類型,這個類型和第一個參數共同參與一些底層算法且產生這個路由事件的Hash Code並被注冊到程序的路由事件列表中。

WPF路由事件有3種路由策略,即RoutingStrategy枚舉有三個值:

  • Bubble(冒泡式):路由事件由事件的激發者出發向它的上級容器一層一層路由,直至最外層容器(Window或者Page)。
  • Tunnel(隧道式):事件的路由方向正好與Bubble策略相反,是由UI樹的樹根向事件激發控件移動。
  • Direct(直達式):模仿CLR直接事件,直接將事件消息送達事件處理器。

創建一個路由事件

下面創建一個路由事件,用途是報告事件發生的時間。創建一個RoutedEventArgs類的派生類,並為其添加ClickTime屬性:

//用於承載時間消息的事件參數
class ReportTimeEventArgs : RoutedEventArgs
{
    public ReportTimeEventArgs(RoutedEvent routedEvent, object source)
        : base(routedEvent, source) { }

    public DateTime ClickTime { get; set; }
}

再創建一個Button類的派生類並按前述步驟為其添加路由事件:

class TimeButton : Button 
{
    //聲明和注冊路由事件
    public static readonly RoutedEvent ReportTimeEvent = EventManager.RegisterRoutedEvent
        ("ReportTime",RoutingStrategy.Bubble,typeof(EventHandler<ReportTimeEventArgs>),typeof(TimeButton));
    //CLR事件包裝器
    public event RoutedEventHandler ReportTime
    {
        add { this.AddHandler(ReportTimeEvent, value); }
        remove { this.RemoveHandler(ReportTimeEvent, value); } 
    }
    //激發路由事件,借用Click事件的激發方法
    protected override void OnClick()
    {
        base.OnClick(); //保證Button原有功能正常使用、Click事件能被激發

        ReportTimeEventArgs args = new ReportTimeEventArgs(ReportTimeEvent, this);
        args.ClickTime = DateTime.Now;
        this.RaiseEvent(args);
    }
}

程序的界面XAML代碼如下:

<!--省略Window的部分代碼-->
<Window x:Name="windows_1" local:TimeButton.ReportTime="ReportTimeHandler">
    <Grid x:Name="grid_1" local:TimeButton.ReportTime="ReportTimeHandler">
        <Grid x:Name="grid_2" local:TimeButton.ReportTime="ReportTimeHandler">
            <Grid x:Name="grid_3" local:TimeButton.ReportTime="ReportTimeHandler">
                <StackPanel x:Name="stackPanel_1" local:TimeButton.ReportTime="ReportTimeHandler">
                    <ListBox x:Name="listBox"/>
                    <local:TimeButton x:Name="timeButton" Width="80" Height="80" Content="報時" local:TimeButton.ReportTime="ReportTimeHandler"/>
                </StackPanel>
            </Grid>
        </Grid>
    </Grid>
</Window>

ReportTimeHandler的代碼如下:

//ReportTimeEvent 路由事件處理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e) 
{

    FrameworkElement element = sender as FrameworkElement;
    string timeStr = e.ClickTime.ToLongTimeString();
    string content = string.Format("{0}到達{1}", timeStr, element.Name);
    this.listBox.Items.Add(content);
}

效果如下:

為TimeButton注冊ReportTimeEvent時使用的是Bubble策略,所以事件是沿這樣的路徑由內向外傳遞的:TimeButton→StackPanel→Grid→Grid→Grid→Window。
如果把TimeReportEvent的策略改為Tunnel,則正好與Bubble策略相反,Tunnel策略使事件沿着從外向內的路徑傳遞:Window→Grid→Grid→Grid→StackPanel→TimeButton。

路由事件攜帶的事件參數必須是RoutedEventArgs類或其派生類的實例,RoutedEventArgs類具有一個bool類型屬性Handled,一旦這個屬性被設置為true,就表示路由事件“已經被處理”了(Handle有“處理”、“搞定”的意思),那么路由事件也就不必再往下傳遞了。
如果把上面的ReportTimeEvent處理器修改為這樣:

//ReportTimeEvent 路由事件處理器
private void ReportTimeHandler(object sender, ReportTimeEventArgs e) 
{

    FrameworkElement element = sender as FrameworkElement;
    string timeStr = e.ClickTime.ToLongTimeString();
    string content = string.Format("{0}到達{1}", timeStr, element.Name);
    this.listBox.Items.Add(content);

    if (element == this.grid_2)
    {
        e.Handled = true;
    }
}

效果如下:

e.Handled被設置為true,無論是Bubble策略還是Tunnel策略,路由事件在經過grid_2后就被處理了、不再向下傳遞。

路由事件將程序中的組件進一步解耦(比用直接事件傳遞消息還要松散),需要注意的是:

  • 很多類的事件都是路由事件,如TextBox類的TextChanged 事件、Binding類的SourceU/pdated事件等,不要墨守傳統NET編程帶來的習慣,活用路由事件
  • 路由事件雖好,但也不要濫用,如讓窗體捕捉並處理所有Button的Click 事件。正確的辦法是,事件該由誰來描捉處理,待到這個地方時就應該處理掉

RoutedEventArgs的Source與OriginalSource

路由事件是沿着VisualTree傳遞的,VisualTree與LogicalTree的區別就在於:LogicalTree的葉子結點是構成用戶界面的控件,而VisualTree要連控件中的細微結構也算上

路由事件的消息包含在RoutedEventArgs實例中,Source和OriginalSource都表示路由事件傳遞的起點(即事件消息的源頭),區別在於:

  • Source表示的是LogicalTree上的消息源頭
  • OriginalSource則表示VisualfTree上的源頭

創建了一個名為MyUserControl的UserControl,XAML代碼如下(沒有C#邏輯代碼):

<!--省略UserControl部分代碼-->
<Grid>
    <Border BorderBrush="Orange" BorderThickness="3" CornerRadius="5">
        <Button x:Name="innerButon" Width="80" Height="80" Content="OK"/>
    </Border>
</Grid>

把這個UserControl添加到主窗體中:

<Grid>
    <local:MyUserControl x:Name="myUserControl" Margin="10"/>
</Grid>

在后台代碼中為主窗體添加對Button.Click路由事件的偵聽:

public MainWindow()
{
    InitializeComponent();

    //為主窗體添加對Button.Click事件的偵聽
    this.AddHandler(Button.ClickEvent,new RoutedEventHandler(this.Button_Click));
}

//路由事件處理器
private void Button_Click(object sender, RoutedEventArgs e) 
{
    string strOriginalSource = string.Format("VisualTree start point:{0},type is {1}",
        (e.OriginalSource as FrameworkElement).Name,e.OriginalSource.GetType().Name);
    string strSource = string.Format("LogicalTree start point:{0},type is {1}",
        (e.Source as FrameworkElement).Name, e.Source.GetType().Name); 
    MessageBox.Show(strOriginalSource + "\r\n" + strSource);
}

效果如下:

Button.Click 路由事件是從MyUserControl的innerButton 發出來的,主窗體中myUserControl是LogicalTree的末端結點,而窗體的VisualTree則包含了myUserControl的內部結構,所以e.Source是myUserControl、e.OriginalSource是innerButton。

附加事件

在WPF事件系統中還有一種事件被稱為附加事件(Attached Event),它就是路由事件。擁有附加事件的類有:

  • Binding類:SourceUpdated事件、TargetUpdated事件。
  • Mouse類:MouseEnter 事件、MouseLeave 事件、MouseDown事件、MouseUp事件等。
  • Keyboard類:KeyDown事件、KeyUp事件等。

對比一下那些擁有路由事件的類,路由事件的宿主都是些擁有可視化實體的界面元素,而附加事件則不具備顯示在用戶界面上的能力

不使用CLR屬性作為包裝器

設計一個名為Student的類,如果Student實例的Name屬性值發生了變化就激發一個路由事件,使用界面元素來捕捉這個事件。這個類的代碼如下:

public class Student
{
    //聲明並定義路由事件
    public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent
        ("NameChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Student));
   
    public int Id { get; set; }
    public string Name { get; set; }
}

設計一個簡單的界面:

<Grid x:Name="gridMain">
    <Button x:Name="button1" Content="OK" Width="80" Height="80" Click="Button_Click"/>
</Grid>

后台代碼如下:

public MainWindow()
{
    InitializeComponent();
    // 為外層Grid添加路由事件偵聽器
    this.gridMain.AddHandler(Student.NameChangedEvent, new RoutedEventHandler(this.StudentNameChangedHandler));
}

//Click 事件處理器
private void Button_Click(object sender,RoutedEventArgs e)
{
    Student stu = new Student(){ Id = 101,Name = "Tim"}; 
    stu.Name = "Tom";
    //准備事件消息並發送路由事件
    RoutedEventArgs arg = new RoutedEventArgs(Student.NameChangedEvent, stu); 
    this.button1.RaiseEvent(arg);
}

//Grid 捕捉到NameChangedEvent后的處理器
private void StudentNameChangedHandler(object sender, RoutedEventArgs e)
{
    MessageBox.Show((e.OriginalSource as Student).Id.ToString());
}    

注:因為Student不是UIElement的派生類,所以它不具有RaiseEvent這個方法,為了發送路由事件就不得不“借用”一下Button的RaiseEvent方法了

運行程序並單擊按鈕,效果如下:

Student類並非派生自UIElement,因此亦不具備AddHandler和RemoveHandler這兩個方法,所以不能使用CLR屬性作為包裝器(因為CLR屬性包裝器的add和remove分支分別調用當前對象的AddHandler和RemoveHandler)。

使用CLR屬性作為包裝器

微軟的官方文檔約定要為附加事件添加一個CLR包裝以便XAML編輯器識別並進行智能提示:

  • 為目標UI元素添加附加事件偵聽器的包裝器是一個名為Add*Handler的public static方法,星號代表事件名稱(與注冊事件時的名稱一致)。
  • 解除UI元素對附加事件偵聽的包裝器是名為RemoveHandler的public static方法,星號亦為事件名稱,參數與AddHandler一致。
  • AddHandler與RemoveHandler的參數一致,接收兩個參數:第一個參數是事件的偵聽者(類型為DependencyObject),第二個參數為事件的處理器(RoutedEventHandler委托類型)。

按照規范,Student類被升級為這樣:

public class Student
{
    //聲明並定義路由事件
    public static readonly RoutedEvent NameChangedEvent = EventManager.RegisterRoutedEvent
        ("NameChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Student));

    //為界面元素添加路由事件偵聽
    public static void AddNameChangedHandler(DependencyObject d,RoutedEventHandler h)
    {
        UIElement e = d as UIElement; 
        if (e!= null)
        {
            e.AddHandler(Student.NameChangedEvent, h);
        }
    }

    //移除偵聽
    public static void RemoveNameChangedHandler(DependencyObject d, RoutedEventHandler h)
    {
        UIElement e = d as UIElement;
        if (e != null)
        {
            e.RemoveHandler(Student.NameChangedEvent, h);
        }
    }

    public int Id { get; set; }
    public string Name { get; set; }
}

原來的代碼只有添加事件偵聽一處需要改動:

//為外層Grid添加路由事件偵聽器
Student.AddNameChangedHandler(this.gridMain, new RoutedEventHandler(this.StudentNameChangedHandler));

UIElement類是路由事件宿主與附加事件宿主的分水嶺,因為從UIElement類開始才具備了在界面上顯示的能力且RaiseEvent、AddHandler和RemoveHandler這些方法也定義在UIElement類中。

附加事件只能算是路由事件的一種用法而非一個新概念,如果在一個非UIElement派生類中注冊了路由事件,則這個類的實例既不能自己激發(Raise)此路由事件也無法自己偵聽此路由事件,只能把這個事件的激發“附着”在某個具有RaiseEvent方法的對象上,借助這個對象的RaiseEvent方法把事件發送出去;事件的偵聽任務也只能交給別的對象去做。

使用附加事件時需注意:

  • 路由事件路由時的第一站就是事件的激發者,附加事件路由的第一站是激發它的元素
  • 實際上很少會把附加事件定義在Student這種與業務邏輯相關的類中,一般都是定義在像Binding、Mouse、Keyboard這種全局的Helper類中。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM