Недавно мы рассказывали о том, как можно логировать действия пользователей в WinForms приложениях: Оно само упало, или следствие ведут колобки. Но что делать, если у вас WPF? Да нет проблем, и в WPF есть жизнь!



В WPF не надо будет вешать никаких хуков и трогать страшный винапи, собственно за пределы WPF мы и не выйдем. Для начала вспомним, что у нас есть routed events, и на них можно подписываться. В принципе, это все, что нам надо знать, чтобы реализовать поставленную задачу :)

Итак, что мы хотим логировать? Клавиатуру, мышку и смены фокуса. Для этого в классе UIElement есть следующие эвенты: PreviewMouseDownEvent, PreviewMouseUpEvent, PreviewKeyDownEvent, PreviewKeyUpEvent, PreviewTextInputEvent ну и Keyboard.GotKeyboardFocus и Keyboard.LostKeyboardFocus для фокуса. Теперь нам надо на них подписаться:

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    UIElement.PreviewMouseDownEvent,
    new MouseButtonEventHandler(MouseDown),
    true
);

Подписка на остальные события
EventManager.RegisterClassHandler(
    typeof(UIElement), 
    UIElement.PreviewMouseUpEvent,
    new MouseButtonEventHandler(MouseUp),
    true
);

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    UIElement.PreviewKeyDownEvent, 
    new KeyEventHandler(KeyDown), 
    true
);

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    UIElement.PreviewKeyUpEvent, 
    new KeyEventHandler(KeyUp), 
    true
);

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    UIElement.PreviewTextInputEvent, 
    new TextCompositionEventHandler(TextInput), 
    true
);

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    Keyboard.GotKeyboardFocusEvent, 
    new KeyboardFocusChangedEventHandler(OnKeyboardFocusChanged), 
    true
);

EventManager.RegisterClassHandler(
    typeof(UIElement), 
    Keyboard.LostKeyboardFocusEvent, 
    new KeyboardFocusChangedEventHandler(OnKeyboardFocusChanged), 
    true
);

Теперь главное, это написать обработчики всех этих эвентов, собрать на них данные о том, какую кнопку нажали, у кого, сколько раз… фу, скука. Вот, давайте лучше на котика посмотрим:

image

Ну а если вам очень хочется посмотреть код, то это можно сделать, раскрыв блок ниже

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

Dictionary<string, string> CollectCommonProperties(FrameworkElement source) {
    Dictionary<string, string> properties = new Dictionary<string, string>();
    properties["Name"] = source.Name;
    properties["ClassName"] = source.GetType().ToString();
    return properties;
}

Свойство Name появляется у нас во FrameworkElement, так что как source принимаем объект этого типа.

Теперь обработаем мышиные эвенты, в них мы соберем информацию о том, какую клавишу нажали и был ли это дабл клик или нет:

void MouseDown(object sender, MouseButtonEventArgs e) {
    FrameworkElement source = sender as FrameworkElement;
    if(source == null)
        return;

    var properties = CollectCommonProperties(source);
    LogMouse(properties, e, isUp: false);
}

void MouseUp(object sender, MouseButtonEventArgs e) {
    FrameworkElement source = sender as FrameworkElement;
    if(source == null)
        return;

    var properties = CollectCommonProperties(source);
    LogMouse(properties, e, isUp: true);
}

void LogMouse(IDictionary<string, string> properties, 
              MouseButtonEventArgs e, 
              bool isUp) {
    properties["mouseButton"] = e.ChangedButton.ToString();
    properties["ClickCount"] = e.ClickCount.ToString();
    Breadcrumb item = new Breadcrumb();
    if(e.ClickCount == 2) {
        properties["action"] = "doubleClick";
        item.Event = BreadcrumbEvent.MouseDoubleClick;
    } else if(isUp) {
        properties["action"] = "up";
        item.Event = BreadcrumbEvent.MouseUp;
    } else {
        properties["action"] = "down";
        item.Event = BreadcrumbEvent.MouseDown;
    }
    item.CustomData = properties;

    AddBreadcrumb(item);
}

