- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

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

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 [11] метода. И еще нюанс, не имеет смысла производить подобную замену при нажатии навигационных клавиш, ибо они точно не могут являться частью пароля, но могут быть отправной точкой для каких-либо иных действий. Например, смены фокуса по нажатию на 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 и вперед:

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

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

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

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

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

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;
    }
}

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

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

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

Теперь мы видим, что у нас оооочень много логов на один и тот же эвент, так как 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
);

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

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

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

Автор: byDesign

Источник [12]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/269334

Ссылки в тексте:

[1] Оно само упало, или следствие ведут колобки: https://habrahabr.ru/company/devexpress/blog/342530/

[2] Image: https://ru.wikipedia.org/wiki/%D0%93%D0%B5%D0%BD%D0%B7%D0%B5%D0%BB%D1%8C_%D0%B8_%D0%93%D1%80%D0%B5%D1%82%D0%B5%D0%BB%D1%8C

[3] routed events: https://docs.microsoft.com/en-us/dotnet/framework/wpf/advanced/routed-events-overview

[4] PreviewMouseDownEvent: https://msdn.microsoft.com/en-us/library/system.windows.uielement.previewmousedownevent(v=vs.110).aspx

[5] PreviewMouseUpEvent: https://msdn.microsoft.com/en-us/library/system.windows.uielement.previewmouseupevent(v=vs.110).aspx

[6] PreviewKeyDownEvent: https://msdn.microsoft.com/en-us/library/system.windows.uielement.previewkeydownevent(v=vs.110).aspx

[7] PreviewKeyUpEvent: https://msdn.microsoft.com/en-us/library/system.windows.uielement.previewkeyupevent(v=vs.110).aspx

[8] PreviewTextInputEvent: https://msdn.microsoft.com/en-us/library/system.windows.uielement.previewtextinputevent(v=vs.110).aspx

[9] Keyboard.GotKeyboardFocus: https://msdn.microsoft.com/en-us/library/system.windows.input.keyboard.gotkeyboardfocus(v=vs.110).aspx

[10] Keyboard.LostKeyboardFocus: https://msdn.microsoft.com/en-us/library/system.windows.input.keyboard.lostkeyboardfocus(v=vs.110).aspx

[11] AutomationPeer.IsPassword: https://msdn.microsoft.com/en-us/library/system.windows.automation.peers.automationpeer.ispassword(v=vs.110).aspx

[12] Источник: https://habrahabr.ru/post/343358/