Общие сведения о перенаправленных событиях и командах в WPF

Автор: Брайан Нойес (Brian Noyes)

Загрузить код можно по ссылке: RoutedWPF2008_09a.exe (175 КБ)
Просмотреть код в сети

Одна из самых пугающих вещей при выведении Windows® Presentation Foundation (WPF) на должный уровень состоит в том, что имеется очень много новых конструкций, которые необходимо освоить. Даже такие простые вещи, как свойства и события Microsoft® .NET Framework, в WPF имеют новые части с дополнительными возможностями и соответствующей сложностью, особенно свойства зависимостей и перенаправленные события. Кроме того, имеются абсолютно новые сущности, например анимация, стили, шаблоны элементов управления и перенаправленные события. Все это необходимо изучить.

В этой статье я планирую сосредоточиться на двух очень важных элементах в списке новых элементов WPF, которые необходимо освоить. Эти элементы, перенаправленные события и перенаправленные команды, связаны друг с другом. Они формируют основу для взаимодействия разных частей пользовательского интерфейса, являются ли эти части отдельными элементами управления в одном большом классе Window, или они представляют собой элементы управления и их поддерживающий код в разделенных частях пользовательского интерфейса. В этой статье предполагается знакомство читателей с основами WPF, такими как конструирование пользовательского интерфейса с помощью встроенных элементов управления WPF и объявление макета пользовательского интерфейса в XAML.

Общие сведения о перенаправленных событиях

При начале работы с WPF пользователи, вероятно, будут применять перенаправленные события, даже не подозревая об этом. Например, если добавить кнопку в окно с помощью конструктора Visual Studio®, назвать ее myButton и дважды щелкнуть ее, то событие Click (щелчка) будет присоединено в разметке XAML, и в код программной части класса Window будет добавлен обработчик события для этого события. Это может выглядеть аналогично присоединению событий в Windows Forms и ASP.NET. На самом деле это немного близко к модели кодирования для ASP.NET, но гораздо более похоже на модель среды выполнения Windows Forms. В частности, разметка XAML для кнопки заканчивается кодом, аналогичным приведенному ниже.

<Button Name="myButton" Click="myButton_Click">Click Me</Button>

Объявление XAML для присоединения события выглядит аналогично назначению свойств в XAML, но приводит к обычному присоединению события в объекте, задающем обработчик события. Это присоединение фактически происходит в разделяемом классе для окна, созданном во время компиляции. Чтобы увидеть это, перейдите в конструктор класса, щелкните правой кнопкой мыши вызов метода InitializeComponent и выберите в контекстном меню пункт "Go To Definition" ("Перейти к определению"). В редакторе отобразится созданный файл кода (в контексте именования IGCS или IGVB), содержащий код, который обычно создается во время компиляции. Прокрутите вниз отображаемый разделяемый класс до метода Connect, где можно будет увидеть следующее:

#line 6 "..\..\Window1.xaml" this.myButton.Click += new System.Windows.RoutedEventHandler( this.myButton_Click);

Этот разделяемый класс создается из XAML во время компиляции и содержит те элементы XAML, которым требуется компиляция во время разработки. Большая часть XAML становится ресурсом с внедренным двоичным объектом в скомпилированной сборке и во время выполнения объединяется со скомпилированным кодом из двоичного представления разметки.

Если взглянуть на код программной части для окна, то обработчик щелчка (события Click) выглядит аналогично приведенному ниже.

private void myButton_Click( object sender, RoutedEventArgs e) { }

Пока это выглядит как любое другое присоединение события .NET — имеется явно объявленный делегат, присоединенный к событию в объекте, и делегат указывает на метод обработки. Единственное, что может подсказать, что используются перенаправленные события, это тип аргумента RoutedEventArgs для события Click. Тогда что является особенным в перенаправленных событиях? Чтобы понять это, сначала необходимо понять элементарный состав модели WPF.

Деревья элементов WPF

Если начать проект с нового окна и в конструкторе перетащить в это окно кнопку, то получится дерево элементов в XAML, выглядящее следующим образом (атрибуты не указаны для ясности):

<Window> <Grid> <Button/> </Grid> </Window>

Каждый из этих элементов представляет экземпляр среды выполнения соответствующего типа .NET, и объявленная иерархия элементов формирует то, что называется логическим деревом. Кроме того, многие элементы управления в WPF являются либо элементами управления содержимым, либо элементами управления элементами, что означает, что они могут иметь дочерние элементы. Например, кнопка является элементом управления содержимым и может иметь в качестве содержимого сложный дочерний элемент. Можно развернуть логическое дерево, как показано ниже:

<Window> <Grid> <Button> <StackPanel> <Image/> <TextBlock/> </StackPanel> </Button> </Grid> </Window>

Результат этого в пользовательском интерфейсе показан на рисунке 1.

Рисунок 1. Простое окно с кнопкой

Как можно представить, дерево может начинаться с нескольких разветвлений (другие кнопки в сетке), и сложность логического дерева может значительно расти. Насчет элементов WPF в логическом дереве следует понять: то, что можно видеть — не то, что в действительности получается во время выполнения. Каждый из этих элементов во время выполнения обычно разворачивается в более сложное дерево визуальных элементов. В данном примере логическое дерево элементов разворачивается в визуальное дерево элементов, показанное на рисунке 2.