В клавиатурных эвентах будем собирать Key. Однако, нам не хочется случайно утянуть вводимые пароли, поэтому хотелось бы понимать куда происходит ввод, чтобы заменять значение Key на Key.Multiply в случае ввода пароля. Узнать это мы можем при помощи AutomationPeer.IsPassword метода. И еще нюанс, не имеет смысла производить подобную замену при нажатии навигационных клавиш, ибо они точно не могут являться частью пароля, но могут быть отправной точкой для каких-либо иных действий. Например, смены фокуса по нажатию на Tab. В результате получаем следующее:

void KeyDown(object sender, KeyEventArgs e) {
    FrameworkElement source = sender as FrameworkElement;
    if(source == null)
        return;

    var properties = CollectCommonProperties(source);
    LogKeyboard(properties, e.Key, 
                isUp: false, 
                isPassword: CheckPasswordElement(e.OriginalSource as UIElement));
}

void KeyUp(object sender, KeyEventArgs e) {
    FrameworkElement source = sender as FrameworkElement;
    if(source == null)
        return;

    var properties = CollectCommonProperties(source);
    LogKeyboard(properties, e.Key, 
                isUp: true,
                isPassword: CheckPasswordElement(e.OriginalSource as UIElement));
}

void LogKeyboard(IDictionary<string, string> properties,
                 Key key,
                 bool isUp,
                 bool isPassword) {
    properties["key"] = GetKeyValue(key, isPassword).ToString();
    properties["action"] = isUp ? "up" : "down";

    Breadcrumb item = new Breadcrumb();
    item.Event = isUp ? BreadcrumbEvent.KeyUp : BreadcrumbEvent.KeyDown;
    item.CustomData = properties;

    AddBreadcrumb(item);
}

Key GetKeyValue(Key key, bool isPassword) {
    if(!isPassword)
        return key;

    switch(key) {
        case Key.Tab:
        case Key.Left:
        case Key.Right:
        case Key.Up:
        case Key.Down:
        case Key.PageUp:
        case Key.PageDown:
        case Key.LeftCtrl:
        case Key.RightCtrl:
        case Key.LeftShift:
        case Key.RightShift:
        case Key.Enter:
        case Key.Home:
        case Key.End:
            return key;

        default:
            return Key.Multiply;
    }
}

bool CheckPasswordElement(UIElement targetElement) {
    if(targetElement != null) {
        AutomationPeer automationPeer = GetAutomationPeer(targetElement);
        return (automationPeer != null) ? automationPeer.IsPassword() : false;
    }
    return false;
}

Перейдем к TextInput. Тут, в принципе, все просто, собираем введенный текст и не забываем про пароли:

void TextInput(object sender, TextCompositionEventArgs e) {
    FrameworkElement source = sender as FrameworkElement;
    if(source == null)
        return;

    var properties = CollectCommonProperties(source);
    LogTextInput(properties,
                 e,
                 CheckPasswordElement(e.OriginalSource as UIElement));
}

void LogTextInput(IDictionary<string, string> properties,
                  TextCompositionEventArgs e,
                  bool isPassword) {
    properties["text"] = isPassword ? "*" : e.Text;
    properties["action"] = "press";

    Breadcrumb item = new Breadcrumb();
    item.Event = BreadcrumbEvent.KeyPress;
    item.CustomData = properties;

    AddBreadcrumb(item);
}

Ну и, наконец, остался фокус:

void OnKeyboardFocusChanged(object sender, KeyboardFocusChangedEventArgs e) {
    FrameworkElement oldFocus = e.OldFocus as FrameworkElement;
    if(oldFocus != null) {
        var properties = CollectCommonProperties(oldFocus);
        LogFocus(properties, isGotFocus: false);
    }
    
    FrameworkElement newFocus = e.NewFocus as FrameworkElement;
    if(newFocus != null) {
        var properties = CollectCommonProperties(newFocus);
        LogFocus(properties, isGotFocus: true);
    }
}

void LogFocus(IDictionary<string, string> properties, bool isGotFocus) {
    Breadcrumb item = new Breadcrumb();
    item.Event = isGotFocus ? BreadcrumbEvent.GotFocus :
                              BreadcrumbEvent.LostFocus;
    item.CustomData = properties;

    AddBreadcrumb(item);
}

Обработчики готовы, пора тестить. Сделаем для этого простенькое приложение, добавим в него Logify и вперед:



Запустим его, введем q в текстовое поле и уроним приложение, нажав на Throw Exception и посмотрим, что же у нас собралось. Там получился страх и ужас, поэтому убрал под спойлер. Если точно хотите на это взглянуть, кликайте ниже:

Очень большой лог

Ээээ… Я думаю вы подумали как-то так:

image

Я именно так и подумал :)

Давайте разбираться, что у нас не так, и почему получилась такая портянка непонятных сообщений.

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

IInputElement FocusedElement { get; set; }

void OnKeyboardFocusChanged(object sender, KeyboardFocusChangedEventArgs e) {
    if(FocusedElement != e.NewFocus) {
        FrameworkElement oldFocus = FocusedElement as FrameworkElement;
        if(oldFocus != null) {
            var properties = CollectCommonProperties(oldFocus);
            LogFocus(properties, false);
        }
        
        FrameworkElement newFocus = e.NewFocus as FrameworkElement;
        if(newFocus != null) {
            var properties = CollectCommonProperties(newFocus);
            LogFocus(properties, true);
        }

        FocusedElement = e.NewFocus;
    }
}

Посмотрим, что получилось:



Вот, гораздо красивее :)

Теперь мы видим, что у нас оооочень много логов на один и тот же эвент, так как routed эвенты идут по дереву элементов, и каждый из них оповещает нас. Дерево элементов у нас небольшое, а каши в логах уже предостаточно. Что же будет на реальном приложении? Даже боюсь подумать. Отбрасывать все эти логи, кроме первого или последнего, мы явно не можем. Если у вас достаточно большое визуальное дерево, то вряд ли вам что-то скажут сообщения о том, что кликнули в Window, или же в TextBox, особенно при отсутствии имен у элементов. Но в наших силах сократить этот список, чтобы его было удобно читать и при этом понимать, в каком именно месте произошло событие.

Мы подписались на эвенты у UIElement, но, по сути, сообщениями от большой части его наследников мы можем пренебречь. Например, вряд ли нам интересно уведомление о нажатии клавиши от Border или TextBlock. Эти элементы в большинстве своем не принимают участия в действиях. Как мне кажется, золотой серединой будет подписаться на эвенты у Control.

EventManager.RegisterClassHandler(
    typeof(Control), 
    UIElement.PreviewMouseDownEvent, 
    new MouseButtonEventHandler(MouseDown), 
    true
);

Другие события
EventManager.RegisterClassHandler(
    typeof(Control), 
    UIElement.PreviewMouseUpEvent, 
    new MouseButtonEventHandler(MouseUp), 
    true
);

EventManager.RegisterClassHandler(
    typeof(Control), 
    UIElement.PreviewKeyDownEvent, 
    new KeyEventHandler(KeyDown), 
    true
);

EventManager.RegisterClassHandler(
    typeof(Control), 
    UIElement.PreviewKeyUpEvent, 
    new KeyEventHandler(KeyUp), 
    true
);

EventManager.RegisterClassHandler(
    typeof(Control), 
    UIElement.PreviewTextInputEvent, 
    new TextCompositionEventHandler(TextInput), 
    true
);

EventManager.RegisterClassHandler(
    typeof(Control), 
    Keyboard.GotKeyboardFocusEvent, 
    new KeyboardFocusChangedEventHandler(OnKeyboardFocusChanged), 
    true
);

EventManager.RegisterClassHandler(
    typeof(Control), 
    Keyboard.LostKeyboardFocusEvent, 
    new KeyboardFocusChangedEventHandler(OnKeyboardFocusChanged), 
    true
);

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



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

