Pull to refresh

Борьба с INotifyPropertyChanged или как я стал опенсорсником — 2

.NET *
Начиналось все как и в прошлый раз, достаточно прозаично: мне пришлось разработать *-надцать ViewModel-ей для своего MVVM-приложения.
Для того, чтобы они оптимально работали как ViewModel-и, мои классы должны были наследоваться от DependencyObject или же реализовывать заезженный до дыр интерфейс INotifyPropertyChanged (INPC).

Давно уже ни для кого не секрет, что DependencyProperty тормознее ручной реализации INPC. Мои тесты показывают, что запись в DependencyProperty в ~13 раз медленнее ручной реализации. Поэтому я, как неисправимый оптимизатор, склоняюсь именно к INPC. Тем более, что код поддержки INPC выглядит логичнее и органичнее, чем описание DependencyProperties.


Много статей написано на тему того, как облегчить реализацию INPC. Это и вариант с исследованием StackTrace, это и вариант с Lambda-методами, это и code-snippets как персональный code-monkey, это и Resharper, как панацея от ошибок рефэкторинга. Все эти варианты требуют много лишних телодвижений, и мне, как неисправимому оптимизатору рутинных дел, не нравятся.

Вот, к примеру, вариант реализации с помощью StackTrace:

  public sealed class StackTraceNPC : INotifyPropertyChanged
  {
    string _myProperty;
    public string MyProperty
    {
      get { return _myProperty; }
      set
      {
        if (_myProperty == value) return;
        _myProperty = value;
        RaisePropertyChanged();
      }
    }

    /// Этот метод можно вызывать только внутри setter-ов свойств
    void RaisePropertyChanged()
    {
      var e = PropertyChanged;
      if (e != null) 
      {
         var propName = new StackTrace().GetFrame(1).GetMethod().Name.Substring(4);
         e(this, new PropertyChangedEventArgs(propName));
      }
    }

    public event PropertyChangedEventHandler PropertyChanged;
  }


А вот пример с expression tree, каждый раз создаваемых компилятором в коде:

  public sealed class LambdaNPC : INotifyPropertyChanged
  {
    string _myProperty;
    public string MyProperty
    {
      get { return _myProperty; }
      set
      {
        if (_myProperty == value) return;
        _myProperty = value;
        RaisePropertyChanged(() => this.MyProperty);
      }
    }

    void RaisePropertyChanged<T>(Expression<Func<T>> raiser)
    {
      var e = PropertyChanged;
      if (e != null)
      {
          var propName = ((MemberExpression)raiser.Body).Member.Name; 
          e(this, new PropertyChangedEventArgs(propName));
      }
    }

    public event PropertyChangedEventHandler PropertyChanged;
  }


А вот и результаты производительности упомянутых выше реализаций INPC:


Неисправимые оптимизаторы, глядя на эти цифры, с ужасом вычеркивают из памяти и StackTrace, и Lambda варианты. Понятно, что setter-ы вызываются не так часто, чтобы всерьез задумываться об их производительности, но если речь идет о ViewModel-ях к DataGrid, или о серьезном количестве полей — тогда эти тормоза могут всплыть на поверхность. Кроме того, речь идет не столько о удобном вызове RaisePropertyChanged, сколько об оптимизации всего того геморроя, что с ним связан, включая, проверку на изменение поля и другую писанину типо литералов названий свойств.

Одним из достойных вариантов был бы AoP-подход на базе PostSharp, но достаточно одного взгляда через Reflector на полученный после компиляции IL-код, чтобы понять — нам и с PostSharp-ом не по пути.

Здесь пора бы уже и закручиниться… Но вдохновившись статьями о Mono.Cecil насчет инъекции MSIL кода в стороннюю сборку при помощи Mono.Cecil, я решил раз и навсегда решить эту проблему.

Для начала приведу пример как БЫЛО:

public class MyViewModel: PropertyChangedBase 
{
    string _stringProperty;
    public string StringProperty
    {
       get { return _stringProperty; }
       set 
       { 
          if (_stringProperty == value) return;
          _stringProperty = value;
          RaisePropertyChanged("StringProperty");
       }
    }

    object _objectProperty;
    public object ObjectProperty
    {
       get { return _objectProperty; }
       set 
       { 
          if (_objectProperty == value) return;
          _objectProperty = value;
          RaisePropertyChanged("ObjectProperty");
       }
    }
}


Теперь пример как СТАЛО:

public class MyViewModel: PropertyChangedBase 
{
    public string StringProperty { get; set;}

    public object ObjectProperty { get; set;}
}


И где же здесь реализация INPC, спросите вы и будете правы. Kind of Magic? Именно, Kind of Magic MSBuild task. Так и называется этот open-source проект на codeplex.

Весь секрет заключается в базовом классе PropertyChangedBase, своя версия которого есть у каждого из нас :)

Давайте посмотрим, что же в нем такого особенного:

[Magic]
public abstract class PropertyChangedBase : INotifyPropertyChanged
{
   protected virtual void RaisePropertyChanged(string propName) 
   {
       var e = PropertyChanged;
       if (e != null) 
          e(this, new PropertyChangedEventArgs(propName)); // некоторые из нас здесь используют Dispatcher, для безопасного взаимодействия с UI thread
   }

   public event PropertyChangedEventHandler PropertyChanged;
}


За исключением аттрибута Magic, все остальное выглядит более-менее в порядке. Разберемся с MagicAttribute, который описан в той же сборке, что и наш класс MyViewModel.

class MagicAttribute: Attribute {}


Одна строчка, спросите вы? Именно так. Достаточно определить в вашей сборке атрибут с именем MagicAttribute, применить его к базовому или любому классу, реализующему INPC. В этом случае все public свойства этих классов и их наследников станут INPC-совместимыми. Можно применять напрямую к свойствам вашего INPC класса, тогда только эти свойства станут INPC-совместимыми.

А добавив такой аттрибут:
class NoMagicAttribute: Attribute {}

можно исключать классы и свойства из магической реализации INPC.

Переживать за лишнии килобайты кода не стоит, после компиляции вашей сборки, от этих аттрибутов не останется и следа, убедиться в чем вам поможет Reflector.

Теперь немного о том, как это работает.

  • Все происходит на этапе компиляции. Точнее после компиляции, но до подписи сборки. Т.е. в рантайме получаем максимальную производительность (в большинстве случаев даже быстрее рукописного кода).
  • KindOfMagic буквально дописывает в setter то, что нам самим лениво писать, тем самым сокращая нагрузку на пальцы, Resharper, редактор кода и нервы.
  • KindOfMagic делает свойства INPC-совместимыми и только это. Причем делает это оптимально с точки зрения IL, быстро и прозрачно. Сопутствующие PDB файлы тоже трансформируются, поэтому проблем с отладкой «заколдованных» свойств нет.
  • KindOfMagic вызывает RaisePropertyChanged только тогда, когда свойство действительно изменилось. Код проверки old-new генерируется в зависимости от типа свойства. Поддерживаются любый типы, включая Nullable<T>.
  • KindOfMagic поддерживает как Silverlight, так и .NET проекты.
  • KindOfMagic использует Mono.Cecil для инъекции кода. Спасибо Мигелю и К.


Ну и теперь, встречаем победителя:



Здесь можно скачать KindOfMagic, а здесь лежит тестовый проектик для сомневающихся. Результаты получены под Win7x64, Core2 Quad @ 2.4GHz.

UPDATE 1
Честно говоря, такого разгромного результата я не ожидал, полученный IL не особо сильно-то и отличается.
При ближайшем рассмотрении был найден баг, баг успешно пофикшен. Результат KindOfMagic сравнялся с рукописным кодом, как и ожидалось.

Настоящих чудес не бывает, бывает что-то типо :)
Tags:
Hubs:
Total votes 84: ↑69 and ↓15 +54
Views 30K
Comments Comments 43