- Засорение памяти экземплярами ResourceDictionary
- Утечки памяти
- Наследование визуальных компонентов и стили
- Ошибки байндинга
- Стандартные средства валидации
- Неправильное использование события PropertyChanged
- Избыточное использование Dispatcher
- Модальные диалоги
- Анализ производительности отображения
- И еще немного о INotifyPropertyChanged
- Вместо послесловия
Засорение памяти экземплярами ResourceDictionary
Зачастую разработчики явно включают необходимые словари ресурсов прямо в XAML разметке пользовательских элементов управления вот таким образом:
<UserControl x:Class="SomeProject.SomeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Styles/General.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
На первый взгляд, в таком подходе нет никакой проблемы — просто для элемента управления указываем минимально необходимый набор стилей. Предположим, в нашем приложении SomeControl существует в 10 экземплярах на одном из окон. Проблема заключается в том, что при создании каждого из этих экземпляров, указанный словарь будет заново вычитываться, обрабатываться и храниться отдельной копией в памяти. Чем больше подключаемые словари, чем больше экземпляров — тем больше уходит времени на инициализацию содержащего их представления и тем больше памяти расходуется впустую. Мне на практике приходилось иметь дело с приложением, в котором перерасход памяти из-за лишних ResourceDictionary был порядка 200 мегабайт.
Мне известно два варианта решения этой проблемы. Первый — подключать все необходимые словари стилей только в App.xaml и больше нигде. Вполне может подойти для небольших приложений, но для сложных проектов может быть неприемлем. Второй — вместо стандартного ResourceDictionary использовать его наследника, который кеширует словари таким образом, что каждый из них хранится в памяти только в одном экземпляре. К сожалению, WPF по какой-то причине не предоставляет такую возможность «из коробки», но ее легко реализовать самостоятельно. Одно из наиболее полных решений можно найти в последнем ответе здесь — http://stackoverflow.com/questions/6857355/memory-leak-when-using-sharedresourcedictionary.
Утечки памяти
Утечки на событиях
Даже в среде с автоматической сборкой мусора можно легко получить утечки памяти. Наиболее частая причина утечек, и не только в WPF проектах — подписка на события без последующего удаления обработчика. Хоть это и не проблема самой технологии, на ней стоит остановиться поподробнее, так как в WPF проектах события используются часто и вероятность появления ошибки высока.
Например, в приложении есть список объектов, свойства которых можно изменять в окне редактирования. Для реализации этого окна понадобилось устанавливать IsModified в true внутри его модели представления при изменении любого свойства редактируемого объекта.
Предположим, модель представления для редактирования реализована так:
public class EntityEditorViewModel
{
//...
public EntityEditorViewModel(EntityViewModel entity)
{
Entity = entity;
Entity.PropertyChanged += (s, e) => IsModified = true;
}
}
Здесь конструктор устанавливает «сильную» ссылку между бизнес-сущностью и моделью представления редактора. Если создавать экземпляр EntityEditorViewModel при каждом показе окна, то такие объекты будут накапливаться в памяти и удалятся только в том случае, если ссылающаяся на них бизнес-сущность станет «мусором».
Один из вариантов решения проблемы — предусмотреть удаление обработчика. Например, реализовать IDisposable и в методе Dispose() «отписываться» от события. Но тут сразу стоит сказать, что обработчики, заданные лямбда-выражениями как в примере, не могут быть удалены простым способом, т.е. вот такое не сработает:
//Этот код не будет работать корректно!
entity.PropertyChanged -= (s, e) => IsModified = true;
Для правильного решения задачи нужно объявить отдельный метод, поместить в него установку IsModified и использовать в качестве обработчика, как всегда и делалось до появления лямбда-выражений в C#.
Но подход с явным удалением не гарантирует отсутствие утечек памяти — можно банально забыть позвать Dispose(). Помимо этого, может быть очень проблематично определить тот момент, когда нужно его вызвать. В качестве альтернативы можно рассмотреть более громоздкий, но действенный подход — Weak Events. Общая идея их реализации в том, что между источником события и подписчиком устанавливается «слабая» ссылка, и подписчик может быть автоматически удален, когда на него больше не станет «сильных» ссылок.
Объяснение реализации паттерна Weak Events выходит за рамки этот статьи, поэтому просто укажу ссылку, где эта тема рассмотрена очень подробно: http://www.codeproject.com/Articles/29922/Weak-Events-in-C.
Утечки при байндинге
Помимо потенциальной проблемы, описанной выше, в WPF есть как минимум два типа утечек, которые специфичны именно для этой технологии.
Предположим, у нас есть простой объект:
public class SomeModelEntity
{
public string Name { get; set; }
}
И мы привязываемся к этому свойству из какого-либо элемента управления:
<TextBlock Text="{Binding Entity.Name, Mode=OneWay}" />
Если свойство, к которому идет привязка, не является DependencyProperty, либо объект, содержащий его, не реализует INotifyPropertyChanged — механизм байндинга использует событие ValueChanged класса System.ComponentModel.PropertyDescriptor для отслеживания изменений. Проблема здесь в том, что фреймворк держит у себя ссылку на экземпляр PropertyDescriptor, который в свою очередь ссылается на исходный объект, и неясно, когда этот экземпляр можно будет удалить. Следует отметить, что в случае с OneTime байндингом проблема не актуальна, так как не нужно отслеживать изменения.
Информация об этой проблеме есть и в Microsoft Knowledge Base: https://support.microsoft.com/en-us/kb/938416, но в ней указано одно дополнительное условие возникновения утечки. Если применить его к предыдущему примеру, то получим, что экземпляр SomeModelEntity должен прямо или косвенно ссылаться на TextBox, чтобы произошла утечка. С одной стороны, такое условие довольно редко выполняется на практике, но в реальности лучше всегда придерживаться более «чистого» подхода — либо явно указывать OneTime режим байндинга, если не нужно следить за изменениями, либо реализовывать INotifyPropertyChanged на объекте-источнике, либо делать свойство DependencyProperty (имеет смысл для свойств визуальных компонентов).
Другая возможная проблема при установке байндингов — привязка к коллекциям, которые не реализуют интерфейс INotifyCollectionChanged. Механизм возникновения утечек в этом случае очень похож на предыдущий. Способ борьбы очевиден — нужно либо явно указывать OneTime режим привязки, либо использовать коллекции, реализующие INotifyCollectionChanged — например, ObservableCollection.
Наследование визуальных компонентов и стили
Иногда возникает надобность в наследовании стандартных элементов управления для расширения их функциональности, изменения поведения. На первый взгляд, это элементарно:
public class CustomComboBox : ComboBox
{
//…
}
Но если в приложении используются стили элементов, отличные от стилей по умолчанию, проблема при использовании такого наследника будет заметна сразу. Следующий фрагмент скриншота показывает разницу в отображении базового элемента управления и производного при включенной теме PresentationFramework.Aero.
Самый простой способ это исправить — в XAML файле после включения ресурсов темы определить стиль для производного элемента, как унаследованного от базового. Это легко осуществляется с помощью атрибута BasedOn:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/PresentationFramework.Aero;component/themes/Aero.NormalColor.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style TargetType="{x:Type my:CustomComboBox}" BasedOn="{StaticResource {x:Type ComboBox}}">
</Style>
</ResourceDictionary>
</Application.Resources>
Но получается, что при использовании производного элемента управления нужно всегда помнить и о добавлении стиля в ресурсы. Или же сделать файл с этим производным стилем и подключать его каждый раз, когда нужно использовать новый элемент.
Есть один способ обойтись без изменений в XAML — в конструкторе производного элемента явно устанавливать ему стиль, взятый с базового:
public CustomComboBox()
{
SetResourceReference(StyleProperty, typeof(ComboBox));
}
Таким образом, если не нужно добавлять никаких изменений в базовый стиль, этот способ будет наиболее оптимальным. В противном случае лучше воспользоваться предыдущим вариантом.
Ошибки байндинга
Декларативное связывание элементов управления с полями модели, конечно же, имеет свои преимущества, но за его целостностью не так уж просто следить. Если по какой-то причине свойство, указанное в байндинге, не найдено — ошибка будет написана в лог отладки… И всё. По умолчанию пользователь не увидит никаких сообщений, при запуске без отладки ни в каких логах этих ошибок не будет.
Чтобы сделать такие ошибки более заметными для разработчика, можно написать специальный Trace Listener, который будет выводить их в виде сообщений:
public class BindingErrorTraceListener : TraceListener
{
private readonly StringBuilder _messageBuilder = new StringBuilder();
public override void Write(string message)
{
_messageBuilder.Append(message);
}
public override void WriteLine(string message)
{
Write(message);
MessageBox.Show(_messageBuilder.ToString(), "Binding error", MessageBoxButton.OK, MessageBoxImage.Warning);
_messageBuilder.Clear();
}
}
И затем активировать его при старте приложения:
PresentationTraceSources.DataBindingSource.Listeners.Add(new BindingErrorTraceListener());
PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Error;
После этих изменений каждая ошибка байндинга будет выводиться в виде диалогового сообщения, но только при запуске с отладкой, поэтому имеет смысл воспользоваться условной компиляцией, чтобы в релизных версиях обработчик не регистрировался.
Стандартные средства валидации
В WPF существует несколько способов валидации данных.
ValidationRule — наследуя этот класс можно создавать специализированные правила валидации, которые затем привязываются к полям в XAML разметке. Из «условных» плюсов — не требуются изменения классов модели для выполнения валидации, хотя в некоторых случаях это может быть не самым оптимальным вариантом. Но при этом есть значительный недостаток — ValidationRule не наследует DependencyObject, соответственно в наследниках нет возможности создавать свойства, на которые в последствии можно будет байндиться. Это означает, что нет простого очевидного способа производить валидацию свойств в связке друг с другом — например, если значение одного не может быть больше значения другого. Валидационное правило, реализованное таким способом, может иметь дело только с текущим значением поля и фиксированными значениями свойств, которые были указаны при создании экземпляра этого правила.
IDataErrorInfo, INotifyDataErrorInfo — реализуя эти интерфейсы в классах моделей представления можно легко осуществлять валидацию как отдельных свойств, так и нескольких свойств в связке друг с другом. Обычно для уменьшения количества кода один из этих интерфейсов реализуют в базовом классе моделей и предоставляют средства лаконичного описания правил в наследниках. Например, через регистрацию правил в статическом конструкторе для каждого из типов:
static SomeModelEntity()
{
RegisterValidator(me => me.Name, me => !string.IsNullOrWhiteSpace(me.Name),
Resources.RequiredFieldMessage);
}
Или через атрибуты:
[Required]
public string Name
{
get { return _name; }
set
{
_name = value;
NotifyPropertyChanged();
}
}
Хорошее описание второго варианта можно найти по адресу http://www.codeproject.com/Articles/97564/Attributes-based-Validation-in-a-WPF-MVVM-Applicat.
Но и подход с использованием DataErrorInfo интерфейсов не покрывает все валидационные задачи — в случаях, когда для проверки правила нужно иметь доступ к объектам за пределами валидируемой сущности, начинают возникать проблемы. Например, проверка на уникальность требует доступа к полной коллекции объектов, а это означает что каждый элемент такой коллекции должен иметь ссылку на нее, что сильно усложняет работу с объектом.
К сожалению, стандартных средств, позволяющих легко обойти эту проблему, в WPF нет, и приходится писать что-то свое. В самом простом случае, если перед сохранением записи должна быть проверена ее уникальность, это можно сделать явно в коде перед вызовом сохранения и показать сообщение в случае ошибки.
Такой подход на самом деле тоже можно обобщить. Воспользуемся упомянутой выше идеей с регистрацией валидаторов в статическом конструкторе. Вот пример базового класса:
public class ValidatableEntity<TEntity> : IDataErrorInfo
{
//Для регистрации "обычных" валидаторов
protected static void RegisterValidator<TProperty>(
Expression<Func<TProperty>> property,
Func<TEntity, bool> validate,
string message)
{
//...
}
//Для валидаторов, которым нужен доступ к объектам за пределами сущности - например, для проверки на уникальность
protected static void RegisterValidatorWithState<TProperty>(
Expression<Func<TProperty>> property,
Func<TEntity, object, bool> validate,
string message)
{
//...
}
public bool Validate(object state, out IEnumerable<string> errors)
{
//Вызывает все зарегистрированные валидаторы и агрегирует все найденные ошибки. В функции, зарегистрированные через RegisterValidatorWithState, передает объект state в качестве второго параметра.
}
//Реализует IDataErrorInfo, используя только функции, зарегистрированные через RegisterValidator
}
А также пример использования:
public class SomeModelEntity : ValidatableEntity<SomeModelEntity>
{
public string Name { get; set; }
static SomeModelEntity()
{
RegisterValidator(me => me.Name, me => !string.IsNullOrWhiteSpace(me.Name),
Resources.RequiredFieldMessage);
RegisterValidatorWithState(me => me.Name,
(me, all) => ((IEnumerable<SomeEntity>)all).Any(e => e.Name == me.Name),
Resources.UniqueNameMessage);
}
}
Таким образом, все валидационные правила находятся внутри самой сущности. Те из них, которые не требуют «внешних» объектов, используются в реализации IDataErrorInfo из базового класса. Для проверки остальных достаточно позвать функцию Validate в нужном месте и использовать результат для принятия решений о дальнейших действиях.
Неправильное использование события PropertyChanged
Мне довольно часто приходилось встречать код подобного вида в WPF проектах:
private void someViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == "Quantity")
{
//Какая-нибудь логика, которая может, в свою очередь, менять другие свойства
}
}
Причем во многих случаях это был обработчик собственных событий, т.е. «слушал» изменения свойств того же класса, где и был объявлен.
В таком подходе есть несколько серьезных недостатков, которые по итогу приводят к очень тяжелому в плане поддержки и расширения коду. Некоторые из них очевидны: например, при переименовании свойств можно забыть изменить константы в условиях, но это мелкий и легко решаемый недостаток. Гораздо более серьезная проблема в том, что при таком подходе бывает практически невозможно отследить все сценарии, при которых выполняется тот или иной кусок логики.
Можно сформулировать следующий критерий для самопроверки, правильно ли используется обработчик события PropertyChanged: если алгоритм внутри обработчика не зависит от конкретных названий свойств, то все в порядке. В противном случае нужно искать более удачное решение. Примером правильного применения может быть, например, установка свойства IsModified в true при изменении какого-либо свойства модели представления.
Избыточное использование Dispatcher
Неоднократно встречал в WPF проектах принудительное выполнение операций на UI потоке даже в тех случаях, когда это не нужно. Для того, чтобы описать масштаб проблемы, приведу пару цифр, полученных с помощью простых тестов на ноутбуке c процессором Core i7-3630QM 2.4GHz:
- Время, затраченное Dispatcher.Invoke сверх «полезной» нагрузки при вызове из того же потока, к которому принадлежит Dispatcher — 0.2 мкс на один вызов.
- Тот же показатель, но при вызове из другого потока — 26 мкс на вызов.
Первая цифра не выглядит страшной, но и вызывать что-то через Dispatcher, когда известно, что код и так будет выполняться на UI потоке — тоже неправильно. А вот вторая цифра уже выглядит заметной. Следует учесть, что в реальных сложныx приложениях, особенно при диспетчеризации с нескольких параллельных потоков, это время может быть значительно больше. А на более слабых устройствах — еще больше.
Чтобы уменьшить вред для производительности, достаточно придерживаться простых правил:
- Диспетчеризировать только то, что действительно нельзя выполнить на фоновом потоке. Например, есть кусок кода, который что-то читает из WEB-сервиса, потом делает расчет по какому-то алгоритму, потом устанавливает пару свойств на модели представления. В этом случае только установка свойств должна диспетчеризироваться (т.к. в свою очередь вызывает обработчики PropertyChanged, среди которых есть код, работающий с UI).
- Избегать циклов, внутри которых есть обращение к Dispatcher. Например, нужно прочитать список с сервера, и по данным каждого элемента сделать обновление UI. В этом случае лучше сначала просчитать на фоновом потоке все, что нужно будет обновлять на UI, и только потом одним вызовом Dispatcher.Invoke сделать обновление. Вызов Invoke после обработки каждого элемента списка будет крайне неоптимальным решением.
Модальные диалоги
Использование стандартных модальных сообщений (MessageBox) в WPF проектах не приветствуется, так как кастомизировать их внешний вид в соответствии с визуальными стилями приложения попросту невозможно. Вместо стандартных сообщений приходится писать свои реализации, которые можно условно разделить на два типа:
- Отдельное модальное окно (Window.ShowDialog), стилизованное нужным образом.
- «Эмуляция» модального окна через добавление панели в визуальное дерево основного окна, которая находится «над» всем остальным содержимым, тем самым перекрывая его.
У каждого подхода есть свои плюсы и минусы. Первый вариант прост в реализации, но не позволяет достичь таких эффектов, как «затемнение» всего содержимого окна, над которым показывается модальный диалог. В некоторых приложения может возникнуть надобность в диалоге нестандартной формы, что с обычным окном сделать не совсем просто.
Второй вариант обычно вызывает множество проблем в реализации, которые вызваны тем, что показ такого окна не может быть синхронным. То есть, нельзя будет написать как с привычными сообщениями:
if (MessageBox.Show(Resources.ResetSettingsQuestion, Resources.ResetSettings,
MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
и ожидать что на основном потоке больше ничего не может произойти, пока пользователь не ответит на вопрос.
Рассмотрим одну из наиболее простых реализаций «эмулированного» диалога.
В первую очередь объявим интерфейс менеджера диалогового окна, через который модель представления будет показывать диалоги. Для начала не будем учитывать возможность получать «ответ» от окна — просто покажем диалог с кнопкой «Закрыть».
public interface IModalDialogHelper
{
public string Text { get; }
ICommand CloseCommand { get; }
void Show(string text);
void Close();
}
Далее реализуем элемент управления, который будет «привязываться» к менеджеру и показывать окно поверх остальных элементов, когда это необходимо:
<UserControl x:Class="TestDialog.ModalDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300"
Panel.ZIndex="1000">
<UserControl.Style>
<Style TargetType="{x:Type UserControl}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding DialogHelper.IsVisible}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</UserControl.Style>
<Grid>
<Border HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="DarkGray"
Opacity=".7" />
<Grid HorizontalAlignment="Stretch" Height="200" Background="AliceBlue">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding DialogHelper.Text}" />
<Button Grid.Row="1" Content="Close" Command="{Binding DialogHelper.CloseCommand}" HorizontalAlignment="Right" />
</Grid>
</Grid>
</UserControl>
Опять же, для упрощения, этот контрол рассчитан на то, что в модели представления есть экземпляр реализации IModalDialogHelper в свойстве DialogHelper. В более универсальном решении должна быть возможность подставлять любое свойство.
Я не буду здесь приводить пример простейшей реализации IModalDialogHelper, так как она очевидна: методы Show() и Close() устанавливают соответствующим образом IsVisible, команда CloseCommand просто вызывает метод Close(). Show() еще устанавливает свойство Text.
Вроде бы все просто: вызываем метод Show() с нужным текстом сообщения, он делает видимым панель с сообщением и кнопкой, последующее нажатие на кнопку Close устанавливает IsVisible в исходное значение и «диалог» пропадет с экрана. Но тут уже есть первая проблема — последовательный показ нескольких сообщений приводит к тому, что пользователь видит только последнее, так как метод Show() не ожидает закрытия предыдущего диалога.
Для решения этой проблемы немного изменим прототип метода Show:
Task Show(string text);
Возможность ожидать завершения этого метода через await дает стразу несколько преимуществ:
- Показ нескольких сообщений подряд с одного и того же потока будет корректно работать, в отличие от предыдущего примера.
- Можно реализовать метод так, что даже вызов сообщения из разных потоков будет работать корректно и дожидаться закрытия уже показанного диалога.
- Можно возвращать модальный результат как в «старом» MessageBox.Show().
Тут я приведу один из вариантов реализации интерфейса IModalDialogHelper с асинхронным Show, которая соответствует указанным выше пунктам (хоть в данной реализации всегда возвращается один и тот же модальный результат, сделать его зависимым от нажатой кнопки не составит труда).
class ModalDialogHelper : INotifyPropertyChanged,
IModalDialogHelper
{
private readonly Queue<TaskCompletionSource<MessageBoxResult>> _waits =
new Queue<TaskCompletionSource<MessageBoxResult>>();
private readonly object syncObject = new object();
private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher;
//...
public async Task Show(string text)
{
List<TaskCompletionSource<MessageBoxResult>> previousWaits;
TaskCompletionSource<MessageBoxResult> currentWait;
lock (syncObject)
{
//Запоминаем список задач, которые надо ожидать
previousWaits = _waits.ToList();
//Создаем задачу для данного конкретного сообщения
currentWait = new TaskCompletionSource<MessageBoxResult>();
_waits.Enqueue(currentWait);
}
//Ждем завершения задач, которые уже есть в очереди
foreach (var wait in previousWaits)
{
await wait.Task;
}
//Этот блок должен быть выполнен на основном потоке
_dispatcher.Invoke(() =>
{
Text = text;
IsVisible = true;
});
await currentWait.Task;
}
public void Close()
{
IsVisible = false;
TaskCompletionSource<MessageBoxResult> wait;
lock (syncObject)
{
//Убрать текущую задачу из очереди
wait = _waits.Dequeue();
}
//В полном решении этот результат будет устанавливаться в зависимости от нажатой кнопки
wait.SetResult(MessageBoxResult.OK);
}
//...
}
Основная идея этого решения заключается в том, что для каждого вызова Show создается экземпляр TaskCompletionSource. Ожидание задачи, созданной внутри него будет продолжаться до тех пор, пока не указан результат через вызов SetResult. Show до показа своего сообщения ждет все задачи, которые уже есть в очереди, после показа — ждет свою собственную, а Close устанавливает результат выполнения текущей задачи, тем самым завершая ее.
И следует сказать еще пару слов об использовании «новых» диалогов в обработчиках событий типа CancelEventHandler. Подтверждение действий в таких событиях тоже нужно будет реализовывать немного не так как раньше.
//Этот код не будет работать корректно!
private async void Window_Closing(object sender, CancelEventArgs e)
{
e.Cancel = true;
if(await dialogHelper.Show("Do you really want to close the window", MessageBoxButton.YesNo) == MessageBoxResult.Yes)
{
e.Cancel = false;
}
}
Проблема в том, что e.Cancel всегда будет true для кода, вызвавшего Window_Closing, так как await не останавливает выполнение потока, а создает возможность «вернуться» в нужное место в методе после завершения асинхронной задачи. Для вызвавшего кода, Windows_Closing завершится сразу после установки e.Cancel в true.
Правильное решение заключается в том, что тело условия должно оперировать уже не e.Cancel, а явно вызывать «отмененное» действие таким образом, чтобы оно гарантированно выполнилось без дополнительных запросов, минуя повторный вызов этого обработчика. В случае закрытия главного окна программы, например, это может быть явный вызов завершения всего приложения.
Анализ производительности отображения
Многие разработчики знают, что такое «профайлер» и знают, какие есть средства для анализа производительности приложения и анализа потребления памяти. Но в WPF приложениях часть нагрузки на процессор исходит, например, из механизма обработки XAML разметки – парсинг, разметка, рисование. «Стандартными» профайлерами непросто определить, на какую именно активность, связанную с XAML, тратятся ресурсы.
Не буду подробно останавливаться на возможностях существующих инструментальных средств, просто перечислю ссылки на информацию о них. Разобраться с тем, как ими пользоваться, не составит труда любому разработчику.
- Наиболее развитые средства по отладке и профилированию WPF приложений включены в Visual Studio 2015 — http://blogs.msdn.com/b/wpf/archive/2015/01/16/new-ui-performance-analysis-tool-for-wpf-applications.aspx
- Для более старых версий Visual Studio можно воспользоваться WPF Performance Suite — https://msdn.microsoft.com/en-us/library/aa969767%28v=vs.110%29.aspx
И еще немного о INotifyPropertyChanged
Одна из самых популярных тем споров в рамках технологии WPF — как наиболее рационально реализовывать INotifyPropertyChanged. Самый лаконичный вариант — использовать АОП, как я уже описывал в одном из примеров в статье об Aspect Injector. Но не всем этот подход нравится, и в качестве альтернативы можно использовать сниппеты. Но тут возникает вопрос о наиболее оптимальном содержимом сниппета. Сперва приведу примеры не самых удачных вариантов.
private string _name;
public string Name
{
get { return _name; }
set
{
_name = value;
NotifyPropertyChanged("Name");
}
}
В данном случае имя свойства указано константой, и не важно — будет оно в именованной константе или, как в примере, «захардкоджено» прямо в вызове метода оповещения — проблема остается той же: при переименовании самого свойства существует вероятность оставить старое значение константы. Эту проблему многие решают следующим изменением метода NotifyPropertyChanged:
public void NotifyPropertyChanged<T>(Expression<Func<T>> property)
{
var handler = PropertyChanged;
if(handler != null)
{
string propertyName = ((MemberExpression)property.Body).Member.Name;
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
В этом случае вместо названия можно указать лямбда-выражение, возвращающее нужное свойство:
NotifyPropertyChanged(() => Name);
К сожалению, этот вариант тоже имеет недостатки — вызов такого метода всегда связан с Reflection, что по итогу в сотни раз медленнее вызова предыдущего варианта NotifyPropertyChanged. В случае приложений для мобильных устройств это может быть критичным.
В .NET 4.5 стал доступным специальный атрибут CallerMemberNameAttribute, благодаря которому можно решить первую из приведенных выше проблем:
public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
{
//...
}
public string Name
{
get { return _name; }
set
{
_name = value;
NotifyPropertyChanged();
}
}
Если параметр, помеченный этим атрибутом, не указан явно, компилятор подставит в него имя члена класса, вызывающего метод. Таким образом вызов NotifyPropertyChanged() из примера выше равнозначен NotifyPropertyChanged(“Name”). Но что делать, если нужно сообщить об изменении какого-либо свойства «снаружи», не из его сеттера?
Например, у нас есть «калькулируемое» свойство:
public int TotalPrice
{
get { return items.Sum(i => i.Price); }
}
При добавлении, удалении или изменении элементов коллекции items нам нужно сообщать об изменении TotalPrice, чтобы интерфейс пользователя всегда отображал его актуальное значение. Учитывая недостатки первых двух решений, приведенных выше, можно сделать следующий ход — все-таки использовать Reflection для получения имени свойства из лямбда-выражения, но сохранять его в статической переменной. Таким образом для каждого отдельно взятого свойства «тяжелая» операция будет выполняться только один раз.
public class ResultsViewModel : INotifyPropertyChanged
{
public static readonly string TotalPricePropertyName = ExpressionUtils.GetPropertyName<ResultsViewModel>(m => m.TotalPrice);
//...
NotifyPropertyChanged(TotalPricePropertyName);
//...
}
public static class ExpressionUtils
{
public static string GetPropertyName<TEntity>(Expression<Func<TEntity, object>> property)
{
var convertExpression = property.Body as UnaryExpression;
if(convertExpression != null)
{
return ((MemberExpression)convertExpression.Operand).Member.Name;
}
return ((MemberExpression)property.Body).Member.Name;
}
}
Саму статическую функию GetPropertyName можно положить и в базовый класс для всех «notifiable» сущностей — это не принципиально. Проверка на UnaryExpression нужна, чтобы функция нормально обрабатывала свойства значимых типов, т.к. компилятор добавляет операцию боксинга, чтобы привести указанное свойство к object.
Если ваш проект уже использует C# 6.0, то ту же задачу получения имени другого свойства можно решить намного проще — с помощью ключевого слова nameof. Надобности в статической переменной, которая запоминает имя, уже не будет.
В качестве итога можно сказать, что если использование АОП для INotifyPropertyChanged по каким-либо причинам не устраивает, то можно воспользоваться сниппетами следующего содержания:
- Если проект еще не перешел на использование .NET 4.5 — для каждого свойства добавлять статическое поле, которое при инициализации будет заполняться именем свойства через функцию GetPropertyName, как показано выше. A cеттер, в свою очередь, будет передавать в NotifyPropertyChanged значение этого поля.
- Если .NET 4.5 уже используется — то для большинства свойств будет достаточно решения с CallerMemberNameAttribute. А для тех случаев, когда этого решения недостаточно — подойдет либо вариант со статическим полем имени свойства, либо ключевое слово nameof, если проект уже использует C# 6.0.
Вместо послесловия
WPF — неплохая технология, которую Microsoft по-прежнему позиционирует как основной фреймворк для разработки «настольных» приложений. К сожалению, при написании программ сложнее «калькулятора» обнаруживается ряд проблем, не заметных с первого взгляда, но все они решаемы. Согласно недавним заявлениям Microsoft, они инвестируют в развитие технологии, и в новой версии уже есть много улучшений. В первую очередь они относятся к инструментальным средствам и производительности. Хочется надеяться, что в будущем новые возможности будут добавлены не только в инструментальные средства, но и в сам фреймворк, облегчая работу программиста и избавляя от «хаков» и «велосипедов», которые приходится делать сейчас.
UPD: изменил второе решение в разделе «Наследование визуальных компонентов и стили» на более оптимальное из комментариев и добавил в раздел о INotifyPropertyChanged решение с nameof() для C# 6.0
UPD2: изменил пример реализации хелпера для модального диалога на более лаконичный с использованием TaskCompletionSource.
Комментарии (21)
lek
11.07.2015 02:55-16Какие же уродливые эти юайные фреймворки. Я работал с разными — MFC, WPF, ATL/WTL, VCL, Qt, WinForms и даже античным Turbo Vision в текстовом режиме под DOS. С++, Pascal/Delphi, C#. Все эти связки оставляют ощущение, что твой инструмент просто не предназначен для написания интерфейсов. Где-то все более уродливо, где-то менее, но в общем и целом все чрезвычайно безнадежно. Особенно удручающим все становится, когда сам фреймворк начинает превращается в неиссякаемый источник проблем и уродств.
Ситуация удивительна, ведь рядом с миром десктоп-приложений существует целая вселенная с отличными технологиями для создания интерфейсов — HTML, CSS, ECMAScript-like языки. Все стандартизировано, не нужно каждый раз изучать заново очередной монструозный фреймворк от проекта к проекту. Дкеларативный UI, стили отделены от разметки, UI можно генерировать прямо из XML с помощью XSLT-преобразований, совершенно понятное и простое как пять копеек DOM-дерево, слои, события, canvas, json, поддержка кучи графических форматов и прочие ништяки, облегчающие жизнь. И это все можно использовать для написания desktop-приложений. Проекты, на которых был выбран именно этот подход к написанию UI были самыми приятными из всех, в которых я участвовал. К несчастью, им мало кто пользуется. Но пользуются. Антивирусы Norton, Yahoo Messenger, Nod32, EverNote, линейка продуктов Mozilla (с оговорками).
Но это все придет на десктоп. Недавно nodejs + скрестили с хромиумом, чтобы писать десктопные приложения. Существуют другие движки, специально заточенные под десктопный юай, вроде Sciter'a, которые можно юзать из С++. Windows Store Apps можно писать на HTML/CSS+JS. День, когда вымрет последний UI-фреймоврк и все перейдут на веб-стек, нужно будет объявить праздничным и сделать его выходным.Nagg
11.07.2015 03:07+1роекты, на которых был выбран именно этот подход к написанию UI были самыми приятными из всех, в которых я участвовал. К несчастью, им мало кто пользуется. Но пользуются. Антивирусы Norton, Yahoo Messenger, Nod32, EverNote, линейка продуктов Mozilla (с оговорками).
Что забавно, Evernote сперва был написан на WPF, но авторы устали бороться с его тормозами.
a553
11.07.2015 06:06+19В корне не согласен. Веб стек совершенно неюзабелен без траспиляторов, а хоть насколько-то адекватный HTML+CSS только сейчас появляется (flexbox и shadow DOM).
Все стандартизировано
Серьёзно? Это не так, даже если вы пишете под единственный браузер.не нужно каждый раз изучать заново очередной монструозный фреймворк от проекта к проекту
Да ладно? Вы в 2005 году, чтоль? У нас каждый день новый js фреймворк а-ля react/angular/etc, а каждый месяц «модным» становится другой.стили отделены от разметки
Без shadow DOM это огромный костыль и тот самый неиссякаемый источник проблем и уродств. В WPF это разделение сделано гораздо лучше.UI можно генерировать прямо из XML с помощью XSLT-преобразований
Пока вы верстаете свою домашнюю страничку, да. А когда вы находете на SO ответ «модифицируйте HTML» все ваши мечты и идеалы разбиваются об этот ваш любимый HTML+CSS.совершенно понятное и простое как пять копеек DOM-дерево
Простой, за исключением before, after, стилей по-умолчанию, текстовых нодов. Теперь ещё shadow DOM, не забывайте!Недавно nodejs + скрестили с хромиумом, чтобы писать десктопные приложения.
Не нещадно тормозящий Electron-редактор появился впервые у майкрософта в виде VSO. 10 вкладок, Notepad++: 5 МБ памяти; 10 вкладок, VSO: 500 МБ памяти.
Такое чувство, что вы троллите тут.
Я буду проклинать тот день, когда разработчики перестанут писать нативные приложения и перейдут на веб стек. Молюсь, чтобы средства разработки подтянулись под уровень веб-средств и порог вхождения стал ниже.
Источник: Писал на Delphi, Qt, WinForms, WPF, а теперь пишу под веб.lek
11.07.2015 11:04-13Вы просто как-нибудь попробуйте написать нативное приложение, используя для создания UI HTML+CSS+JS, и сразу увидите разницу. Есть очень легкие движки, которые не отжирают по 500 метров. HTML/CSS/JS стандартны и везде примерно одинаковы, в разных движках между ними нет таких различий как между WPF и MFC, и ознакомиться с этими различиями в разы проще, чем переучиваться с WPF на MFC. Плюс они не умрут в ближайшем будущем, их будет проще и дешевле поддерживать, будет легче находить специалистов, понимающих как все работает. DOM это в сотни раз круче, чем ничего, предлагаемое большинством UI фреймворков. Чтобы не возникали вопросы «модифицируйте HTML», составляйте спецификации перед началом проекта. Но даже если придется менять, это будет в разы проще, чем переписывать какой-нибудь Qt-контрол, проще и дешевле. Вы сначала попробуйте, а потом вердикт выносите.
Nagg
11.07.2015 18:44-7Не нещадно тормозящий Electron-редактор появился впервые у майкрософта в виде VSO. 10 вкладок, Notepad++: 5 МБ памяти; 10 вкладок, VSO: 500 МБ памяти.
А что будет с таким редактором на WPF? :-) Сколько секунд он будет запускаться, жрать памяти и насиловать видеокарту?
PS: редактор на WPF — вытекут глаза от шрифтов.
Nagg
11.07.2015 03:05+21 — UserControl'ы в принципе зло, лучше писать на кастом контролах т.к. проблема с RD применима ко всему XAML'у UC в целом.
2 — не имеет отношение к WPF, к тому же тот же Resharper подскажет, что не стоит лямбдой отписываться
3 — интересно. хотя зачем делать Mode=TwoWay для свойства у класса, не реализующего INPC?
5 —при переименовании свойств можно забыть изменить константы в условиях
опять же, решарпер выскажет подозрения при переименовании и предложит переименовать эту константу тоже. А вообще такой код конечно плохой, лучше использовать RX :-)
6 —
Время, затраченное Dispatcher.Invoke сверх «полезной» нагрузки при вызове из того же потока, к которому принадлежит Dispatcher — 0.2 мкс на один вызов.
Тот же показатель, но при вызове из другого потока — 26 мкс на вызов.
Надеюсь, вы понимаете что есть еще и BeginInvoke, но всё же мои тесты с Invoke:
с UI потока: 0.0002 мкс
с потока тредпула: 0.0004 мкс (специально перевел 4000 тиков в микросекунды, чтобы показать разницу с вашими)
ЧЯДНТ? Вот так тестил: gist.github.com/EgorBo/0659e8e1b42ea72190b7
8 — я бы ваш ModalDialogHelper переписал на TaskCompletionSource'ы без вот такой жести: Task.Run(() => waitEvent.WaitOne()); и AutoResetEvent'ов в целом.
Моё мнение — WPF не нужен: в интерпрайзах перешли уже на веб давно (а неинтерпрайз на нем и не писали особо), а wpf так и остался тормозным, с мыльными шрифтами на офисном dpi и его не обновляли уже лет 5 (блог wpf команды через 5 лет бездействия проснулся 4 месяца назад с какими-то невнятными обещаниями и опять заснул) с монструозным XAML. Если уж надо делать неинтерпрайзный клиент чего-то нужного для десктопа, то современные реалии требуют и поддержку Mac OS, с чем у WPF чуть лучше чем никак.Nagg
11.07.2015 03:11Виноват, перепутал мкс с мс, корретные результаты в мкс такие (что все равно много меньше чем у вас для Invoke):
с UI потока: 0.2 мкс
с потока тредпула: 0.4 мкс
YuriyIvon Автор
11.07.2015 09:47+11. Делать совсем кастомные контролы для вьюшек и панелей — перебор, для этого UC как раз и предназначены. Делать просто элементы управления типа кастомных комбо-боксов и прочих с помощью UC — да, неправильно.
2.1. Я так и написал, что не имеет отношения непосредственно к WPF, но сказать про эту проблему стОит.
2.2. С TwoWay в примере я погорячился, там лучше написать OneWay, так как это наиболее частый (и, по итогу, проблематичный) сценарий. Подправлю.
5. Не у всех есть Решарпер. А касательно Rx — да, вещь хорошая.
6. В ближайшее время перемеряю и выложу свои тесты. Да, про BeginInvoke, конечно, знаю.
8. Надо подумать
Насчет того, что энтерпрайз давно перешел на ВЕБ — заблуждение, я вижу как минимум 5 больших проектов для очень крупных компаний, которые используют либо только WPF, либо микс WPF и WEB решений для своих нужд. Полностью переходить на ВЕБ никто из них пока не собирается. Не энтерпрайз — тоже немало десктопных утилит используют сейчас WPF. Не стОит забывать, что разработка под Windows 8 и Windows Phone использует те же принципы и некоторые из перечисленных проблем актуальны и там.
YuriyIvon Автор
12.07.2015 13:16Перемерял — результаты у меня особо не изменились — вот код:
private void TestPerformance(object param) { Task.Run(() => TestPerformanceInternal()); } private void TestPerformanceInternal() { int iterations = 1000000; var sw = new Stopwatch(); var csw = new Stopwatch(); sw.Start(); for (int i = 0; i < iterations; i++) { csw.Start(); _dispatcher.Invoke(() => { csw.Stop(); }); } sw.Stop(); long crossThreadTiming = csw.ElapsedMilliseconds; long invokeTiming = sw.ElapsedMilliseconds; MessageBox.Show(string.Format("Cross-thread timing per call: {0} mcs\nFull invoke timing per call: {1} mcs", (double)crossThreadTiming * 1000 / iterations, (double)invokeTiming * 1000 / iterations)); }
Резльутат:
Cross-thread timing per call: 14,762 mcs
Full invoke timing per call: 20,688 mcs
В статье я имел в виду вторую цифру. В этот раз она получилась чуть меньше 26 мкс, но порядок тот же.
YuriyIvon Автор
12.07.2015 23:05Насчет TaskCompletionSource — спасибо за идею, реализация получилась проще, обновил статью.
HomoLuden
11.07.2015 13:10Утечки на событиях
Можно реализовать специализированный WeakEventManager, который даст возможность подписываться на изменение конкретного свойства у INPC.
Примеры гуглятся по «Custom Weak Event Manager WPF»YuriyIvon Автор
11.07.2015 13:21Я про это тоже писал ;)
Объяснение реализации паттерна Weak Events выходит за рамки этот статьи, поэтому просто укажу ссылку, где эта тема рассмотрена очень подробно: www.codeproject.com/Articles/29922/Weak-Events-in-C.
HomoLuden
11.07.2015 13:28Действительно, есть такое дело. Подписка-отписка в итоге получается еще более громоздкими, т.к. может понадобиться много на что подписаться. А правильная реализация Weak Event сама по себе многострочна, но в одном месте, а дальше чистый реюз.
TimeCoder
14.07.2015 18:551. Насчет ошибок при биндинге, их можно поймать, добавив в биндинг опцию: PresentationTraceSources.TraceLevel=«High»
2. Насчет нотификации при изменении properties во ViewModel. Придерживаюсь следующего подхода: ВСЕ поля, на которые идет биндинг, делать как dependency-properties. Не важно, идет ли речь о юзерском контроле, или нет. Эта идеалогия вытекает из изпользуемого мною (сначала на работе в enterprise-проектах, теперь и во всех личных проектах) фреймворка Catel, где даже Model содержит dependency properties. Если кратко, это, вкупе с другими фишками Catel позволяет быстро создавать WPF-приложения с удобной моделью валидации и обновления UI на лету, интерфейс получается реактивный, а код понятный, т.к. на уровне фреймворка реализовано множество MVVM-примитивов.
Зависимые свойства в Catel объявляются немного иначе, примерно так:
public Book SelectedBook
{
get { return GetValue(SelectedBookProperty); }
set { SetValue(SelectedBookProperty, value); }
}
public static readonly PropertyData SelectedBookProperty = RegisterProperty(«SelectedBook», typeof(Book));
Есть сниппет, т.е. писать все это не приходится, буквально пара нажатий и готово. Если даже это смущает, то Catel.Fody позволяет прикрутить это в runtime к обычным auto-properties.
P.S. если интересны подробности, то легкая вводная здесь:
Qbit
15.07.2015 16:24+1>>> Для правильного решения задачи нужно объявить отдельный метод, поместить в него установку IsModified и использовать в качестве обработчика, как всегда и делалось до появления лямбда-выражений в C#.
До появления лямбда-выражений в C# уже существовали замыкания, хоть и в другом синтаксисе. Если использовать отдельный метод, то не получится замкнуть «локальные» переменные метода.
Можно делать примерно так так:
PropertyChangedEventHandler handler = (sender, e) => { /* Capturing some local variables. */ };
Entity.PropertyChanged += handler;
IDisposable subscriptionToken = Disposable.Create(() => { Entity.PropertyChanged -= handler; });
_compositeDisposable.Add(subscriptionToken);
Нам не нужно «помнить» метод, чтобы от него отписаться в Dispose(). Мы определяем отписку сразу же при подписывании лямбдой, просто откладываем её на потом.
dymanoid
К последнему пункту можно добавить новшество C# 6.0: nameof(). Решает все проблемы.
YuriyIvon Автор
Спасибо, совсем не подумал о 6 шарпе. Добавлю в статью nameof тоже.
Reeze
Новые байдинги на уровне компилятора гляньте.