Рисунок 2. Визуальное дерево простого окна

Я использовал инструмент Snoop (blois.us/Snoop) для просмотра элементов визуального дерева, показанного на рисунке 2. Можно видеть, что окно (EventsWindow) в действительности включает свое содержимое в элементы Border и Adorner­Decorator и представляет его с помощью ContentPresenter. Кнопка делает нечто аналогичное, включая свое содержимое в объект ButtonChrome и представляя его с помощью ContentPresenter.

Когда я нажимаю на мою кнопку, на самом деле я могу выполнять щелчок не в самом элементе Button; я могу выполнять щелчок в дочернем элементе визуального дерева, возможно даже в том, который не показан в моем логическом дереве (например в ButtonChrome). Предположим, что я щелкнул кнопкой мыши вверху изображения внутри моей кнопки. Этот щелчок в действительности изначально проявляет себя как событие MouseLeftButtonDown в элементе Image. Но это каким-нибудь образом необходимо транслировать в событие Click на уровне кнопки. Вот здесь и возникает маршрутизация перенаправленных событий.

Маршрутизация событий

Необходимо небольшое пояснение, касающееся логических и визуальных деревьев, поскольку перенаправленные события перенаправляются главным образом на основе визуального дерева. Перенаправленные события поддерживают восходящую, нисходящую или прямую стратегию маршрутизации.

Наиболее распространенной является восходящая маршрутизация, которая означает, что событие будет передаваться (распространяться) вверх по визуальному дереву от исходного элемента, пока либо не будет обработано, либо не достигнет корневого элемента. Это позволяет обрабатывать событие в объекте, расположенном выше исходного в иерархии элементов. Например, можно подключить обработчик Button.Click в заключающем элементе Grid вместо самой кнопки. Имена событий восходящей маршрутизации указывают их действие (например MouseDown).

События нисходящей маршрутизации перемещаются в другом направлении, начиная с корневого элемента и проходя вниз по дереву элементов, пока не будут обработаны или пока не достигнут исходного элемента события. Это позволяет восходящим элементам перехватывать событие и обрабатывать его прежде, чем событие достигнет исходного элемента. В именах событий нисходящей маршрутизации принято использовать префикс Preview (например PreviewMouseDown).

События прямой маршрутизации ведут себя аналогично обычным событиям в платформе .NET Framework. Единственным потенциальным обработчиком для такого события является делегат, который присоединен к событию.

Обычно, если для конкретного элемента задается событие нисходящей маршрутизации, то имеется и соответствующее событие восходящей маршрутизации. В таком случае событие нисходящей маршрутизации срабатывает первым, начиная с корня и проходя путь вниз до исходного элемента в поисках обработчика. После того как оно будет обработано или достигнет исходного элемента, срабатывает событие восходящей маршрутизации, проходя путь от исходного элемента в поисках обработчика. Событие восходящей или нисходящей маршрутизации не останавливает свой маршрут только потому, что вызван обработчик события. Если требуется остановить процесс восходящей или нисходящей маршрутизации, то в обработчике событий событие помечается как обработанное с помощью передаваемых аргументов события:

private void OnChildElementMouseDown(object sender, MouseButtonEventArgs e) { e.Handled = true; }

После того как обработчик пометит событие как обработанное, оно не будет передаваться в какие-либо другие обработчики. Но это только частично верно. В действительности маршрутизация события продолжается в фоновом режиме, и можно явно присоединить обработчики событий в коде, переопределив метод UIElement.AddHandler, который имеет дополнительный флаг, позволяющий эффективно сказать "вызовите меня, если событие помечено как обработанное". Этот флаг задается в вызове, например следующим образом:

m_SomeChildElement.AddHandler(UIElement.MouseDownEvent, (RoutedEventHandler)OnMouseDownCallMeAlways,true);

Первый параметр в AddHandler — это перенаправленное событие, которое требуется обработать. Второй параметр является делегатом в метод обработки событий (который должен иметь правильную сигнатуру для делегата события). Третий параметр указывает, что необходимо уведомление, даже если другой обработчик пометил событие как обработанное. Элемент, в котором вызывается AddHandler, будет искаться при проходе события во время маршрутизации.

Перенаправленные события и композиция

Давайте рассмотрим, как событие Button.Click начинает двигаться домой, поскольку все это важно. Как было упомянуто выше, пользователь будет инициировать событие Click (щелчка) с помощью события MouseLeftButtonDown в некотором дочернем элементе визуального дерева кнопки, таком как Image (изображение) в предыдущем примере.

Когда событие MouseLeftButtonDown происходит в элементе Image, событие PreviewMouseLeft­ButtonDown начинает нисходящую маршрутизацию в корне и проходит вниз до элемента Image. Если ни один обработчик не устанавливает значение true флага Handled для события Preview, то событие MouseLeftButtonDown начинает восходящую маршрутизацию от элемента Image, пока не дойдет до элемента Button (кнопки). Кнопка обрабатывает это событие, устанавливает флаг Handled в значение true и вызывает собственное событие Click. Демонстрационный код для этой статьи включает приложение с обработчиками, присоединенными по всей цепочке маршрутизации, чтобы облегчить визуализацию процесса.

