суббота, 19 ноября 2011 г.

Централизованное управление командами в WPF

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

Команды в WinForms

Впервые столкнувшись с WinForms, я был неприятно удивлен отсутствием аналогичных возможностей по работе с командами. Вместо этого требовалось вручную связывать каждый обработчик с соответствующим интерфейсным элементом – кнопкой или пунктом меню. Для обновления доступности интерфейсных элементов  в зависимости от состояния программы нет вообще никакой инфраструктуры.Такой подход даже удобен для небольших программок – когда не хочется городить большой огород ради трех кнопок. Но для сложных программных продуктов, которые постоянно изменяются и развиваются, необходимость связывать обработчики непосредственно с интерфейсными элементами ужасно мешает! Добавление дублирующего кнопку пункта меню или поиск всех обработчиков нажатия кнопки может стать проблемой.

Команды в WPF

В WPF я удовольствием снова обнаружил возможность объявлять команды централизовано и независимо от интерфейсных элементов. Команда в WPF – это экземпляр класса RoutedUICommand. К каждому экземпляру команды можно присоединить обработчики выполнения команды или проверки её доступности. В процессе выполнения программы можно легко и просто создавать новые команды – это существенное отличие от возможностей MFC.

Мне не понравились два момента –

  1. Необходимость вручную (в коде или XAML) добавлять привязки к командам в CommandBindings. Т.е. для каждой необходимой мне команды я должен написать процедуру создания, методы-обработчики и добавление в коллекцию привязок. В случае MFC всем этим занимался ClassWizard, а здесь все приходится делать руками – это увеличивает простор для ошибок.
  2. Обработчик команды прозрачно и легко (через CommandBinding) добавляется только в интерфейсные элементы. Имеющаяся инфраструктура плохо пригодна для добавления обработчиков команд, например, в класс приложения. Хотя для многих команд это более естественно и логично, чем обработка в интерфейсных объектах.

Что получилось

Когда-то я уже реализовывал концепцию команд для Windows Forms. Теперь я с энтузиазмом принялся сочинять расширение для модели команд WPF. В итоге у меня получились несколько классов + правила их применения, которые позволяют:

  1. Объявить все нужные мне команды в виде перечисления. Текстовое значение элемента перечисления я использую в качестве имени команды, эта же строка используется в качестве идентификатора строкового ресурса, содержащего описание команды (см. пост про управление строками).
  2. Размещать обработчики команд я разместил в нужных мне классах (в моем случае это были класс приложения, страницы и некоторые элементах управления). Связь между командой и её обработчиком декларируется с помощью атрибутов метода-обработчика.


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

Технические детали

Создание команд

А теперь несколько слов о том, как именно это было реализовано.

Как я уже говорил, все команды приложения оформлены в виде перечисления
(в пример я включил не все команды):

public enum CommandName
{
    Activate,
    NewWorker,
    NewProject,
    NewWork,
    NewMilestone
}

Для создания экземпляров команд и управления ими предназначен класс Commands. В статическом конструкторе этого класса для каждого элемента перечисления создается команда и добавляется в словарь. После этого к командам добавляются горячие клавиши.



//словарь команд
static Dictionary<Names, RoutedUICommand> _commands = new Dictionary<Names, RoutedUICommand>(); 
 
//статический конструктор
static Commands ()
{    
    Enum.GetValues(typeof(CommandName)).Cast<CommandName>()        
        .ToList()        
        .ForEach(x => _commands.Add(x, CreateCommand(x)));
 
    AddHotKeys();
}

CreateCommand – фабричный метод для создания команды для каждого элемента перечисления:



private static RoutedUICommand CreateCommand(CommandName cmdName)
{
    string cmdString = cmdName.ToString();
    return new RoutedUICommand(App.RString(cmdString), cmdString, typeof(Commands));
}

Для получения перечисления всех созданных команд в классе Commands есть метод GetAllCommands



public static IEnumerable<RoutedUICommand> GetAllCommands()
{
    return _commands.Values;
}

Вызов обработчиков команд

Для декларации связи метода-обработчика с командой создан я объявил атрибут:



[System.AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
class CommandHandlerAttribute : System.Attribute
{
   Type _myType;
   string _name;
 
   public CommandHandlerAttribute(CommandName cmdn)
       : this(typeof(Commands), cmdn.ToString())
   {}
 
 
   private CommandHandlerAttribute(Type tType, string name)
   {
       _name = name;
       _myType = tType;
   }
 
   public bool IsMyCommand(object cmd)
   {
       RoutedUICommand rc = cmd as RoutedUICommand;
       if (rc == null)
           return false;
 
       return (_name == rc.Name && _myType.Equals(rc.OwnerType));
   }
}

В конструктор атрибута передается элемент перечисления, соответствующий команде. Этот атрибут предназначен для обозначения методов в классе, которые нужно вызвать для обработки команды. Метод без возвращаемого значения – для обработки команды; метод, возвращающий bool – для определения доступности команды.



[CommandHandler(CommandName.NewProject)]
public void NewProject(object Param)
{
...
}
 
[CommandHandler(CommandName.NewProject)]
[CommandHandler(CommandName.NewWorker)]
public bool CanCreateNew(object Param)
{
    ...
}

Возникает закономерный вопрос: а кто же вызовет эти методы? А вызовет эти методы экземпляр класса для маршрутизации команд CommandsRouter, который должен быть создан в каждом классе, использующем для своих методов атрибут CommandHandler. Приведу пример его создания в классе страницы:



private CommandsRouter _cmdRouter;
 
internal CommandsRouter CmdRouter
{
 get
 {
     return _cmdRouter ?? (_cmdRouter = new CommandsRouter(this, App.Me.CmdRouter));
 }
}

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


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



//связываем команды приложения c обработчиками
CommandBindings.AddRange(Commands.GetAllCommands().Select(x => CmdRouter.CreateBinding(x)).ToList());

Все привязки к командам конструирует маршрутизатор команд, подставляя свои обработчики для выполнения и обработки доступности команд:



public CommandBinding CreateBinding(RoutedUICommand rc)
{
    return new CommandBinding(rc, CommandExecuted, CanExecuteCommand);
}
 
private void CommandExecuted(object sender, ExecutedRoutedEventArgs e)
{
    ExecuteCommand(e.Command, e.Parameter);
}
 
private void CanExecuteCommand(object sender, CanExecuteRoutedEventArgs e)
{
    e.CanExecute = CanExecuteCommand(e.Command, e.Parameter);
}

Заключение


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



  1. Добавить необходимый элемент перечисления
  2. Добавить в ресурсы строку с описанием команды
  3. Добавить методы обработки доступности и выполнения команды.

И все!


P.S.


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

5 комментариев:

  1. Привет.
    как реализован CommandsRouter?

    ОтветитьУдалить
  2. Доброго дня.
    CommandsRouter просто ищет среди методов объекта подходящие по сигнатуре и помеченные атрибутами, а затем вызывает их через Reflection.

    private void ExecuteCommand(ICommand cmd, object Parameter)
    {
    ///ищем void метод помеченный атрибутом
    var method = _me.GetType().GetMethods().FirstOrDefault(m => m
    .GetCustomAttributes(typeof(CommandHandlerAttribute), true)
    .Cast()
    .Any(a => a.IsMyCommand(cmd)) && m.ReturnType.Equals(typeof(void)));

    if (method == null)
    {
    if (_other == null)
    throw new NotSupportedException();

    _other.ExecuteCommand(cmd, Parameter);

    return;
    }

    method.Invoke(_me, new object[] { Parameter });
    }

    ОтветитьУдалить
  3. Очень интересная идея, но многие детали к сожалению не раскрыты. Было бы интересно увидеть пример целиком.
    В частности интересно каким способом происходит привязка конкретной команды к контролу в xaml'е (конвертер, аттачед проперти или ещё что то). Так же возникает вопрос - перечисление с командами одно на приложение или же есть возможность сделать их несколько (например для отдельных модулей).

    ОтветитьУдалить
    Ответы
    1. Для привязки команда в XAML я использовал статические свойства в классе. Например, объявил

      class MyCommands {
      public static RoutedUICommand Save;
      ...
      В классе MyCommand я завел метод Initalize(), в котором создаю все свойства методом CreateCommand.

      void Initalize()
      {
      Save = CreateCommand(CommandName.Save);
      ...
      далее создаются остальные свойства
      }

      Сначала я писал код метода вручную, потом стал использовать генерацию кода непосредственно по перечислению (с помощью шаблонов T4).

      Удалить
    2. А что касается количества перечислений с командами - я использовал одно, но это не догма. Перечисление нужно лишь как перечень именованных констант. Метод несложно доработать и для использования нескольких перечислений - нужно лишь свести названия команд в общий список, который будет обрабатываться маршрутизатором.

      Удалить