Собираем пользовательскую активность в 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,00

    Компания

    Поделиться публикацией

    Похожие публикации

    Комментарии 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 тоже создавалось немало. В прошлом году соотношение было примерно такое же, сильного тренда в ту или иную сторону не видно.

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

                        Самое читаемое