Последствия оказываются довольно внушительными. Например, если я предпочитаю заменить внешний вид кнопки по умолчанию путем применения шаблона элемента управления, содержащего элемент Ellipse, мне не придется ничего делать, чтобы убедиться, что щелчки вне элемента Ellipse (вне овала) не вызывают событие Click. Щелчки за границей овала еще могут быть в пределах прямоугольных границ моей кнопки, но Ellipse имеет свое собственное определение попадания для события MouseLeftButtonDown, а пустые части кнопки вне овала — нет.

Поэтому только щелчки в пределах овала вызывают событие MouseLeftButtonDown. Оно по-прежнему обрабатывается классом Button, к которому присоединен шаблон, поэтому получается предсказуемое поведение даже от настроенной кнопки. Это также исключительно важно помнить при написании собственных настраиваемых составных элементов управления, поскольку скорее всего потребуется делать нечто аналогичное тому, что делает кнопка, для обработки событий в дочерних элементах, которые помещены в этот настраиваемый элемент управления.

Присоединенные события

Чтобы позволить элементам обрабатывать события, объявленные в другом элементе, WPF поддерживает то, что называется присоединенными событиями. Присоединенные события — это перенаправленные события, которые поддерживают присоединение в XAML в элементах, отличных о тех, в которых объявлено событие. Например, если требуется, чтобы элемент Grid прослушивал событие Button.Click для восходящей маршрутизации, можно просто присоединить его следующим образом:

<Grid Button.Click="myButton_Click"> <Button Name="myButton" >Click Me</Button> </Grid>

Итоговый код в разделяемом классе, созданном во время компиляции, теперь выглядит следующим образом:

#line 5 "..\..\Window1.xaml" ((System.Windows.Controls.Grid)(target)).AddHandler( System.Windows.Controls.Primitives.ButtonBase.ClickEvent, new System.Windows.RoutedEventHandler(this.myButton_Click));

Присоединенные события просто предоставляют немного больше гибкости, когда выполняется присоединение обработчиков событий. Но если элементы содержатся в том же классе (как в данном примере), разница может быть не заметна, поскольку в любом случае метод обработки — это по-прежнему только метод в классе Window.

Это имеет значение в двух случаях. Во-первых, обработчики событий вызываются на основе того, где находится обрабатывающий элемент в цепочке элементов восходящей или нисходящей маршрутизации. Во-вторых, это позволяет выполнять что-либо типа обработки событий из объектов, которые могут быть инкапсулированы в используемом элементе управления. Например, можно обрабатывать события Button.Click, аналогичные тем, которые показаны в сетке, но такие события Button.Click должны быть событиями восходящей маршрутизации из пользовательского элемента управления, содержащегося в окне.

Совет. Именование обработчиков событий

Если нет желания придерживаться контекста именования по умолчанию для обработчиков событий (objectName_eventName), просто введите нужное имя обработчика событий, щелкните его правой кнопкой мыши и выберите в контекстном меню пункт "Navigate to Event Handler" ("Перейти к обработчику событий"). Затем Visual Studio создаст обработчик событий для указанного имени.

В Visual Studio 2008 с пакетом обновления 1 окно свойств будет иметь представление событий, аналогичное имеющемуся в Windows Forms, и можно будет указывать там имя события при наличии пакета обновления 1. Но при работе в XAML это удобный способ получить явно именованный созданный обработчик.

Создание обработчика событий

Не все события объявляются как присоединенные события. Фактически, большинство событий так не объявляется. Но присоединенные события могут быть довольно удобны при необходимости делать обработку событий где-либо вне источника в элементе управления.

Общие сведения о перенаправленных командах

Теперь, после изучения перенаправленных событий, можно приступить к объяснению перенаправленных команд. Перенаправленные WPF команды предоставляют особый механизм для присоединения элементов управления пользовательского интерфейса, таких как кнопки панели инструментов и пункты меню, к обработчикам без введения в приложение множества тесных связей и повторяющегося кода. Перенаправленные команды предоставляют помимо обычной обработки событий следующие три важные дополнения.

  • Исходные элементы перенаправленных команд (вызывающие объекты) могут быть отделены от целевых объектов команд (обработчиков) — им не требуются прямые ссылки друг на друга, которые потребовались бы, если бы они были связаны обработчиком событий.
  • Перенаправленные команды будут автоматически включать или отключать все соответствующие элементы управления пользовательского интерфейса, когда обработчик указывает, что команда отключена.
  • Перенаправленные команды позволяют связать сочетания клавиш и другие формы действий ввода (например действий рукописного ввода) в качестве другого способа вызова команды.

Кроме того, дополнительная особенность перенаправленной команды, класс Routed­UICommand, добавляет возможность задать одно свойство Text, которое будет использоваться для текстовых запросов всех элементов управления, которые вызывают команды. При необходимости локализации также проще локализовать одно свойство Text, чем заходить в каждый связанный элемент управления вызывающего объекта.

Чтобы объявить команду в вызывающем объекте, можно просто установить свойство Command в элементе управления, который будет вызывать команду:

<Button Command="ApplicationCommands.Save">Save</Button>

Свойство Command поддерживается элементами управления MenuItem, Button, Radio­Button, CheckBox, Hyperlink и многими другими.

Для элемента, который должен действовать как обработчик команды, устанавливается CommandBinding:

<UserControl ...> <UserControl.CommandBindings> <CommandBinding Command="ApplicationCommands.Save" CanExecute="OnCanExecute" Executed="OnExecute"/> </UserControl.CommandBindings> ... </UserControl>

Свойства CanExecute и Executed привязки команды указывают методы в коде программной части объявленного класса, которые вызываются в процессе обработки команды. Здесь важный момент в том, что вызывающему объекту команды не требуется знание обработчиков команд или ссылка на них, а обработчику не нужно знать, какой элемент вызывает команду.

CanExecute вызывается, чтобы определить, следует ли включать команду. Чтобы включить команду, установите свойство CanExecute аргументов события в значение true, как показано ниже:

private void OnCanExecute(object sender, CanExecuteRoutedEventArgs e) { e.CanExecute = true; }

Команда также включается, если имеется обработчик команд с заданным методом Executed, но нет метода CanExecute (в этом случае Can­Execute неявно имеет значение true). В методе Executed выбирается какое-либо соответствующее действие на основе вызываемой команды. Это может быть сохранение документа, отправка заказа, отправка электронной почты или какое-либо другое действие, с которым связана команда.

Перенаправленные команды в действии

Чтобы сделать изложение более конкретным и быстро увидеть преимущества перенаправленных команд, давайте рассмотрим простой пример. На рисунке 3 можно видеть простой пользовательский интерфейс с двумя текстовыми полями для ввода и кнопкой панели инструментов для выполнения действия Cut (вырезать) в тексте текстовых полей.

Рисунок 3. Простое приложение с кнопкой панели инструментов, выполняющей команду Cut

Чтобы получить такое присоединение с использованием событий, потребуется задать обработчик Click, в коде которого будут ссылки на два текстовых поля для кнопки панели инструментов. Необходимо будет определять, в каком текстовом поле находится фокус, и вызывать соответствующие операции буфера обмена в зависимости от выбора текста в элементе управления. Нужно будет также побеспокоиться о включении и отключении кнопки панели инструментов в соответствующее время, в зависимости от того, где находится фокус, и выбрано ли что-нибудь в текстовом поле. Некрасивый, беспорядочный, тесно связанный код.

Для данной простой формы это звучит не так уж плохо, но что, если эти текстовые поля включены в пользовательский или настраиваемый элемент управления, и код программной части окна не имеет к ним прямого доступа? Придется либо предоставлять API на границе пользовательского элемента управления, чтобы присоединять объекты из возможного контейнера, либо предоставлять текстовые поля открыто — ни один подход не идеален.

При использовании команд все, что потребуется сделать, это установить в качестве значения свойства Command кнопки панели инструментов команду Cut, которая задана в WPF, следующим образом:

<ToolBar DockPanel.Dock="Top" Height="25"> <Button Command="ApplicationCommands.Cut"> <Image Source="cut.png"/> </Button> </ToolBar>

Теперь можно запустить приложение и увидеть, что кнопка инструментальной панели изначально отключена. После выделения какого-либо текста в одном из текстовых полей кнопка становится включенной, и если ее нажать, текст действительно вырезается в буфер обмена. И это будет работать для любого текстового поля, расположенного в любом месте пользовательского интерфейса. Здорово, просто круто, да?

Что же здесь произошло? Класс TextBox имеет встроенную привязку команды Cut и инкапсулирует обработку буфера обмена для этой команды (а также команд Copy и Paste). Итак, каким образом команда вызывается только в текстовом поле, имеющем фокус, и каким образом текстовое поле получает сообщение, указывающее обработать команду? Здесь начинает работать перенаправленная часть перенаправленных команд.

Маршрутизация команд

Различие между перенаправленными командами и перенаправленными событиями в том, как команда перенаправляется от объекта, вызвавшего команду, в обработчик команды. В частности, скрыто используются перенаправленные события для маршрутизации сообщений между объектами, вызывающими команды, и обработчиками команд (посредством привязки команды, которая присоединяет команду к визуальному дереву).

Здесь могло быть отношение "многие-ко-многим", но в любое конкретное время фактически будет активен только один обработчик команд. Активный обработчик команд определяется исходя из комбинации сведений о том, где находятся вызывающий команду объект и обработчик команд в визуальном дереве, и о том, где находится фокус в пользовательском интерфейсе. С помощью перенаправленных событий вызывается активный обработчик команд для выяснения, следует ли включить команду, а также для вызова обработчика метода Executed обработчика команд.

Обычно объект, вызывающий команду, ищет привязку команды между собственным расположением в визуальном дереве и корнем этого визуального дерева. При ее обнаружении обработчик привязанной команды определяет, включена ли команда, и будет ли вызываться. Если команда присоединена к элементу управления в панели инструментов или в меню (или, в более общем случае, в контейнере, который устанавливает FocusManager.IsFocusScope = true), то выполняется дополнительная логика, которая также ищет привязку команды по пути визуального дерева от корня до элемента фокуса.

