В 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
);
Теперь главное, это написать обработчики всех этих эвентов, собрать на них данные о том, какую кнопку нажали, у кого, сколько раз… фу, скука. Вот, давайте лучше на котика посмотрим:
Ну а если вам очень хочется посмотреть код, то это можно сделать, раскрыв блок ниже
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 и посмотрим, что же у нас собралось. Там получился страх и ужас, поэтому убрал под спойлер. Если точно хотите на это взглянуть, кликайте ниже:
Ээээ… Я думаю вы подумали как-то так:
Я именно так и подумал :)
Давайте разбираться, что у нас не так, и почему получилась такая портянка непонятных сообщений.
Первое, за что у меня цепляется взгляд, это куча эвентов о том, что фокус гуляет между двумя элементами. При этом объем этих сообщения равен чуть ли не половине общего объема логов. Дело в том, что фактически фокус был изменен один раз, но нотификацию об этом изменении мы получаем от каждого элемента по дереву, на которые мы подписаны. Ну а мы же не из анекдота, нам несколько раз повторять не надо. Поэтому давайте впишем проверочку:
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)
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
Поскольку у нас есть полный путь до контрола, который содержал фокус в момент нажатия кнопок, мы всегда сможем найти виноватого.byDesign Автор
28.11.2017 16:15В .NET имен может не быть. Мало того, в WPF при MVVM их не будет в 99% случаев. Ну и в общем случае вложенность по визуальному дереву может быть огромная, так что path получится на несколько строк. А оно надо?
XopHeT
28.11.2017 16:25+1Ну и в общем случае вложенность по визуальному дереву может быть огромная, так что path получится на несколько строк. А оно надо?
В текущем случае вы получите гораздо больше строк лога так что аргумент спорный.
В .NET имен может не быть. Мало того, в WPF при MVVM их не будет в 99% случаев.
Понял, спасибо. Вопрос снимается, как неактуальный.byDesign Автор
28.11.2017 17:21В текущем случае вы получите гораздо больше строк лога так что аргумент спорный.
Идея в том, что на клиентах мы максимально просто собираем сырые данные с разных платформ (Win, Wpf, Aps.net, js), а на сервере обрабатываем.
В итоге в клиентах меньше багов, нет проблем с версионностью алгортимов, не надо централизованно обновлять всех клиентов, вся обработка в одном месте и рядом (на сервере), имеем возможность показывать как сырые данные, так и обработанные.
Про представление эвентов в удобочитаемой форме будет отдельная статья.
NekoSin4eG
28.11.2017 17:22Mirimon
28.11.2017 17:29А как же DevExpress MVVM Framework? Да и вагон и маленькая тележка других фреймворколв туда же.
ad1Dima
29.11.2017 09:10Откройте для себя свойство OriginalSource тогда не придется создавать свои поля. Просто проверяете сравниваете sender и OriginalSource
Mirimon
29.11.2017 10:10+1В OriginalSource будут лежать внутренние потроха контрола, которые нам скорее всего ничего не скажут. Для примера, если посмотреть OriginalSource у PreviewMouseDown эвента кнопки, то там будет либо Border, либо TextBlock (в зависимости от того, где клик произойдет). А если еще учесть, что мы подписаны на эвенты у Control, то становится понятно, что в данном случае никогда sender не будет равен OriginalSource, так как Border и TextBlock не являются его наследниками, а значит они нам эвент не пришлют.
ad1Dima
29.11.2017 10:47Но focused генерируют только focusable элементы. По умолчанию — наследники Control.
Mirimon
29.11.2017 11:29Да с фокусами на сервере и так нет проблемы с выбором какой показывать, так как клиент уже отбивает все ненужные.
Mirimon
29.11.2017 11:52Я извиняюсь за, возможно, ответы немного невпопад. Дело в том, что сейчас готовится еще одна статья, где как раз будет решаться задача, для которой мы когда-то думали использовать сравнение OriginalSource и sender, но не подошло, по причинам, которые я описал в первом комментарии. Вот я немного мыслями там :)
Да, в задаче фильтрации эвента фокуса ваш подход выглядит лучше. Просто для статьи мы немного адаптировал наш подход, который сложнее, чем описанный тут, вот и получилось такое решение :)
sergey_prokofiev
29.11.2017 11:26Со стороны кажется что WPF уже давно умер. Если бы ТС пошарил бы собранную в DevExpress статистику покупок(живых кастомеров, обращений в сапорт и тд) WPF-решений по сравнению с остальными, было бы очень интересно и познавательно.
Заранее спасибо.byDesign Автор
29.11.2017 11:39Ну не WinForms, конечно, но платформа очень даже живая, активности по ней много, так что нечего ее хоронить! Саппорт трафик сравним с WinForms
ad1Dima
30.11.2017 10:28Вы мне напомнили недавний диалог:
— Да .Net никому не нужен, большинство программ написано на Джаве.
— У меня на Джаве только android studio, HTML-приложений и то больше.
— Да быть такого не может, у меня все приложения на Джаве.
Собственно с тех пор у меня приложений на Java стало только меньше.
evgenicx
А InputManager почему не подошел? С ним вроде бы попроще все было
byDesign Автор
InputManager ловит все эвенты, а это значит, что нам придется фильтровать все ненужные, да и нужные распихивать по разным обработчикам. Как мне кажется, наш текущий подход понятнее для понимания получился.