Комментарии (18)


  1. evgenicx
    28.11.2017 15:41

    А InputManager почему не подошел? С ним вроде бы попроще все было


    1. byDesign Автор
      28.11.2017 16:17

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


  1. XopHeT
    28.11.2017 15:47

    Отбрасывать все эти логи, кроме первого или последнего, мы явно не можем. Если у вас достаточно большое визуальное дерево, то вряд ли вам что-то скажут сообщения о том, что кликнули в Window, или же в TextBox, особенно при отсутствии имен у элементов.

    Могут ли контролы содержать пустое имя? (на сколько помню в Delphi нельзя было использовать контролы без имени, или добавить несколько контролов с одинаковым именем в контейнер).

    Если в c# так же, то можно отфильтровать все события нажатия и в лог добавить одно, указав источник события и полный путь в приложении.

    Итого вместо 3х событий KeyDown источниками которых являются TextBox1, Part_ContentHost, MainWindow получим одно событие, со следующим содержанием

    KeyDown «Кнопка» down from TextBox1 (System.Windows.Controls.Textbox) path: MainWindow.Part_ContentHost.TextBox1

    Поскольку у нас есть полный путь до контрола, который содержал фокус в момент нажатия кнопок, мы всегда сможем найти виноватого.


    1. byDesign Автор
      28.11.2017 16:15

      В .NET имен может не быть. Мало того, в WPF при MVVM их не будет в 99% случаев. Ну и в общем случае вложенность по визуальному дереву может быть огромная, так что path получится на несколько строк. А оно надо?


      1. XopHeT
        28.11.2017 16:25
        +1

        Ну и в общем случае вложенность по визуальному дереву может быть огромная, так что path получится на несколько строк. А оно надо?

        В текущем случае вы получите гораздо больше строк лога так что аргумент спорный.

        В .NET имен может не быть. Мало того, в WPF при MVVM их не будет в 99% случаев.

        Понял, спасибо. Вопрос снимается, как неактуальный.


        1. byDesign Автор
          28.11.2017 17:21

          В текущем случае вы получите гораздо больше строк лога так что аргумент спорный.

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


          1. XopHeT
            28.11.2017 17:30
            +2

            Спасибо за ответ.
            Буду ждать следующей статьи!


        1. NekoSin4eG
          28.11.2017 17:22

          Мало того, в WPF при MVVM их не будет в 99% случаев

          А как же Caliburn.Micro?


          1. Mirimon
            28.11.2017 17:29

            А как же DevExpress MVVM Framework? Да и вагон и маленькая тележка других фреймворколв туда же.


          1. Shersh
            28.11.2017 20:08

            А что с ним? Там необязательно, чтобы у контрола было имя.


  1. ad1Dima
    29.11.2017 09:10

    Откройте для себя свойство OriginalSource тогда не придется создавать свои поля. Просто проверяете сравниваете sender и OriginalSource


    1. Mirimon
      29.11.2017 10:10
      +1

      В OriginalSource будут лежать внутренние потроха контрола, которые нам скорее всего ничего не скажут. Для примера, если посмотреть OriginalSource у PreviewMouseDown эвента кнопки, то там будет либо Border, либо TextBlock (в зависимости от того, где клик произойдет). А если еще учесть, что мы подписаны на эвенты у Control, то становится понятно, что в данном случае никогда sender не будет равен OriginalSource, так как Border и TextBlock не являются его наследниками, а значит они нам эвент не пришлют.


      1. ad1Dima
        29.11.2017 10:47

        Но focused генерируют только focusable элементы. По умолчанию — наследники Control.


        1. Mirimon
          29.11.2017 11:29

          Да с фокусами на сервере и так нет проблемы с выбором какой показывать, так как клиент уже отбивает все ненужные.


        1. Mirimon
          29.11.2017 11:52

          Я извиняюсь за, возможно, ответы немного невпопад. Дело в том, что сейчас готовится еще одна статья, где как раз будет решаться задача, для которой мы когда-то думали использовать сравнение OriginalSource и sender, но не подошло, по причинам, которые я описал в первом комментарии. Вот я немного мыслями там :)

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


  1. sergey_prokofiev
    29.11.2017 11:26

    Со стороны кажется что WPF уже давно умер. Если бы ТС пошарил бы собранную в DevExpress статистику покупок(живых кастомеров, обращений в сапорт и тд) WPF-решений по сравнению с остальными, было бы очень интересно и познавательно.
    Заранее спасибо.


    1. byDesign Автор
      29.11.2017 11:39

      Ну не WinForms, конечно, но платформа очень даже живая, активности по ней много, так что нечего ее хоронить! Саппорт трафик сравним с WinForms


    1. ad1Dima
      30.11.2017 10:28

      Вы мне напомнили недавний диалог:
      — Да .Net никому не нужен, большинство программ написано на Джаве.
      — У меня на Джаве только android studio, HTML-приложений и то больше.
      — Да быть такого не может, у меня все приложения на Джаве.

      Собственно с тех пор у меня приложений на Java стало только меньше.