В простом приложении, показанном на рисунке 3, происходит следующее: поскольку кнопка команды Cut находится в панели инструментов, методы CanExecute и Execute обрабатываются экземпляром TextBox, имеющим фокус. Если бы текстовые поля на рисунке 3 содержались в пользовательском элементе управления, то имелась бы возможность установить привязку команды в окне, в содержащем элемент Grid пользовательском элементе управления, в содержащем эти текстовые поля элементе Grid или в отдельных текстовых полях. Текстовое поле, имеющее фокус, будет определять конец пути фокуса, начинающегося в корне.

В маршрутизации WPF перенаправленных команд важно понять то, что если вызывается один обработчик команд, то другие обработчики команд вызываться не будут. Поэтому если пользовательский элемент управления обрабатывает метод CanExecute, то реализация CanExecute в элементе TextBox не будет вызываться.

Определение команд

Команды ApplicationCommands.Save и Application­Commands.Cut представляют две из множества команд, имеющихся в WPF. Пять встроенных классов команд в WPF с несколькими примерами содержащихся в них команд показаны на рисунке 4.

Рисунок 4. Классы команд WPF

Класс команд

Примеры команд

ApplicationCommands (команды приложений)

Close, Cut, Copy, Paste, Save, Print

NavigationCommands (команды навигации)

BrowseForward, BrowseBack, Zoom, Search

EditingCommands (команды редактирования)

AlignXXX, MoveXXX, SelectXXX

MediaCommands (команды воспроизведения)

Play, Pause, NextTrack, IncreaseVolume, Record, Stop

ComponentCommands (команды компонентов)

MoveXXX, SelectXXX, ScrollXXX, ExtendSelectionXXX

XXX обозначает ряд операций, например Move­Next and MovePrevious. Команды в каждом классе определены как открытые статические (Shared (общие) в Visual Basic®) свойства, поэтому их можно легко присоединить. Используя этот же подход, нетрудно определить собственные настраиваемые команды. Немного позже я покажу пример.

Эти команды можно также использовать в сокращенном варианте написания, например:

<Button Command="Save">Save</Button>

При использовании сокращенной версии преобразователь типов в WPF будет пытаться найти указанную команду в коллекции встроенных команд. В этом случае итоговый результат будет тот же. Я предпочитаю использовать полное написание, чтобы сделать код более понятным и более простым в обслуживании. Если команда уже определена, то неоднозначность не возникнет. Даже встроенные команды в некоторой степени дублируются в классах EditingCommands и ComponentCommands.

Подключение команд

Перенаправленные команды имеют особую реализацию интерфейса I­Command, заданного WPF. ICommand имеет следующее определение:

public interface ICommand { event EventHandler CanExecuteChanged; bool CanExecute(object parameter); void Execute(object parameter); }

Встроенные типы команд WPF — Routed­Command и RoutedUICommand. Оба этих класса реализуют интерфейс I­Command и используют перенаправленные события, которые рассматривались выше, для выполнения маршрутизации.

Предполагается, что вызывающий команду объект вызывает CanExecute, чтобы определить, должен ли включаться или отключаться любой код вызова, связанный с командой. Вызывающий команду объект может определить, когда вызывать этот метод, подписавшись на событие CanExecuteChanged. В классе RoutedCommand событие CanExecuteChanged возникает на основе изменений состояния или фокуса в пользовательском интерфейсе. При вызове команды вызывается метод Executed и отправляется в обработчик посредством перенаправленного события по визуальному дереву.

Классы, поддерживающие свойство Command, например класс Button­Base, реализуют интерфейс ICommandSource:

public interface ICommandSource { ICommand Command { get; } object CommandParameter { get; } IInputElement CommandTarget { get; } }

Свойство Command связывает вызывающий объект с вызываемой командой. CommandParameter позволяет вызывающему объекту передавать некоторые данные наряду с вызовом команды. Свойство Command­Target позволяет переопределить маршрутизацию по умолчанию на основе пути фокуса и указать командной системе использовать заданный элемент в качестве обработчика этой команды, вместо того чтобы использовать перенаправленное событие и определение обработчика команд на основе фокуса.

Задачи перенаправленных команд

Перенаправленные команды хорошо работают в сценариях простых пользовательских интерфейсов, присоединяя элементы панели инструментов и пункты меню и обрабатывая то, что наследственно соединено с фокусом ввода (например операции буфера обмена). Однако перенаправленных команд недостаточно, когда начинается разработка сложных пользовательских интерфейсов, когда логика обработки команд находится в поддерживающем коде для определений представлений, и вызывающие команды объекты не всегда находятся в панели инструментов или в меню. Это часто возникает при использовании сложных шаблонов пользовательских интерфейсов, таких как Model View Controller или MVC (msdn.microsoft.com/magazine/cc337884), Model View Presenter или MVP (msdn.microsoft.com/magazine/cc188690) и модель представления, которая в кругах WPF также называется представлением модели ViewModel (msdn.microsoft.com/library/cc707885).

