Собираем пользовательскую активность в WPF

    Недавно мы рассказывали о том, как можно логировать действия пользователей в 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
    );
    

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



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

    Comments 20

      +2
      А InputManager почему не подошел? С ним вроде бы попроще все было
        +1
        InputManager ловит все эвенты, а это значит, что нам придется фильтровать все ненужные, да и нужные распихивать по разным обработчикам. Как мне кажется, наш текущий подход понятнее для понимания получился.
        +2
        Отбрасывать все эти логи, кроме первого или последнего, мы явно не можем. Если у вас достаточно большое визуальное дерево, то вряд ли вам что-то скажут сообщения о том, что кликнули в Window, или же в TextBox, особенно при отсутствии имен у элементов.

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

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

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

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

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

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

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

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

              Идея в том, что на клиентах мы максимально просто собираем сырые данные с разных платформ (Win, Wpf, Aps.net, js), а на сервере обрабатываем.
              В итоге в клиентах меньше багов, нет проблем с версионностью алгортимов, не надо централизованно обновлять всех клиентов, вся обработка в одном месте и рядом (на сервере), имеем возможность показывать как сырые данные, так и обработанные.
              Про представление эвентов в удобочитаемой форме будет отдельная статья.
                +2
                Спасибо за ответ.
                Буду ждать следующей статьи!
                0
                Мало того, в WPF при MVVM их не будет в 99% случаев

                А как же Caliburn.Micro?
                  0
                  А как же DevExpress MVVM Framework? Да и вагон и маленькая тележка других фреймворколв туда же.
                    0
                    А что с ним? Там необязательно, чтобы у контрола было имя.
              0
              Откройте для себя свойство OriginalSource тогда не придется создавать свои поля. Просто проверяете сравниваете sender и OriginalSource
                +2
                В OriginalSource будут лежать внутренние потроха контрола, которые нам скорее всего ничего не скажут. Для примера, если посмотреть OriginalSource у PreviewMouseDown эвента кнопки, то там будет либо Border, либо TextBlock (в зависимости от того, где клик произойдет). А если еще учесть, что мы подписаны на эвенты у Control, то становится понятно, что в данном случае никогда sender не будет равен OriginalSource, так как Border и TextBlock не являются его наследниками, а значит они нам эвент не пришлют.
                  0
                  Но focused генерируют только focusable элементы. По умолчанию — наследники Control.
                    0
                    Да с фокусами на сервере и так нет проблемы с выбором какой показывать, так как клиент уже отбивает все ненужные.
                      +1
                      Я извиняюсь за, возможно, ответы немного невпопад. Дело в том, что сейчас готовится еще одна статья, где как раз будет решаться задача, для которой мы когда-то думали использовать сравнение OriginalSource и sender, но не подошло, по причинам, которые я описал в первом комментарии. Вот я немного мыслями там :)

                      Да, в задаче фильтрации эвента фокуса ваш подход выглядит лучше. Просто для статьи мы немного адаптировал наш подход, который сложнее, чем описанный тут, вот и получилось такое решение :)
                      0
                      Взять Hash Code от OriginalSource, быть может?
                    0
                    Со стороны кажется что WPF уже давно умер. Если бы ТС пошарил бы собранную в DevExpress статистику покупок(живых кастомеров, обращений в сапорт и тд) WPF-решений по сравнению с остальными, было бы очень интересно и познавательно.
                    Заранее спасибо.
                      0
                      Ну не WinForms, конечно, но платформа очень даже живая, активности по ней много, так что нечего ее хоронить! Саппорт трафик сравним с WinForms
                        0
                        Вы мне напомнили недавний диалог:
                        — Да .Net никому не нужен, большинство программ написано на Джаве.
                        — У меня на Джаве только android studio, HTML-приложений и то больше.
                        — Да быть такого не может, у меня все приложения на Джаве.

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

                          Новых больших проектов (которые разрабатываются больше 3-х месяцев) в этом году на WinForms создавалось раза в два больше чем на WPF. Но на WPF тоже создавалось немало. В прошлом году соотношение было примерно такое же, сильного тренда в ту или иную сторону не видно.

                        Only users with full accounts can post comments. Log in, please.