При входе в эту область проблема состоит в том, что логика включения и обработки команды может не быть непосредственно частью визуального дерева, а может быть расположена в средстве представления или в модели представления. Кроме того, состояние, которое определяет, следует ли включить команду, может не иметь связи с тем местом визуального дерева, в котором находятся вызывающие команду объекты и представления. Могут также быть сценарии, в которых для конкретной команды в определенное время может быть допустимо несколько обработчиков.

Чтобы увидеть, где могут возникнуть проблемы с перенаправленными командами, изучите рисунок 5. Это простое окно, содержащее два пользовательских элемента управления, которые отражают представления в шаблоне MVP или MVC. В главном окне имеется меню File и панель инструментов с кнопкой команды Save. Вверху главного окна также имеется текстовое поле для ввода и кнопка, в качестве значения свойства Command которой установлена команда Save.

Рисунок 5. Сложный пользовательский интерфейс (нажмите для просмотра увеличенного изображения)

Совет. Присоединение анонимных методов

В коде, показанном на рисунке 6, я использовал прием, взятый у моего коллеги Юваля Лоуи (Juval Lowy) — присоединение пустых анонимных методов к делегату в его объявлении:

Action<string> m_ExecuteTargets = delegate { };

При использовании такого приема не потребуется проверять на значения NULL перед вызовом делегата, поскольку в его списке вызова всегда будет один пустой подписчик. Кроме того, таким образом исключается потенциальное состояние гонки с отменой подписки в многопотоковой среде, могущее возникнуть при использовании проверок на значения NULL.

Дополнительные сведения об этом приеме см. в статье Юваля Лоуи (Juval Lowy) "Programming .NET Components, Second Edition" ("Программирование компонентов .NET, вторая редакция").

Остальную часть пользовательского интерфейса составляют два представления, каждое из которых является экземпляром простого пользовательского элемента управления. Цвет границы устанавливается по отдельности для каждого экземпляра пользовательского элемента управления, чтобы проще было увидеть, какую часть пользовательского интерфейса представляет каждый из них. В каждом экземпляре пользовательского элемента управления имеется кнопка Save, в качестве значения свойства Command которой установлена команда Save.

В этом простом примере становится очевидной задача привязки перенаправленных команд к расположению в визуальном дереве. На рисунке 5 само окно не имеет привязки CommandBinding для команды Save. Однако оно содержит два вызывающих объекта (меню и панель инструментов) для этой команды. В такой ситуации я не хочу, чтобы окно верхнего уровня знало, что делать при вызове команды. Я хочу оставить обработку команды дочерним представлениям, которые задаются пользовательскими элементами управления. Класс пользовательского элемента управления в этом примере имеет привязку CommandBinding для команды Save, и эта привязка возвращает значение true для метода CanExecute.

Однако на рисунке 5 можно видеть, что когда фокус находится на уровне окна в текстовом поле вверху, вызывающие команду объекты на этом уровне отключены. В то же время кнопки Save в пользовательских элементах управления включены, даже если фокус не находится внутри этих элементов управления.

Если фокус перемещается в одно из текстовых полей в одном из экземпляров пользовательских элементов управления, то вызывающие команду объекты в меню и в панели инструментов становятся включенными. Но кнопка Save в самом окне не становится включенной. Фактически в данном случае нет способа включить кнопку Save, расположенную рядом с текстовым полем вверху окна, с помощью обычной маршрутизации.

Причина опять связана с расположением отдельных элементов управления. Если отсутствуют обработчики команд на уровне окна, то когда фокус находится вне пользовательских элементов управления, нет обработчика команд для визуального дерева или в пути фокуса для включения элементов управления, которые присоединены в качестве вызывающих команды объектов. Следовательно, команда отключена по умолчанию, когда речь идет об этих элементах управления. Однако для вызывающих команду объектов, находящихся в этих элементах управления, команда включена, поскольку обработчик доходит до визуального дерева от них.

При переключении фокуса в текстовое поле в одном из пользовательских элементов управления этот элемент управления, находящийся между окном и текстовым полем в пути фокуса, предоставляет обработчик команд, который включает команду для панели инструментов или меню, поскольку они проверяют путь фокуса, а также путь от их расположения в визуальном дереве до корня. И нет способа включить кнопку на уровне окна, потому что она никогда не получит обработчик между ней и окном.

Если пытаться выполнить всю эту тарабарщину с визуальным деревом и путем фокуса, то можно повредить голову даже в этом простом маленьком примере. Только представьте, что придется выдержать, продумывая включение и вызов команд для серьезно усложненного пользовательского интерфейса с вызывающими команды объектами и обработчиками во многих разных расположениях в визуальном дереве. Если говорить проще, то на ум приходит фильм "Сканнеры" и взрывающиеся головы.

Исключение проблем команд

Чтобы исключить потенциальные проблемы с расположением перенаправленных команд в визуальном дереве, необходимо сохранять простоту. Обычно будет требоваться обеспечить нахождение обработчиков команд в том же элементе, который будет вызывать команду, или выше по визуальному дереву от этого элемента. Один из способов состоит во включении привязки команды на уровне окна с помощью метода CommandManager.RegisterClassCommandBinding из элемента управления, участвующего в обработчике команд.

Исключением является ситуация, когда реализуется настраиваемый элемент управления, который сам принимает фокус ввода (как текстовое поле). В таком случае, если требуется внедрить обработку команды в самом элементе управления, и эта обработка уместна, только когда фокус находится в элементе управления, то можно так сделать, и это будет работать, как пример с командой Cut, показанный ранее.

Можно также обойти проблемы с фокусом, явно указывая обработчик команд с помощью свойства CommandTarget. Например, для кнопки Save, находящейся на уровне окна, которая никогда не включалась на рисунке 5, можно было бы изменить ее присоединение команды следующим образом:

<Button Command="Save" CommandTarget="{Binding ElementName=uc1}" Width="75" Height="25">Save</Button>

В этом коде Button специально устанавливает свое свойство CommandTarget в экземпляр UIElement, который имеет обработчик команд. В этом случае задается элемент с именем uc1, который оказывается одним из двух экземпляров пользовательского элемента управления в примере. Поскольку этот элемент имеет обработчик команд, который всегда возвращает CanExecute = true, кнопка Save на уровне окна становится всегда включенной и будет вызывать только обработчик команд этого элемента управления, независимо от того, относится ли вызывающий объект к этому обработчику команд.

Выход за пределы перенаправленных команд

В результате ограничений перенаправленных команд многие компании, создающие сложные пользовательские интерфейсы с помощью WPF, начали использовать настраиваемые реализации ICommand, которые позволяют предоставлять собственные механизмы маршрутизации, в частности те, которые не связаны с визуальным деревом и могут поддерживать несколько обработчиков команд.

Создавать настраиваемые реализации команд нетрудно. Интерфейс ICommand реализуется в классе, предоставляя способ присоединения для обработчиков команд, а затем выполняя маршрутизацию при вызове команды. Можно также решить, какие условия будут использоваться для определения, когда должно возникать событие CanExecuteChanged.

Использование делегатов представляет хорошую отправную точку для создания настраиваемой команды. Делегат уже поддерживает вызов целевого метода, а также он поддерживает несколько подписчиков.

На рисунке 6 показан класс настраиваемых команд с именем StringDelegateCommand, который использует делегатов, чтобы позволить присоединение нескольких обработчиков. Он поддерживает передачу строкового аргумента в обработчики, а также будет использовать CommandParameter вызывающего объекта для определения, какое сообщение передается в обработчики.

Рисунок 6. Класс настраиваемых команд

public class StringDelegateCommand : ICommand 

{ 

   Action<string> m_ExecuteTargets = delegate { };  
   Func<bool> m_CanExecuteTargets = delegate { return false; };  
   bool m_Enabled = false; 

   public bool CanExecute(object parameter) 

   { 

       Delegate[] targets = m_CanExecuteTargets.GetInvocationList(); foreach (Func<bool> target in targets) 

       {

            m_Enabled = false; bool localenable = target.Invoke(); if (localenable) 

           {

                m_Enabled = true; break; 

           } 

       }

   return m_Enabled; 

   } 

   public void Execute(object parameter) 

   { 

      if (m_Enabled) m_ExecuteTargets(parameter != null ? parameter.ToString() : null); 

   } 

   public event EventHandler CanExecuteChanged = delegate { }; ... 

}

Можно видеть, что я выбрал использование делегата Func<bool> для присоединения обработчиков, которые будут определять, включена команда или нет. В реализации CanExecute класс выполняет циклический проход по обработчикам, присоединенным в делегат m_CanExecuteTargets, и смотрит, хочет ли выполняться каждый обработчик. Если да, то возвращает значение true для включения StringDelegateCommand. Когда вызывается метод Execute, он просто выясняет, включена ли команда, и если включена, вызывает все обработчики, присоединенные к делегату m_ExecuteTargets Action<string>.

Для присоединения обработчиков к методам CanExecute и Execute класс StringDelegateCommand предоставляет методы доступа к событиям, показанные на рисунке 7, чтобы позволить обработчикам просто подписываться или отменять подписку на базовые делегаты. Обратите внимание, что метод доступа к событиям также предоставляет возможность активации события CanExecuteChanged всякий раз, когда обработчик подписывается или отменяет подписку.

Рисунок 7. Методы доступа к событиям команд

public event Action<string> ExecuteTargets 

{ 

  add { m_ExecuteTargets += value; } 
  remove { m_ExecuteTargets -= value; }

} 

public event Func<bool> CanExecuteTargets 

{ 

  add { m_CanExecuteTargets += value; CanExecuteChanged(this, EventArgs.Empty); } 
  remove { m_CanExecuteTargets -= value; CanExecuteChanged(this, EventArgs.Empty); } 

}

Пример перенаправленного обработчика

Я получил этот класс присоединенным в демонстрационном приложении в загрузке кода. В этом примере имеется простое представление с фоновым средством представления (вместе со строками MVP, но без модели). Средство представления предлагает модель представления представлению для привязки данных (можно считать модель представления находящейся между средством представления и представлением, в то время как модель MVP находится за средством представления из представления). Модель представления обычно предоставляет свойства, к которым представление может привязать данные. В этом случае она предоставляет только одно свойство для команды, поэтому можно легко присоединить ее в XAML представления с помощью привязки данных:

<Window x:Class="CustomCommandsDemo.SimpleView" ...> <Grid> <Button Command="{Binding CookDinnerCommand}" CommandParameter="Dinner is served!" ...>Cook Dinner</Button> <Button Click="OnAddHandler" ...>Add Cook Dinner Handler</Button> </Grid> </Window>

Оператор Binding будет просто искать свойство в текущем DataContext с именем CookDinnerCommand и при обнаружении пытаться привести его к ICommand. Ранее уже упоминавшийся CommandParameter является способом, которым вызывающий объект передает некоторые данные наряду с командой. Обратите внимание, что в этом случае я просто передаю строку, которая будет передана в обработчики посредством String­DelegateCommand.

Ниже показан код программной части представления (класс Window).

public partial class SimpleView : Window { SimpleViewPresenter m_Presenter = new SimpleViewPresenter(); public SimpleView() { InitializeComponent(); DataContext = m_Presenter.Model; } private void OnAddHandler(object sender, RoutedEventArgs e) { m_Presenter.AddCommandHandler(); } }

Представление создает свое средство представления, получает от него модель представления и устанавливает ее в качестве DataContext. Оно также имеет обработчик нажатия кнопки, который обращается в средство представления, прося добавить обработчик для команды.

Составные события и команды

В этом году я работал с группой шаблонов и методик Майкрософт, помогая разрабатывать руководство по составным приложениям для WPF, которое представляет ряд рекомендаций по разработке сложных составных приложений в WPF. Этот набор рекомендаций содержит библиотеки, называемые библиотеками составных приложений (Composite Application Libraries — CAL), предлагающие службы и вспомогательные классы для составных приложений.

Дополнительные сведения о руководстве по составным приложениям для WPF можно прочитать в статье Гленна Блока (Glenn Block) "Patterns for Building Composite Applications with WPF" ("Шаблоны для разработки составных приложений в WPF") по ссылке msdn.microsoft.com/magazine/cc785479.

На рисунке 8 показано это приложение в работе. Первое окно — это начальное состояние без присоединенных обработчиков команд. Можно видеть, что первая кнопка (вызывающий объект) отключена, поскольку нет обработчиков команд. Затем, при нажатии второй кнопки, она обращается в средство представления и присоединяет новый обработчик команд. После этого включается первая кнопка, и при ее нажатии она вызывает обработчик команд, с которым она слабо связана посредством привязки данных, и список подписчиков базовой команды.

Рисунок 8. Пример настраиваемой команды в действии

Код средства представления показан на рисунке 9. Можно видеть, что средство представления создает модель представления и предлагает ее представлению посредством свойства Model. Когда из представления вызывается AddCommandHandler (в ответ на событие нажатия второй кнопки), он добавляет подписчика в события CanExecuteTargets и Execute­Targets модели. Эти методы подписки являются простыми методами, расположенными в средстве представления, возвращающими значение true и отображающими MessageBox соответственно.

Рисунок 9. Класс средства представления

public class SimpleViewPresenter 

{ 

   public SimpleViewPresenter() 

   { 

        Model = new SimpleViewPresentationModel(); 

    } 

   public SimpleViewPresentationModel Model 

   { 

        get; set; 

   } 

   public void AddCommandHandler() 

   { 

        Model.CookDinnerCommand.CanExecuteTargets += CanExecuteHandler; 
        Model.CookDinnerCommand.ExecuteTargets += ExecuteHandler; 

   } 

   bool CanExecuteHandler() 

  { 

        return true; 

   } 

       void ExecuteHandler(string msg) 

   { 

       MessageBox.Show(msg); 
 
   } 

}

В этом примере показывается, что объединение привязки данных, шаблонов пользовательского интерфейса и настраиваемых команд может дать ясный независимый подход для команд без связи с ограничениями присоединенных команд. Поскольку команда присоединяется в XAML посредством привязки, можно даже расширить этот подход, задавая представления только в XAML (без кода программной части), используя связанные команды из XAML для активации действий в модели представления и инициируя действие, которое обычно следует делать в коде программной части, из модели представления.

Потребуется контроллер для создания представлений и предоставления им модели представления, но у вас будет возможность создания интерактивных представлений без необходимости кода программной части. Если нет необходимости в коде программной части, то существует гораздо меньше возможностей добавления тесно связанного и нетестируемого "макаронного" кода, что так часто происходит в приложениях пользовательского интерфейса. Этот подход — только начало того, что подлежит изучению в WPF. Но его несомненно следует учитывать, и необходимо искать дополнительные примеры.

Брайан Нойес (Brian Noyes) является главным архитектором IDesign (www.idesign.net), региональным директором Майкрософт (www.theregion.com) и имеет статус Microsoft MVP. Он является автором руководств "Developing Applications with Windows Workflow Foundation" ("Разработка приложений с помощью Windows Workflow Foundation"), "Smart Client Deployment with ClickOnce" ("Развертывание интеллектуальных клиентов с помощью ClickOnce") и "Data Binding with Windows Forms 2.0" ("Привязка данных в Windows Forms 2.0"). Он также часто выступает на отраслевых конференциях по всему миру. С Брайаном можно связаться в его блоге по адресу briannoyes.net.